Forwarding
forward(commands, send) registers commands whose handlers run in another process. Each forwarder sends the command's name and args through send and returns the outcome that comes back. The app supplies send: a fetch, an IPC call, a socket message.
The two processes share the commands' descriptions, in a file that holds data only:
import type { Commands } from '@ufo-soft/caelus'
export const commands = {
increment: { description: 'Add one', parameters: [] },
decrement: { description: 'Subtract one', parameters: [] },
reset: { description: 'Back to zero', parameters: [] },
} satisfies CommandsThe sending process
import { Registry, failed, forward } from '@ufo-soft/caelus'
import type { Send } from '@ufo-soft/caelus'
import { commands } from './descriptions.js'
const send: Send = async (name, args) => {
try {
const response = await fetch('http://localhost:3000/invoke', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, args }),
})
return response.ok ? await response.json() : failed(`${response.status} from the server`)
} catch (error) {
return failed(error instanceof Error ? error.message : String(error))
}
}
const registry = new Registry()
registry.register(forward(commands, send))send has the type Send: (name, args) => Promise<Outcome>. A transport failure should come back as a failed outcome, as above. A send that throws rejects the promise invoke returns.
Any surface works on a registry of forwarders. As a CLI:
import { cli } from '@ufo-soft/caelus-cli'
process.exitCode = await cli(registry, process.argv.slice(2), 'counter')$ counter increment
1
$ counter increment
2
$ counter decrement
1With the server down, the forwarder's failed outcome becomes the CLI's error:
$ counter increment
fetch failedThe receiving process
The other process binds handlers to the same descriptions and invokes its own registry with what arrives:
import { createServer } from 'node:http'
import { text } from 'node:stream/consumers'
import { Registry, bind, failed, ok } from '@ufo-soft/caelus'
import { commands } from './descriptions.js'
let count = 0
const registry = new Registry()
registry.register(
bind(commands, {
increment: () => ok(++count),
decrement: () => ok(--count),
reset: () => ok((count = 0)),
}),
)
// POST /invoke with { name, args }; the reply is the outcome as JSON
createServer(async (request, response) => {
if (request.method !== 'POST' || request.url !== '/invoke') return void response.writeHead(404).end()
const body = await text(request)
.then(JSON.parse)
.catch(() => null)
const outcome =
typeof body?.name === 'string'
? await registry.invoke(body.name, body.args ?? {})
: failed('expected a JSON body { name, args }')
response.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(outcome))
}).listen(3000)The receiving end is a trust boundary. Check the shape of what arrives before invoking, as above. The registry checks only that the name exists: the handlers check their own args.
An outcome is plain data, so it crosses the boundary as JSON. Data a handler returns has to survive that trip: no functions, class instances, or Date objects that the other side expects back as they were.
Mixing local and forwarded commands
One registry can hold both. A page can forward its data commands to a server and register a command that only touches the page, such as clearing a log, with a local handler. Every surface sees one list of commands either way.
See the examples for forwarding over Electron IPC and over a REST client.