@heojeongbo/wirestage 0.1.0 → 0.2.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.
Files changed (65) hide show
  1. package/dist/entities/plugin/model/http.d.ts +12 -3
  2. package/dist/entities/plugin/model/http.d.ts.map +1 -1
  3. package/dist/entities/plugin/model/socket.d.ts +12 -7
  4. package/dist/entities/plugin/model/socket.d.ts.map +1 -1
  5. package/dist/entities/plugin/model/stream.d.ts +7 -4
  6. package/dist/entities/plugin/model/stream.d.ts.map +1 -1
  7. package/dist/features/sse/lib/create-sse.d.ts.map +1 -1
  8. package/dist/features/sse/lib/create-sse.js +2 -1
  9. package/dist/features/sse/lib/create-sse.js.map +1 -1
  10. package/dist/features/sse/model/types.d.ts +8 -2
  11. package/dist/features/sse/model/types.d.ts.map +1 -1
  12. package/dist/features/websocket/lib/proxy.d.ts.map +1 -1
  13. package/dist/features/websocket/lib/proxy.js +16 -3
  14. package/dist/features/websocket/lib/proxy.js.map +1 -1
  15. package/dist/features/websocket/model/types.d.ts +5 -1
  16. package/dist/features/websocket/model/types.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/shared/api/playwright/ports.d.ts +10 -1
  21. package/dist/shared/api/playwright/ports.d.ts.map +1 -1
  22. package/dist/shared/api/stream-server/stream-server.d.ts +3 -1
  23. package/dist/shared/api/stream-server/stream-server.d.ts.map +1 -1
  24. package/dist/shared/api/stream-server/stream-server.js +10 -3
  25. package/dist/shared/api/stream-server/stream-server.js.map +1 -1
  26. package/dist/widgets/stage/index.d.ts +1 -1
  27. package/dist/widgets/stage/index.d.ts.map +1 -1
  28. package/dist/widgets/stage/lib/create-stage.d.ts.map +1 -1
  29. package/dist/widgets/stage/lib/create-stage.js +4 -0
  30. package/dist/widgets/stage/lib/create-stage.js.map +1 -1
  31. package/dist/widgets/stage/lib/http-router.d.ts +11 -2
  32. package/dist/widgets/stage/lib/http-router.d.ts.map +1 -1
  33. package/dist/widgets/stage/lib/http-router.js +317 -76
  34. package/dist/widgets/stage/lib/http-router.js.map +1 -1
  35. package/dist/widgets/stage/lib/reads.d.ts +4 -1
  36. package/dist/widgets/stage/lib/reads.d.ts.map +1 -1
  37. package/dist/widgets/stage/lib/reads.js +32 -1
  38. package/dist/widgets/stage/lib/reads.js.map +1 -1
  39. package/dist/widgets/stage/lib/response-stream.d.ts +17 -3
  40. package/dist/widgets/stage/lib/response-stream.d.ts.map +1 -1
  41. package/dist/widgets/stage/lib/response-stream.js +88 -36
  42. package/dist/widgets/stage/lib/response-stream.js.map +1 -1
  43. package/dist/widgets/stage/lib/socket-router.d.ts.map +1 -1
  44. package/dist/widgets/stage/lib/socket-router.js +44 -15
  45. package/dist/widgets/stage/lib/socket-router.js.map +1 -1
  46. package/dist/widgets/stage/model/stage.d.ts +40 -7
  47. package/dist/widgets/stage/model/stage.d.ts.map +1 -1
  48. package/package.json +1 -1
  49. package/src/entities/plugin/model/http.ts +12 -3
  50. package/src/entities/plugin/model/socket.ts +12 -7
  51. package/src/entities/plugin/model/stream.ts +7 -4
  52. package/src/features/sse/lib/create-sse.ts +2 -1
  53. package/src/features/sse/model/types.ts +8 -2
  54. package/src/features/websocket/lib/proxy.ts +15 -3
  55. package/src/features/websocket/model/types.ts +5 -1
  56. package/src/index.ts +2 -0
  57. package/src/shared/api/playwright/ports.ts +7 -2
  58. package/src/shared/api/stream-server/stream-server.ts +11 -3
  59. package/src/widgets/stage/index.ts +2 -0
  60. package/src/widgets/stage/lib/create-stage.ts +4 -0
  61. package/src/widgets/stage/lib/http-router.ts +425 -91
  62. package/src/widgets/stage/lib/reads.ts +44 -2
  63. package/src/widgets/stage/lib/response-stream.ts +110 -37
  64. package/src/widgets/stage/lib/socket-router.ts +45 -16
  65. package/src/widgets/stage/model/stage.ts +42 -7
@@ -1,11 +1,13 @@
1
1
  import type { FrameInit, Timeline } from "../../../entities/frame/index.js";
2
2
  import type {
3
+ AbortErrorCode,
3
4
  HttpOutcome,
4
5
  HttpRequestView,
5
6
  HttpRouter,
6
7
  HttpRule,
7
8
  HttpRuleHandle,
8
9
  PassthroughResponse,
10
+ StreamEnd,
9
11
  } from "../../../entities/plugin/index.js";
10
12
  import type {
11
13
  PagePort,
@@ -14,13 +16,18 @@ import type {
14
16
  RoutePort,
15
17
  UrlPredicate,
16
18
  } from "../../../shared/api/playwright/index.js";
17
- import type { StreamServer } from "../../../shared/api/stream-server/index.js";
19
+ import type {
20
+ NodeHandler,
21
+ StreamServer,
22
+ StreamSession,
23
+ } from "../../../shared/api/stream-server/index.js";
18
24
  import { toBuffer, toBytes, utf8 } from "../../../shared/lib/bytes/index.js";
25
+ import type { Clock } from "../../../shared/lib/clock/index.js";
19
26
  import { WirestageError } from "../../../shared/lib/errors/index.js";
20
27
  import type { ScopedLogger } from "../../../shared/lib/logger/index.js";
21
28
  import { compileUrlMatcher } from "../../../shared/lib/match/index.js";
22
29
  import type { AttachedContext } from "./attached-context.js";
23
- import { createResponseStream } from "./response-stream.js";
30
+ import { createResponseStream, type ServedStream } from "./response-stream.js";
24
31
 
25
32
  export interface HttpRouterDeps {
26
33
  readonly timeline: Timeline;
@@ -28,14 +35,22 @@ export interface HttpRouterDeps {
28
35
  readonly streams: Pick<StreamServer, "open">;
29
36
  readonly pageId: (page: PagePort) => string;
30
37
  readonly correlation: () => string;
38
+ readonly clock: Clock;
39
+ /**
40
+ * How long the browser may take over what the router waits on: handing
41
+ * over a passed-through response's body, which a dialog holds back, and
42
+ * bringing a live stream's request to the stream server.
43
+ */
44
+ readonly timeoutMs: number;
31
45
  }
32
46
 
33
47
  export interface HttpRouterEngine extends HttpRouter {
34
48
  attach(attachment: AttachedContext): Promise<void>;
35
49
  detach(attachment: AttachedContext): Promise<void>;
36
50
  /**
37
- * Wait for the responses that already came back to be on the timeline:
38
- * reading a passed-through response takes a round trip to the browser.
51
+ * Wait for what is on its way to the timeline: the responses that came
52
+ * back — reading one is a round trip to the browser, waited on for
53
+ * `timeoutMs` at most — and the ends of the streams a stub closed.
39
54
  */
40
55
  settle(): Promise<void>;
41
56
  }
@@ -49,6 +64,8 @@ interface Entry {
49
64
  active: boolean;
50
65
  }
51
66
 
67
+ type RequestFrame = ReturnType<RequestPort["frame"]>;
68
+
52
69
  /**
53
70
  * One request, as every rule that sees it shares it: one view, one
54
71
  * correlation id, one raw request frame per protocol.
@@ -58,6 +75,8 @@ interface Exchange {
58
75
  readonly attachment: AttachedContext;
59
76
  readonly route: RoutePort;
60
77
  readonly request: RequestPort;
78
+ /** The frame that sent it; undefined for a service worker's request. */
79
+ readonly frame: RequestFrame | undefined;
61
80
  /** Protocol → endpoint of the raw request frames on the timeline. */
62
81
  readonly recorded: Map<string, string>;
63
82
  /** Rules that passed the request on, newest first. */
@@ -74,6 +93,13 @@ interface Listeners {
74
93
  readonly failed: (request: RequestPort) => void;
75
94
  }
76
95
 
96
+ /** A live stream redirected to the stream server, before the browser comes. */
97
+ interface Unclaimed {
98
+ readonly attachment: AttachedContext;
99
+ /** Stop waiting: the browser came, or `frame` says why it never will. */
100
+ readonly settle: (frame?: Closing) => void;
101
+ }
102
+
77
103
  /** A frame that ends an exchange, before the exchange's own fields. */
78
104
  type Closing = Omit<
79
105
  FrameInit,
@@ -86,6 +112,10 @@ type Answer = Exclude<HttpOutcome, { readonly type: "fallback" }>;
86
112
  * How each outcome is carried out. A table keyed by the outcome's `type`,
87
113
  * not a `switch`: an outcome added to the union and not here is a compile
88
114
  * error, where a missing `case` would let the request hang.
115
+ *
116
+ * Each checks its answer before it touches the route: Playwright marks a
117
+ * route handled before it checks an answer, so one it refuses would leave
118
+ * no way to tell the page anything.
89
119
  */
90
120
  type Executors = {
91
121
  readonly [Type in Answer["type"]]: (
@@ -95,6 +125,125 @@ type Executors = {
95
125
  ) => Promise<void>;
96
126
  };
97
127
 
128
+ /**
129
+ * Playwright's abort codes. Keyed by the contract's type, so the two cannot
130
+ * drift: a code missing here, or one too many, does not compile.
131
+ */
132
+ const ABORT_CODES: Readonly<Record<AbortErrorCode, true>> = {
133
+ aborted: true,
134
+ accessdenied: true,
135
+ addressunreachable: true,
136
+ blockedbyclient: true,
137
+ blockedbyresponse: true,
138
+ connectionaborted: true,
139
+ connectionclosed: true,
140
+ connectionfailed: true,
141
+ connectionrefused: true,
142
+ connectionreset: true,
143
+ internetdisconnected: true,
144
+ namenotresolved: true,
145
+ timedout: true,
146
+ failed: true,
147
+ };
148
+
149
+ /** RFC 9110's `token`: what a header name may be made of. */
150
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
151
+
152
+ /** A host whose http pages are secure contexts. */
153
+ const LOOPBACK_HOST = /^(localhost|.+\.localhost|127(\.\d{1,3}){3}|\[::1\])$/;
154
+
155
+ /** A loopback address, as Chromium reports the server of a response. */
156
+ const LOOPBACK_ADDRESS = /^(127\.|::ffff:127\.|\[?::1\]?$)/;
157
+
158
+ /**
159
+ * Refuse a header Chromium refuses: a name that is not a token, a value
160
+ * with a line break or NUL. Playwright reports such an answer sent while
161
+ * Chromium never sends it, and the request hangs.
162
+ */
163
+ function checkHeader(name: string, value: string): void {
164
+ if (!HEADER_NAME.test(name))
165
+ throw new WirestageError(
166
+ "INVALID_ARGUMENT",
167
+ `${JSON.stringify(name)} is not a valid header name`,
168
+ );
169
+ if (/[\r\n\0]/.test(value))
170
+ throw new WirestageError(
171
+ "INVALID_ARGUMENT",
172
+ `the value of header "${name}" has a line break or NUL in it`,
173
+ );
174
+ }
175
+
176
+ /**
177
+ * A fulfill's headers, as Chromium takes them. A value may carry several,
178
+ * one per line: Playwright sends `set-cookie` as a header line each, and
179
+ * any other header is folded into one value, `, `-separated, as HTTP joins
180
+ * repeated header lines.
181
+ */
182
+ function fulfillHeaders(
183
+ headers: Readonly<Record<string, string>> = {},
184
+ ): Record<string, string> {
185
+ const sent: Record<string, string> = {};
186
+ for (const [name, value] of Object.entries(headers)) {
187
+ const values = value.split("\n");
188
+ for (const each of values) checkHeader(name, each);
189
+ sent[name] =
190
+ name.toLowerCase() === "set-cookie" ? value : values.join(", ");
191
+ }
192
+ return sent;
193
+ }
194
+
195
+ /** Where a `continue` goes: relative to the request, in its scheme. */
196
+ function continueUrl(target: string, request: URL): string {
197
+ const url = new URL(target, request);
198
+ if (url.protocol !== request.protocol)
199
+ throw new WirestageError(
200
+ "INVALID_ARGUMENT",
201
+ `continue cannot change the scheme (${request.protocol} to ${url.protocol}): Playwright rewrites a URL within its scheme only`,
202
+ );
203
+ return url.href;
204
+ }
205
+
206
+ /** Whether a page at `url` is a secure context: https, or http on loopback. */
207
+ const isSecure = ({ protocol, hostname }: URL): boolean =>
208
+ protocol === "https:" ||
209
+ (protocol === "http:" && LOOPBACK_HOST.test(hostname));
210
+
211
+ /** A request the browser reports failed, with its error text. */
212
+ const networkFault = (error: string | undefined): Closing => ({
213
+ kind: "fault",
214
+ direction: "none",
215
+ source: "network",
216
+ meta: { reason: "network", error },
217
+ });
218
+
219
+ /** A live stream whose request never reached the stream server. */
220
+ const unreached = (error: string): Closing => ({
221
+ kind: "fault",
222
+ direction: "none",
223
+ source: "stub",
224
+ meta: { reason: "unreached", error },
225
+ });
226
+
227
+ /** How a stream that ran its course is on record. */
228
+ const endOf = (end: StreamEnd): Closing =>
229
+ end === "cut"
230
+ ? {
231
+ kind: "fault",
232
+ direction: "none",
233
+ source: "stub",
234
+ meta: { reason: "cut" },
235
+ }
236
+ : {
237
+ kind: "lifecycle",
238
+ direction: "none",
239
+ // The page let go of it, or the stub ended it.
240
+ source: end === "cancelled" ? "page" : "stub",
241
+ meta: { event: "close", end },
242
+ };
243
+
244
+ const messageOf = (error: unknown): string =>
245
+ error instanceof Error ? error.message : String(error);
246
+
98
247
  /**
99
248
  * Turns HTTP rules into ONE Playwright route per attached context and runs
100
249
  * the chain itself: the newest rule that matches decides, and a `fallback`
@@ -112,36 +261,96 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
112
261
  const attachments = new Map<AttachedContext, Listeners>();
113
262
  const routes = new Map<AttachedContext, Promise<Installed>>();
114
263
  const pending = new WeakMap<RequestPort, Exchange>();
115
- const localNetwork = new WeakMap<AttachedContext, Promise<void>>();
264
+ /** Live streams whose request the browser has not brought yet. */
265
+ const unclaimed = new Map<RequestPort, Unclaimed>();
266
+ /** Live streams the stream server is serving. */
267
+ const serving = new Set<ServedStream>();
268
+ /** Where each frame's document came from, as its navigation reported. */
269
+ const documents = new WeakMap<
270
+ RequestFrame,
271
+ { readonly origin: string; readonly loopback: boolean }
272
+ >();
116
273
  /** Responses being read off the browser, on their way to the timeline. */
117
274
  const settling = new Set<Promise<void>>();
118
275
 
119
276
  /**
120
- * Chromium's Local Network Access blocks a page whose address space it
121
- * cannot place — every page a route fulfilled, and any remote origin — from
122
- * reaching the loopback stream server. Granted once per context, the first
123
- * time a stream needs it, and every stream waits for that one grant; a
124
- * browser without the permission simply refuses it, and then needs none.
277
+ * The origin a live stream from `frame` needs `local-network-access`
278
+ * for, if any. Chromium's Local Network Access stops a page it cannot
279
+ * place in an address space — any page a route fulfilled — from reaching
280
+ * the loopback stream server unless its origin holds the permission. A
281
+ * page a loopback server really served needs none, and one that is not a
282
+ * secure context is refused loopback, granted or not.
283
+ */
284
+ const grantFor = (frame: RequestFrame | undefined): string | undefined => {
285
+ // A service worker's request has no frame, and a frame that has shown
286
+ // no document yet no URL: neither has an origin to grant.
287
+ if (frame === undefined || !URL.canParse(frame.url())) return undefined;
288
+ const url = new URL(frame.url());
289
+ if (!isSecure(url)) return undefined;
290
+ const document = documents.get(frame);
291
+ return document?.origin === url.origin && document.loopback
292
+ ? undefined
293
+ : url.origin;
294
+ };
295
+
296
+ /**
297
+ * Granted per stream, to that page's origin alone: Playwright merges it
298
+ * with the grants the test made for that origin, and a test that cleared
299
+ * its permissions has it back with the next stream. A browser without
300
+ * the permission refuses it, and then needs none.
125
301
  */
126
- const allowLocalNetwork = (attachment: AttachedContext): Promise<void> => {
127
- let granting = localNetwork.get(attachment);
128
- if (granting === undefined) {
129
- granting = Promise.resolve()
130
- .then(() =>
131
- attachment.context.grantPermissions?.(["local-network-access"]),
132
- )
133
- .catch((error: unknown) => {
134
- deps.log.debug("local-network-access not granted", {}, error);
135
- });
136
- localNetwork.set(attachment, granting);
302
+ const allowLocalNetwork = async ({
303
+ attachment,
304
+ frame,
305
+ }: Exchange): Promise<void> => {
306
+ const origin = grantFor(frame);
307
+ if (origin === undefined) return;
308
+ try {
309
+ await attachment.context.grantPermissions?.(["local-network-access"], {
310
+ origin,
311
+ });
312
+ } catch (error) {
313
+ deps.log.debug("local-network-access not granted", { origin }, error);
314
+ }
315
+ };
316
+
317
+ /** Note where a frame's document came from, for `grantFor`. */
318
+ const noteDocument = async (request: RequestPort): Promise<void> => {
319
+ try {
320
+ const frame = request.frame();
321
+ const server = await (await request.response())?.serverAddr();
322
+ documents.set(frame, {
323
+ origin: new URL(request.url()).origin,
324
+ loopback: server != null && LOOPBACK_ADDRESS.test(server.ipAddress),
325
+ });
326
+ } catch (error) {
327
+ // A service worker's document has no frame, a closed page no answer.
328
+ deps.log.debug(
329
+ "could not tell where a document came from",
330
+ { url: request.url() },
331
+ error,
332
+ );
333
+ }
334
+ };
335
+
336
+ /** The frame and the page a request came from; a service worker's has neither. */
337
+ const senderOf = (
338
+ request: RequestPort,
339
+ ): { readonly frame?: RequestFrame; readonly page?: string } => {
340
+ try {
341
+ const frame = request.frame();
342
+ return { frame, page: deps.pageId(frame.page()) };
343
+ } catch {
344
+ // Service-worker requests have no frame.
345
+ return {};
137
346
  }
138
- return granting;
139
347
  };
140
348
 
141
349
  const createView = async (
142
350
  request: RequestPort,
143
351
  attachment: AttachedContext,
144
352
  url: URL,
353
+ page: string | undefined,
145
354
  ): Promise<HttpRequestView> => {
146
355
  let body: Uint8Array | undefined;
147
356
  let correlation: string | undefined;
@@ -149,13 +358,6 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
149
358
  body ??= toBytes(request.postDataBuffer() ?? new Uint8Array(0));
150
359
  return body;
151
360
  };
152
- let page: string | undefined;
153
- try {
154
- page = deps.pageId(request.frame().page());
155
- } catch {
156
- // Service-worker requests have no frame.
157
- page = undefined;
158
- }
159
361
  return {
160
362
  get correlation() {
161
363
  correlation ??= deps.correlation();
@@ -276,10 +478,114 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
276
478
  pending.set(exchange.request, exchange);
277
479
  };
278
480
 
481
+ /** A request that outlived its context fails for that reason alone. */
482
+ const levelOf = ({ attachment }: Exchange): "error" | "debug" =>
483
+ attachments.has(attachment) ? "error" : "debug";
484
+
485
+ /** What the page is told of a rule that failed; the app may display it. */
486
+ const failureBody = ({ rule }: Entry, error: unknown): string =>
487
+ `wirestage: rule "${rule.label}" failed: ${messageOf(error)}`;
488
+
489
+ /** Say which rule failed: in the log, and on the timeline. */
490
+ const report = (exchange: Exchange, entry: Entry, error: unknown) => {
491
+ const { rule } = entry;
492
+ deps.log[levelOf(exchange)](
493
+ "rule failed",
494
+ { rule: rule.label, url: exchange.view.url.href },
495
+ error,
496
+ );
497
+ append(exchange, rule.protocol, nameOf(entry, exchange.view), {
498
+ kind: "fault",
499
+ direction: "none",
500
+ source: "stub",
501
+ meta: { reason: "rule-error", error: messageOf(error) },
502
+ });
503
+ };
504
+
505
+ /**
506
+ * Serve a live stream once the browser has brought its request to the
507
+ * stream server. A rule that throws there is a rule that failed, as a
508
+ * throwing `fulfill` is — not the page letting go.
509
+ */
510
+ const serveStream =
511
+ (
512
+ outcome: Extract<Answer, { readonly type: "stream" }>,
513
+ exchange: Exchange,
514
+ entry: Entry,
515
+ ): NodeHandler =>
516
+ async (request, response) => {
517
+ const served = createResponseStream(request, response, (end, failed) => {
518
+ serving.delete(served);
519
+ // A rule that failed is on record as its error, not as an end.
520
+ if (!failed) close(exchange, endOf(end));
521
+ });
522
+ serving.add(served);
523
+ try {
524
+ await outcome.open(served.stream);
525
+ } catch (error) {
526
+ if (served.fail(failureBody(entry, error)))
527
+ report(exchange, entry, error);
528
+ // It was over first, and is on record as such: a write into a
529
+ // connection the page let go of, or one the rule ended.
530
+ else
531
+ deps.log.debug(
532
+ "a stream's rule failed after the stream was over",
533
+ { rule: entry.rule.label },
534
+ error,
535
+ );
536
+ }
537
+ };
538
+
539
+ /**
540
+ * Wait for the browser to bring a redirected stream to the stream
541
+ * server. Until it does, the session holds a place on a listener and the
542
+ * exchange has no end — and a page that let go first, while the rule
543
+ * decided or just after the redirect, never comes: Playwright takes the
544
+ * redirect all the same. Given up when the request fails, the context is
545
+ * detached, or `timeoutMs` passes.
546
+ */
547
+ const awaitBrowser = (exchange: Exchange, session: StreamSession): void => {
548
+ const release = (frame: Closing) => {
549
+ session.dispose();
550
+ close(exchange, frame);
551
+ };
552
+ const failure = exchange.request.failure();
553
+ if (failure !== null) {
554
+ // It failed while the rule decided or the redirect was made.
555
+ release(networkFault(failure.errorText));
556
+ return;
557
+ }
558
+ const deadline = new AbortController();
559
+ const settle = (frame?: Closing) => {
560
+ unclaimed.delete(exchange.request);
561
+ deadline.abort();
562
+ if (frame !== undefined) release(frame);
563
+ };
564
+ unclaimed.set(exchange.request, {
565
+ attachment: exchange.attachment,
566
+ settle,
567
+ });
568
+ void deps.clock.sleep(deps.timeoutMs, deadline.signal).then(
569
+ () => {
570
+ deps.log.warn(
571
+ "a live stream's request never reached the stream server, and was given up",
572
+ { url: exchange.view.url.href, timeoutMs: deps.timeoutMs },
573
+ );
574
+ settle(
575
+ unreached(
576
+ `the browser did not bring the request to the stream server within ${deps.timeoutMs} ms`,
577
+ ),
578
+ );
579
+ },
580
+ // It came, failed or was detached first.
581
+ () => undefined,
582
+ );
583
+ };
584
+
279
585
  const execute: Executors = {
280
586
  async fulfill(outcome, exchange) {
281
587
  const status = outcome.status ?? 200;
282
- const headers = { ...outcome.headers };
588
+ const headers = fulfillHeaders(outcome.headers);
283
589
  const body =
284
590
  outcome.body === undefined ? undefined : toBytes(outcome.body);
285
591
  await exchange.route.fulfill(
@@ -295,46 +601,38 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
295
601
  meta: { status, headers },
296
602
  });
297
603
  },
298
- async stream(outcome, exchange) {
604
+ async stream(outcome, exchange, entry) {
605
+ let claimed = false;
606
+ const serve = serveStream(outcome, exchange, entry);
299
607
  const session = await deps.streams.open(
300
608
  (request, response) => {
301
- const stream = createResponseStream(request, response);
302
- void stream.closed.then((end) =>
303
- close(
304
- exchange,
305
- end === "cut"
306
- ? {
307
- kind: "fault",
308
- direction: "none",
309
- source: "stub",
310
- meta: { reason: "cut" },
311
- }
312
- : {
313
- kind: "lifecycle",
314
- direction: "none",
315
- // The page let go of it, or the stub ended it.
316
- source: end === "cancelled" ? "page" : "stub",
317
- meta: { event: "close", end },
318
- },
319
- ),
320
- );
321
- return outcome.open(stream);
609
+ claimed = true;
610
+ unclaimed.get(exchange.request)?.settle();
611
+ return serve(request, response);
322
612
  },
323
613
  { secure: exchange.view.url.protocol === "https:" },
324
614
  );
325
- await allowLocalNetwork(exchange.attachment);
615
+ await allowLocalNetwork(exchange);
326
616
  try {
327
617
  await exchange.route.continue({ url: session.url });
328
618
  } catch (error) {
329
619
  session.dispose();
330
620
  throw error;
331
621
  }
622
+ // The browser may come before Playwright says the redirect is made.
623
+ if (!claimed) awaitBrowser(exchange, session);
332
624
  },
333
625
  async continue(outcome, exchange, entry) {
626
+ const url =
627
+ outcome.url === undefined
628
+ ? undefined
629
+ : continueUrl(outcome.url, exchange.view.url);
630
+ for (const [name, value] of Object.entries(outcome.headers ?? {}))
631
+ checkHeader(name, value);
334
632
  exchange.passed.push(entry);
335
633
  track(exchange);
336
634
  await exchange.route.continue({
337
- ...(outcome.url === undefined ? {} : { url: outcome.url }),
635
+ ...(url === undefined ? {} : { url }),
338
636
  ...(outcome.method === undefined ? {} : { method: outcome.method }),
339
637
  ...(outcome.headers === undefined
340
638
  ? {}
@@ -346,6 +644,11 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
346
644
  },
347
645
  async abort(outcome, exchange) {
348
646
  const error = outcome.error ?? "failed";
647
+ if (!Object.hasOwn(ABORT_CODES, error))
648
+ throw new WirestageError(
649
+ "INVALID_ARGUMENT",
650
+ `"${error}" is not an abort error code; Playwright takes ${Object.keys(ABORT_CODES).join(", ")}`,
651
+ );
349
652
  await exchange.route.abort(error);
350
653
  close(exchange, {
351
654
  kind: "fault",
@@ -357,36 +660,28 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
357
660
  };
358
661
 
359
662
  /**
360
- * A throwing stub is a bug in the test, and a stream that cannot open is a
361
- * setup problem. Either way answer 500 so the page sees an error instead of
663
+ * A throwing stub is a bug in the test, as is an answer Playwright or
664
+ * Chromium would refuse, and a stream that cannot open is a setup
665
+ * problem. Either way answer 500 so the page sees an error instead of
362
666
  * hanging, and say which rule failed — in the log AND in the body the app
363
667
  * may display.
364
668
  */
365
669
  const fail = async (exchange: Exchange, entry: Entry, error: unknown) => {
366
- const { rule } = entry;
367
- const message = error instanceof Error ? error.message : String(error);
368
- // A request that outlived its context fails for that reason alone.
369
- const level = attachments.has(exchange.attachment) ? "error" : "debug";
370
- deps.log[level](
371
- "rule failed",
372
- { rule: rule.label, url: exchange.view.url.href },
373
- error,
374
- );
375
- append(exchange, rule.protocol, nameOf(entry, exchange.view), {
376
- kind: "fault",
377
- direction: "none",
378
- source: "stub",
379
- meta: { reason: "rule-error", error: message },
380
- });
670
+ report(exchange, entry, error);
381
671
  await exchange.route
382
672
  .fulfill({
383
673
  status: 500,
384
674
  headers: { "content-type": "text/plain; charset=utf-8" },
385
- body: `wirestage: rule "${rule.label}" failed: ${message}`,
675
+ body: failureBody(entry, error),
386
676
  })
387
677
  .catch((secondary: unknown) => {
388
- // The route was already answered before the failure.
389
- deps.log.debug("could not answer a failed rule", {}, secondary);
678
+ // The route took an answer before the failure, and Playwright
679
+ // takes no other: the page waits for good.
680
+ deps.log[levelOf(exchange)](
681
+ "could not answer a failed rule: the request is left without an answer",
682
+ { rule: entry.rule.label, url: exchange.view.url.href },
683
+ secondary,
684
+ );
390
685
  });
391
686
  };
392
687
 
@@ -400,11 +695,13 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
400
695
  // decided does not join its chain.
401
696
  const chain = entries.filter((entry) => wants(entry, url)).reverse();
402
697
  if (chain.length === 0) return route.fallback();
698
+ const sender = senderOf(request);
403
699
  const exchange: Exchange = {
404
- view: await createView(request, attachment, url),
700
+ view: await createView(request, attachment, url, sender.page),
405
701
  attachment,
406
702
  route,
407
703
  request,
704
+ frame: sender.frame,
408
705
  recorded: new Map(),
409
706
  passed: [],
410
707
  };
@@ -478,10 +775,8 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
478
775
  }
479
776
  };
480
777
 
481
- const onFinished = async (request: RequestPort) => {
482
- const exchange = pending.get(request);
483
- if (exchange === undefined) return;
484
- pending.delete(request);
778
+ /** Record the network's answer to a request a rule passed on. */
779
+ const record = async (exchange: Exchange, request: RequestPort) => {
485
780
  try {
486
781
  const response = await request.response();
487
782
  if (response === null) return;
@@ -514,17 +809,44 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
514
809
  }
515
810
  };
516
811
 
812
+ /** Whether a finished request was passed on, and its recording if so. */
813
+ const onFinished = (request: RequestPort): Promise<void> | undefined => {
814
+ const exchange = pending.get(request);
815
+ if (exchange === undefined) return undefined;
816
+ pending.delete(request);
817
+ return record(exchange, request);
818
+ };
819
+
820
+ /**
821
+ * `reading`, as long as a read waits for it. Reading a body is a round
822
+ * trip to the page, which a dialog holds up until it closes: a read waits
823
+ * as long as a page is given to answer, then goes on, and the response
824
+ * reaches the timeline when its body does.
825
+ */
826
+ const bounded = (reading: Promise<void>, url: string): Promise<void> => {
827
+ const waiting = new AbortController();
828
+ return Promise.race([
829
+ reading,
830
+ deps.clock.sleep(deps.timeoutMs, waiting.signal).then(
831
+ () =>
832
+ deps.log.warn(
833
+ "a response did not hand over its body in time; reads go on without it until it does (is a dialog open?)",
834
+ { url, timeoutMs: deps.timeoutMs },
835
+ ),
836
+ // The body came first.
837
+ () => undefined,
838
+ ),
839
+ ]).finally(() => waiting.abort());
840
+ };
841
+
517
842
  const onFailed = (request: RequestPort) => {
843
+ const error = request.failure()?.errorText;
844
+ // A live stream the page let go of before the browser could bring it.
845
+ unclaimed.get(request)?.settle(networkFault(error));
518
846
  const exchange = pending.get(request);
519
847
  if (exchange === undefined) return;
520
848
  pending.delete(request);
521
- const error = request.failure()?.errorText;
522
- close(exchange, {
523
- kind: "fault",
524
- direction: "none",
525
- source: "network",
526
- meta: { reason: "network", error },
527
- });
849
+ close(exchange, networkFault(error));
528
850
  for (const entry of exchange.passed)
529
851
  notify(entry, (rule) =>
530
852
  rule.onPassthroughFailure?.(error, exchange.view),
@@ -582,10 +904,12 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
582
904
  async attach(attachment) {
583
905
  const own: Listeners = {
584
906
  finished: (request) => {
585
- const reading = onFinished(request).finally(() =>
586
- settling.delete(reading),
587
- );
588
- settling.add(reading);
907
+ if (request.resourceType() === "document") void noteDocument(request);
908
+ const reading = onFinished(request);
909
+ if (reading === undefined) return;
910
+ const waiting = bounded(reading, request.url());
911
+ settling.add(waiting);
912
+ void waiting.then(() => settling.delete(waiting));
589
913
  },
590
914
  failed: onFailed,
591
915
  };
@@ -602,6 +926,10 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
602
926
  attachments.delete(attachment);
603
927
  attachment.context.off("requestfinished", own.finished);
604
928
  attachment.context.off("requestfailed", own.failed);
929
+ // Nothing brings these now, and each holds a place on a listener.
930
+ for (const waiting of [...unclaimed.values()])
931
+ if (waiting.attachment === attachment)
932
+ waiting.settle(unreached("the context was detached first"));
605
933
  const installing = routes.get(attachment);
606
934
  routes.delete(attachment);
607
935
  if (installing === undefined) return;
@@ -614,7 +942,13 @@ export function createHttpRouter(deps: HttpRouterDeps): HttpRouterEngine {
614
942
  }
615
943
  },
616
944
  async settle() {
617
- await Promise.all(settling);
945
+ await Promise.all([
946
+ ...settling,
947
+ // A stream the stub ended or cut is on its way to the timeline.
948
+ ...[...serving]
949
+ .filter((served) => served.closing)
950
+ .map((served) => served.stream.closed),
951
+ ]);
618
952
  },
619
953
  };
620
954
  }