Appearance
Channels and the barrier
State and services is the gentle introduction: what a channel is for, how to declare one, and how a component reads it. This page is what is underneath, for when the answer matters: what crosses, in which direction, in what shape, and what the framework does and does not decide for you.
The example below is a task list whose data is on the far side of a channel. The panel on the right is the wire itself: every message that crosses, as it crosses. Press a task:
↑ is a command leaving the view. ↓ is a batch of patches arriving at it. There is no third kind of line, and there is nothing else in the protocol.
The framework's whole opinion about your data layer
A channel is a name, a shape, and the value each key holds before anything has been sent:
tsx
/**
* The barrier, declared once. Both threads import this and nothing
* else of each other's.
*
* `view` keys travel inward as patches, `commands` travel outward as
* messages, and the initial value is what the screen draws until the
* first patch arrives, so nothing ever observes `undefined` for a
* declared key. A channel that is genuinely still loading says so in
* its own shape, as `remaining` does here, rather than leaving the
* view to infer it from an absence.
*/
export interface TaskRow {
readonly id: string;
readonly title: string;
readonly done: boolean;
}
export interface TasksView {
tasks: readonly TaskRow[];
remaining: string;
}
export interface TasksCommands {
toggle(id: string): void;
reset(): void;
}
export const Tasks = channel<TasksView, TasksCommands>('docs-tasks', {
tasks: [],
remaining: 'Waiting for the first patch'
});That is the contract, and it is all of it. There is no store base class, no action type, no reducer, no selector, no dependency injection for data. The framework subscribes to observables of plain data, diffs them, and ships patches; where those observables came from is the application's business and nothing here can tell the difference.
tsx
/**
* The application. Plain RxJS, no framework import, nothing here that
* knows a view exists.
*
* In a real project this is an api client, a repository, a domain
* model and a view model, in the application worker's module graph.
* The framework cannot tell the difference between that and this, and
* that is the whole of its opinion about an application's data layer:
* it defines the barrier and leaves everything above it alone.
*/
export function createTaskStore(seed: readonly TaskRow[] = SEED) {
const tasks = new BehaviorSubject<readonly TaskRow[]>(seed);
return {
tasks,
remaining: tasks.pipe(
map(rows => {
const left = rows.filter(row => !row.done).length;
return left === 0 ? 'All done' : `${left} of ${rows.length} left`;
})
),
toggle(id: string): void {
tasks.next(tasks.value.map(row => (row.id === id ? { ...row, done: !row.done } : row)));
},
reset(): void {
tasks.next(seed);
}
};
}
export type TaskStore = ReturnType<typeof createTaskStore>;
/**
* One Observable per view key, one handler per command. This object is
* the entire seam between the application above and the view below.
*/
export function taskSource(store: TaskStore): ChannelSource<TasksView, TasksCommands> {
return {
view: { tasks: store.tasks, remaining: store.remaining },
commands: {
toggle: (id: string) => store.toggle(id),
reset: () => store.reset()
}
};
}An api client, a repository, a domain model and a view model is one arrangement. A single subject is another. Both satisfy the barrier identically, and the module above the source has no framework import in it at all, which is what makes it testable with bare vitest and what keeps it out of the render worker's bundle.
The token module is the only thing both threads import. It holds names and shapes and no implementation, so an application's http client, its storage layer and its domain models never reach the thread that draws.
Where each half runs
| Thread | Holds |
|---|---|
| Shell (main) | the canvas, input forwarding, and the platform APIs that exist only there |
| Application worker | api, storage, domain models, view models. Plain RxJS, no framework import above provide |
| Render worker | components, layout, input dispatch, rasterization, and the channel replicas |
The application layer is a worker rather than the main thread for a specific reason: FileSystemSyncAccessHandle, the fast OPFS path and the one an SQLite-wasm VFS needs, exists only in a dedicated worker. On the main thread it is not merely refused, it is not defined.
The patch stream does not go through the shell. The shell spawns both workers and hands the render worker a port to the application worker; from then on the two talk directly. Routing the data path through the main thread would re-couple it to the thread this arrangement exists to keep out of the way.
That is measured rather than argued. With the shell busy-looped for 5000 ms in Chrome, a click made during the block reported 1988 ms of input latency, while the render worker's worst frame gap stayed at 110 ms against an unchanged baseline and a channel fed from the application worker kept delivering patches throughout. The person waits for the click; the frames and the data never notice.
Registering it, and where the data lives
Where a channel's data lives is decided at registration and nowhere else. The view above it never learns which arrangement it got.
| Registration | Where the data lives |
|---|---|
.useChannel(Tasks) | the application worker the shell spawned |
.useChannel(Tasks, { worker }) | a worker of this channel's own |
.useChannel(Tasks, { source }) | this thread, over a MessageChannel to itself |
A WorkerHandle shared between registrations puts those channels in one worker, which is the arrangement the barrier exists for: api, storage, domain and view models together, one thread, several channels. A source registration still crosses a real port and is diffed, patched and plain-data checked exactly like a worker's.
In the application worker, one call publishes everything it offers:
ts
// tasks.worker.ts
import { serveChannels } from 'gesso-framework';
import { Tasks } from './tasks.contract';
import { createTaskStore, taskSource } from './tasks';
serveChannels([{ token: Tasks, source: taskSource(createTaskStore()) }]);Call it synchronously, at the top level of the worker module and before any await, so no handshake is missed. Everything above that call is the application's own; that function is the entire seam between it and the view.
One differ, and what it costs
Each view key is subscribed on the first sync request, so a channel nobody is watching costs nothing. When a key emits, its new value is compared against what the other side is known to hold and the difference is posted as patches.
| Patch | Says |
|---|---|
set | this path now holds this value |
delete | this key or index is gone |
splice | this array changed length here, and here is what is in it |
Paths are relative to the key's own root, so a patch is self-contained: the replica applies it to the value it already holds and never needs the previous one. Applying shares structure with the original everywhere the patch did not reach, and nothing is mutated, because bindings hold on to the values they were given.
The array differ trims a common prefix and a common suffix and then recurses. It is deliberately not a minimal edit script: append, prepend, remove one and edit in place all reduce to one small patch, while a re-sort degrades to replacing the middle. Computing a true longest-common-subsequence on every change would cost more than it saves.
So granularity is the tuning knob. Declare keys finely: tasks and remaining separately, never one state object. A key holding a large array is re-diffed whenever any part of it changes, and two values that change for different reasons are two keys. In the panel above, toggling one task posts set tasks[1].done and not a resend of the list, and the derived summary arrives as a batch of its own because keys are diffed and posted one at a time.
Patches are queued on arrival and applied in the frame's first phase. A chatty application thread can deliver many between two frames, and applied on arrival each one would push a value through the bindings watching it, rebuilding a subtree once per patch when only the last state is ever drawn. Queued, a burst costs one pass, and a batch touching one key three times emits once.
The wire is a port and four messages
| Direction | Message | Carries |
|---|---|---|
| view → data | channel:sync | nothing: "send me everything" |
| view → data | channel:command | a command name and one payload |
| data → view | channel:patch | a batch of patches |
| data → view | channel:error | a message and a stack |
The replica asks for a sync rather than waiting to be pushed to, so neither end depends on which finished starting up first, and a provider answers a client that reattaches by re-sending every key in full. Commands are fire and forget: at most one argument, structure cloned onto the owning thread, returning nothing. There is no synchronous answer to be had across a thread, and the effect comes back as a patch or not at all.
A ChannelPort is postMessage plus onmessage and nothing else, which is why anything port-shaped can sit in the middle of one. The tap in this example relays both directions and writes down what went past:
tsx
/**
* Stands on the wire between the replica and the provider.
*
* A `ChannelPort` is `postMessage` plus `onmessage` and nothing else,
* so anything port-shaped can sit in the middle of one: this relays
* both directions and writes down what went past. It is the whole
* reason the seam is the port rather than a callback, and it is what
* `gesso-devtools`' action log does properly, with a timeline and
* time travel. Twenty lines is enough to make the traffic visible.
*
* The provider is created here, on the far side of the relay, so the
* data in this example lives in the same worker as the view. Nothing
* below the relay can tell: a channel served by an application worker
* exchanges the same two messages over the same kind of port.
*/
export function tapChannel(source: ChannelSource<TasksView, TasksCommands>): ChannelTap {
const traffic = new BehaviorSubject<readonly string[]>([]);
const ports: MessagePort[] = [];
const record = (line: string | null): void => {
if (line !== null) {
traffic.next([...traffic.value, line].slice(-LINES));
}
};
const handle: WorkerHandle = {
open(): MessagePort {
const view = new MessageChannel();
const data = new MessageChannel();
view.port2.onmessage = event => {
record(describe(event.data));
data.port1.postMessage(event.data);
};
data.port1.onmessage = event => {
record(describe(event.data));
view.port2.postMessage(event.data);
};
provide(Tasks, source, data.port2 as unknown as ChannelPort);
ports.push(view.port1, view.port2, data.port1, data.port2);
return view.port1;
},
spawned: true,
terminate(): void {
for (const port of ports) {
port.close();
}
ports.length = 0;
}
};
return { handle, traffic };
}
/** One message, as a line: `↑` leaves the view, `↓` arrives at it. */
export function describe(data: unknown): string | null {
if (isChannelClientMessage(data)) {
return data.type === 'channel:sync' ? '↑ sync' : `↑ ${data.command}(${JSON.stringify(data.payload) ?? ''})`;
}
if (isChannelHostMessage(data)) {
return data.type === 'channel:error' ? `! ${data.message}` : `↓ ${data.patches.map(patchLine).join(', ')}`;
}
return null;
}
/** `set tasks[1].done`: the op, the key, and the path inside it. */
function patchLine(patch: Patch): string {
const path = patch.path.map(step => (typeof step === 'number' ? `[${step}]` : `.${step}`)).join('');
return `${patch.op} ${patch.projection}${path}`;
}That is the seam the devtools stand on, and standing on the wire is what lets the action log do the second half of its job: putting the view back to an earlier step is a patch batch shaped exactly like the one a reattaching client gets, so nothing downstream can tell a replay from a resync.
Only plain data crosses
Primitives, arrays and plain objects. A Date, a Map, a Set, a class instance or a function compares by reference, which reports "changed" on every evaluation, so a key holding one would re-emit forever and dirty the subtree bound to it while looking perfectly correct.
That failure is invisible in a test and surfaces much later as a vague slowness, so it is caught instead: the first emission of each key is checked, once, and a key that cannot cross is reported as a channel error naming the channel, the key and the path inside it. The view-model layer is where rich objects become flat data, and this is what makes that a rule rather than a convention.
What must not cross
Every channel hop is a message. View state that round-trips to another thread is a visible lag on work that never needed to leave the thread that draws, so a hover highlight, a caret, a scroll offset or which tab is open stays in a cell or a service.
The test is not how important the value is:
| The value | Where it belongs |
|---|---|
| Written by an event handler, dies with the component | internalState |
| Shared between screens, never leaves the thread | a service |
| Authoritative, outlives a screen, or is on another thread | a channel |
Limits
- A command has no completion signal. It is fire and forget by design. A form submit that must disable its button until the write lands does it by publishing a
statuskey and binding to that. - The diffing cost at
DataTablescale has not been measured. It is real work, and it runs on the thread that owns the data rather than the one that draws, which is the point. The magnitude is not a number anyone here has taken. - This page's example serves its channel from the render worker. Everything above was measured in that configuration, over a real
MessageChannel. Moving the data to an application worker changes the registration line and nothing else in these files, which is a property of the API rather than something this page measured.
Next
The action log is the panel above, done properly: one timeline across every channel, with the view rewindable to any step.