@client-compose/client 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The Client Compose Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # @client-compose/client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Framework-agnostic client for [Client Compose](https://github.com/client-compose/client-compose):
4
+ a protocol for server-composed screens made of client-owned islands.
5
+
6
+ Tier: **core** · Protocol: **v0** · Any JS runtime with `fetch` (browsers, React Native, Node 20+)
7
+
8
+ ```sh
9
+ npm install @client-compose/client
10
+ ```
11
+
12
+ > Using React or React Native? Use [`@client-compose/react`](https://www.npmjs.com/package/@client-compose/react),
13
+ > which builds on this package.
14
+
15
+ ## Usage
16
+
17
+ `IslandRegistry<R>` maps island types and versions to renderers. `R` is whatever your UI renders
18
+ (a DOM node, a string of HTML, a framework element), so this core works with any UI layer.
19
+
20
+ ```ts
21
+ import { buildRequest, fetchScreen, IslandRegistry, plan } from "@client-compose/client";
22
+ import { ProfileHeaderV2 } from "./islands.generated"; // from @client-compose/codegen
23
+
24
+ const registry = new IslandRegistry<HTMLElement>()
25
+ .register(ProfileHeaderV2, (props) => renderProfileHeader(props));
26
+
27
+ const request = buildRequest(registry, "profile", { platform: "web", appVersion: "1.4.0" }, { userId });
28
+ const response = await fetchScreen("https://api.example.com/compose", request, {
29
+ headers: { Authorization: `Bearer ${token}` },
30
+ });
31
+
32
+ const islands = plan(registry, response, {
33
+ onSkipped: (entry, reason) => console.warn("skipped island", entry.key, reason),
34
+ });
35
+ container.replaceChildren(...islands.map((island) => island.render()));
36
+ ```
37
+
38
+ - **`buildRequest`** sends the island types and versions this client can render, so the composer
39
+ never returns one it can't.
40
+ - **`plan`** returns renderable islands in order. Errored islands are omitted unless you pass
41
+ `onError`; islands with an unknown type, version or status are skipped and reported.
42
+ - **`fetchScreen`** throws `ScreenFetchError` (with `status` and `body`) for non-2xx responses, and
43
+ accepts a custom `fetch` as its fourth argument.
44
+
45
+ Props aren't validated at runtime; generate types with
46
+ [`@client-compose/codegen`](https://www.npmjs.com/package/@client-compose/codegen) to keep them in
47
+ sync with the composer.
48
+
49
+ ## Links
50
+
51
+ - [Protocol spec](https://github.com/client-compose/client-compose/blob/main/spec/PROTOCOL.md)
52
+ - [Examples](https://github.com/client-compose/client-compose/tree/main/examples/profile)
@@ -0,0 +1,38 @@
1
+ import { type Capabilities, type ClientContext, type IslandDescriptor, type IslandEntry, type ScreenRequest, type ScreenResponse } from "@client-compose/protocol";
2
+ type OkEntry<P = any> = IslandEntry<P> & {
3
+ status: "ok";
4
+ };
5
+ type ErrorEntry = IslandEntry & {
6
+ status: "error";
7
+ };
8
+ export type IslandRenderer<R, P = any> = (props: P, entry: OkEntry<P>) => R;
9
+ export declare class IslandRegistry<R> {
10
+ private renderers;
11
+ /** Register a renderer for a generated island descriptor: `register(ProfileHeaderV2, (props) => …)`. */
12
+ register<P>(island: IslandDescriptor<P>, render: IslandRenderer<R, P>): this;
13
+ register<P>(type: string, version: number, render: IslandRenderer<R, P>): this;
14
+ capabilities(): Capabilities;
15
+ get(type: string, version: number): IslandRenderer<R> | undefined;
16
+ }
17
+ export declare function buildRequest(registry: IslandRegistry<unknown>, screen: string, client: ClientContext, params?: Record<string, string>): ScreenRequest;
18
+ export interface PlannedIsland<R> {
19
+ key: string;
20
+ render: () => R;
21
+ }
22
+ export type SkipReason = "unknown_island" | "unknown_status";
23
+ export interface PlanOptions<R> {
24
+ /** Render something for errored islands. Default: omit them (spec §4.3). */
25
+ onError?: (entry: ErrorEntry) => R | undefined;
26
+ /** Called for islands the client can't render (spec §4.4). Useful for telemetry. */
27
+ onSkipped?: (entry: IslandEntry, reason: SkipReason) => void;
28
+ }
29
+ /** Turn a response into an ordered list of renderable islands. */
30
+ export declare function plan<R>(registry: IslandRegistry<R>, response: ScreenResponse, options?: PlanOptions<R>): PlannedIsland<R>[];
31
+ export declare class ScreenFetchError extends Error {
32
+ readonly status: number;
33
+ readonly body: string;
34
+ constructor(status: number, body: string);
35
+ }
36
+ export declare function fetchScreen(baseUrl: string, request: ScreenRequest, init?: RequestInit, fetchImpl?: typeof fetch): Promise<ScreenResponse>;
37
+ export {};
38
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAEL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,KAAK,cAAc,EACpB,MAAM,0BAA0B,CAAC;AAElC,KAAK,OAAO,CAAC,CAAC,GAAG,GAAG,IAAI,WAAW,CAAC,CAAC,CAAC,GAAG;IAAE,MAAM,EAAE,IAAI,CAAA;CAAE,CAAC;AAC1D,KAAK,UAAU,GAAG,WAAW,GAAG;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;AAEpD,MAAM,MAAM,cAAc,CAAC,CAAC,EAAE,CAAC,GAAG,GAAG,IAAI,CAAC,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;AAE5E,qBAAa,cAAc,CAAC,CAAC;IAC3B,OAAO,CAAC,SAAS,CAAqD;IAEtE,wGAAwG;IACxG,QAAQ,CAAC,CAAC,EAAE,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI;IAC5E,QAAQ,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,GAAG,IAAI;IAe9E,YAAY,IAAI,YAAY;IAM5B,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,cAAc,CAAC,CAAC,CAAC,GAAG,SAAS;CAGlE;AAED,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,cAAc,CAAC,OAAO,CAAC,EACjC,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,aAAa,EACrB,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,aAAa,CAEf;AAED,MAAM,WAAW,aAAa,CAAC,CAAC;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC,CAAC;CACjB;AAED,MAAM,MAAM,UAAU,GAAG,gBAAgB,GAAG,gBAAgB,CAAC;AAE7D,MAAM,WAAW,WAAW,CAAC,CAAC;IAC5B,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,CAAC,GAAG,SAAS,CAAC;IAC/C,oFAAoF;IACpF,SAAS,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,KAAK,IAAI,CAAC;CAC9D;AAED,kEAAkE;AAClE,wBAAgB,IAAI,CAAC,CAAC,EACpB,QAAQ,EAAE,cAAc,CAAC,CAAC,CAAC,EAC3B,QAAQ,EAAE,cAAc,EACxB,OAAO,GAAE,WAAW,CAAC,CAAC,CAAM,GAC3B,aAAa,CAAC,CAAC,CAAC,EAAE,CAoBpB;AAED,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAEV,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAKzC;AAED,wBAAsB,WAAW,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,aAAa,EACtB,IAAI,GAAE,WAAgB,EACtB,SAAS,GAAE,OAAO,KAAa,GAC9B,OAAO,CAAC,cAAc,CAAC,CASzB"}
package/dist/index.js ADDED
@@ -0,0 +1,68 @@
1
+ // Framework-agnostic client core for JS/TS frontends (web, React Native, etc).
2
+ // `R` is whatever your UI layer renders: a React element, a DOM node, a string...
3
+ import { PROTOCOL_VERSION, } from "@client-compose/protocol";
4
+ export class IslandRegistry {
5
+ renderers = new Map();
6
+ register(islandOrType, versionOrRender, maybeRender) {
7
+ const [type, version, render] = typeof islandOrType === "string"
8
+ ? [islandOrType, versionOrRender, maybeRender]
9
+ : [islandOrType.type, islandOrType.version, versionOrRender];
10
+ if (!this.renderers.has(type))
11
+ this.renderers.set(type, new Map());
12
+ this.renderers.get(type).set(version, render);
13
+ return this;
14
+ }
15
+ capabilities() {
16
+ return Object.fromEntries([...this.renderers].map(([type, versions]) => [type, [...versions.keys()].sort((a, b) => a - b)]));
17
+ }
18
+ get(type, version) {
19
+ return this.renderers.get(type)?.get(version);
20
+ }
21
+ }
22
+ export function buildRequest(registry, screen, client, params) {
23
+ return { protocol: PROTOCOL_VERSION, screen, params, client, islands: registry.capabilities() };
24
+ }
25
+ /** Turn a response into an ordered list of renderable islands. */
26
+ export function plan(registry, response, options = {}) {
27
+ const out = [];
28
+ for (const entry of response.islands) {
29
+ if (entry.status === "error") {
30
+ const fallback = options.onError?.(entry);
31
+ if (fallback !== undefined)
32
+ out.push({ key: entry.key, render: () => fallback });
33
+ continue;
34
+ }
35
+ if (entry.status !== "ok") {
36
+ options.onSkipped?.(entry, "unknown_status");
37
+ continue;
38
+ }
39
+ const renderer = registry.get(entry.type, entry.version);
40
+ if (!renderer) {
41
+ options.onSkipped?.(entry, "unknown_island");
42
+ continue;
43
+ }
44
+ out.push({ key: entry.key, render: () => renderer(entry.props, entry) });
45
+ }
46
+ return out;
47
+ }
48
+ export class ScreenFetchError extends Error {
49
+ status;
50
+ body;
51
+ constructor(status, body) {
52
+ super(`Screen request failed with status ${status}`);
53
+ this.status = status;
54
+ this.body = body;
55
+ }
56
+ }
57
+ export async function fetchScreen(baseUrl, request, init = {}, fetchImpl = fetch) {
58
+ const res = await fetchImpl(`${baseUrl}/screens/${encodeURIComponent(request.screen)}`, {
59
+ ...init,
60
+ method: "POST",
61
+ headers: { "content-type": "application/json", ...init.headers },
62
+ body: JSON.stringify(request),
63
+ });
64
+ if (!res.ok)
65
+ throw new ScreenFetchError(res.status, await res.text());
66
+ return (await res.json());
67
+ }
68
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "@client-compose/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Framework-agnostic Client Compose client: island registry, screen requests and render planning.",
5
+ "keywords": [
6
+ "client-compose",
7
+ "server-driven-ui",
8
+ "backend-for-frontend",
9
+ "islands",
10
+ "client"
11
+ ],
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/client-compose/client-compose.git",
16
+ "directory": "clients/typescript/core"
17
+ },
18
+ "homepage": "https://github.com/client-compose/client-compose/tree/main/clients/typescript/core#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/client-compose/client-compose/issues"
21
+ },
22
+ "type": "module",
23
+ "sideEffects": false,
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./package.json": "./package.json"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "!dist/**/*.tsbuildinfo",
34
+ "src",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "dependencies": {
39
+ "@client-compose/protocol": "0.1.0"
40
+ },
41
+ "devDependencies": {
42
+ "@client-compose/conformance": "0.1.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "main": "./dist/index.js",
48
+ "types": "./dist/index.d.ts"
6
49
  }
package/src/index.ts ADDED
@@ -0,0 +1,125 @@
1
+ // Framework-agnostic client core for JS/TS frontends (web, React Native, etc).
2
+ // `R` is whatever your UI layer renders: a React element, a DOM node, a string...
3
+
4
+ import {
5
+ PROTOCOL_VERSION,
6
+ type Capabilities,
7
+ type ClientContext,
8
+ type IslandDescriptor,
9
+ type IslandEntry,
10
+ type ScreenRequest,
11
+ type ScreenResponse,
12
+ } from "@client-compose/protocol";
13
+
14
+ type OkEntry<P = any> = IslandEntry<P> & { status: "ok" };
15
+ type ErrorEntry = IslandEntry & { status: "error" };
16
+
17
+ export type IslandRenderer<R, P = any> = (props: P, entry: OkEntry<P>) => R;
18
+
19
+ export class IslandRegistry<R> {
20
+ private renderers = new Map<string, Map<number, IslandRenderer<R>>>();
21
+
22
+ /** Register a renderer for a generated island descriptor: `register(ProfileHeaderV2, (props) => …)`. */
23
+ register<P>(island: IslandDescriptor<P>, render: IslandRenderer<R, P>): this;
24
+ register<P>(type: string, version: number, render: IslandRenderer<R, P>): this;
25
+ register<P>(
26
+ islandOrType: IslandDescriptor<P> | string,
27
+ versionOrRender: number | IslandRenderer<R, P>,
28
+ maybeRender?: IslandRenderer<R, P>,
29
+ ): this {
30
+ const [type, version, render] =
31
+ typeof islandOrType === "string"
32
+ ? [islandOrType, versionOrRender as number, maybeRender!]
33
+ : [islandOrType.type, islandOrType.version, versionOrRender as IslandRenderer<R, P>];
34
+ if (!this.renderers.has(type)) this.renderers.set(type, new Map());
35
+ this.renderers.get(type)!.set(version, render);
36
+ return this;
37
+ }
38
+
39
+ capabilities(): Capabilities {
40
+ return Object.fromEntries(
41
+ [...this.renderers].map(([type, versions]) => [type, [...versions.keys()].sort((a, b) => a - b)]),
42
+ );
43
+ }
44
+
45
+ get(type: string, version: number): IslandRenderer<R> | undefined {
46
+ return this.renderers.get(type)?.get(version);
47
+ }
48
+ }
49
+
50
+ export function buildRequest(
51
+ registry: IslandRegistry<unknown>,
52
+ screen: string,
53
+ client: ClientContext,
54
+ params?: Record<string, string>,
55
+ ): ScreenRequest {
56
+ return { protocol: PROTOCOL_VERSION, screen, params, client, islands: registry.capabilities() };
57
+ }
58
+
59
+ export interface PlannedIsland<R> {
60
+ key: string;
61
+ render: () => R;
62
+ }
63
+
64
+ export type SkipReason = "unknown_island" | "unknown_status";
65
+
66
+ export interface PlanOptions<R> {
67
+ /** Render something for errored islands. Default: omit them (spec §4.3). */
68
+ onError?: (entry: ErrorEntry) => R | undefined;
69
+ /** Called for islands the client can't render (spec §4.4). Useful for telemetry. */
70
+ onSkipped?: (entry: IslandEntry, reason: SkipReason) => void;
71
+ }
72
+
73
+ /** Turn a response into an ordered list of renderable islands. */
74
+ export function plan<R>(
75
+ registry: IslandRegistry<R>,
76
+ response: ScreenResponse,
77
+ options: PlanOptions<R> = {},
78
+ ): PlannedIsland<R>[] {
79
+ const out: PlannedIsland<R>[] = [];
80
+ for (const entry of response.islands) {
81
+ if (entry.status === "error") {
82
+ const fallback = options.onError?.(entry);
83
+ if (fallback !== undefined) out.push({ key: entry.key, render: () => fallback });
84
+ continue;
85
+ }
86
+ if (entry.status !== "ok") {
87
+ options.onSkipped?.(entry, "unknown_status");
88
+ continue;
89
+ }
90
+ const renderer = registry.get(entry.type, entry.version);
91
+ if (!renderer) {
92
+ options.onSkipped?.(entry, "unknown_island");
93
+ continue;
94
+ }
95
+ out.push({ key: entry.key, render: () => renderer(entry.props, entry) });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ export class ScreenFetchError extends Error {
101
+ readonly status: number;
102
+ readonly body: string;
103
+
104
+ constructor(status: number, body: string) {
105
+ super(`Screen request failed with status ${status}`);
106
+ this.status = status;
107
+ this.body = body;
108
+ }
109
+ }
110
+
111
+ export async function fetchScreen(
112
+ baseUrl: string,
113
+ request: ScreenRequest,
114
+ init: RequestInit = {},
115
+ fetchImpl: typeof fetch = fetch,
116
+ ): Promise<ScreenResponse> {
117
+ const res = await fetchImpl(`${baseUrl}/screens/${encodeURIComponent(request.screen)}`, {
118
+ ...init,
119
+ method: "POST",
120
+ headers: { "content-type": "application/json", ...init.headers },
121
+ body: JSON.stringify(request),
122
+ });
123
+ if (!res.ok) throw new ScreenFetchError(res.status, await res.text());
124
+ return (await res.json()) as ScreenResponse;
125
+ }