@client-compose/client 0.3.0 → 0.5.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 +31 -0
- package/dist/proxy.d.ts +41 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +80 -0
- package/dist/proxy.js.map +1 -0
- package/dist/testing.d.ts +70 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +160 -0
- package/dist/testing.js.map +1 -0
- package/package.json +9 -1
- package/src/proxy.ts +132 -0
- package/src/testing.ts +242 -0
package/README.md
CHANGED
|
@@ -66,6 +66,37 @@ Props aren't validated at runtime; generate types with
|
|
|
66
66
|
[`@client-compose/codegen`](https://www.npmjs.com/package/@client-compose/codegen) to keep them in
|
|
67
67
|
sync with the composer.
|
|
68
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 code that calls the global `fetch` itself, such as a whole app rendered in a test,
|
|
87
|
+
`installFakeGateway(handlers)` puts the fake composer in its place; call `restore()` afterwards.
|
|
88
|
+
|
|
89
|
+
For React, `@client-compose/react/testing` adds a provider config built around it.
|
|
90
|
+
|
|
91
|
+
## Through your own server
|
|
92
|
+
|
|
93
|
+
`@client-compose/client/proxy` passes a browser's screen and action requests to the composer
|
|
94
|
+
through your server, so it can add what the browser mustn't hold, such as a session kept in an
|
|
95
|
+
httpOnly cookie. `createComposerProxy({ baseUrl, client, headers, onActionResponse })` checks each
|
|
96
|
+
request (anyone can call a server endpoint with anything), sets the client context and headers,
|
|
97
|
+
and returns the composer's failures as data. `@client-compose/react`'s `viaProxy` is the browser
|
|
98
|
+
half.
|
|
99
|
+
|
|
69
100
|
## Links
|
|
70
101
|
|
|
71
102
|
- [Protocol spec](https://github.com/client-compose/client-compose/blob/main/spec/PROTOCOL.md)
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ActionRequest, type ActionResponse, type ClientContext, type ScreenRequest, type ScreenResponse } from "@client-compose/protocol";
|
|
2
|
+
/** A screen request from the browser: the server sets the client context. */
|
|
3
|
+
export type ProxiedScreenRequest = Omit<ScreenRequest, "client">;
|
|
4
|
+
/** An action request from the browser: the server sets the client context. */
|
|
5
|
+
export type ProxiedActionRequest = Omit<ActionRequest, "client">;
|
|
6
|
+
/** The composer's response, or the status and body it failed with. */
|
|
7
|
+
export type ProxyResult<T> = {
|
|
8
|
+
ok: true;
|
|
9
|
+
response: T;
|
|
10
|
+
} | {
|
|
11
|
+
ok: false;
|
|
12
|
+
status: number;
|
|
13
|
+
body: string;
|
|
14
|
+
};
|
|
15
|
+
export interface ComposerProxyOptions {
|
|
16
|
+
/** Composer base URL. */
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
/** The client context for each request, e.g. with the locale from the request's cookies. */
|
|
19
|
+
client: () => ClientContext | Promise<ClientContext>;
|
|
20
|
+
/** Extra headers for each request, e.g. `Authorization` from a session cookie. */
|
|
21
|
+
headers?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
22
|
+
/** Extra options for every request, e.g. `{ cache: "no-store" }`. */
|
|
23
|
+
requestInit?: RequestInit & Record<string, unknown>;
|
|
24
|
+
/** Custom fetch implementation. Defaults to the global `fetch`. */
|
|
25
|
+
fetch?: typeof fetch;
|
|
26
|
+
/**
|
|
27
|
+
* Sees each action's response (ok or a declared error) before the browser does, and returns
|
|
28
|
+
* what the browser gets: e.g. keep a new session in a cookie, and take its token out.
|
|
29
|
+
*/
|
|
30
|
+
onActionResponse?: (request: ActionRequest, response: ActionResponse) => ActionResponse | Promise<ActionResponse>;
|
|
31
|
+
}
|
|
32
|
+
export interface ComposerProxy {
|
|
33
|
+
/** Checks a browser's action request, then performs it with the server's context and headers. */
|
|
34
|
+
performAction(body: unknown): Promise<ProxyResult<ActionResponse>>;
|
|
35
|
+
/** Checks a browser's screen request, then loads it with the server's context and headers. */
|
|
36
|
+
loadScreen(body: unknown): Promise<ProxyResult<ScreenResponse>>;
|
|
37
|
+
}
|
|
38
|
+
export declare function isProxiedScreenRequest(value: unknown): value is ProxiedScreenRequest;
|
|
39
|
+
export declare function isProxiedActionRequest(value: unknown): value is ProxiedActionRequest;
|
|
40
|
+
export declare function createComposerProxy(options: ComposerProxyOptions): ComposerProxy;
|
|
41
|
+
//# sourceMappingURL=proxy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAeA,OAAO,EAEL,KAAK,aAAa,EAClB,KAAK,cAAc,EAEnB,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,cAAc,EACpB,MAAM,0BAA0B,CAAC;AAGlC,6EAA6E;AAC7E,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;AACjE,8EAA8E;AAC9E,MAAM,MAAM,oBAAoB,GAAG,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;AAEjE,sEAAsE;AACtE,MAAM,MAAM,WAAW,CAAC,CAAC,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAErG,MAAM,WAAW,oBAAoB;IACnC,yBAAyB;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,4FAA4F;IAC5F,MAAM,EAAE,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,CAAC,CAAC;IACrD,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACzE,qEAAqE;IACrE,WAAW,CAAC,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpD,mEAAmE;IACnE,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;IACrB;;;OAGG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,cAAc,KAAK,cAAc,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;CACnH;AAED,MAAM,WAAW,aAAa;IAC5B,iGAAiG;IACjG,aAAa,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC,CAAC;IACnE,8FAA8F;IAC9F,UAAU,CAAC,IAAI,EAAE,OAAO,GAAG,OAAO,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC,CAAC;CACjE;AAgBD,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,oBAAoB,CAQpF;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,oBAAoB,CASpF;AASD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CA8BhF"}
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Passing a browser's screen and action requests to the composer through your own server, e.g. a
|
|
2
|
+
// Next.js Server Action or a Remix action. The server adds what the browser mustn't hold (a session
|
|
3
|
+
// kept in an httpOnly cookie) and vouches for the client context, so the browser's is ignored.
|
|
4
|
+
//
|
|
5
|
+
// const proxy = createComposerProxy({
|
|
6
|
+
// baseUrl: COMPOSER_URL,
|
|
7
|
+
// client: async () => ({ platform: "web", appVersion, locale: await getLocale() }),
|
|
8
|
+
// headers: async () => ({ Authorization: `Bearer ${await getSessionToken()}` }),
|
|
9
|
+
// });
|
|
10
|
+
// export async function performAction(body: unknown) { return proxy.performAction(body); }
|
|
11
|
+
//
|
|
12
|
+
// Anyone can call a server endpoint with anything, so requests are checked before they're sent on.
|
|
13
|
+
// Errors come back as data, since frameworks often strip thrown errors' details on the way to the
|
|
14
|
+
// browser.
|
|
15
|
+
import { PROTOCOL_VERSION, } from "@client-compose/protocol";
|
|
16
|
+
import { ActionFetchError, fetchAction, fetchScreen, ScreenFetchError } from "./index.js";
|
|
17
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
function isCapabilities(value) {
|
|
19
|
+
return (isRecord(value) &&
|
|
20
|
+
Object.values(value).every((versions) => Array.isArray(versions) && versions.every((v) => typeof v === "number")));
|
|
21
|
+
}
|
|
22
|
+
function isStringRecord(value) {
|
|
23
|
+
return isRecord(value) && Object.values(value).every((v) => typeof v === "string");
|
|
24
|
+
}
|
|
25
|
+
export function isProxiedScreenRequest(value) {
|
|
26
|
+
return (isRecord(value) &&
|
|
27
|
+
value.protocol === PROTOCOL_VERSION &&
|
|
28
|
+
typeof value.screen === "string" &&
|
|
29
|
+
(value.params === undefined || isStringRecord(value.params)) &&
|
|
30
|
+
isCapabilities(value.islands));
|
|
31
|
+
}
|
|
32
|
+
export function isProxiedActionRequest(value) {
|
|
33
|
+
return (isRecord(value) &&
|
|
34
|
+
value.protocol === PROTOCOL_VERSION &&
|
|
35
|
+
typeof value.action === "string" &&
|
|
36
|
+
typeof value.version === "number" &&
|
|
37
|
+
"input" in value &&
|
|
38
|
+
(value.idempotencyKey === undefined || typeof value.idempotencyKey === "string"));
|
|
39
|
+
}
|
|
40
|
+
/** What the composer itself answers to a malformed request. */
|
|
41
|
+
const BAD_REQUEST = {
|
|
42
|
+
ok: false,
|
|
43
|
+
status: 400,
|
|
44
|
+
body: JSON.stringify({ protocol: PROTOCOL_VERSION, error: { code: "bad_request" } }),
|
|
45
|
+
};
|
|
46
|
+
export function createComposerProxy(options) {
|
|
47
|
+
async function init() {
|
|
48
|
+
const headers = (await options.headers?.()) ?? {};
|
|
49
|
+
return { ...options.requestInit, headers: { ...options.requestInit?.headers, ...headers } };
|
|
50
|
+
}
|
|
51
|
+
async function performAction(body) {
|
|
52
|
+
if (!isProxiedActionRequest(body))
|
|
53
|
+
return BAD_REQUEST;
|
|
54
|
+
const request = { ...body, client: await options.client() };
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetchAction(options.baseUrl, request, await init(), options.fetch);
|
|
57
|
+
return { ok: true, response: (await options.onActionResponse?.(request, response)) ?? response };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof ActionFetchError)
|
|
61
|
+
return { ok: false, status: error.status, body: error.body };
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function loadScreen(body) {
|
|
66
|
+
if (!isProxiedScreenRequest(body))
|
|
67
|
+
return BAD_REQUEST;
|
|
68
|
+
const request = { ...body, client: await options.client() };
|
|
69
|
+
try {
|
|
70
|
+
return { ok: true, response: await fetchScreen(options.baseUrl, request, await init(), options.fetch) };
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof ScreenFetchError)
|
|
74
|
+
return { ok: false, status: error.status, body: error.body };
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { performAction, loadScreen };
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=proxy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"proxy.js","sourceRoot":"","sources":["../src/proxy.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,oGAAoG;AACpG,+FAA+F;AAC/F,EAAE;AACF,0CAA0C;AAC1C,+BAA+B;AAC/B,0FAA0F;AAC1F,uFAAuF;AACvF,UAAU;AACV,+FAA+F;AAC/F,EAAE;AACF,mGAAmG;AACnG,kGAAkG;AAClG,WAAW;AAEX,OAAO,EACL,gBAAgB,GAOjB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAmC1F,MAAM,QAAQ,GAAG,CAAC,KAAc,EAAoC,EAAE,CACpE,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;AAEvE,SAAS,cAAc,CAAC,KAAc;IACpC,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAClH,CAAC;AACJ,CAAC;AAED,SAAS,cAAc,CAAC,KAAc;IACpC,OAAO,QAAQ,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC;AACrF,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,KAAc;IACnD,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,KAAK,CAAC,QAAQ,KAAK,gBAAgB;QACnC,OAAO,KAAK,CAAC,MAAM,KAAK,QAAQ;QAChC,CAAC,KAAK,CAAC,MAAM,KAAK,SAAS,IAAI,cAAc,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QAC5D,cAAc,CAAC,KAAK,CAAC,OAAO,CAAC,CAC9B,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,sBAAsB,CAAC,KAAc;IACnD,OAAO,CACL,QAAQ,CAAC,KAAK,CAAC;QACf,KAAK,CAAC,QAAQ,KAAK,gBAAgB;QACnC,OAAO,KAAK,CAAC,MAAM,KAAK,QAAQ;QAChC,OAAO,KAAK,CAAC,OAAO,KAAK,QAAQ;QACjC,OAAO,IAAI,KAAK;QAChB,CAAC,KAAK,CAAC,cAAc,KAAK,SAAS,IAAI,OAAO,KAAK,CAAC,cAAc,KAAK,QAAQ,CAAC,CACjF,CAAC;AACJ,CAAC;AAED,+DAA+D;AAC/D,MAAM,WAAW,GAAG;IAClB,EAAE,EAAE,KAAK;IACT,MAAM,EAAE,GAAG;IACX,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,gBAAgB,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE,EAAE,CAAC;CAC5E,CAAC;AAEX,MAAM,UAAU,mBAAmB,CAAC,OAA6B;IAC/D,KAAK,UAAU,IAAI;QACjB,MAAM,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,EAAE,CAAC;QAClD,OAAO,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,CAAC,WAAW,EAAE,OAAO,EAAE,GAAG,OAAO,EAAE,EAAE,CAAC;IAC9F,CAAC;IAED,KAAK,UAAU,aAAa,CAAC,IAAa;QACxC,IAAI,CAAC,sBAAsB,CAAC,IAAI,CAAC;YAAE,OAAO,WAAW,CAAC;QACtD,MAAM,OAAO,GAAkB,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;QAC3E,IAAI,CAAC;YACH,MAAM,QAAQ,GAAG,MAAM,WAAW,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,OAAO,CAAC,KAAK,CAAC,CAAC;YAC1F,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,MAAM,OAAO,CAAC,gBAAgB,EAAE,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,IAAI,QAAQ,EAAE,CAAC;QACnG,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,KAAK,YAAY,gBAAgB;gBAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC;YACpG,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,KAAK,UAAU,UAAU,CAAC,IAAa;QACrC,IAAI,CAAC,sBAAsB,CAAC,IAAI,CAAC;YAAE,OAAO,WAAW,CAAC;QACtD,MAAM,OAAO,GAAkB,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC;QAC3E,IAAI,CAAC;YACH,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,IAAI,EAAE,EAAE,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;QAC1G,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,IAAI,KAAK,YAAY,gBAAgB;gBAAE,OAAO,EAAE,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC;YACpG,MAAM,KAAK,CAAC;QACd,CAAC;IACH,CAAC;IAED,OAAO,EAAE,aAAa,EAAE,UAAU,EAAE,CAAC;AACvC,CAAC"}
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
export interface InstalledFakeGateway extends FakeGateway {
|
|
48
|
+
/** Puts back the `fetch` that was there before. */
|
|
49
|
+
restore(): void;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A fake composer installed as the global `fetch`, for code that fetches by itself (e.g. a whole
|
|
53
|
+
* app rendered in a test). Call `restore()` after the test.
|
|
54
|
+
*/
|
|
55
|
+
export declare function installFakeGateway(handlers?: FakeGatewayHandlers): InstalledFakeGateway;
|
|
56
|
+
/** An island in a screen response, typed by its generated descriptor. */
|
|
57
|
+
export declare function island<P>(key: string, descriptor: IslandDescriptor<P>, props: P): IslandEntry<P>;
|
|
58
|
+
/** An island that failed on the composer (spec §3.4). */
|
|
59
|
+
export declare function islandError(key: string, descriptor: IslandDescriptor<unknown>, error: IslandError): IslandEntry;
|
|
60
|
+
/** A screen response. */
|
|
61
|
+
export declare function screen(id: string, islands: IslandEntry<unknown>[], meta?: ScreenMeta): FakeResponse;
|
|
62
|
+
/** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
|
|
63
|
+
export declare function screenError(status: number, code: string): FakeResponse;
|
|
64
|
+
/** An action that succeeded, with its result typed by the descriptor. */
|
|
65
|
+
export declare function actionOk<I, R, E extends DeclaredError>(action: ActionDescriptor<I, R, E>, result: R, effects?: ActionEffects): FakeResponse;
|
|
66
|
+
/** An action that ended with one of its declared errors. */
|
|
67
|
+
export declare function actionError<I, R, E extends DeclaredError>(action: ActionDescriptor<I, R, E>, error: E): FakeResponse;
|
|
68
|
+
/** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
|
|
69
|
+
export declare function actionFailure(status: number, code: string): FakeResponse;
|
|
70
|
+
//# 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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"}
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
// For code that uses the global fetch itself, `installFakeGateway` puts one in its place.
|
|
12
|
+
import { PROTOCOL_VERSION, } from "@client-compose/protocol";
|
|
13
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
function strings(value) {
|
|
15
|
+
if (!isRecord(value))
|
|
16
|
+
return {};
|
|
17
|
+
return Object.fromEntries(Object.entries(value).filter((e) => typeof e[1] === "string"));
|
|
18
|
+
}
|
|
19
|
+
function capabilities(value) {
|
|
20
|
+
if (!isRecord(value))
|
|
21
|
+
return {};
|
|
22
|
+
return Object.fromEntries(Object.entries(value).map(([type, versions]) => [
|
|
23
|
+
type,
|
|
24
|
+
Array.isArray(versions) ? versions.filter((v) => typeof v === "number") : [],
|
|
25
|
+
]));
|
|
26
|
+
}
|
|
27
|
+
function clientContext(value) {
|
|
28
|
+
if (!isRecord(value) || typeof value.platform !== "string" || typeof value.appVersion !== "string")
|
|
29
|
+
return undefined;
|
|
30
|
+
return { ...value, platform: value.platform, appVersion: value.appVersion };
|
|
31
|
+
}
|
|
32
|
+
function parseBody(body) {
|
|
33
|
+
if (typeof body !== "string")
|
|
34
|
+
return body;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(body);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return body;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function urlOf(input) {
|
|
43
|
+
if (typeof input === "string")
|
|
44
|
+
return input;
|
|
45
|
+
return input instanceof URL ? input.href : input.url;
|
|
46
|
+
}
|
|
47
|
+
function headersOf(init) {
|
|
48
|
+
const out = {};
|
|
49
|
+
new Headers(init).forEach((value, name) => (out[name] = value));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
function asScreenRequest(request) {
|
|
53
|
+
const { body } = request;
|
|
54
|
+
if (!request.url.includes("/screens/") || !isRecord(body) || typeof body.screen !== "string")
|
|
55
|
+
return undefined;
|
|
56
|
+
return {
|
|
57
|
+
...request,
|
|
58
|
+
screen: body.screen,
|
|
59
|
+
params: strings(body.params),
|
|
60
|
+
client: clientContext(body.client),
|
|
61
|
+
islands: capabilities(body.islands),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function asActionRequest(request) {
|
|
65
|
+
const { body } = request;
|
|
66
|
+
if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string")
|
|
67
|
+
return undefined;
|
|
68
|
+
return {
|
|
69
|
+
...request,
|
|
70
|
+
action: body.action,
|
|
71
|
+
version: typeof body.version === "number" ? body.version : 0,
|
|
72
|
+
input: body.input,
|
|
73
|
+
client: clientContext(body.client),
|
|
74
|
+
idempotencyKey: typeof body.idempotencyKey === "string" ? body.idempotencyKey : undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function respond(response) {
|
|
78
|
+
return new Response(JSON.stringify(response.body), {
|
|
79
|
+
status: response.status,
|
|
80
|
+
headers: { "content-type": "application/json" },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** A composer whose screens and actions are the handlers given. */
|
|
84
|
+
export function createFakeGateway(handlers = {}) {
|
|
85
|
+
const requests = [];
|
|
86
|
+
async function fakeFetch(input, init) {
|
|
87
|
+
const request = {
|
|
88
|
+
url: urlOf(input),
|
|
89
|
+
method: init?.method ?? "GET",
|
|
90
|
+
headers: headersOf(init?.headers),
|
|
91
|
+
body: parseBody(init?.body),
|
|
92
|
+
};
|
|
93
|
+
requests.push(request);
|
|
94
|
+
const other = handlers.other?.(request);
|
|
95
|
+
if (other)
|
|
96
|
+
return respond(other);
|
|
97
|
+
const screenRequest = asScreenRequest(request);
|
|
98
|
+
const screenHandler = screenRequest && handlers.screens?.[screenRequest.screen];
|
|
99
|
+
if (screenRequest && screenHandler)
|
|
100
|
+
return respond(screenHandler(screenRequest));
|
|
101
|
+
const actionRequest = asActionRequest(request);
|
|
102
|
+
const actionHandler = actionRequest && handlers.actions?.[actionRequest.action];
|
|
103
|
+
if (actionRequest && actionHandler)
|
|
104
|
+
return respond(actionHandler(actionRequest));
|
|
105
|
+
throw new Error(`fake gateway: no handler for ${request.method} ${request.url} ${JSON.stringify(request.body)}`);
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
fetch: fakeFetch,
|
|
109
|
+
requests,
|
|
110
|
+
screenRequests: () => requests.flatMap((r) => asScreenRequest(r) ?? []),
|
|
111
|
+
actionRequests: () => requests.flatMap((r) => asActionRequest(r) ?? []),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* A fake composer installed as the global `fetch`, for code that fetches by itself (e.g. a whole
|
|
116
|
+
* app rendered in a test). Call `restore()` after the test.
|
|
117
|
+
*/
|
|
118
|
+
export function installFakeGateway(handlers = {}) {
|
|
119
|
+
const gateway = createFakeGateway(handlers);
|
|
120
|
+
const original = globalThis.fetch;
|
|
121
|
+
globalThis.fetch = gateway.fetch;
|
|
122
|
+
return {
|
|
123
|
+
...gateway,
|
|
124
|
+
restore: () => {
|
|
125
|
+
globalThis.fetch = original;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
// ---- Responses ----
|
|
130
|
+
/** An island in a screen response, typed by its generated descriptor. */
|
|
131
|
+
export function island(key, descriptor, props) {
|
|
132
|
+
return { key, type: descriptor.type, version: descriptor.version, status: "ok", props };
|
|
133
|
+
}
|
|
134
|
+
/** An island that failed on the composer (spec §3.4). */
|
|
135
|
+
export function islandError(key, descriptor, error) {
|
|
136
|
+
return { key, type: descriptor.type, version: descriptor.version, status: "error", error };
|
|
137
|
+
}
|
|
138
|
+
/** A screen response. */
|
|
139
|
+
export function screen(id, islands, meta) {
|
|
140
|
+
return { status: 200, body: { protocol: PROTOCOL_VERSION, screen: id, islands, ...(meta && { meta }) } };
|
|
141
|
+
}
|
|
142
|
+
/** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
|
|
143
|
+
export function screenError(status, code) {
|
|
144
|
+
return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
|
|
145
|
+
}
|
|
146
|
+
/** An action that succeeded, with its result typed by the descriptor. */
|
|
147
|
+
export function actionOk(action, result, effects) {
|
|
148
|
+
const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
|
|
149
|
+
return { status: 200, body: { ...base, status: "ok", result, ...(effects && { effects }) } };
|
|
150
|
+
}
|
|
151
|
+
/** An action that ended with one of its declared errors. */
|
|
152
|
+
export function actionError(action, error) {
|
|
153
|
+
const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
|
|
154
|
+
return { status: 200, body: { ...base, status: "error", error } };
|
|
155
|
+
}
|
|
156
|
+
/** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
|
|
157
|
+
export function actionFailure(status, code) {
|
|
158
|
+
return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
|
|
159
|
+
}
|
|
160
|
+
//# 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.
|
|
3
|
+
"version": "0.5.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,14 @@
|
|
|
26
26
|
"types": "./dist/index.d.ts",
|
|
27
27
|
"default": "./dist/index.js"
|
|
28
28
|
},
|
|
29
|
+
"./proxy": {
|
|
30
|
+
"types": "./dist/proxy.d.ts",
|
|
31
|
+
"default": "./dist/proxy.js"
|
|
32
|
+
},
|
|
33
|
+
"./testing": {
|
|
34
|
+
"types": "./dist/testing.d.ts",
|
|
35
|
+
"default": "./dist/testing.js"
|
|
36
|
+
},
|
|
29
37
|
"./package.json": "./package.json"
|
|
30
38
|
},
|
|
31
39
|
"files": [
|
package/src/proxy.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Passing a browser's screen and action requests to the composer through your own server, e.g. a
|
|
2
|
+
// Next.js Server Action or a Remix action. The server adds what the browser mustn't hold (a session
|
|
3
|
+
// kept in an httpOnly cookie) and vouches for the client context, so the browser's is ignored.
|
|
4
|
+
//
|
|
5
|
+
// const proxy = createComposerProxy({
|
|
6
|
+
// baseUrl: COMPOSER_URL,
|
|
7
|
+
// client: async () => ({ platform: "web", appVersion, locale: await getLocale() }),
|
|
8
|
+
// headers: async () => ({ Authorization: `Bearer ${await getSessionToken()}` }),
|
|
9
|
+
// });
|
|
10
|
+
// export async function performAction(body: unknown) { return proxy.performAction(body); }
|
|
11
|
+
//
|
|
12
|
+
// Anyone can call a server endpoint with anything, so requests are checked before they're sent on.
|
|
13
|
+
// Errors come back as data, since frameworks often strip thrown errors' details on the way to the
|
|
14
|
+
// browser.
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
PROTOCOL_VERSION,
|
|
18
|
+
type ActionRequest,
|
|
19
|
+
type ActionResponse,
|
|
20
|
+
type Capabilities,
|
|
21
|
+
type ClientContext,
|
|
22
|
+
type ScreenRequest,
|
|
23
|
+
type ScreenResponse,
|
|
24
|
+
} from "@client-compose/protocol";
|
|
25
|
+
import { ActionFetchError, fetchAction, fetchScreen, ScreenFetchError } from "./index.ts";
|
|
26
|
+
|
|
27
|
+
/** A screen request from the browser: the server sets the client context. */
|
|
28
|
+
export type ProxiedScreenRequest = Omit<ScreenRequest, "client">;
|
|
29
|
+
/** An action request from the browser: the server sets the client context. */
|
|
30
|
+
export type ProxiedActionRequest = Omit<ActionRequest, "client">;
|
|
31
|
+
|
|
32
|
+
/** The composer's response, or the status and body it failed with. */
|
|
33
|
+
export type ProxyResult<T> = { ok: true; response: T } | { ok: false; status: number; body: string };
|
|
34
|
+
|
|
35
|
+
export interface ComposerProxyOptions {
|
|
36
|
+
/** Composer base URL. */
|
|
37
|
+
baseUrl: string;
|
|
38
|
+
/** The client context for each request, e.g. with the locale from the request's cookies. */
|
|
39
|
+
client: () => ClientContext | Promise<ClientContext>;
|
|
40
|
+
/** Extra headers for each request, e.g. `Authorization` from a session cookie. */
|
|
41
|
+
headers?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
42
|
+
/** Extra options for every request, e.g. `{ cache: "no-store" }`. */
|
|
43
|
+
requestInit?: RequestInit & Record<string, unknown>;
|
|
44
|
+
/** Custom fetch implementation. Defaults to the global `fetch`. */
|
|
45
|
+
fetch?: typeof fetch;
|
|
46
|
+
/**
|
|
47
|
+
* Sees each action's response (ok or a declared error) before the browser does, and returns
|
|
48
|
+
* what the browser gets: e.g. keep a new session in a cookie, and take its token out.
|
|
49
|
+
*/
|
|
50
|
+
onActionResponse?: (request: ActionRequest, response: ActionResponse) => ActionResponse | Promise<ActionResponse>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ComposerProxy {
|
|
54
|
+
/** Checks a browser's action request, then performs it with the server's context and headers. */
|
|
55
|
+
performAction(body: unknown): Promise<ProxyResult<ActionResponse>>;
|
|
56
|
+
/** Checks a browser's screen request, then loads it with the server's context and headers. */
|
|
57
|
+
loadScreen(body: unknown): Promise<ProxyResult<ScreenResponse>>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
61
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
62
|
+
|
|
63
|
+
function isCapabilities(value: unknown): value is Capabilities {
|
|
64
|
+
return (
|
|
65
|
+
isRecord(value) &&
|
|
66
|
+
Object.values(value).every((versions) => Array.isArray(versions) && versions.every((v) => typeof v === "number"))
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
71
|
+
return isRecord(value) && Object.values(value).every((v) => typeof v === "string");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isProxiedScreenRequest(value: unknown): value is ProxiedScreenRequest {
|
|
75
|
+
return (
|
|
76
|
+
isRecord(value) &&
|
|
77
|
+
value.protocol === PROTOCOL_VERSION &&
|
|
78
|
+
typeof value.screen === "string" &&
|
|
79
|
+
(value.params === undefined || isStringRecord(value.params)) &&
|
|
80
|
+
isCapabilities(value.islands)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function isProxiedActionRequest(value: unknown): value is ProxiedActionRequest {
|
|
85
|
+
return (
|
|
86
|
+
isRecord(value) &&
|
|
87
|
+
value.protocol === PROTOCOL_VERSION &&
|
|
88
|
+
typeof value.action === "string" &&
|
|
89
|
+
typeof value.version === "number" &&
|
|
90
|
+
"input" in value &&
|
|
91
|
+
(value.idempotencyKey === undefined || typeof value.idempotencyKey === "string")
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What the composer itself answers to a malformed request. */
|
|
96
|
+
const BAD_REQUEST = {
|
|
97
|
+
ok: false,
|
|
98
|
+
status: 400,
|
|
99
|
+
body: JSON.stringify({ protocol: PROTOCOL_VERSION, error: { code: "bad_request" } }),
|
|
100
|
+
} as const;
|
|
101
|
+
|
|
102
|
+
export function createComposerProxy(options: ComposerProxyOptions): ComposerProxy {
|
|
103
|
+
async function init(): Promise<RequestInit> {
|
|
104
|
+
const headers = (await options.headers?.()) ?? {};
|
|
105
|
+
return { ...options.requestInit, headers: { ...options.requestInit?.headers, ...headers } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function performAction(body: unknown): Promise<ProxyResult<ActionResponse>> {
|
|
109
|
+
if (!isProxiedActionRequest(body)) return BAD_REQUEST;
|
|
110
|
+
const request: ActionRequest = { ...body, client: await options.client() };
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetchAction(options.baseUrl, request, await init(), options.fetch);
|
|
113
|
+
return { ok: true, response: (await options.onActionResponse?.(request, response)) ?? response };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (error instanceof ActionFetchError) return { ok: false, status: error.status, body: error.body };
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function loadScreen(body: unknown): Promise<ProxyResult<ScreenResponse>> {
|
|
121
|
+
if (!isProxiedScreenRequest(body)) return BAD_REQUEST;
|
|
122
|
+
const request: ScreenRequest = { ...body, client: await options.client() };
|
|
123
|
+
try {
|
|
124
|
+
return { ok: true, response: await fetchScreen(options.baseUrl, request, await init(), options.fetch) };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof ScreenFetchError) return { ok: false, status: error.status, body: error.body };
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { performAction, loadScreen };
|
|
132
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
// For code that uses the global fetch itself, `installFakeGateway` puts one in its place.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
PROTOCOL_VERSION,
|
|
15
|
+
type ActionDescriptor,
|
|
16
|
+
type ActionEffects,
|
|
17
|
+
type Capabilities,
|
|
18
|
+
type ClientContext,
|
|
19
|
+
type DeclaredError,
|
|
20
|
+
type IslandDescriptor,
|
|
21
|
+
type IslandEntry,
|
|
22
|
+
type IslandError,
|
|
23
|
+
type ScreenMeta,
|
|
24
|
+
} from "@client-compose/protocol";
|
|
25
|
+
|
|
26
|
+
/** A response for the fake composer to send. */
|
|
27
|
+
export interface FakeResponse {
|
|
28
|
+
status: number;
|
|
29
|
+
body: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A request the fake composer received. */
|
|
33
|
+
export interface RecordedRequest {
|
|
34
|
+
url: string;
|
|
35
|
+
method: string;
|
|
36
|
+
/** Header names are lower case. */
|
|
37
|
+
headers: Record<string, string>;
|
|
38
|
+
/** The JSON body, parsed; or the raw body when it isn't JSON (e.g. a multipart upload). */
|
|
39
|
+
body: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RecordedScreenRequest extends RecordedRequest {
|
|
43
|
+
screen: string;
|
|
44
|
+
params: Record<string, string>;
|
|
45
|
+
client: ClientContext | undefined;
|
|
46
|
+
islands: Capabilities;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RecordedActionRequest extends RecordedRequest {
|
|
50
|
+
action: string;
|
|
51
|
+
version: number;
|
|
52
|
+
input: unknown;
|
|
53
|
+
client: ClientContext | undefined;
|
|
54
|
+
idempotencyKey: string | undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FakeGatewayHandlers {
|
|
58
|
+
/** By screen id. */
|
|
59
|
+
screens?: Record<string, (request: RecordedScreenRequest) => FakeResponse>;
|
|
60
|
+
/** By action name. */
|
|
61
|
+
actions?: Record<string, (request: RecordedActionRequest) => FakeResponse>;
|
|
62
|
+
/** Anything else, e.g. uploads to storage: return undefined to leave a request to the others. */
|
|
63
|
+
other?: (request: RecordedRequest) => FakeResponse | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FakeGateway {
|
|
67
|
+
/** Use as the client's `fetch`. Requests without a handler reject, failing the test. */
|
|
68
|
+
fetch: typeof fetch;
|
|
69
|
+
/** Every request received, in order. */
|
|
70
|
+
requests: RecordedRequest[];
|
|
71
|
+
screenRequests(): RecordedScreenRequest[];
|
|
72
|
+
actionRequests(): RecordedActionRequest[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
76
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
|
+
|
|
78
|
+
function strings(value: unknown): Record<string, string> {
|
|
79
|
+
if (!isRecord(value)) return {};
|
|
80
|
+
return Object.fromEntries(Object.entries(value).filter((e): e is [string, string] => typeof e[1] === "string"));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function capabilities(value: unknown): Capabilities {
|
|
84
|
+
if (!isRecord(value)) return {};
|
|
85
|
+
return Object.fromEntries(
|
|
86
|
+
Object.entries(value).map(([type, versions]) => [
|
|
87
|
+
type,
|
|
88
|
+
Array.isArray(versions) ? versions.filter((v): v is number => typeof v === "number") : [],
|
|
89
|
+
]),
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function clientContext(value: unknown): ClientContext | undefined {
|
|
94
|
+
if (!isRecord(value) || typeof value.platform !== "string" || typeof value.appVersion !== "string") return undefined;
|
|
95
|
+
return { ...value, platform: value.platform, appVersion: value.appVersion } as ClientContext;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseBody(body: unknown): unknown {
|
|
99
|
+
if (typeof body !== "string") return body;
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(body) as unknown;
|
|
102
|
+
} catch {
|
|
103
|
+
return body;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function urlOf(input: RequestInfo | URL): string {
|
|
108
|
+
if (typeof input === "string") return input;
|
|
109
|
+
return input instanceof URL ? input.href : input.url;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function headersOf(init: HeadersInit | undefined): Record<string, string> {
|
|
113
|
+
const out: Record<string, string> = {};
|
|
114
|
+
new Headers(init).forEach((value, name) => (out[name] = value));
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function asScreenRequest(request: RecordedRequest): RecordedScreenRequest | undefined {
|
|
119
|
+
const { body } = request;
|
|
120
|
+
if (!request.url.includes("/screens/") || !isRecord(body) || typeof body.screen !== "string") return undefined;
|
|
121
|
+
return {
|
|
122
|
+
...request,
|
|
123
|
+
screen: body.screen,
|
|
124
|
+
params: strings(body.params),
|
|
125
|
+
client: clientContext(body.client),
|
|
126
|
+
islands: capabilities(body.islands),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function asActionRequest(request: RecordedRequest): RecordedActionRequest | undefined {
|
|
131
|
+
const { body } = request;
|
|
132
|
+
if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string") return undefined;
|
|
133
|
+
return {
|
|
134
|
+
...request,
|
|
135
|
+
action: body.action,
|
|
136
|
+
version: typeof body.version === "number" ? body.version : 0,
|
|
137
|
+
input: body.input,
|
|
138
|
+
client: clientContext(body.client),
|
|
139
|
+
idempotencyKey: typeof body.idempotencyKey === "string" ? body.idempotencyKey : undefined,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function respond(response: FakeResponse): Response {
|
|
144
|
+
return new Response(JSON.stringify(response.body), {
|
|
145
|
+
status: response.status,
|
|
146
|
+
headers: { "content-type": "application/json" },
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A composer whose screens and actions are the handlers given. */
|
|
151
|
+
export function createFakeGateway(handlers: FakeGatewayHandlers = {}): FakeGateway {
|
|
152
|
+
const requests: RecordedRequest[] = [];
|
|
153
|
+
async function fakeFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
154
|
+
const request: RecordedRequest = {
|
|
155
|
+
url: urlOf(input),
|
|
156
|
+
method: init?.method ?? "GET",
|
|
157
|
+
headers: headersOf(init?.headers),
|
|
158
|
+
body: parseBody(init?.body),
|
|
159
|
+
};
|
|
160
|
+
requests.push(request);
|
|
161
|
+
|
|
162
|
+
const other = handlers.other?.(request);
|
|
163
|
+
if (other) return respond(other);
|
|
164
|
+
const screenRequest = asScreenRequest(request);
|
|
165
|
+
const screenHandler = screenRequest && handlers.screens?.[screenRequest.screen];
|
|
166
|
+
if (screenRequest && screenHandler) return respond(screenHandler(screenRequest));
|
|
167
|
+
const actionRequest = asActionRequest(request);
|
|
168
|
+
const actionHandler = actionRequest && handlers.actions?.[actionRequest.action];
|
|
169
|
+
if (actionRequest && actionHandler) return respond(actionHandler(actionRequest));
|
|
170
|
+
throw new Error(`fake gateway: no handler for ${request.method} ${request.url} ${JSON.stringify(request.body)}`);
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
fetch: fakeFetch,
|
|
174
|
+
requests,
|
|
175
|
+
screenRequests: () => requests.flatMap((r) => asScreenRequest(r) ?? []),
|
|
176
|
+
actionRequests: () => requests.flatMap((r) => asActionRequest(r) ?? []),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface InstalledFakeGateway extends FakeGateway {
|
|
181
|
+
/** Puts back the `fetch` that was there before. */
|
|
182
|
+
restore(): void;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A fake composer installed as the global `fetch`, for code that fetches by itself (e.g. a whole
|
|
187
|
+
* app rendered in a test). Call `restore()` after the test.
|
|
188
|
+
*/
|
|
189
|
+
export function installFakeGateway(handlers: FakeGatewayHandlers = {}): InstalledFakeGateway {
|
|
190
|
+
const gateway = createFakeGateway(handlers);
|
|
191
|
+
const original = globalThis.fetch;
|
|
192
|
+
globalThis.fetch = gateway.fetch;
|
|
193
|
+
return {
|
|
194
|
+
...gateway,
|
|
195
|
+
restore: () => {
|
|
196
|
+
globalThis.fetch = original;
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- Responses ----
|
|
202
|
+
|
|
203
|
+
/** An island in a screen response, typed by its generated descriptor. */
|
|
204
|
+
export function island<P>(key: string, descriptor: IslandDescriptor<P>, props: P): IslandEntry<P> {
|
|
205
|
+
return { key, type: descriptor.type, version: descriptor.version, status: "ok", props };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** An island that failed on the composer (spec §3.4). */
|
|
209
|
+
export function islandError(key: string, descriptor: IslandDescriptor<unknown>, error: IslandError): IslandEntry {
|
|
210
|
+
return { key, type: descriptor.type, version: descriptor.version, status: "error", error };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A screen response. */
|
|
214
|
+
export function screen(id: string, islands: IslandEntry<unknown>[], meta?: ScreenMeta): FakeResponse {
|
|
215
|
+
return { status: 200, body: { protocol: PROTOCOL_VERSION, screen: id, islands, ...(meta && { meta }) } };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
|
|
219
|
+
export function screenError(status: number, code: string): FakeResponse {
|
|
220
|
+
return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** An action that succeeded, with its result typed by the descriptor. */
|
|
224
|
+
export function actionOk<I, R, E extends DeclaredError>(
|
|
225
|
+
action: ActionDescriptor<I, R, E>,
|
|
226
|
+
result: R,
|
|
227
|
+
effects?: ActionEffects,
|
|
228
|
+
): FakeResponse {
|
|
229
|
+
const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
|
|
230
|
+
return { status: 200, body: { ...base, status: "ok", result, ...(effects && { effects }) } };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** An action that ended with one of its declared errors. */
|
|
234
|
+
export function actionError<I, R, E extends DeclaredError>(action: ActionDescriptor<I, R, E>, error: E): FakeResponse {
|
|
235
|
+
const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
|
|
236
|
+
return { status: 200, body: { ...base, status: "error", error } };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
|
|
240
|
+
export function actionFailure(status: number, code: string): FakeResponse {
|
|
241
|
+
return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
|
|
242
|
+
}
|