@client-compose/client 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +51 -2
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +80 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -3
- package/src/index.ts +137 -0
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
|
-
#
|
|
1
|
+
# @client-compose/client
|
|
2
2
|
|
|
3
|
-
|
|
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)
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
/** The protocol error code from the response body (e.g. "not_found"), if there was one. */
|
|
35
|
+
readonly code: string | undefined;
|
|
36
|
+
constructor(status: number, body: string);
|
|
37
|
+
}
|
|
38
|
+
export declare function fetchScreen(baseUrl: string, request: ScreenRequest, init?: RequestInit, fetchImpl?: typeof fetch): Promise<ScreenResponse>;
|
|
39
|
+
export {};
|
|
40
|
+
//# 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;IACtB,2FAA2F;IAC3F,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,CAAC;gBAEtB,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAMzC;AAWD,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,80 @@
|
|
|
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
|
+
/** The protocol error code from the response body (e.g. "not_found"), if there was one. */
|
|
52
|
+
code;
|
|
53
|
+
constructor(status, body) {
|
|
54
|
+
super(`Screen request failed with status ${status}`);
|
|
55
|
+
this.status = status;
|
|
56
|
+
this.body = body;
|
|
57
|
+
this.code = errorCode(body);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function errorCode(body) {
|
|
61
|
+
try {
|
|
62
|
+
const code = JSON.parse(body).error?.code;
|
|
63
|
+
return typeof code === "string" ? code : undefined;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function fetchScreen(baseUrl, request, init = {}, fetchImpl = fetch) {
|
|
70
|
+
const res = await fetchImpl(`${baseUrl}/screens/${encodeURIComponent(request.screen)}`, {
|
|
71
|
+
...init,
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "content-type": "application/json", ...init.headers },
|
|
74
|
+
body: JSON.stringify(request),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok)
|
|
77
|
+
throw new ScreenFetchError(res.status, await res.text());
|
|
78
|
+
return (await res.json());
|
|
79
|
+
}
|
|
80
|
+
//# 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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.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.2.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@client-compose/conformance": "0.3.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,137 @@
|
|
|
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
|
+
/** The protocol error code from the response body (e.g. "not_found"), if there was one. */
|
|
104
|
+
readonly code: string | undefined;
|
|
105
|
+
|
|
106
|
+
constructor(status: number, body: string) {
|
|
107
|
+
super(`Screen request failed with status ${status}`);
|
|
108
|
+
this.status = status;
|
|
109
|
+
this.body = body;
|
|
110
|
+
this.code = errorCode(body);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function errorCode(body: string): string | undefined {
|
|
115
|
+
try {
|
|
116
|
+
const code = (JSON.parse(body) as { error?: { code?: unknown } }).error?.code;
|
|
117
|
+
return typeof code === "string" ? code : undefined;
|
|
118
|
+
} catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function fetchScreen(
|
|
124
|
+
baseUrl: string,
|
|
125
|
+
request: ScreenRequest,
|
|
126
|
+
init: RequestInit = {},
|
|
127
|
+
fetchImpl: typeof fetch = fetch,
|
|
128
|
+
): Promise<ScreenResponse> {
|
|
129
|
+
const res = await fetchImpl(`${baseUrl}/screens/${encodeURIComponent(request.screen)}`, {
|
|
130
|
+
...init,
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "content-type": "application/json", ...init.headers },
|
|
133
|
+
body: JSON.stringify(request),
|
|
134
|
+
});
|
|
135
|
+
if (!res.ok) throw new ScreenFetchError(res.status, await res.text());
|
|
136
|
+
return (await res.json()) as ScreenResponse;
|
|
137
|
+
}
|