@shutter-network/concorde/http-channel
The HTTP Channel is a Channel implementation for the Messenger, carrying Messages between the shared agent and a User over HTTP. The Messenger owns the log and reaches nobody; a Channel is what reaches a person over one medium. This one exposes a submission and a poll on the Public server, which a browser can drive with no client library.
createHttpChannel makes one, and HttpChannelOptions is what it takes. HttpChannel is what comes back, and it has no programmatic API at all. Sending and reading belong to the Messenger, and HTTP needs no identity of its own beyond the Token a User already presents, so an Operator's own code calls the Messenger and never this.
Construct the Messenger first. The constructor registers itself with it, and a Messenger accepts at most one Channel, so a deployment that registers this one gives up every other medium. Nothing else is taken: both routes run behind the Public server's own hook, so a deployment with no Auth registered on that server refuses every submission and every poll.
It does not use the Db and exports no schema. It stores nothing, and it queues nothing either: HTTP delivery is the User asking, so an outbound Message is already in the Messenger's log and the next poll carries it.
Example
A Gateway a browser can talk to: the Messenger, this Channel registered with it, and a Handler for the Signal a submission emits.
import { readFileSync } from "node:fs";
import { createGateway } from "@shutter-network/concorde/gateway";
import { createHttpChannel } from "@shutter-network/concorde/http-channel";
import type { MessageRecord } from "@shutter-network/concorde/messenger";
import { createMessenger, messageReceivedKind } from "@shutter-network/concorde/messenger";
import { createPasswordAuth } from "@shutter-network/concorde/password-auth";
import { createPiRuntime } from "@shutter-network/concorde/pi";
import { templateHandler } from "@shutter-network/concorde/signals";
import { createUsers } from "@shutter-network/concorde/users";
const gateway = createGateway({
databaseUrl: process.env.DATABASE_URL ?? "",
runtime: createPiRuntime({ image: "my-agent:1" }),
// Not loopback: the agent reaches this server from a container of its own.
agentListen: { host: "0.0.0.0", port: 8081 },
publicListen: { host: "0.0.0.0", port: 8080 },
extend: ({ db, agentServer, publicServer, worker }) => {
const users = createUsers({ db, agentServer, publicServer });
const messenger = createMessenger({ db, users, worker, agentServer });
return {
users,
// Some scheme has to be registered, or both routes below refuse every request.
passwordAuth: createPasswordAuth({ db, users, publicServer, tokenTtl: 86_400_000 }),
messenger,
http: createHttpChannel({ db, messenger, publicServer }),
};
},
handlers: ({ messenger }) => ({
[messageReceivedKind]: templateHandler<MessageRecord>({
template: readFileSync(new URL("./prompts/message.hbs", import.meta.url), "utf8"),
session: (signal) => `user_${signal.payload.userId}`,
data: async (signal) => ({ log: await messenger.history(signal.payload.userId) }),
}),
}),
});
await gateway.start();
// The Public server now answers `POST /messages` and `GET /messages?after=<seq>`. Nothing in
// the Operator's own code calls this Channel: what the agent says back goes through the
// Messenger and arrives on the same log the poll reads.Type Aliases
HttpChannel
type HttpChannel = Channel;The HTTP Channel as a Component, and every member of it does nothing.
send is the Messenger's to call and is a no-op: HTTP delivery is the User asking, so an outbound Message needs nothing from here, being in the log already for the next poll to carry. start and stop are no-ops too, because polling opens no connection and sets no ticker going. name is "http", which nothing routes on and nothing stores.
It keeps nothing: it exports no schema, it queues nothing, and it records no read position, so a restart loses nothing this component was holding and there is nothing here to migrate. The log and every Message in it are the Messenger's.
So it has no programmatic API. Everything this Channel does it does for a request on the Public server or for the Messenger that registered it.
HttpChannelOptions
type HttpChannelOptions = {
readonly db: Db;
readonly messenger: Messenger;
readonly publicServer: {
readonly fastify: FastifyInstance;
readonly requireUser: preHandlerAsyncHookHandler;
};
};Properties
db
readonly db: Db;The Db one transaction is opened on, and queried through not at all.
This component exports no schema and has no table to read. What it needs a Db for is the submission: the Message and the Signal that wakes the agent for it are one act, and the Messenger's inbound write joins that transaction rather than opening one of its own.
messenger
readonly messenger: Messenger;The Messenger that owns the log. Build it before this.
The constructor registers with it, and what comes back is the only way to write an inbound Message. A Messenger that already has a Channel refuses the second, so this is where a deployment settles on one medium.
publicServer
readonly publicServer: {
readonly fastify: FastifyInstance;
readonly requireUser: preHandlerAsyncHookHandler;
};Where Users submit and poll, at /messages, and the schemes those two routes accept.
A Channel nobody can reach is broken rather than smaller, so there is no assembly of this component that omits it.
requireUser is the server's own composed hook, taken as one option on each route and neither wrapped nor re-implemented, so this component authenticates nobody and an unauthenticated submission or read is refused with the same 401 every protected route on that server answers. Nothing else is read off this option.
Structural: anything carrying a Fastify instance and a requireUser satisfies it, which is what serverComponent answers with.
fastify
readonly fastify: FastifyInstance;requireUser
readonly requireUser: preHandlerAsyncHookHandler;Functions
createHttpChannel()
function createHttpChannel(options: HttpChannelOptions): Channel;Builds the HTTP Channel, registers it with the Messenger, and registers one route group at /messages on the Public server: a submission, and a cursored read of the submitting User's own log.
Nothing here connects, listens or applies DDL.
Parameters
options
Returns
Throws
ChannelAlreadyRegisteredError if a Channel is already registered with that Messenger. Thrown before either route reaches the server, so a refused second Channel leaves nothing behind on it.