@client-compose/client
Framework-agnostic client for Client Compose: a protocol for server-composed screens made of client-owned islands.
Tier: core · Protocol: v0 · Any JS runtime with fetch (browsers, React Native, Node 20+)
npm install @client-compose/client
Using React or React Native? Use
@client-compose/react, which builds on this package.
Usage
IslandRegistry<R> maps island types and versions to renderers. R is whatever your UI renders
(a DOM node, a string of HTML, a framework element), so this core works with any UI layer.
import { buildRequest, fetchScreen, IslandRegistry, plan } from "@client-compose/client";
import { ProfileHeaderV2 } from "./islands.generated"; // from @client-compose/codegen
const registry = new IslandRegistry<HTMLElement>()
.register(ProfileHeaderV2, (props) => renderProfileHeader(props));
const request = buildRequest(registry, "profile", { platform: "web", appVersion: "1.4.0" }, { userId });
const response = await fetchScreen("https://api.example.com/compose", request, {
headers: { Authorization: `Bearer ${token}` },
});
const islands = plan(registry, response, {
onSkipped: (entry, reason) => console.warn("skipped island", entry.key, reason),
});
container.replaceChildren(...islands.map((island) => island.render()));
buildRequestsends the island types and versions this client can render, so the composer never returns one it can't.planreturns renderable islands in order. Errored islands are omitted unless you passonError; islands with an unknown type, version or status are skipped and reported.registry.declare(...)names islands the app renders somewhere else, such as on the server: the registry can't render them, but actions say the app can draw them, and changes to their entities apply to them (spec §5.3).fetchScreenreads the screen's stream to its end, and returns it whole. It throwsScreenFetchError(withstatusandbody) for non-2xx responses, and accepts a customfetchas its fourth argument.
Streams
A screen arrives as a stream (spec §4.6): its head, with any island not yet ready pending, then
each of those as the composer resolves it. openScreen resolves once the head is in, so you can
show the screen and fill it in:
const stream = await openScreen(baseUrl, request, init);
let screen = stream.head;
show(plan(registry, screen)); // pending islands are their placeholders, or left out
for await (const island of stream.islands) {
screen = withIsland(screen, island);
show(plan(registry, screen));
}
- Register a placeholder for an island with
registry.registerPlaceholder(Descriptor, () => …):planshows it while the island is pending. Without one, the island is left out until it arrives. - An island the stream never brings (the connection dropped) arrives as an error,
incomplete, retryable. collectScreen(stream)reads the rest and returns the whole screen.- The body is read as it comes when your
fetchgives a readable stream (browsers, Node, Deno, Bun, andexpo/fetchin React Native). React Native's built-infetchdoesn't, so there the screen arrives all at once.
Actions
import { ActionFetchError, callAction } from "@client-compose/client";
import { FollowUserV1 } from "./actions.generated"; // from @client-compose/codegen
const res = await callAction("https://api.example.com/compose", FollowUserV1, { userId }, {
client: { platform: "web", appVersion: "1.4.0" },
islands: registry.capabilities(), // updates come at versions the app can render
init: { headers: { Authorization: `Bearer ${token}` } },
});
if (res.status === "ok") showFollowers(res.result.followers); // and apply res.effects (spec §5.3)
else if (res.error.code === "blocked") showBlocked();
else showGenericError(); // codes added after this app was built
- Declared errors are outcomes (
status: "error"), typed per action. Anything else, such asinvalid_input,unauthenticatedoraction_failed, throwsActionFetchErrorwithstatusandcode. - Each call sends a new idempotency key. Pass
idempotencyKeyyourself to retry the same intent.
Props aren't validated at runtime; generate types with
@client-compose/codegen to keep them in
sync with the composer.
Islands with pages
An island entry carries next while the island has more pages (spec §4.5).
fetchIsland(baseUrl, buildIslandRequest(screen, params, entry, client, entry.next)) fetches the
next one: the island's entry again, with that page's props and its own next. Without a cursor, it
resolves the island again from its first page. The React binding's usePages does this for you.
Strings
Every word comes from the composer (spec §4.7). Register an island by a descriptor whose manifest
declares strings, and its renderer's props have them as t, in the screen's language, with counted
strings picking their words by its plural rules:
registry.register(ArticleListV1, (props) => `${props.t.heading}: ${props.t.articleCount({ count: props.total })}`);
The app strings come from POST {base}/strings:
const response = await fetchStrings(baseUrl, client);
const t = appStrings(AppStrings, response); // t.retry, t.uploaded({ count, total })
Send the person's language as client.locale; preferredLocale picks it from a device's tags or
an Accept-Language header (preferredLocale("pl-PL,pl;q=0.9") is "pl-PL"), and the composer
answers in the nearest language it has. formatString(template, values, plurals) and
pluralCategory(plurals, count) are there for anything else. In tests, the fake composer answers strings: () => stringsResponse("en", { … }),
and island(key, Descriptor, props, { strings }) gives an island its strings. catalogStrings(catalog)
reads a catalog file (codegen's catalog) as the composer would send it, for tests that read the
real words: t(Descriptor), app(AppStrings), island(…) with its strings, and stringsResponse().
Testing
@client-compose/client/testing is a fake composer for tests, with no test framework of its own.
createFakeGateway answers screen and action requests from handlers and records them; pass its
fetch to the client. Build responses with island, islandError, screen, screenError,
actionOk, actionError and actionFailure, typed by the generated descriptors. A screen is
sent whole (a head and an end); to control when each island arrives, answer with
streamedScreen(id, [island(…), pending(key, Descriptor)]), then call its send(entry), end(),
or cut() for a dropped connection.
import { actionError, createFakeGateway } from "@client-compose/client/testing";
const gateway = createFakeGateway({
actions: { follow: () => actionError(FollowUserV1, { code: "blocked" }) },
});
const res = await callAction(baseUrl, FollowUserV1, { userId }, { client, islands: {}, fetch: gateway.fetch });
assert.equal(gateway.actionRequests()[0].input.userId, userId);
For code that calls the global fetch itself, such as a whole app rendered in a test,
installFakeGateway(handlers) puts the fake composer in its place; call restore() afterwards.
For React, @client-compose/react/testing adds a provider config built around it.
Through your own server
@client-compose/client/proxy passes a browser's screen, island and action requests to the composer
through your server, so it can add what the browser mustn't hold, such as a session kept in an
httpOnly cookie. createComposerProxy({ baseUrl, client, headers, onActionResponse }) checks each
request (anyone can call a server endpoint with anything), sets the client context and headers,
and returns the composer's failures as data. @client-compose/react's viaProxy is the browser
half.
Strings go through your server too: proxy.loadStrings().