@client-compose/client 0.6.0 → 0.8.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 +40 -4
- package/dist/index.d.ts +71 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +288 -25
- package/dist/index.js.map +1 -1
- package/dist/proxy.d.ts +9 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +87 -21
- package/dist/proxy.js.map +1 -1
- package/dist/testing.d.ts +49 -6
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +202 -21
- package/dist/testing.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +362 -28
- package/src/proxy.ts +107 -25
- package/src/testing.ts +256 -23
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 {
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 = (
|
|
112
|
-
|
|
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))
|
|
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 {
|
|
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))
|
|
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 {
|
|
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 {
|
|
224
|
+
return {
|
|
225
|
+
performAction,
|
|
226
|
+
loadScreen,
|
|
227
|
+
loadIsland,
|
|
228
|
+
};
|
|
147
229
|
}
|
package/src/testing.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// const gateway = createFakeGateway({
|
|
6
6
|
// screens: { profile: () => screen("profile", [island("header", ProfileHeaderV1, { name: "Ada" })]) },
|
|
7
7
|
// actions: { follow: () => actionOk(FollowUserV1, { followers: 8 }) },
|
|
8
|
+
// islands: { "profile/posts": (r) => islandResponse("profile", island("posts", PostsV1, …, "page:3")) },
|
|
8
9
|
// });
|
|
9
10
|
// // pass gateway.fetch as the client's fetch, then assert on gateway.actionRequests()
|
|
10
11
|
//
|
|
@@ -19,14 +20,20 @@ import {
|
|
|
19
20
|
type IslandDescriptor,
|
|
20
21
|
type IslandEntry,
|
|
21
22
|
type IslandError,
|
|
23
|
+
type PendingIsland,
|
|
22
24
|
PROTOCOL_VERSION,
|
|
23
25
|
type ScreenMeta,
|
|
24
26
|
} from "@client-compose/protocol";
|
|
25
27
|
|
|
26
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* A response for the fake composer to send: a JSON body, or a screen's stream (spec §4.6), as
|
|
30
|
+
* lines that come when they're given.
|
|
31
|
+
*/
|
|
27
32
|
export interface FakeResponse {
|
|
28
33
|
status: number;
|
|
29
|
-
body
|
|
34
|
+
body?: unknown;
|
|
35
|
+
/** Opens the stream's body, for each request answered with it. */
|
|
36
|
+
stream?: () => ReadableStream<Uint8Array>;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/** A request the fake composer received. */
|
|
@@ -46,6 +53,16 @@ export interface RecordedScreenRequest extends RecordedRequest {
|
|
|
46
53
|
islands: Capabilities;
|
|
47
54
|
}
|
|
48
55
|
|
|
56
|
+
/** An island request (spec §4.5): a page of one island, or the island resolved again. */
|
|
57
|
+
export interface RecordedIslandRequest extends RecordedRequest {
|
|
58
|
+
screen: string;
|
|
59
|
+
key: string;
|
|
60
|
+
version: number;
|
|
61
|
+
params: Record<string, string>;
|
|
62
|
+
cursor: string | undefined;
|
|
63
|
+
client: ClientContext | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
export interface RecordedActionRequest extends RecordedRequest {
|
|
50
67
|
action: string;
|
|
51
68
|
version: number;
|
|
@@ -59,6 +76,8 @@ export interface FakeGatewayHandlers {
|
|
|
59
76
|
screens?: Record<string, (request: RecordedScreenRequest) => FakeResponse>;
|
|
60
77
|
/** By action name. */
|
|
61
78
|
actions?: Record<string, (request: RecordedActionRequest) => FakeResponse>;
|
|
79
|
+
/** Island requests, by `"{screen}/{key}"`. */
|
|
80
|
+
islands?: Record<string, (request: RecordedIslandRequest) => FakeResponse>;
|
|
62
81
|
/** Anything else, e.g. uploads to storage: return undefined to leave a request to the others. */
|
|
63
82
|
other?: (request: RecordedRequest) => FakeResponse | undefined;
|
|
64
83
|
}
|
|
@@ -69,6 +88,7 @@ export interface FakeGateway {
|
|
|
69
88
|
/** Every request received, in order. */
|
|
70
89
|
requests: RecordedRequest[];
|
|
71
90
|
screenRequests(): RecordedScreenRequest[];
|
|
91
|
+
islandRequests(): RecordedIslandRequest[];
|
|
72
92
|
actionRequests(): RecordedActionRequest[];
|
|
73
93
|
}
|
|
74
94
|
|
|
@@ -76,14 +96,18 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
76
96
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
97
|
|
|
78
98
|
function strings(value: unknown): Record<string, string> {
|
|
79
|
-
if (!isRecord(value))
|
|
99
|
+
if (!isRecord(value)) {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
80
102
|
return Object.fromEntries(
|
|
81
103
|
Object.entries(value).filter((e): e is [string, string] => typeof e[1] === "string"),
|
|
82
104
|
);
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
function capabilities(value: unknown): Capabilities {
|
|
86
|
-
if (!isRecord(value))
|
|
108
|
+
if (!isRecord(value)) {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
87
111
|
return Object.fromEntries(
|
|
88
112
|
Object.entries(value).map(([type, versions]) => [
|
|
89
113
|
type,
|
|
@@ -97,13 +121,20 @@ function clientContext(value: unknown): ClientContext | undefined {
|
|
|
97
121
|
!isRecord(value) ||
|
|
98
122
|
typeof value.platform !== "string" ||
|
|
99
123
|
typeof value.appVersion !== "string"
|
|
100
|
-
)
|
|
124
|
+
) {
|
|
101
125
|
return undefined;
|
|
102
|
-
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
...value,
|
|
129
|
+
platform: value.platform,
|
|
130
|
+
appVersion: value.appVersion,
|
|
131
|
+
} as ClientContext;
|
|
103
132
|
}
|
|
104
133
|
|
|
105
134
|
function parseBody(body: unknown): unknown {
|
|
106
|
-
if (typeof body !== "string")
|
|
135
|
+
if (typeof body !== "string") {
|
|
136
|
+
return body;
|
|
137
|
+
}
|
|
107
138
|
try {
|
|
108
139
|
return JSON.parse(body) as unknown;
|
|
109
140
|
} catch {
|
|
@@ -112,7 +143,9 @@ function parseBody(body: unknown): unknown {
|
|
|
112
143
|
}
|
|
113
144
|
|
|
114
145
|
function urlOf(input: RequestInfo | URL): string {
|
|
115
|
-
if (typeof input === "string")
|
|
146
|
+
if (typeof input === "string") {
|
|
147
|
+
return input;
|
|
148
|
+
}
|
|
116
149
|
return input instanceof URL ? input.href : input.url;
|
|
117
150
|
}
|
|
118
151
|
|
|
@@ -122,10 +155,16 @@ function headersOf(init: HeadersInit | undefined): Record<string, string> {
|
|
|
122
155
|
return out;
|
|
123
156
|
}
|
|
124
157
|
|
|
158
|
+
function isIslandURL(url: string): boolean {
|
|
159
|
+
return /\/screens\/[^/]+\/islands\/[^/?]+/.test(url);
|
|
160
|
+
}
|
|
161
|
+
|
|
125
162
|
function asScreenRequest(request: RecordedRequest): RecordedScreenRequest | undefined {
|
|
126
163
|
const { body } = request;
|
|
127
|
-
|
|
164
|
+
const isScreen = request.url.includes("/screens/") && !isIslandURL(request.url);
|
|
165
|
+
if (!isScreen || !isRecord(body) || typeof body.screen !== "string") {
|
|
128
166
|
return undefined;
|
|
167
|
+
}
|
|
129
168
|
return {
|
|
130
169
|
...request,
|
|
131
170
|
screen: body.screen,
|
|
@@ -135,10 +174,30 @@ function asScreenRequest(request: RecordedRequest): RecordedScreenRequest | unde
|
|
|
135
174
|
};
|
|
136
175
|
}
|
|
137
176
|
|
|
177
|
+
function asIslandRequest(request: RecordedRequest): RecordedIslandRequest | undefined {
|
|
178
|
+
const { body } = request;
|
|
179
|
+
if (!isIslandURL(request.url) || !isRecord(body)) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
if (typeof body.screen !== "string" || typeof body.key !== "string") {
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
...request,
|
|
187
|
+
screen: body.screen,
|
|
188
|
+
key: body.key,
|
|
189
|
+
version: typeof body.version === "number" ? body.version : 0,
|
|
190
|
+
params: strings(body.params),
|
|
191
|
+
cursor: typeof body.cursor === "string" ? body.cursor : undefined,
|
|
192
|
+
client: clientContext(body.client),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
138
196
|
function asActionRequest(request: RecordedRequest): RecordedActionRequest | undefined {
|
|
139
197
|
const { body } = request;
|
|
140
|
-
if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string")
|
|
198
|
+
if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string") {
|
|
141
199
|
return undefined;
|
|
200
|
+
}
|
|
142
201
|
return {
|
|
143
202
|
...request,
|
|
144
203
|
action: body.action,
|
|
@@ -150,6 +209,12 @@ function asActionRequest(request: RecordedRequest): RecordedActionRequest | unde
|
|
|
150
209
|
}
|
|
151
210
|
|
|
152
211
|
function respond(response: FakeResponse): Response {
|
|
212
|
+
if (response.stream) {
|
|
213
|
+
return new Response(response.stream(), {
|
|
214
|
+
status: response.status,
|
|
215
|
+
headers: { "content-type": "application/jsonl" },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
153
218
|
return new Response(JSON.stringify(response.body), {
|
|
154
219
|
status: response.status,
|
|
155
220
|
headers: { "content-type": "application/json" },
|
|
@@ -169,13 +234,25 @@ export function createFakeGateway(handlers: FakeGatewayHandlers = {}): FakeGatew
|
|
|
169
234
|
requests.push(request);
|
|
170
235
|
|
|
171
236
|
const other = handlers.other?.(request);
|
|
172
|
-
if (other)
|
|
237
|
+
if (other) {
|
|
238
|
+
return respond(other);
|
|
239
|
+
}
|
|
240
|
+
const islandRequest = asIslandRequest(request);
|
|
241
|
+
const islandHandler =
|
|
242
|
+
islandRequest && handlers.islands?.[`${islandRequest.screen}/${islandRequest.key}`];
|
|
243
|
+
if (islandRequest && islandHandler) {
|
|
244
|
+
return respond(islandHandler(islandRequest));
|
|
245
|
+
}
|
|
173
246
|
const screenRequest = asScreenRequest(request);
|
|
174
247
|
const screenHandler = screenRequest && handlers.screens?.[screenRequest.screen];
|
|
175
|
-
if (screenRequest && screenHandler)
|
|
248
|
+
if (screenRequest && screenHandler) {
|
|
249
|
+
return respond(screenHandler(screenRequest));
|
|
250
|
+
}
|
|
176
251
|
const actionRequest = asActionRequest(request);
|
|
177
252
|
const actionHandler = actionRequest && handlers.actions?.[actionRequest.action];
|
|
178
|
-
if (actionRequest && actionHandler)
|
|
253
|
+
if (actionRequest && actionHandler) {
|
|
254
|
+
return respond(actionHandler(actionRequest));
|
|
255
|
+
}
|
|
179
256
|
throw new Error(
|
|
180
257
|
`fake gateway: no handler for ${request.method} ${request.url} ${JSON.stringify(request.body)}`,
|
|
181
258
|
);
|
|
@@ -184,6 +261,7 @@ export function createFakeGateway(handlers: FakeGatewayHandlers = {}): FakeGatew
|
|
|
184
261
|
fetch: fakeFetch,
|
|
185
262
|
requests,
|
|
186
263
|
screenRequests: () => requests.flatMap((r) => asScreenRequest(r) ?? []),
|
|
264
|
+
islandRequests: () => requests.flatMap((r) => asIslandRequest(r) ?? []),
|
|
187
265
|
actionRequests: () => requests.flatMap((r) => asActionRequest(r) ?? []),
|
|
188
266
|
};
|
|
189
267
|
}
|
|
@@ -211,9 +289,24 @@ export function installFakeGateway(handlers: FakeGatewayHandlers = {}): Installe
|
|
|
211
289
|
|
|
212
290
|
// ---- Responses ----
|
|
213
291
|
|
|
214
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
292
|
+
/**
|
|
293
|
+
* An island in a screen or island response, typed by its generated descriptor. Give `next` for an
|
|
294
|
+
* island with more pages (spec §4.5).
|
|
295
|
+
*/
|
|
296
|
+
export function island<P>(
|
|
297
|
+
key: string,
|
|
298
|
+
descriptor: IslandDescriptor<P>,
|
|
299
|
+
props: P,
|
|
300
|
+
next?: string,
|
|
301
|
+
): IslandEntry<P> {
|
|
302
|
+
return {
|
|
303
|
+
key,
|
|
304
|
+
type: descriptor.type,
|
|
305
|
+
version: descriptor.version,
|
|
306
|
+
status: "ok",
|
|
307
|
+
props,
|
|
308
|
+
...(next !== undefined && { next }),
|
|
309
|
+
};
|
|
217
310
|
}
|
|
218
311
|
|
|
219
312
|
/** An island that failed on the composer (spec §3.4). */
|
|
@@ -222,18 +315,135 @@ export function islandError(
|
|
|
222
315
|
descriptor: IslandDescriptor<unknown>,
|
|
223
316
|
error: IslandError,
|
|
224
317
|
): IslandEntry {
|
|
225
|
-
return {
|
|
318
|
+
return {
|
|
319
|
+
key,
|
|
320
|
+
type: descriptor.type,
|
|
321
|
+
version: descriptor.version,
|
|
322
|
+
status: "error",
|
|
323
|
+
error,
|
|
324
|
+
};
|
|
226
325
|
}
|
|
227
326
|
|
|
228
|
-
/** A screen
|
|
327
|
+
/** A screen, all of it at once: its stream's head with every island, then its end (spec §4.6). */
|
|
229
328
|
export function screen(
|
|
230
329
|
id: string,
|
|
231
330
|
islands: IslandEntry<unknown>[],
|
|
232
331
|
meta?: ScreenMeta,
|
|
233
332
|
): FakeResponse {
|
|
333
|
+
const head = {
|
|
334
|
+
kind: "head",
|
|
335
|
+
protocol: PROTOCOL_VERSION,
|
|
336
|
+
screen: id,
|
|
337
|
+
islands,
|
|
338
|
+
...(meta && { meta }),
|
|
339
|
+
};
|
|
234
340
|
return {
|
|
235
341
|
status: 200,
|
|
236
|
-
|
|
342
|
+
stream: () => linesStream([head, { kind: "end" }]),
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** An island still being resolved, in a streamed screen's head (spec §4.6). */
|
|
347
|
+
export function pending(key: string, descriptor: IslandDescriptor<unknown>): PendingIsland {
|
|
348
|
+
return {
|
|
349
|
+
key,
|
|
350
|
+
type: descriptor.type,
|
|
351
|
+
version: descriptor.version,
|
|
352
|
+
status: "pending",
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A streamed screen whose lines a test sends, so it controls when each island arrives. */
|
|
357
|
+
export interface StreamedScreen extends FakeResponse {
|
|
358
|
+
/** Sends a pending island's entry. */
|
|
359
|
+
send(entry: IslandEntry<unknown>): void;
|
|
360
|
+
/** Sends the end line. */
|
|
361
|
+
end(): void;
|
|
362
|
+
/** Ends the stream without its end line, as a dropped connection would. */
|
|
363
|
+
cut(): void;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* A screen whose head is sent at once, with its islands resolved or `pending(…)`, and whose other
|
|
368
|
+
* lines are sent when the test says (spec §4.6). Answer one request with it.
|
|
369
|
+
*
|
|
370
|
+
* const results = streamedScreen("search", [island("box", BoxV1, {}), pending("results", ResultsV1)]);
|
|
371
|
+
* // render, check the placeholder, then:
|
|
372
|
+
* results.send(island("results", ResultsV1, { items: [] }));
|
|
373
|
+
* results.end();
|
|
374
|
+
*/
|
|
375
|
+
export function streamedScreen(
|
|
376
|
+
id: string,
|
|
377
|
+
islands: (IslandEntry<unknown> | PendingIsland)[],
|
|
378
|
+
meta?: ScreenMeta,
|
|
379
|
+
): StreamedScreen {
|
|
380
|
+
const encoder = new TextEncoder();
|
|
381
|
+
const queued: string[] = [];
|
|
382
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
383
|
+
let closed = false;
|
|
384
|
+
const write = (line: unknown) => {
|
|
385
|
+
const text = `${JSON.stringify(line)}\n`;
|
|
386
|
+
if (controller) {
|
|
387
|
+
controller.enqueue(encoder.encode(text));
|
|
388
|
+
} else {
|
|
389
|
+
queued.push(text);
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const close = () => {
|
|
393
|
+
closed = true;
|
|
394
|
+
controller?.close();
|
|
395
|
+
};
|
|
396
|
+
write({
|
|
397
|
+
kind: "head",
|
|
398
|
+
protocol: PROTOCOL_VERSION,
|
|
399
|
+
screen: id,
|
|
400
|
+
islands,
|
|
401
|
+
...(meta && { meta }),
|
|
402
|
+
});
|
|
403
|
+
return {
|
|
404
|
+
status: 200,
|
|
405
|
+
stream: () =>
|
|
406
|
+
new ReadableStream<Uint8Array>({
|
|
407
|
+
start(c) {
|
|
408
|
+
controller = c;
|
|
409
|
+
for (const text of queued.splice(0)) {
|
|
410
|
+
c.enqueue(encoder.encode(text));
|
|
411
|
+
}
|
|
412
|
+
if (closed) {
|
|
413
|
+
c.close();
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
}),
|
|
417
|
+
send: (entry) => write({ kind: "island", island: entry }),
|
|
418
|
+
end: () => {
|
|
419
|
+
write({ kind: "end" });
|
|
420
|
+
close();
|
|
421
|
+
},
|
|
422
|
+
cut: close,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function linesStream(lines: unknown[]): ReadableStream<Uint8Array> {
|
|
427
|
+
const encoder = new TextEncoder();
|
|
428
|
+
return new ReadableStream({
|
|
429
|
+
start(controller) {
|
|
430
|
+
for (const line of lines) {
|
|
431
|
+
controller.enqueue(encoder.encode(`${JSON.stringify(line)}\n`));
|
|
432
|
+
}
|
|
433
|
+
controller.close();
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** An island response (spec §4.5): a page of one island, or the island resolved again. */
|
|
439
|
+
export function islandResponse(screen: string, entry: IslandEntry<unknown>): FakeResponse {
|
|
440
|
+
return {
|
|
441
|
+
status: 200,
|
|
442
|
+
body: {
|
|
443
|
+
protocol: PROTOCOL_VERSION,
|
|
444
|
+
screen,
|
|
445
|
+
island: entry,
|
|
446
|
+
},
|
|
237
447
|
};
|
|
238
448
|
}
|
|
239
449
|
|
|
@@ -248,8 +458,20 @@ export function actionOk<I, R, E extends DeclaredError>(
|
|
|
248
458
|
result: R,
|
|
249
459
|
effects?: ActionEffects,
|
|
250
460
|
): FakeResponse {
|
|
251
|
-
const base = {
|
|
252
|
-
|
|
461
|
+
const base = {
|
|
462
|
+
protocol: PROTOCOL_VERSION,
|
|
463
|
+
action: action.action,
|
|
464
|
+
version: action.version,
|
|
465
|
+
};
|
|
466
|
+
return {
|
|
467
|
+
status: 200,
|
|
468
|
+
body: {
|
|
469
|
+
...base,
|
|
470
|
+
status: "ok",
|
|
471
|
+
result,
|
|
472
|
+
...(effects && { effects }),
|
|
473
|
+
},
|
|
474
|
+
};
|
|
253
475
|
}
|
|
254
476
|
|
|
255
477
|
/** An action that ended with one of its declared errors. */
|
|
@@ -257,8 +479,19 @@ export function actionError<I, R, E extends DeclaredError>(
|
|
|
257
479
|
action: ActionDescriptor<I, R, E>,
|
|
258
480
|
error: E,
|
|
259
481
|
): FakeResponse {
|
|
260
|
-
const base = {
|
|
261
|
-
|
|
482
|
+
const base = {
|
|
483
|
+
protocol: PROTOCOL_VERSION,
|
|
484
|
+
action: action.action,
|
|
485
|
+
version: action.version,
|
|
486
|
+
};
|
|
487
|
+
return {
|
|
488
|
+
status: 200,
|
|
489
|
+
body: {
|
|
490
|
+
...base,
|
|
491
|
+
status: "error",
|
|
492
|
+
error,
|
|
493
|
+
},
|
|
494
|
+
};
|
|
262
495
|
}
|
|
263
496
|
|
|
264
497
|
/** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
|