Skip to content

Browser clients

This guide builds a browser client for a minimal A11 echo service over HTTP/2 Server-Sent Events (SSE). The action has one text/plain input named input and one text/plain output named output. The Python server and the TypeScript browser use the same ActionSchema, Action, AsyncNode, Session, and wire-message concepts; only the transport-facing syntax differs.

Before you start

Install the Python package and npm install a11@npm:@curiositystack/a11. Browsers negotiate HTTP/2 through TLS. Browsers do not support clear-text HTTP/2 prior knowledge (h2c), so local browser testing also requires a trusted development certificate.

1. Define the action contract

Both peers must agree on this schema. The Python service declares:

ECHO_SCHEMA = a11.ActionSchema(
    name="echo",
    description="Return the supplied text unchanged.",
    inputs={"input": a11.ActionPortSchema(
        name="input", type="text/plain", typeinfo=str, required=True
    )},
    outputs={"output": a11.ActionPortSchema(
        name="output", type="text/plain", typeinfo=str, required=True
    )},
)

The browser creates the equivalent ActionSchema and ActionPortSchema. These are not HTTP request DTOs: they describe the same runnable action and streaming ports on each side.

const echoSchema = new ActionSchema({
    name: 'echo',
    inputs: {input: new ActionPortSchema({name: 'input', type: 'text/plain', required: true})},
    outputs: {output: new ActionPortSchema({name: 'output', type: 'text/plain', required: true})},
});

2. Implement the server-only handler

Only the server registers a handler. Inputs and outputs are AsyncNodes, so the handler consumes the final input value and puts the same value into the output node:

async def echo(action: a11.Action) -> None:
    value = await action["input"].consume(str)
    await action["output"].put(value, final=True)

The browser registers the schema without a handler. Calling it therefore creates an action message for the remote session instead of executing code in the page.

3. Prepare and run the Python service

Each SSE connection becomes an accepting Session whose registry knows how to run echo. The endpoint pair shares the /demos/echo prefix:

registry = a11.ActionRegistry()
registry.register("echo", ECHO_SCHEMA, echo)


async def accept(stream):
    session = a11.Session(action_registry=registry)
    await session.add_stream(stream, mode="accept")
    await session.done.wait()


options = a11.HttpSseOptions()
options.connect_endpoint = "/demos/echo/connect"
options.message_endpoint = "/demos/echo/streams/{id}/message"
server = a11.HttpSseServer.create("127.0.0.1", 80, accept, options)

The complete deployable module is a11/demos/echo_server.py. Create and trust a localhost certificate with mkcert, then run the service with TLS:

mkcert localhost 127.0.0.1 ::1
python -m a11.demos.echo_server \
  --host 127.0.0.1 --port 9000 \
  --certificate ./localhost+2.pem \
  --private-key ./localhost+2-key.pem

4. Create a browser session and connect

Create the client registry and session, then attach an SSE stream in START mode. The server attaches the other end in ACCEPT mode. Because the service URL includes a path, pass its endpoint paths explicitly:

const registry = new ActionRegistry();
need(registry.register('echo', echoSchema));
const session = need(Session.create({actionRegistry: registry}));
const stream = need(HttpSseClientWireStream.create(server.origin, {
    connectEndpoint: '/demos/echo/connect',
    messageEndpoint: '/demos/echo/streams/{id}/message',
}));
need(await session.addStream(stream, StreamMode.START));

5. Wire the interface through AsyncNodes

Make the action against the session's shared node map and stream. call() sends the action description; writing the input and marking it final sends its node fragments. The response arrives through the output AsyncNode:

const action = need(registry.makeAction('echo', {
    nodeMap: session.getNodeMap(), stream, session,
}));
need(await action.call());
const input = need(await action.getInput('input'));
need(await input.putFinal(text));
need(await action.waitForDispatch(10_000));
need(await action.wait(30_000));
const output = need(await action.getOutput('output', false));
const reply = need(await output.next({timeoutMs: 10_000}));

This symmetry is the useful part: an interface field is not translated into a special REST body. It remains an A11 node, and the action and session retain their identities and lifecycle on both peers.

6. Display failures

A11 APIs return a StatusOr<T>: success values pass isOk, while failures carry a status code, message, and structured details. Convert failures at the UI boundary and render them in a live error region:

const need = <T>(value: T | Status): T => {
    if (!isOk(value)) {
        throw new Error(`${StatusCode[value.code]}: ${value.message}`);
    }
    return value as T;
};

try {
    await sendEcho(text);
} catch (error) {
    errorRegion.textContent = error instanceof Error ? error.message : String(error);
}

Try it

The wire inspector records both action messages and node fragments; select a row to see its action names, node IDs, and encoded byte size. Half-close says that the client will send no more data while allowing already-sent work to drain. Reconnect creates a fresh transport and session.