@client-compose/client 0.2.0 → 0.4.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/README.md CHANGED
@@ -42,10 +42,49 @@ container.replaceChildren(...islands.map((island) => island.render()));
42
42
  - **`fetchScreen`** throws `ScreenFetchError` (with `status` and `body`) for non-2xx responses, and
43
43
  accepts a custom `fetch` as its fourth argument.
44
44
 
45
+ ## Actions
46
+
47
+ ```ts
48
+ import { ActionFetchError, callAction } from "@client-compose/client";
49
+ import { FollowUserV1 } from "./actions.generated"; // from @client-compose/codegen
50
+
51
+ const res = await callAction("https://api.example.com/compose", FollowUserV1, { userId }, {
52
+ client: { platform: "web", appVersion: "1.4.0" },
53
+ init: { headers: { Authorization: `Bearer ${token}` } },
54
+ });
55
+ if (res.status === "ok") showFollowers(res.result.followers); // and reload res.effects?.invalidate
56
+ else if (res.error.code === "blocked") showBlocked();
57
+ else showGenericError(); // codes added after this app was built
58
+ ```
59
+
60
+ - Declared errors are outcomes (`status: "error"`), typed per action. Anything else, such as
61
+ `invalid_input`, `unauthenticated` or `action_failed`, throws `ActionFetchError` with `status`
62
+ and `code`.
63
+ - Each call sends a new idempotency key. Pass `idempotencyKey` yourself to retry the same intent.
64
+
45
65
  Props aren't validated at runtime; generate types with
46
66
  [`@client-compose/codegen`](https://www.npmjs.com/package/@client-compose/codegen) to keep them in
47
67
  sync with the composer.
48
68
 
69
+ ## Testing
70
+
71
+ `@client-compose/client/testing` is a fake composer for tests, with no test framework of its own.
72
+ `createFakeGateway` answers screen and action requests from handlers and records them; pass its
73
+ `fetch` to the client. Build responses with `island`, `islandError`, `screen`, `screenError`,
74
+ `actionOk`, `actionError` and `actionFailure`, typed by the generated descriptors.
75
+
76
+ ```ts
77
+ import { actionError, createFakeGateway } from "@client-compose/client/testing";
78
+
79
+ const gateway = createFakeGateway({
80
+ actions: { follow: () => actionError(FollowUserV1, { code: "blocked" }) },
81
+ });
82
+ const res = await callAction(baseUrl, FollowUserV1, { userId }, { client, fetch: gateway.fetch });
83
+ assert.equal(gateway.actionRequests()[0].input.userId, userId);
84
+ ```
85
+
86
+ For React, `@client-compose/react/testing` adds a provider config built around it.
87
+
49
88
  ## Links
50
89
 
51
90
  - [Protocol spec](https://github.com/client-compose/client-compose/blob/main/spec/PROTOCOL.md)
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Capabilities, type ClientContext, type IslandDescriptor, type IslandEntry, type ScreenRequest, type ScreenResponse } from "@client-compose/protocol";
1
+ import { type ActionDescriptor, type ActionRequest, type ActionResponse, type Capabilities, type ClientContext, type DeclaredError, type IslandDescriptor, type IslandEntry, type ScreenRequest, type ScreenResponse } from "@client-compose/protocol";
2
2
  type OkEntry<P = any> = IslandEntry<P> & {
3
3
  status: "ok";
4
4
  };
@@ -36,5 +36,37 @@ export declare class ScreenFetchError extends Error {
36
36
  constructor(status: number, body: string);
37
37
  }
38
38
  export declare function fetchScreen(baseUrl: string, request: ScreenRequest, init?: RequestInit, fetchImpl?: typeof fetch): Promise<ScreenResponse>;
39
+ /**
40
+ * The composer didn't perform the action: a non-2xx response such as `invalid_input`,
41
+ * `unauthenticated` or `action_failed`. Declared errors are not thrown; they're outcomes.
42
+ */
43
+ export declare class ActionFetchError extends Error {
44
+ readonly status: number;
45
+ readonly body: string;
46
+ /** The protocol error code from the response body (e.g. "unauthenticated"), if there was one. */
47
+ readonly code: string | undefined;
48
+ constructor(status: number, body: string);
49
+ }
50
+ /** A fresh idempotency key: one per user intent, reused when retrying it (spec §5.2). */
51
+ export declare function newIdempotencyKey(): string;
52
+ export declare function buildActionRequest<I>(action: ActionDescriptor<I, unknown, DeclaredError>, input: I, client: ClientContext, idempotencyKey?: string): ActionRequest<I>;
53
+ /** POST an action request. Resolves with the outcome (ok or a declared error); throws ActionFetchError otherwise. */
54
+ export declare function fetchAction(baseUrl: string, request: ActionRequest, init?: RequestInit, fetchImpl?: typeof fetch): Promise<ActionResponse>;
55
+ export interface CallActionOptions {
56
+ client: ClientContext;
57
+ /** Default: a new key. Pass the same key again to retry the same user intent. */
58
+ idempotencyKey?: string;
59
+ init?: RequestInit;
60
+ fetch?: typeof fetch;
61
+ }
62
+ /**
63
+ * Perform an action with its generated descriptor:
64
+ *
65
+ * const res = await callAction(baseUrl, FollowUserV1, { userId }, { client });
66
+ * if (res.status === "ok") show(res.result.followers);
67
+ * else if (res.error.code === "blocked") showBlocked();
68
+ * else showGenericError(); // codes added after this app was built
69
+ */
70
+ export declare function callAction<I, R, E extends DeclaredError>(baseUrl: string, action: ActionDescriptor<I, R, E>, input: I, options: CallActionOptions): Promise<ActionResponse<R, E>>;
39
71
  export {};
40
72
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -77,4 +77,66 @@ export async function fetchScreen(baseUrl, request, init = {}, fetchImpl = fetch
77
77
  throw new ScreenFetchError(res.status, await res.text());
78
78
  return (await res.json());
79
79
  }
80
+ // ---- Actions (spec §5) ----
81
+ /**
82
+ * The composer didn't perform the action: a non-2xx response such as `invalid_input`,
83
+ * `unauthenticated` or `action_failed`. Declared errors are not thrown; they're outcomes.
84
+ */
85
+ export class ActionFetchError extends Error {
86
+ status;
87
+ body;
88
+ /** The protocol error code from the response body (e.g. "unauthenticated"), if there was one. */
89
+ code;
90
+ constructor(status, body) {
91
+ super(`Action request failed with status ${status}`);
92
+ this.status = status;
93
+ this.body = body;
94
+ this.code = errorCode(body);
95
+ }
96
+ }
97
+ /** A fresh idempotency key: one per user intent, reused when retrying it (spec §5.2). */
98
+ export function newIdempotencyKey() {
99
+ const c = globalThis.crypto;
100
+ if (c?.randomUUID)
101
+ return c.randomUUID();
102
+ // React Native's Hermes has no crypto.randomUUID. Uniqueness, not secrecy, is what matters here.
103
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
104
+ const r = (Math.random() * 16) | 0;
105
+ return (ch === "x" ? r : (r & 0x3) | 0x8).toString(16);
106
+ });
107
+ }
108
+ export function buildActionRequest(action, input, client, idempotencyKey) {
109
+ return {
110
+ protocol: PROTOCOL_VERSION,
111
+ action: action.action,
112
+ version: action.version,
113
+ input,
114
+ client,
115
+ ...(idempotencyKey !== undefined && { idempotencyKey }),
116
+ };
117
+ }
118
+ /** POST an action request. Resolves with the outcome (ok or a declared error); throws ActionFetchError otherwise. */
119
+ export async function fetchAction(baseUrl, request, init = {}, fetchImpl = fetch) {
120
+ const res = await fetchImpl(`${baseUrl}/actions/${encodeURIComponent(request.action)}`, {
121
+ ...init,
122
+ method: "POST",
123
+ headers: { "content-type": "application/json", ...init.headers },
124
+ body: JSON.stringify(request),
125
+ });
126
+ if (!res.ok)
127
+ throw new ActionFetchError(res.status, await res.text());
128
+ return (await res.json());
129
+ }
130
+ /**
131
+ * Perform an action with its generated descriptor:
132
+ *
133
+ * const res = await callAction(baseUrl, FollowUserV1, { userId }, { client });
134
+ * if (res.status === "ok") show(res.result.followers);
135
+ * else if (res.error.code === "blocked") showBlocked();
136
+ * else showGenericError(); // codes added after this app was built
137
+ */
138
+ export async function callAction(baseUrl, action, input, options) {
139
+ const request = buildActionRequest(action, input, options.client, options.idempotencyKey ?? newIdempotencyKey());
140
+ return (await fetchAction(baseUrl, request, options.init, options.fetch));
141
+ }
80
142
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,61 @@
1
+ import { type ActionDescriptor, type ActionEffects, type Capabilities, type ClientContext, type DeclaredError, type IslandDescriptor, type IslandEntry, type IslandError, type ScreenMeta } from "@client-compose/protocol";
2
+ /** A response for the fake composer to send. */
3
+ export interface FakeResponse {
4
+ status: number;
5
+ body: unknown;
6
+ }
7
+ /** A request the fake composer received. */
8
+ export interface RecordedRequest {
9
+ url: string;
10
+ method: string;
11
+ /** Header names are lower case. */
12
+ headers: Record<string, string>;
13
+ /** The JSON body, parsed; or the raw body when it isn't JSON (e.g. a multipart upload). */
14
+ body: unknown;
15
+ }
16
+ export interface RecordedScreenRequest extends RecordedRequest {
17
+ screen: string;
18
+ params: Record<string, string>;
19
+ client: ClientContext | undefined;
20
+ islands: Capabilities;
21
+ }
22
+ export interface RecordedActionRequest extends RecordedRequest {
23
+ action: string;
24
+ version: number;
25
+ input: unknown;
26
+ client: ClientContext | undefined;
27
+ idempotencyKey: string | undefined;
28
+ }
29
+ export interface FakeGatewayHandlers {
30
+ /** By screen id. */
31
+ screens?: Record<string, (request: RecordedScreenRequest) => FakeResponse>;
32
+ /** By action name. */
33
+ actions?: Record<string, (request: RecordedActionRequest) => FakeResponse>;
34
+ /** Anything else, e.g. uploads to storage: return undefined to leave a request to the others. */
35
+ other?: (request: RecordedRequest) => FakeResponse | undefined;
36
+ }
37
+ export interface FakeGateway {
38
+ /** Use as the client's `fetch`. Requests without a handler reject, failing the test. */
39
+ fetch: typeof fetch;
40
+ /** Every request received, in order. */
41
+ requests: RecordedRequest[];
42
+ screenRequests(): RecordedScreenRequest[];
43
+ actionRequests(): RecordedActionRequest[];
44
+ }
45
+ /** A composer whose screens and actions are the handlers given. */
46
+ export declare function createFakeGateway(handlers?: FakeGatewayHandlers): FakeGateway;
47
+ /** An island in a screen response, typed by its generated descriptor. */
48
+ export declare function island<P>(key: string, descriptor: IslandDescriptor<P>, props: P): IslandEntry<P>;
49
+ /** An island that failed on the composer (spec §3.4). */
50
+ export declare function islandError(key: string, descriptor: IslandDescriptor<unknown>, error: IslandError): IslandEntry;
51
+ /** A screen response. */
52
+ export declare function screen(id: string, islands: IslandEntry<unknown>[], meta?: ScreenMeta): FakeResponse;
53
+ /** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
54
+ export declare function screenError(status: number, code: string): FakeResponse;
55
+ /** An action that succeeded, with its result typed by the descriptor. */
56
+ export declare function actionOk<I, R, E extends DeclaredError>(action: ActionDescriptor<I, R, E>, result: R, effects?: ActionEffects): FakeResponse;
57
+ /** An action that ended with one of its declared errors. */
58
+ export declare function actionError<I, R, E extends DeclaredError>(action: ActionDescriptor<I, R, E>, error: E): FakeResponse;
59
+ /** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
60
+ export declare function actionFailure(status: number, code: string): FakeResponse;
61
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,143 @@
1
+ // Test helpers for apps built on Client Compose: a fake composer that answers screen and action
2
+ // requests from handlers and records them, and builders for the responses it returns. No test
3
+ // framework of its own, so it works with node:test, Jest, Vitest, Playwright and others.
4
+ //
5
+ // const gateway = createFakeGateway({
6
+ // screens: { profile: () => screen("profile", [island("header", ProfileHeaderV1, { name: "Ada" })]) },
7
+ // actions: { follow: () => actionOk(FollowUserV1, { followers: 8 }) },
8
+ // });
9
+ // // pass gateway.fetch as the client's fetch, then assert on gateway.actionRequests()
10
+ import { PROTOCOL_VERSION, } from "@client-compose/protocol";
11
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12
+ function strings(value) {
13
+ if (!isRecord(value))
14
+ return {};
15
+ return Object.fromEntries(Object.entries(value).filter((e) => typeof e[1] === "string"));
16
+ }
17
+ function capabilities(value) {
18
+ if (!isRecord(value))
19
+ return {};
20
+ return Object.fromEntries(Object.entries(value).map(([type, versions]) => [
21
+ type,
22
+ Array.isArray(versions) ? versions.filter((v) => typeof v === "number") : [],
23
+ ]));
24
+ }
25
+ function clientContext(value) {
26
+ if (!isRecord(value) || typeof value.platform !== "string" || typeof value.appVersion !== "string")
27
+ return undefined;
28
+ return { ...value, platform: value.platform, appVersion: value.appVersion };
29
+ }
30
+ function parseBody(body) {
31
+ if (typeof body !== "string")
32
+ return body;
33
+ try {
34
+ return JSON.parse(body);
35
+ }
36
+ catch {
37
+ return body;
38
+ }
39
+ }
40
+ function urlOf(input) {
41
+ if (typeof input === "string")
42
+ return input;
43
+ return input instanceof URL ? input.href : input.url;
44
+ }
45
+ function headersOf(init) {
46
+ const out = {};
47
+ new Headers(init).forEach((value, name) => (out[name] = value));
48
+ return out;
49
+ }
50
+ function asScreenRequest(request) {
51
+ const { body } = request;
52
+ if (!request.url.includes("/screens/") || !isRecord(body) || typeof body.screen !== "string")
53
+ return undefined;
54
+ return {
55
+ ...request,
56
+ screen: body.screen,
57
+ params: strings(body.params),
58
+ client: clientContext(body.client),
59
+ islands: capabilities(body.islands),
60
+ };
61
+ }
62
+ function asActionRequest(request) {
63
+ const { body } = request;
64
+ if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string")
65
+ return undefined;
66
+ return {
67
+ ...request,
68
+ action: body.action,
69
+ version: typeof body.version === "number" ? body.version : 0,
70
+ input: body.input,
71
+ client: clientContext(body.client),
72
+ idempotencyKey: typeof body.idempotencyKey === "string" ? body.idempotencyKey : undefined,
73
+ };
74
+ }
75
+ function respond(response) {
76
+ return new Response(JSON.stringify(response.body), {
77
+ status: response.status,
78
+ headers: { "content-type": "application/json" },
79
+ });
80
+ }
81
+ /** A composer whose screens and actions are the handlers given. */
82
+ export function createFakeGateway(handlers = {}) {
83
+ const requests = [];
84
+ async function fakeFetch(input, init) {
85
+ const request = {
86
+ url: urlOf(input),
87
+ method: init?.method ?? "GET",
88
+ headers: headersOf(init?.headers),
89
+ body: parseBody(init?.body),
90
+ };
91
+ requests.push(request);
92
+ const other = handlers.other?.(request);
93
+ if (other)
94
+ return respond(other);
95
+ const screenRequest = asScreenRequest(request);
96
+ const screenHandler = screenRequest && handlers.screens?.[screenRequest.screen];
97
+ if (screenRequest && screenHandler)
98
+ return respond(screenHandler(screenRequest));
99
+ const actionRequest = asActionRequest(request);
100
+ const actionHandler = actionRequest && handlers.actions?.[actionRequest.action];
101
+ if (actionRequest && actionHandler)
102
+ return respond(actionHandler(actionRequest));
103
+ throw new Error(`fake gateway: no handler for ${request.method} ${request.url} ${JSON.stringify(request.body)}`);
104
+ }
105
+ return {
106
+ fetch: fakeFetch,
107
+ requests,
108
+ screenRequests: () => requests.flatMap((r) => asScreenRequest(r) ?? []),
109
+ actionRequests: () => requests.flatMap((r) => asActionRequest(r) ?? []),
110
+ };
111
+ }
112
+ // ---- Responses ----
113
+ /** An island in a screen response, typed by its generated descriptor. */
114
+ export function island(key, descriptor, props) {
115
+ return { key, type: descriptor.type, version: descriptor.version, status: "ok", props };
116
+ }
117
+ /** An island that failed on the composer (spec §3.4). */
118
+ export function islandError(key, descriptor, error) {
119
+ return { key, type: descriptor.type, version: descriptor.version, status: "error", error };
120
+ }
121
+ /** A screen response. */
122
+ export function screen(id, islands, meta) {
123
+ return { status: 200, body: { protocol: PROTOCOL_VERSION, screen: id, islands, ...(meta && { meta }) } };
124
+ }
125
+ /** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
126
+ export function screenError(status, code) {
127
+ return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
128
+ }
129
+ /** An action that succeeded, with its result typed by the descriptor. */
130
+ export function actionOk(action, result, effects) {
131
+ const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
132
+ return { status: 200, body: { ...base, status: "ok", result, ...(effects && { effects }) } };
133
+ }
134
+ /** An action that ended with one of its declared errors. */
135
+ export function actionError(action, error) {
136
+ const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
137
+ return { status: 200, body: { ...base, status: "error", error } };
138
+ }
139
+ /** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
140
+ export function actionFailure(status, code) {
141
+ return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
142
+ }
143
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@client-compose/client",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Framework-agnostic Client Compose client: island registry, screen requests and render planning.",
5
5
  "keywords": [
6
6
  "client-compose",
@@ -26,6 +26,10 @@
26
26
  "types": "./dist/index.d.ts",
27
27
  "default": "./dist/index.js"
28
28
  },
29
+ "./testing": {
30
+ "types": "./dist/testing.d.ts",
31
+ "default": "./dist/testing.js"
32
+ },
29
33
  "./package.json": "./package.json"
30
34
  },
31
35
  "files": [
@@ -36,10 +40,10 @@
36
40
  "LICENSE"
37
41
  ],
38
42
  "dependencies": {
39
- "@client-compose/protocol": "0.2.0"
43
+ "@client-compose/protocol": "0.3.0"
40
44
  },
41
45
  "devDependencies": {
42
- "@client-compose/conformance": "0.3.0"
46
+ "@client-compose/conformance": "0.4.0"
43
47
  },
44
48
  "publishConfig": {
45
49
  "access": "public"
package/src/index.ts CHANGED
@@ -3,8 +3,12 @@
3
3
 
4
4
  import {
5
5
  PROTOCOL_VERSION,
6
+ type ActionDescriptor,
7
+ type ActionRequest,
8
+ type ActionResponse,
6
9
  type Capabilities,
7
10
  type ClientContext,
11
+ type DeclaredError,
8
12
  type IslandDescriptor,
9
13
  type IslandEntry,
10
14
  type ScreenRequest,
@@ -135,3 +139,93 @@ export async function fetchScreen(
135
139
  if (!res.ok) throw new ScreenFetchError(res.status, await res.text());
136
140
  return (await res.json()) as ScreenResponse;
137
141
  }
142
+
143
+ // ---- Actions (spec §5) ----
144
+
145
+ /**
146
+ * The composer didn't perform the action: a non-2xx response such as `invalid_input`,
147
+ * `unauthenticated` or `action_failed`. Declared errors are not thrown; they're outcomes.
148
+ */
149
+ export class ActionFetchError extends Error {
150
+ readonly status: number;
151
+ readonly body: string;
152
+ /** The protocol error code from the response body (e.g. "unauthenticated"), if there was one. */
153
+ readonly code: string | undefined;
154
+
155
+ constructor(status: number, body: string) {
156
+ super(`Action request failed with status ${status}`);
157
+ this.status = status;
158
+ this.body = body;
159
+ this.code = errorCode(body);
160
+ }
161
+ }
162
+
163
+ /** A fresh idempotency key: one per user intent, reused when retrying it (spec §5.2). */
164
+ export function newIdempotencyKey(): string {
165
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
166
+ if (c?.randomUUID) return c.randomUUID();
167
+ // React Native's Hermes has no crypto.randomUUID. Uniqueness, not secrecy, is what matters here.
168
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
169
+ const r = (Math.random() * 16) | 0;
170
+ return (ch === "x" ? r : (r & 0x3) | 0x8).toString(16);
171
+ });
172
+ }
173
+
174
+ export function buildActionRequest<I>(
175
+ action: ActionDescriptor<I, unknown, DeclaredError>,
176
+ input: I,
177
+ client: ClientContext,
178
+ idempotencyKey?: string,
179
+ ): ActionRequest<I> {
180
+ return {
181
+ protocol: PROTOCOL_VERSION,
182
+ action: action.action,
183
+ version: action.version,
184
+ input,
185
+ client,
186
+ ...(idempotencyKey !== undefined && { idempotencyKey }),
187
+ };
188
+ }
189
+
190
+ /** POST an action request. Resolves with the outcome (ok or a declared error); throws ActionFetchError otherwise. */
191
+ export async function fetchAction(
192
+ baseUrl: string,
193
+ request: ActionRequest,
194
+ init: RequestInit = {},
195
+ fetchImpl: typeof fetch = fetch,
196
+ ): Promise<ActionResponse> {
197
+ const res = await fetchImpl(`${baseUrl}/actions/${encodeURIComponent(request.action)}`, {
198
+ ...init,
199
+ method: "POST",
200
+ headers: { "content-type": "application/json", ...init.headers },
201
+ body: JSON.stringify(request),
202
+ });
203
+ if (!res.ok) throw new ActionFetchError(res.status, await res.text());
204
+ return (await res.json()) as ActionResponse;
205
+ }
206
+
207
+ export interface CallActionOptions {
208
+ client: ClientContext;
209
+ /** Default: a new key. Pass the same key again to retry the same user intent. */
210
+ idempotencyKey?: string;
211
+ init?: RequestInit;
212
+ fetch?: typeof fetch;
213
+ }
214
+
215
+ /**
216
+ * Perform an action with its generated descriptor:
217
+ *
218
+ * const res = await callAction(baseUrl, FollowUserV1, { userId }, { client });
219
+ * if (res.status === "ok") show(res.result.followers);
220
+ * else if (res.error.code === "blocked") showBlocked();
221
+ * else showGenericError(); // codes added after this app was built
222
+ */
223
+ export async function callAction<I, R, E extends DeclaredError>(
224
+ baseUrl: string,
225
+ action: ActionDescriptor<I, R, E>,
226
+ input: I,
227
+ options: CallActionOptions,
228
+ ): Promise<ActionResponse<R, E>> {
229
+ const request = buildActionRequest(action, input, options.client, options.idempotencyKey ?? newIdempotencyKey());
230
+ return (await fetchAction(baseUrl, request, options.init, options.fetch)) as ActionResponse<R, E>;
231
+ }
package/src/testing.ts ADDED
@@ -0,0 +1,219 @@
1
+ // Test helpers for apps built on Client Compose: a fake composer that answers screen and action
2
+ // requests from handlers and records them, and builders for the responses it returns. No test
3
+ // framework of its own, so it works with node:test, Jest, Vitest, Playwright and others.
4
+ //
5
+ // const gateway = createFakeGateway({
6
+ // screens: { profile: () => screen("profile", [island("header", ProfileHeaderV1, { name: "Ada" })]) },
7
+ // actions: { follow: () => actionOk(FollowUserV1, { followers: 8 }) },
8
+ // });
9
+ // // pass gateway.fetch as the client's fetch, then assert on gateway.actionRequests()
10
+
11
+ import {
12
+ PROTOCOL_VERSION,
13
+ type ActionDescriptor,
14
+ type ActionEffects,
15
+ type Capabilities,
16
+ type ClientContext,
17
+ type DeclaredError,
18
+ type IslandDescriptor,
19
+ type IslandEntry,
20
+ type IslandError,
21
+ type ScreenMeta,
22
+ } from "@client-compose/protocol";
23
+
24
+ /** A response for the fake composer to send. */
25
+ export interface FakeResponse {
26
+ status: number;
27
+ body: unknown;
28
+ }
29
+
30
+ /** A request the fake composer received. */
31
+ export interface RecordedRequest {
32
+ url: string;
33
+ method: string;
34
+ /** Header names are lower case. */
35
+ headers: Record<string, string>;
36
+ /** The JSON body, parsed; or the raw body when it isn't JSON (e.g. a multipart upload). */
37
+ body: unknown;
38
+ }
39
+
40
+ export interface RecordedScreenRequest extends RecordedRequest {
41
+ screen: string;
42
+ params: Record<string, string>;
43
+ client: ClientContext | undefined;
44
+ islands: Capabilities;
45
+ }
46
+
47
+ export interface RecordedActionRequest extends RecordedRequest {
48
+ action: string;
49
+ version: number;
50
+ input: unknown;
51
+ client: ClientContext | undefined;
52
+ idempotencyKey: string | undefined;
53
+ }
54
+
55
+ export interface FakeGatewayHandlers {
56
+ /** By screen id. */
57
+ screens?: Record<string, (request: RecordedScreenRequest) => FakeResponse>;
58
+ /** By action name. */
59
+ actions?: Record<string, (request: RecordedActionRequest) => FakeResponse>;
60
+ /** Anything else, e.g. uploads to storage: return undefined to leave a request to the others. */
61
+ other?: (request: RecordedRequest) => FakeResponse | undefined;
62
+ }
63
+
64
+ export interface FakeGateway {
65
+ /** Use as the client's `fetch`. Requests without a handler reject, failing the test. */
66
+ fetch: typeof fetch;
67
+ /** Every request received, in order. */
68
+ requests: RecordedRequest[];
69
+ screenRequests(): RecordedScreenRequest[];
70
+ actionRequests(): RecordedActionRequest[];
71
+ }
72
+
73
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
74
+ typeof value === "object" && value !== null && !Array.isArray(value);
75
+
76
+ function strings(value: unknown): Record<string, string> {
77
+ if (!isRecord(value)) return {};
78
+ return Object.fromEntries(Object.entries(value).filter((e): e is [string, string] => typeof e[1] === "string"));
79
+ }
80
+
81
+ function capabilities(value: unknown): Capabilities {
82
+ if (!isRecord(value)) return {};
83
+ return Object.fromEntries(
84
+ Object.entries(value).map(([type, versions]) => [
85
+ type,
86
+ Array.isArray(versions) ? versions.filter((v): v is number => typeof v === "number") : [],
87
+ ]),
88
+ );
89
+ }
90
+
91
+ function clientContext(value: unknown): ClientContext | undefined {
92
+ if (!isRecord(value) || typeof value.platform !== "string" || typeof value.appVersion !== "string") return undefined;
93
+ return { ...value, platform: value.platform, appVersion: value.appVersion } as ClientContext;
94
+ }
95
+
96
+ function parseBody(body: unknown): unknown {
97
+ if (typeof body !== "string") return body;
98
+ try {
99
+ return JSON.parse(body) as unknown;
100
+ } catch {
101
+ return body;
102
+ }
103
+ }
104
+
105
+ function urlOf(input: RequestInfo | URL): string {
106
+ if (typeof input === "string") return input;
107
+ return input instanceof URL ? input.href : input.url;
108
+ }
109
+
110
+ function headersOf(init: HeadersInit | undefined): Record<string, string> {
111
+ const out: Record<string, string> = {};
112
+ new Headers(init).forEach((value, name) => (out[name] = value));
113
+ return out;
114
+ }
115
+
116
+ function asScreenRequest(request: RecordedRequest): RecordedScreenRequest | undefined {
117
+ const { body } = request;
118
+ if (!request.url.includes("/screens/") || !isRecord(body) || typeof body.screen !== "string") return undefined;
119
+ return {
120
+ ...request,
121
+ screen: body.screen,
122
+ params: strings(body.params),
123
+ client: clientContext(body.client),
124
+ islands: capabilities(body.islands),
125
+ };
126
+ }
127
+
128
+ function asActionRequest(request: RecordedRequest): RecordedActionRequest | undefined {
129
+ const { body } = request;
130
+ if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string") return undefined;
131
+ return {
132
+ ...request,
133
+ action: body.action,
134
+ version: typeof body.version === "number" ? body.version : 0,
135
+ input: body.input,
136
+ client: clientContext(body.client),
137
+ idempotencyKey: typeof body.idempotencyKey === "string" ? body.idempotencyKey : undefined,
138
+ };
139
+ }
140
+
141
+ function respond(response: FakeResponse): Response {
142
+ return new Response(JSON.stringify(response.body), {
143
+ status: response.status,
144
+ headers: { "content-type": "application/json" },
145
+ });
146
+ }
147
+
148
+ /** A composer whose screens and actions are the handlers given. */
149
+ export function createFakeGateway(handlers: FakeGatewayHandlers = {}): FakeGateway {
150
+ const requests: RecordedRequest[] = [];
151
+ async function fakeFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
152
+ const request: RecordedRequest = {
153
+ url: urlOf(input),
154
+ method: init?.method ?? "GET",
155
+ headers: headersOf(init?.headers),
156
+ body: parseBody(init?.body),
157
+ };
158
+ requests.push(request);
159
+
160
+ const other = handlers.other?.(request);
161
+ if (other) return respond(other);
162
+ const screenRequest = asScreenRequest(request);
163
+ const screenHandler = screenRequest && handlers.screens?.[screenRequest.screen];
164
+ if (screenRequest && screenHandler) return respond(screenHandler(screenRequest));
165
+ const actionRequest = asActionRequest(request);
166
+ const actionHandler = actionRequest && handlers.actions?.[actionRequest.action];
167
+ if (actionRequest && actionHandler) return respond(actionHandler(actionRequest));
168
+ throw new Error(`fake gateway: no handler for ${request.method} ${request.url} ${JSON.stringify(request.body)}`);
169
+ }
170
+ return {
171
+ fetch: fakeFetch,
172
+ requests,
173
+ screenRequests: () => requests.flatMap((r) => asScreenRequest(r) ?? []),
174
+ actionRequests: () => requests.flatMap((r) => asActionRequest(r) ?? []),
175
+ };
176
+ }
177
+
178
+ // ---- Responses ----
179
+
180
+ /** An island in a screen response, typed by its generated descriptor. */
181
+ export function island<P>(key: string, descriptor: IslandDescriptor<P>, props: P): IslandEntry<P> {
182
+ return { key, type: descriptor.type, version: descriptor.version, status: "ok", props };
183
+ }
184
+
185
+ /** An island that failed on the composer (spec §3.4). */
186
+ export function islandError(key: string, descriptor: IslandDescriptor<unknown>, error: IslandError): IslandEntry {
187
+ return { key, type: descriptor.type, version: descriptor.version, status: "error", error };
188
+ }
189
+
190
+ /** A screen response. */
191
+ export function screen(id: string, islands: IslandEntry<unknown>[], meta?: ScreenMeta): FakeResponse {
192
+ return { status: 200, body: { protocol: PROTOCOL_VERSION, screen: id, islands, ...(meta && { meta }) } };
193
+ }
194
+
195
+ /** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
196
+ export function screenError(status: number, code: string): FakeResponse {
197
+ return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
198
+ }
199
+
200
+ /** An action that succeeded, with its result typed by the descriptor. */
201
+ export function actionOk<I, R, E extends DeclaredError>(
202
+ action: ActionDescriptor<I, R, E>,
203
+ result: R,
204
+ effects?: ActionEffects,
205
+ ): FakeResponse {
206
+ const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
207
+ return { status: 200, body: { ...base, status: "ok", result, ...(effects && { effects }) } };
208
+ }
209
+
210
+ /** An action that ended with one of its declared errors. */
211
+ export function actionError<I, R, E extends DeclaredError>(action: ActionDescriptor<I, R, E>, error: E): FakeResponse {
212
+ const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
213
+ return { status: 200, body: { ...base, status: "error", error } };
214
+ }
215
+
216
+ /** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
217
+ export function actionFailure(status: number, code: string): FakeResponse {
218
+ return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
219
+ }