@client-compose/client 0.5.1 → 0.7.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/src/index.ts CHANGED
@@ -10,6 +10,8 @@ import {
10
10
  type DeclaredError,
11
11
  type IslandDescriptor,
12
12
  type IslandEntry,
13
+ type IslandRequest,
14
+ type IslandResponse,
13
15
  PROTOCOL_VERSION,
14
16
  type ScreenRequest,
15
17
  type ScreenResponse,
@@ -31,13 +33,25 @@ export class IslandRegistry<R> {
31
33
  versionOrRender: number | IslandRenderer<R, P>,
32
34
  maybeRender?: IslandRenderer<R, P>,
33
35
  ): this {
34
- const [type, version, render] =
35
- typeof islandOrType === "string"
36
- ? [islandOrType, versionOrRender as number, maybeRender!]
37
- : [islandOrType.type, islandOrType.version, versionOrRender as IslandRenderer<R, P>];
38
- if (!this.renderers.has(type)) this.renderers.set(type, new Map());
39
- // The registry hands each renderer the props of its own island type and version.
40
- this.renderers.get(type)!.set(version, render as IslandRenderer<R>);
36
+ if (typeof islandOrType !== "string") {
37
+ if (typeof versionOrRender === "number") {
38
+ throw new Error("register(descriptor, render): pass the renderer, not a version");
39
+ }
40
+ return this.add(islandOrType.type, islandOrType.version, versionOrRender);
41
+ }
42
+ if (typeof versionOrRender !== "number" || !maybeRender) {
43
+ throw new Error("register(type, version, render): pass a version and a renderer");
44
+ }
45
+ return this.add(islandOrType, versionOrRender, maybeRender);
46
+ }
47
+
48
+ private add<P>(type: string, version: number, render: IslandRenderer<R, P>): this {
49
+ const versions = this.renderers.get(type) ?? new Map<number, IslandRenderer<R>>();
50
+ // The registry hands each renderer the props of its own island type and version, so the
51
+ // renderer's props type holds. TypeScript can't see that through one map of every island.
52
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
53
+ versions.set(version, render as IslandRenderer<R>);
54
+ this.renderers.set(type, versions);
41
55
  return this;
42
56
  }
43
57
 
@@ -61,7 +75,13 @@ export function buildRequest(
61
75
  client: ClientContext,
62
76
  params?: Record<string, string>,
63
77
  ): ScreenRequest {
64
- return { protocol: PROTOCOL_VERSION, screen, params, client, islands: registry.capabilities() };
78
+ return {
79
+ protocol: PROTOCOL_VERSION,
80
+ screen,
81
+ params,
82
+ client,
83
+ islands: registry.capabilities(),
84
+ };
65
85
  }
66
86
 
67
87
  export interface PlannedIsland<R> {
@@ -88,7 +108,9 @@ export function plan<R>(
88
108
  for (const entry of response.islands) {
89
109
  if (entry.status === "error") {
90
110
  const fallback = options.onError?.(entry);
91
- if (fallback !== undefined) out.push({ key: entry.key, render: () => fallback });
111
+ if (fallback !== undefined) {
112
+ out.push({ key: entry.key, render: () => fallback });
113
+ }
92
114
  continue;
93
115
  }
94
116
  if (entry.status !== "ok") {
@@ -105,6 +127,41 @@ export function plan<R>(
105
127
  return out;
106
128
  }
107
129
 
130
+ // ---- Reading what the composer answered ----
131
+
132
+ function isRecord(value: unknown): value is Record<string, unknown> {
133
+ return typeof value === "object" && value !== null && !Array.isArray(value);
134
+ }
135
+
136
+ /** The body as JSON, or undefined if it isn't JSON. */
137
+ function parseJson(text: string): unknown {
138
+ try {
139
+ return JSON.parse(text);
140
+ } catch {
141
+ return undefined;
142
+ }
143
+ }
144
+
145
+ function isIslandEntry(value: unknown): value is IslandEntry {
146
+ if (!isRecord(value)) {
147
+ return false;
148
+ }
149
+ const named = typeof value.key === "string" && typeof value.type === "string";
150
+ return named && typeof value.version === "number" && typeof value.status === "string";
151
+ }
152
+
153
+ function isScreenResponse(value: unknown): value is ScreenResponse {
154
+ if (!isRecord(value) || typeof value.screen !== "string") {
155
+ return false;
156
+ }
157
+ return Array.isArray(value.islands) && value.islands.every(isIslandEntry);
158
+ }
159
+
160
+ /** An action's outcome: what a client needs of one is its status (the client fixtures, spec §8.2). */
161
+ function isActionResponse(value: unknown): value is ActionResponse {
162
+ return isRecord(value) && (value.status === "ok" || value.status === "error");
163
+ }
164
+
108
165
  export class ScreenFetchError extends Error {
109
166
  readonly status: number;
110
167
  readonly body: string;
@@ -121,13 +178,27 @@ export class ScreenFetchError extends Error {
121
178
 
122
179
  function errorCode(body: string): string | undefined {
123
180
  try {
124
- const code = (JSON.parse(body) as { error?: { code?: unknown } }).error?.code;
181
+ const parsed: unknown = JSON.parse(body);
182
+ const error = isRecord(parsed) ? parsed.error : undefined;
183
+ const code = isRecord(error) ? error.code : undefined;
125
184
  return typeof code === "string" ? code : undefined;
126
185
  } catch {
127
186
  return undefined;
128
187
  }
129
188
  }
130
189
 
190
+ /**
191
+ * The caller's headers, given any way fetch takes them, sent as JSON unless they say otherwise.
192
+ * (Spreading a `Headers` object would drop them all.)
193
+ */
194
+ function jsonHeaders(init: HeadersInit | undefined): Headers {
195
+ const headers = new Headers(init);
196
+ if (!headers.has("content-type")) {
197
+ headers.set("content-type", "application/json");
198
+ }
199
+ return headers;
200
+ }
201
+
131
202
  export async function fetchScreen(
132
203
  baseUrl: string,
133
204
  request: ScreenRequest,
@@ -137,11 +208,99 @@ export async function fetchScreen(
137
208
  const res = await fetchImpl(`${baseUrl}/screens/${encodeURIComponent(request.screen)}`, {
138
209
  ...init,
139
210
  method: "POST",
140
- headers: { "content-type": "application/json", ...init.headers },
211
+ headers: jsonHeaders(init.headers),
212
+ body: JSON.stringify(request),
213
+ });
214
+ const text = await res.text();
215
+ if (!res.ok) {
216
+ throw new ScreenFetchError(res.status, text);
217
+ }
218
+ const response = parseJson(text);
219
+ if (!isScreenResponse(response)) {
220
+ throw new ScreenFetchError(res.status, text);
221
+ }
222
+ return response;
223
+ }
224
+
225
+ // ---- Island requests (spec §4.5) ----
226
+
227
+ /**
228
+ * A request for one island of a screen: the page `cursor` asks for (an entry's `next`), or the
229
+ * island from its first page without one. `params` are the screen's own.
230
+ */
231
+ export function buildIslandRequest(
232
+ screen: string,
233
+ params: Record<string, string> | undefined,
234
+ entry: { key: string; version: number },
235
+ client: ClientContext,
236
+ cursor?: string,
237
+ ): IslandRequest {
238
+ return {
239
+ protocol: PROTOCOL_VERSION,
240
+ screen,
241
+ key: entry.key,
242
+ version: entry.version,
243
+ params,
244
+ client,
245
+ ...(cursor !== undefined && { cursor }),
246
+ };
247
+ }
248
+
249
+ /**
250
+ * POST an island request. Resolves with the island's entry: its props (and `next`, if it has more
251
+ * pages) or the error it failed with. Throws IslandFetchError for a non-2xx response (e.g.
252
+ * `invalid_cursor`, `unknown_island`, `unauthenticated`), or an answer that isn't this island.
253
+ */
254
+ export async function fetchIsland(
255
+ baseUrl: string,
256
+ request: IslandRequest,
257
+ init: RequestInit = {},
258
+ fetchImpl: typeof fetch = fetch,
259
+ ): Promise<IslandEntry> {
260
+ const path = `${encodeURIComponent(request.screen)}/islands/${encodeURIComponent(request.key)}`;
261
+ const res = await fetchImpl(`${baseUrl}/screens/${path}`, {
262
+ ...init,
263
+ method: "POST",
264
+ headers: jsonHeaders(init.headers),
141
265
  body: JSON.stringify(request),
142
266
  });
143
- if (!res.ok) throw new ScreenFetchError(res.status, await res.text());
144
- return (await res.json()) as ScreenResponse;
267
+ const text = await res.text();
268
+ if (!res.ok) {
269
+ throw new IslandFetchError(res.status, text);
270
+ }
271
+ const response = parseJson(text);
272
+ if (!isIslandResponse(response) || !isIslandAsked(response.island, request)) {
273
+ throw new IslandFetchError(res.status, text);
274
+ }
275
+ return response.island;
276
+ }
277
+
278
+ /**
279
+ * An island request failed: a non-2xx response such as `invalid_cursor`, `unknown_island` or
280
+ * `unauthenticated`, or an answer that isn't the island asked for. An island that failed on the
281
+ * composer isn't thrown: it's an entry with `status: "error"`.
282
+ */
283
+ export class IslandFetchError extends Error {
284
+ readonly status: number;
285
+ readonly body: string;
286
+ /** The protocol error code from the response body (e.g. "invalid_cursor"), if there was one. */
287
+ readonly code: string | undefined;
288
+
289
+ constructor(status: number, body: string) {
290
+ super(`Island request failed with status ${status}`);
291
+ this.status = status;
292
+ this.body = body;
293
+ this.code = errorCode(body);
294
+ }
295
+ }
296
+
297
+ function isIslandResponse(value: unknown): value is IslandResponse {
298
+ return isRecord(value) && typeof value.screen === "string" && isIslandEntry(value.island);
299
+ }
300
+
301
+ /** Whether the answer is the island the request asked for, at the version it asked for. */
302
+ function isIslandAsked(island: IslandEntry, request: IslandRequest): boolean {
303
+ return island.key === request.key && island.version === request.version;
145
304
  }
146
305
 
147
306
  // ---- Actions (spec §5) ----
@@ -166,8 +325,11 @@ export class ActionFetchError extends Error {
166
325
 
167
326
  /** A fresh idempotency key: one per user intent, reused when retrying it (spec §5.2). */
168
327
  export function newIdempotencyKey(): string {
169
- const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
170
- if (c?.randomUUID) return c.randomUUID();
328
+ // React Native's Hermes may have no crypto, or crypto without randomUUID.
329
+ const c: Partial<Pick<Crypto, "randomUUID">> | undefined = globalThis.crypto;
330
+ if (c?.randomUUID) {
331
+ return c.randomUUID();
332
+ }
171
333
  // React Native's Hermes has no crypto.randomUUID. Uniqueness, not secrecy, is what matters here.
172
334
  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
173
335
  const r = (Math.random() * 16) | 0;
@@ -191,7 +353,11 @@ export function buildActionRequest<I>(
191
353
  };
192
354
  }
193
355
 
194
- /** POST an action request. Resolves with the outcome (ok or a declared error); throws ActionFetchError otherwise. */
356
+ /**
357
+ * POST an action request. Resolves with the outcome the composer sent (ok or an error); throws
358
+ * ActionFetchError for a non-2xx response or a body that isn't an outcome. Check the outcome against
359
+ * the action with `checkActionResponse` (callAction does).
360
+ */
195
361
  export async function fetchAction(
196
362
  baseUrl: string,
197
363
  request: ActionRequest,
@@ -201,11 +367,40 @@ export async function fetchAction(
201
367
  const res = await fetchImpl(`${baseUrl}/actions/${encodeURIComponent(request.action)}`, {
202
368
  ...init,
203
369
  method: "POST",
204
- headers: { "content-type": "application/json", ...init.headers },
370
+ headers: jsonHeaders(init.headers),
205
371
  body: JSON.stringify(request),
206
372
  });
207
- if (!res.ok) throw new ActionFetchError(res.status, await res.text());
208
- return (await res.json()) as ActionResponse;
373
+ const text = await res.text();
374
+ if (!res.ok) {
375
+ throw new ActionFetchError(res.status, text);
376
+ }
377
+ const response = parseJson(text);
378
+ if (!isActionResponse(response)) {
379
+ throw new ActionFetchError(res.status, text);
380
+ }
381
+ return response;
382
+ }
383
+
384
+ /**
385
+ * The response, if it's a result or one of this action's own declared errors. Anything else throws
386
+ * ActionFetchError (status 200, with the code if there is one): an outcome the client can't read,
387
+ * or a code the action doesn't declare, such as one added after this app was built (spec §5.1).
388
+ */
389
+ export function checkActionResponse<I, R, E extends DeclaredError>(
390
+ action: ActionDescriptor<I, R, E>,
391
+ response: unknown,
392
+ ): ActionResponse<R, E> {
393
+ const error = isRecord(response) ? response.error : undefined;
394
+ const code = isRecord(error) ? error.code : undefined;
395
+ const status = isRecord(response) ? response.status : undefined;
396
+ const declared = status === "error" && typeof code === "string" && action.errors.includes(code);
397
+ if (isActionResponse(response) && (status === "ok" || declared)) {
398
+ // The result's and error's types are the action's: generated from its manifest, which the
399
+ // composer checks its outcomes against. They exist only at the type level.
400
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
401
+ return response as ActionResponse<R, E>;
402
+ }
403
+ throw new ActionFetchError(200, JSON.stringify(response));
209
404
  }
210
405
 
211
406
  export interface CallActionOptions {
@@ -236,5 +431,8 @@ export async function callAction<I, R, E extends DeclaredError>(
236
431
  options.client,
237
432
  options.idempotencyKey ?? newIdempotencyKey(),
238
433
  );
239
- return (await fetchAction(baseUrl, request, options.init, options.fetch)) as ActionResponse<R, E>;
434
+ return checkActionResponse(
435
+ action,
436
+ await fetchAction(baseUrl, request, options.init, options.fetch),
437
+ );
240
438
  }
package/src/proxy.ts CHANGED
@@ -18,15 +18,26 @@ import {
18
18
  type ActionResponse,
19
19
  type Capabilities,
20
20
  type ClientContext,
21
+ type IslandEntry,
22
+ type IslandRequest,
21
23
  PROTOCOL_VERSION,
22
24
  type ScreenRequest,
23
25
  type ScreenResponse,
24
26
  } from "@client-compose/protocol";
25
27
 
26
- import { ActionFetchError, fetchAction, fetchScreen, ScreenFetchError } from "./index.ts";
28
+ import {
29
+ ActionFetchError,
30
+ fetchAction,
31
+ fetchIsland,
32
+ fetchScreen,
33
+ IslandFetchError,
34
+ ScreenFetchError,
35
+ } from "./index.ts";
27
36
 
28
37
  /** A screen request from the browser: the server sets the client context. */
29
38
  export type ProxiedScreenRequest = Omit<ScreenRequest, "client">;
39
+ /** An island request from the browser: the server sets the client context. */
40
+ export type ProxiedIslandRequest = Omit<IslandRequest, "client">;
30
41
  /** An action request from the browser: the server sets the client context. */
31
42
  export type ProxiedActionRequest = Omit<ActionRequest, "client">;
32
43
 
@@ -60,6 +71,11 @@ export interface ComposerProxy {
60
71
  performAction(body: unknown): Promise<ProxyResult<ActionResponse>>;
61
72
  /** Checks a browser's screen request, then loads it with the server's context and headers. */
62
73
  loadScreen(body: unknown): Promise<ProxyResult<ScreenResponse>>;
74
+ /**
75
+ * Checks a browser's island request (a page of an island, spec §4.5), then loads it with the
76
+ * server's context and headers. Resolves with the island's entry.
77
+ */
78
+ loadIsland(body: unknown): Promise<ProxyResult<IslandEntry>>;
63
79
  }
64
80
 
65
81
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -78,25 +94,48 @@ function isStringRecord(value: unknown): value is Record<string, string> {
78
94
  return isRecord(value) && Object.values(value).every((v) => typeof v === "string");
79
95
  }
80
96
 
97
+ // Each guard returns at the first thing a request gets wrong, so it checks no more than it must.
98
+
81
99
  export function isProxiedScreenRequest(value: unknown): value is ProxiedScreenRequest {
82
- return (
83
- isRecord(value) &&
84
- value.protocol === PROTOCOL_VERSION &&
85
- typeof value.screen === "string" &&
86
- (value.params === undefined || isStringRecord(value.params)) &&
87
- isCapabilities(value.islands)
88
- );
100
+ if (!isRecord(value) || value.protocol !== PROTOCOL_VERSION) {
101
+ return false;
102
+ }
103
+ if (typeof value.screen !== "string") {
104
+ return false;
105
+ }
106
+ if (value.params !== undefined && !isStringRecord(value.params)) {
107
+ return false;
108
+ }
109
+ return isCapabilities(value.islands);
110
+ }
111
+
112
+ export function isProxiedIslandRequest(value: unknown): value is ProxiedIslandRequest {
113
+ if (!isRecord(value) || value.protocol !== PROTOCOL_VERSION) {
114
+ return false;
115
+ }
116
+ if (typeof value.screen !== "string" || typeof value.key !== "string") {
117
+ return false;
118
+ }
119
+ if (typeof value.version !== "number" || !Number.isInteger(value.version)) {
120
+ return false;
121
+ }
122
+ if (value.params !== undefined && !isStringRecord(value.params)) {
123
+ return false;
124
+ }
125
+ return value.cursor === undefined || typeof value.cursor === "string";
89
126
  }
90
127
 
91
128
  export function isProxiedActionRequest(value: unknown): value is ProxiedActionRequest {
92
- return (
93
- isRecord(value) &&
94
- value.protocol === PROTOCOL_VERSION &&
95
- typeof value.action === "string" &&
96
- typeof value.version === "number" &&
97
- "input" in value &&
98
- (value.idempotencyKey === undefined || typeof value.idempotencyKey === "string")
99
- );
129
+ if (!isRecord(value) || value.protocol !== PROTOCOL_VERSION) {
130
+ return false;
131
+ }
132
+ if (typeof value.action !== "string" || typeof value.version !== "number") {
133
+ return false;
134
+ }
135
+ if (!("input" in value)) {
136
+ return false;
137
+ }
138
+ return value.idempotencyKey === undefined || typeof value.idempotencyKey === "string";
100
139
  }
101
140
 
102
141
  /** What the composer itself answers to a malformed request. */
@@ -108,12 +147,17 @@ const BAD_REQUEST = {
108
147
 
109
148
  export function createComposerProxy(options: ComposerProxyOptions): ComposerProxy {
110
149
  async function init(): Promise<RequestInit> {
111
- const headers = (await options.headers?.()) ?? {};
112
- return { ...options.requestInit, headers: { ...options.requestInit?.headers, ...headers } };
150
+ const headers = new Headers(options.requestInit?.headers);
151
+ for (const [name, value] of Object.entries((await options.headers?.()) ?? {})) {
152
+ headers.set(name, value);
153
+ }
154
+ return { ...options.requestInit, headers };
113
155
  }
114
156
 
115
157
  async function performAction(body: unknown): Promise<ProxyResult<ActionResponse>> {
116
- if (!isProxiedActionRequest(body)) return BAD_REQUEST;
158
+ if (!isProxiedActionRequest(body)) {
159
+ return BAD_REQUEST;
160
+ }
117
161
  const request: ActionRequest = { ...body, client: await options.client() };
118
162
  try {
119
163
  const response = await fetchAction(options.baseUrl, request, await init(), options.fetch);
@@ -122,14 +166,21 @@ export function createComposerProxy(options: ComposerProxyOptions): ComposerProx
122
166
  response: (await options.onActionResponse?.(request, response)) ?? response,
123
167
  };
124
168
  } catch (error) {
125
- if (error instanceof ActionFetchError)
126
- return { ok: false, status: error.status, body: error.body };
169
+ if (error instanceof ActionFetchError) {
170
+ return {
171
+ ok: false,
172
+ status: error.status,
173
+ body: error.body,
174
+ };
175
+ }
127
176
  throw error;
128
177
  }
129
178
  }
130
179
 
131
180
  async function loadScreen(body: unknown): Promise<ProxyResult<ScreenResponse>> {
132
- if (!isProxiedScreenRequest(body)) return BAD_REQUEST;
181
+ if (!isProxiedScreenRequest(body)) {
182
+ return BAD_REQUEST;
183
+ }
133
184
  const request: ScreenRequest = { ...body, client: await options.client() };
134
185
  try {
135
186
  return {
@@ -137,11 +188,42 @@ export function createComposerProxy(options: ComposerProxyOptions): ComposerProx
137
188
  response: await fetchScreen(options.baseUrl, request, await init(), options.fetch),
138
189
  };
139
190
  } catch (error) {
140
- if (error instanceof ScreenFetchError)
141
- return { ok: false, status: error.status, body: error.body };
191
+ if (error instanceof ScreenFetchError) {
192
+ return {
193
+ ok: false,
194
+ status: error.status,
195
+ body: error.body,
196
+ };
197
+ }
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ async function loadIsland(body: unknown): Promise<ProxyResult<IslandEntry>> {
203
+ if (!isProxiedIslandRequest(body)) {
204
+ return BAD_REQUEST;
205
+ }
206
+ const request: IslandRequest = { ...body, client: await options.client() };
207
+ try {
208
+ return {
209
+ ok: true,
210
+ response: await fetchIsland(options.baseUrl, request, await init(), options.fetch),
211
+ };
212
+ } catch (error) {
213
+ if (error instanceof IslandFetchError) {
214
+ return {
215
+ ok: false,
216
+ status: error.status,
217
+ body: error.body,
218
+ };
219
+ }
142
220
  throw error;
143
221
  }
144
222
  }
145
223
 
146
- return { performAction, loadScreen };
224
+ return {
225
+ performAction,
226
+ loadScreen,
227
+ loadIsland,
228
+ };
147
229
  }