Skip to content

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:

ts
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 Commands

The sending process ​

ts
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:

ts
import { cli } from '@ufo-soft/caelus-cli'

process.exitCode = await cli(registry, process.argv.slice(2), 'counter')
txt
$ counter increment
1
$ counter increment
2
$ counter decrement
1

With the server down, the forwarder's failed outcome becomes the CLI's error:

txt
$ counter increment
fetch failed

The receiving process ​

The other process binds handlers to the same descriptions and invokes its own registry with what arrives:

ts
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.