Surfaces
A surface is UI your service ships and Requence renders on your node, in the task canvas. What a builder and a viewer get out of it is on the Surfaces planning page; this page is how you write one.
There is no UI protocol. The contract is props: your service sends a plain object, and a function inside it arrives in the component as something the viewer can invoke. That is the entire API surface between the two.
The component
Section titled “The component”The component is always a built JavaScript ES module, whatever language your service is written in. Requence never bundles for you — build it with your own toolchain and point the SDK at the output.
It has one default export, mount(element, ctx), and a hard 1 MiB limit on the built file. The limit is checked when you register it, so an oversized component fails at startup rather than on its first render in production.
In React you do not write mount. Leave react, react-dom/client, react/jsx-runtime and @requence/surface-ui external — the surface host resolves them from its own shared runtime, which also ships the mount helper:
import { Button, createSurface, Stack, Text } from '@requence/surface-ui'
export default createSurface(({ props, canInvoke }) => ( <Stack space={2}> <Button disabled={!canInvoke} onClick={() => props.onClick()}> {props.label} </Button> <Text variant="muted">{props.clicks} dispatched</Text> </Stack>))No root, no useState, no teardown to forget. Anything nested reads the same state with useSurface(). A bundle that ships its own React writes mount itself — create a root, subscribe to ctx.onUpdate, unmount on teardown.
This helper and the primitives it comes with are JavaScript-only by design, not a gap in any other SDK: a component is a JS module regardless of what language rendered it.
Registering it
Section titled “Registering it”Register the component next to your service, at module scope. You get back an opaque handle whose only use is passing it to a render.
import { createService, createUI } from '@requence/service'
const panelUI = createUI<{ title: string; at: string; dense: boolean }>( // A URL, so it resolves against THIS module — not against whatever // directory the service was started from. new URL('./dist/panel.js', import.meta.url), // Optional defaults: what does not change from one message to the next. { dense: true },)from pathlib import Pathfrom requence.service import Service, create_ui
panel_ui = create_ui( # An ABSOLUTE path, so it resolves against THIS module — not against # whatever directory the service was started from. Path(__file__).parent / "dist" / "panel.js", # Optional defaults: what does not change from one message to the next. {"dense": True},)Every component also has a stable id — its built file’s name by default, overridable — and it is worth knowing about, because it is what a long-lived handler is addressed by. It survives rebuilds and restarts, which the file’s contents deliberately do not. Two components claiming one id is a startup error, so two index.js-shaped builds need explicit ids.
Rendering
Section titled “Rendering”createService('1.0.0', async (ctx) => { ctx.render(panelUI, { title: 'Processing', at: new Date().toISOString() }) return await doTheWork(ctx.input)})def handler(ctx): ctx.render(panel_ui, {"title": "Processing", "at": now()}) return do_the_work(ctx.input)
Service("1.0.0", handler)A render is fire-and-forget, like sending data: it is enrolled in the message’s ledger, so a failure fails the node when the message settles rather than at the call site. Any handler may render — a generator is only needed for the connection-bound handlers described below.
A render is a patch
Section titled “A render is a patch”What the surface shows is the fold of three layers — the component’s defaults, everything previously rendered onto that surface, and this render. So redrawing one field takes one prop and leaves the rest standing:
ctx.render(panelUI, { title: 'Processing', at: now() })ctx.render(panelUI, { at: now() }) // still says "Processing"ctx.render(panelUI, { title: 'Done' }) // and still carries the last `at`ctx.render(panel_ui, {"title": "Processing", "at": now()})ctx.render(panel_ui, {"at": now()}) # still says "Processing"ctx.render(panel_ui, {"title": "Done"}) # and still carries the last `at`Three rules follow from that:
- The merge is shallow. A nested object is replaced wholesale, so changing part of one means restating that subtree.
- Nothing is ever removed. An omitted prop means “nothing to say about this” — that is what makes a render a patch — so a prop is taken back by saying something else about it (
{ error: null }). - Defaults are the weakest layer. They are what a surface shows until some render says otherwise, and a render that overrides one keeps it overridden for every later render that stays silent about it.
Because every render is a patch, no render is required to be complete. Completing the fold — making sure a first render says everything the component needs — is yours.
More than one surface on a node
Section titled “More than one surface on a node”A render targets a named surface (surfaceId / surface_id, default "default"), so one node can carry several — a preview beside a form, say. Render to the same name to update it; render to a different name to add one.
Handlers: where you write the function is the whole decision
Section titled “Handlers: where you write the function is the whole decision”A function in a surface’s props arrives in the component as an invokable function. There are two kinds, and you pick one by where you write it:
const askUI = createUI<{ question: string; onSubmit(answer: string): void }>( new URL('./dist/ask.js', import.meta.url), { // DURABLE. Module scope, so ANY instance of this version can run it — // this one, or the one that replaces it in a redeploy. onSubmit(answer) { void record(answer) }, },)
createService('1.0.0', async function* (ctx) { ctx.render(askUI, { question: 'ship it?', // CONNECTION-BOUND. Closes over this running handler, so only this // process can serve it, and it dies when the message settles. onCancel: () => ctx.debug.log('cancelled'), })})def on_submit(answer): # DURABLE. Module scope, so ANY instance of this version can run it — # this one, or the one that replaces it in a redeploy. record(answer)
ask_ui = create_ui(HERE / "dist" / "ask.js", {"onSubmit": on_submit})
def handler(ctx): ctx.render(ask_ui, { "question": "ship it?", # CONNECTION-BOUND. Closes over this running handler, so only this # process can serve it, and it dies when the message settles. "onCancel": lambda _arg: ctx.debug.log("cancelled"), }) yield from ()| Written in the defaults | Written inline at the render | |
|---|---|---|
| Kind | durable | connection-bound |
| Who can run it | any instance of the version | only the process that rendered |
| Lives as long as | the version is deployed | the message is unsettled |
| Survives a redeploy | yes — an invocation waits in the version’s queue | no |
| Needs a generator handler | no | yes |
The consequence worth planning around: a node that waits for days needs a durable handler. Write the form’s submit in the component’s defaults, and the handler that renders it is then free to defer and exit — no process has to stay connected for the wait, and a rolling restart is invisible to the viewer.
A durable handler gets no injected context. It reaches your stores, and your service’s act(), through its own closure, exactly as a module-scope function would in any other process.
A handler that deferred cannot render a connection-bound handler at all — so a surface whose buttons are written inline has to be drawn from the actor instead. See Rendering from act().
A durable handler still needs someone home. The node can wait days; the click cannot. While no instance of the version is running, the viewer’s control is withheld, and an invocation that does get published expires after about a minute and is reported back to the viewer rather than running whenever somebody next deploys. So the queue rides out a redeploy, not a weekend.
Three rules you do not get to skip
Section titled “Three rules you do not get to skip”None of these is enforced for you.
1. The argument comes from the browser, and it is forgeable. A viewer with update access can invoke any handler your props published, with anything under the size cap — the component that was rendered is not what sends it. Requence bounds which handler and how big the argument is, and deliberately nothing else: nothing on the wire declares a shape for your props, and your handler is ordinary service code that can check its own input. Validate the argument, exactly as you would a webhook body, and keep anything the handler must not be told in its closure rather than in the props.
2. An interaction is accepted, not awaited. A handler prop returns nothing: there is no promise, no result, and no request left to answer — a durable handler may run minutes later, in a process the viewer never had a connection to. Feedback is a re-render. State what happened in the props ({ submitted: true }, { error: 'unknown address' }) and let the component read them. A component must not lock itself optimistically, either: nothing on the server changed, so no update would arrive to unlock it.
3. Egress is open; credentials are not. A component may fetch any URL and load images, fonts and media from any host. It cannot load code from anywhere else, and its frame carries no cookies and no identity — it acts on its own behalf, never as the viewer, so it cannot reach an authenticated endpoint of your deployment. Therefore never put a credential in props for the component to fetch with: the service fetches and re-renders. The flip side is the accepted cost — your component’s code holds the props you sent it and can post them anywhere.
Iterating on a component
Section titled “Iterating on a component”With a dev overlay running, rebuilding a component swaps the bundle in place: same props, same handlers, same node, no new task run. It reaches every surface your dev session ever drew — a live panel, a form on a node that is still waiting, a surface on a task that finished last week — and nothing else.
What swaps is the code, never the props. A component that only shows what the last render sent it shows exactly that until your service renders again.
Language differences
Section titled “Language differences”The wire is one contract, and both SDKs draw the same surfaces. One difference is worth stating: TypeScript type-checks your props against the component’s, via createUI<Props> and PropsOf — a wrong prop name or type is a compile error. Python’s create_ui carries the type parameter but ctx.render takes any dict, so the same mistake is a prop the component quietly ignores. Prop names are the component’s, in the component’s case (onSubmit, not on_submit): nothing renames anything in either direction.