@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/src/proxy.ts CHANGED
@@ -18,15 +18,26 @@ import {
18
18
  type ActionResponse,
19
19
  type Capabilities,
20
20
  type ClientContext,
21
+ type IslandEntry,
22
+ type IslandRequest,
21
23
  PROTOCOL_VERSION,
22
24
  type ScreenRequest,
23
25
  type ScreenResponse,
24
26
  } from "@client-compose/protocol";
25
27
 
26
- import { ActionFetchError, fetchAction, fetchScreen, ScreenFetchError } from "./index.ts";
28
+ import {
29
+ ActionFetchError,
30
+ fetchAction,
31
+ fetchIsland,
32
+ fetchScreen,
33
+ IslandFetchError,
34
+ ScreenFetchError,
35
+ } from "./index.ts";
27
36
 
28
37
  /** A screen request from the browser: the server sets the client context. */
29
38
  export type ProxiedScreenRequest = Omit<ScreenRequest, "client">;
39
+ /** An island request from the browser: the server sets the client context. */
40
+ export type ProxiedIslandRequest = Omit<IslandRequest, "client">;
30
41
  /** An action request from the browser: the server sets the client context. */
31
42
  export type ProxiedActionRequest = Omit<ActionRequest, "client">;
32
43
 
@@ -60,6 +71,11 @@ export interface ComposerProxy {
60
71
  performAction(body: unknown): Promise<ProxyResult<ActionResponse>>;
61
72
  /** Checks a browser's screen request, then loads it with the server's context and headers. */
62
73
  loadScreen(body: unknown): Promise<ProxyResult<ScreenResponse>>;
74
+ /**
75
+ * Checks a browser's island request (a page of an island, spec §4.5), then loads it with the
76
+ * server's context and headers. Resolves with the island's entry.
77
+ */
78
+ loadIsland(body: unknown): Promise<ProxyResult<IslandEntry>>;
63
79
  }
64
80
 
65
81
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -78,25 +94,48 @@ function isStringRecord(value: unknown): value is Record<string, string> {
78
94
  return isRecord(value) && Object.values(value).every((v) => typeof v === "string");
79
95
  }
80
96
 
97
+ // Each guard returns at the first thing a request gets wrong, so it checks no more than it must.
98
+
81
99
  export function isProxiedScreenRequest(value: unknown): value is ProxiedScreenRequest {
82
- return (
83
- isRecord(value) &&
84
- value.protocol === PROTOCOL_VERSION &&
85
- typeof value.screen === "string" &&
86
- (value.params === undefined || isStringRecord(value.params)) &&
87
- isCapabilities(value.islands)
88
- );
100
+ if (!isRecord(value) || value.protocol !== PROTOCOL_VERSION) {
101
+ return false;
102
+ }
103
+ if (typeof value.screen !== "string") {
104
+ return false;
105
+ }
106
+ if (value.params !== undefined && !isStringRecord(value.params)) {
107
+ return false;
108
+ }
109
+ return isCapabilities(value.islands);
110
+ }
111
+
112
+ export function isProxiedIslandRequest(value: unknown): value is ProxiedIslandRequest {
113
+ if (!isRecord(value) || value.protocol !== PROTOCOL_VERSION) {
114
+ return false;
115
+ }
116
+ if (typeof value.screen !== "string" || typeof value.key !== "string") {
117
+ return false;
118
+ }
119
+ if (typeof value.version !== "number" || !Number.isInteger(value.version)) {
120
+ return false;
121
+ }
122
+ if (value.params !== undefined && !isStringRecord(value.params)) {
123
+ return false;
124
+ }
125
+ return value.cursor === undefined || typeof value.cursor === "string";
89
126
  }
90
127
 
91
128
  export function isProxiedActionRequest(value: unknown): value is ProxiedActionRequest {
92
- return (
93
- isRecord(value) &&
94
- value.protocol === PROTOCOL_VERSION &&
95
- typeof value.action === "string" &&
96
- typeof value.version === "number" &&
97
- "input" in value &&
98
- (value.idempotencyKey === undefined || typeof value.idempotencyKey === "string")
99
- );
129
+ if (!isRecord(value) || value.protocol !== PROTOCOL_VERSION) {
130
+ return false;
131
+ }
132
+ if (typeof value.action !== "string" || typeof value.version !== "number") {
133
+ return false;
134
+ }
135
+ if (!("input" in value)) {
136
+ return false;
137
+ }
138
+ return value.idempotencyKey === undefined || typeof value.idempotencyKey === "string";
100
139
  }
101
140
 
102
141
  /** What the composer itself answers to a malformed request. */
@@ -108,12 +147,17 @@ const BAD_REQUEST = {
108
147
 
109
148
  export function createComposerProxy(options: ComposerProxyOptions): ComposerProxy {
110
149
  async function init(): Promise<RequestInit> {
111
- const headers = (await options.headers?.()) ?? {};
112
- return { ...options.requestInit, headers: { ...options.requestInit?.headers, ...headers } };
150
+ const headers = new Headers(options.requestInit?.headers);
151
+ for (const [name, value] of Object.entries((await options.headers?.()) ?? {})) {
152
+ headers.set(name, value);
153
+ }
154
+ return { ...options.requestInit, headers };
113
155
  }
114
156
 
115
157
  async function performAction(body: unknown): Promise<ProxyResult<ActionResponse>> {
116
- if (!isProxiedActionRequest(body)) return BAD_REQUEST;
158
+ if (!isProxiedActionRequest(body)) {
159
+ return BAD_REQUEST;
160
+ }
117
161
  const request: ActionRequest = { ...body, client: await options.client() };
118
162
  try {
119
163
  const response = await fetchAction(options.baseUrl, request, await init(), options.fetch);
@@ -122,14 +166,21 @@ export function createComposerProxy(options: ComposerProxyOptions): ComposerProx
122
166
  response: (await options.onActionResponse?.(request, response)) ?? response,
123
167
  };
124
168
  } catch (error) {
125
- if (error instanceof ActionFetchError)
126
- return { ok: false, status: error.status, body: error.body };
169
+ if (error instanceof ActionFetchError) {
170
+ return {
171
+ ok: false,
172
+ status: error.status,
173
+ body: error.body,
174
+ };
175
+ }
127
176
  throw error;
128
177
  }
129
178
  }
130
179
 
131
180
  async function loadScreen(body: unknown): Promise<ProxyResult<ScreenResponse>> {
132
- if (!isProxiedScreenRequest(body)) return BAD_REQUEST;
181
+ if (!isProxiedScreenRequest(body)) {
182
+ return BAD_REQUEST;
183
+ }
133
184
  const request: ScreenRequest = { ...body, client: await options.client() };
134
185
  try {
135
186
  return {
@@ -137,11 +188,42 @@ export function createComposerProxy(options: ComposerProxyOptions): ComposerProx
137
188
  response: await fetchScreen(options.baseUrl, request, await init(), options.fetch),
138
189
  };
139
190
  } catch (error) {
140
- if (error instanceof ScreenFetchError)
141
- return { ok: false, status: error.status, body: error.body };
191
+ if (error instanceof ScreenFetchError) {
192
+ return {
193
+ ok: false,
194
+ status: error.status,
195
+ body: error.body,
196
+ };
197
+ }
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ async function loadIsland(body: unknown): Promise<ProxyResult<IslandEntry>> {
203
+ if (!isProxiedIslandRequest(body)) {
204
+ return BAD_REQUEST;
205
+ }
206
+ const request: IslandRequest = { ...body, client: await options.client() };
207
+ try {
208
+ return {
209
+ ok: true,
210
+ response: await fetchIsland(options.baseUrl, request, await init(), options.fetch),
211
+ };
212
+ } catch (error) {
213
+ if (error instanceof IslandFetchError) {
214
+ return {
215
+ ok: false,
216
+ status: error.status,
217
+ body: error.body,
218
+ };
219
+ }
142
220
  throw error;
143
221
  }
144
222
  }
145
223
 
146
- return { performAction, loadScreen };
224
+ return {
225
+ performAction,
226
+ loadScreen,
227
+ loadIsland,
228
+ };
147
229
  }
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
- /** A response for the fake composer to send. */
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: unknown;
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)) return {};
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)) return {};
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
- return { ...value, platform: value.platform, appVersion: value.appVersion } as ClientContext;
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") return body;
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") return input;
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
- if (!request.url.includes("/screens/") || !isRecord(body) || typeof body.screen !== "string")
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) return respond(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) return respond(screenHandler(screenRequest));
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) return respond(actionHandler(actionRequest));
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
- /** An island in a screen response, typed by its generated descriptor. */
215
- export function island<P>(key: string, descriptor: IslandDescriptor<P>, props: P): IslandEntry<P> {
216
- return { key, type: descriptor.type, version: descriptor.version, status: "ok", props };
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 { key, type: descriptor.type, version: descriptor.version, status: "error", error };
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 response. */
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
- body: { protocol: PROTOCOL_VERSION, screen: id, islands, ...(meta && { meta }) },
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 = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
252
- return { status: 200, body: { ...base, status: "ok", result, ...(effects && { effects }) } };
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 = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
261
- return { status: 200, body: { ...base, status: "error", error } };
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). */