@heojeongbo/wirestage 0.1.0 → 0.1.1

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 (53) 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/shared/api/playwright/ports.d.ts +10 -1
  18. package/dist/shared/api/playwright/ports.d.ts.map +1 -1
  19. package/dist/shared/api/stream-server/stream-server.d.ts +3 -1
  20. package/dist/shared/api/stream-server/stream-server.d.ts.map +1 -1
  21. package/dist/shared/api/stream-server/stream-server.js +10 -3
  22. package/dist/shared/api/stream-server/stream-server.js.map +1 -1
  23. package/dist/widgets/stage/lib/create-stage.d.ts.map +1 -1
  24. package/dist/widgets/stage/lib/create-stage.js +2 -0
  25. package/dist/widgets/stage/lib/create-stage.js.map +1 -1
  26. package/dist/widgets/stage/lib/http-router.d.ts +11 -2
  27. package/dist/widgets/stage/lib/http-router.d.ts.map +1 -1
  28. package/dist/widgets/stage/lib/http-router.js +317 -76
  29. package/dist/widgets/stage/lib/http-router.js.map +1 -1
  30. package/dist/widgets/stage/lib/response-stream.d.ts +17 -3
  31. package/dist/widgets/stage/lib/response-stream.d.ts.map +1 -1
  32. package/dist/widgets/stage/lib/response-stream.js +88 -36
  33. package/dist/widgets/stage/lib/response-stream.js.map +1 -1
  34. package/dist/widgets/stage/lib/socket-router.d.ts.map +1 -1
  35. package/dist/widgets/stage/lib/socket-router.js +44 -15
  36. package/dist/widgets/stage/lib/socket-router.js.map +1 -1
  37. package/dist/widgets/stage/model/stage.d.ts +8 -6
  38. package/dist/widgets/stage/model/stage.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/entities/plugin/model/http.ts +12 -3
  41. package/src/entities/plugin/model/socket.ts +12 -7
  42. package/src/entities/plugin/model/stream.ts +7 -4
  43. package/src/features/sse/lib/create-sse.ts +2 -1
  44. package/src/features/sse/model/types.ts +8 -2
  45. package/src/features/websocket/lib/proxy.ts +15 -3
  46. package/src/features/websocket/model/types.ts +5 -1
  47. package/src/shared/api/playwright/ports.ts +7 -2
  48. package/src/shared/api/stream-server/stream-server.ts +11 -3
  49. package/src/widgets/stage/lib/create-stage.ts +2 -0
  50. package/src/widgets/stage/lib/http-router.ts +425 -91
  51. package/src/widgets/stage/lib/response-stream.ts +110 -37
  52. package/src/widgets/stage/lib/socket-router.ts +45 -16
  53. package/src/widgets/stage/model/stage.ts +8 -6
@@ -1,4 +1,8 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
1
+ import type {
2
+ IncomingMessage,
3
+ OutgoingHttpHeader,
4
+ ServerResponse,
5
+ } from "node:http";
2
6
 
3
7
  import type {
4
8
  ResponseStream,
@@ -8,59 +12,128 @@ import { createDeferred } from "../../../shared/lib/async/index.js";
8
12
  import { toBytes } from "../../../shared/lib/bytes/index.js";
9
13
  import { WirestageError } from "../../../shared/lib/errors/index.js";
10
14
 
15
+ /** A stream as the stage serves it: the rule's end of it, and the stage's. */
16
+ export interface ServedStream {
17
+ /** What the rule writes. */
18
+ readonly stream: ResponseStream;
19
+ /** The stub asked for the end, and `closed` is on its way. */
20
+ readonly closing: boolean;
21
+ /**
22
+ * The rule writing the stream failed: answer 500 with `body` while
23
+ * nothing has gone out, and cut the connection after. False when the
24
+ * response was over first — ended, cut, or let go of by the page.
25
+ */
26
+ fail(body: string): boolean;
27
+ }
28
+
11
29
  /**
12
30
  * A Node response as the stream a rule writes: the adapter between the
13
31
  * stream server's sockets and the plugin contract, which knows nothing of
14
- * Node.
32
+ * Node. `onClose` hears how the response ended before `closed` resolves, so
33
+ * whoever awaits `closed` finds the end already on record.
15
34
  */
16
35
  export function createResponseStream(
17
36
  request: IncomingMessage,
18
37
  response: ServerResponse,
19
- ): ResponseStream {
38
+ onClose: (end: StreamEnd, failed: boolean) => void,
39
+ ): ServedStream {
20
40
  let state: "open" | "ended" | "cut" = "open";
41
+ let failed = false;
21
42
  const closed = createDeferred<StreamEnd>();
22
- response.once("close", () =>
43
+ response.once("close", () => {
23
44
  // Not ended and not cut: the page let go of it.
24
- closed.resolve(state === "open" ? "cancelled" : state),
25
- );
45
+ const end = state === "open" ? "cancelled" : state;
46
+ onClose(end, failed);
47
+ closed.resolve(end);
48
+ });
49
+ /** The stream server's own headers (CORS, `connection`), before the rule's. */
50
+ const own = Object.entries(response.getHeaders());
51
+ /** Back to those: what a rule's head set is gone, what it replaced is back. */
52
+ const restore = () => {
53
+ for (const name of response.getHeaderNames()) response.removeHeader(name);
54
+ for (const [name, value] of own)
55
+ response.setHeader(name, value as OutgoingHttpHeader);
56
+ };
57
+ /**
58
+ * Whether a write can reach the page. Node drops a write — callback and
59
+ * all — into a socket destroyed or closed for writing, which is what the
60
+ * page letting go leaves, well before the response reports its close.
61
+ */
62
+ const writable = () => state === "open" && response.socket?.writable === true;
26
63
  const requestHeaders: Record<string, string> = {};
27
64
  for (const [name, value] of Object.entries(request.headers))
28
65
  requestHeaders[name] = String(value);
29
66
  return {
30
- requestHeaders,
31
- head(status, headers = {}) {
32
- response.writeHead(status, { ...headers });
33
- },
34
- flush() {
35
- response.flushHeaders();
36
- },
37
- write(chunk) {
38
- return new Promise<void>((resolve, reject) => {
39
- if (state !== "open" || response.destroyed) {
40
- reject(new WirestageError("CLOSED", "the response stream is closed"));
41
- return;
67
+ stream: {
68
+ requestHeaders,
69
+ head(status, headers = {}) {
70
+ // Node checks a status only as the head goes out, and refused by
71
+ // `end()` it leaves the response broken: a 500 after it has no body.
72
+ if (!Number.isInteger(status) || status < 100 || status > 999)
73
+ throw new WirestageError(
74
+ "INVALID_ARGUMENT",
75
+ `a response status is an integer from 100 to 999, got ${status}`,
76
+ );
77
+ // Kept off the wire until the first write or flush, so a rule that
78
+ // fails before then can still be answered with its 500.
79
+ restore();
80
+ response.statusCode = status;
81
+ for (const [name, value] of Object.entries(headers)) {
82
+ // The stream server's to say: one response per connection.
83
+ if (name.toLowerCase() === "connection") continue;
84
+ // Several values, one per line (`set-cookie`): Node sends a
85
+ // header line for each, and refuses a value with a newline.
86
+ const values = value.split("\n");
87
+ response.setHeader(name, values.length > 1 ? values : value);
42
88
  }
43
- response.write(toBytes(chunk), (error) =>
44
- error ? reject(error) : resolve(),
45
- );
46
- });
47
- },
48
- async end() {
49
- if (state !== "open") return;
50
- state = "ended";
51
- // A response the page already let go of never calls back from
52
- // end(): resolve on whichever comes first.
53
- if (!response.destroyed)
54
- await new Promise<void>((resolve) => {
55
- response.once("close", resolve);
56
- response.end(resolve);
89
+ },
90
+ flush() {
91
+ response.flushHeaders();
92
+ },
93
+ write(chunk) {
94
+ return new Promise<void>((resolve, reject) => {
95
+ if (!writable()) {
96
+ reject(
97
+ new WirestageError("CLOSED", "the response stream is closed"),
98
+ );
99
+ return;
100
+ }
101
+ response.write(toBytes(chunk), (error) =>
102
+ error ? reject(error) : resolve(),
103
+ );
57
104
  });
105
+ },
106
+ async end() {
107
+ if (writable()) {
108
+ state = "ended";
109
+ response.end();
110
+ }
111
+ await closed.promise;
112
+ },
113
+ cut() {
114
+ if (!writable()) return;
115
+ state = "cut";
116
+ response.destroy();
117
+ },
118
+ closed: closed.promise,
58
119
  },
59
- cut() {
60
- if (state !== "open") return;
61
- state = "cut";
62
- response.destroy();
120
+ get closing() {
121
+ return state !== "open" && !closed.settled;
122
+ },
123
+ fail(body) {
124
+ if (!writable()) return false;
125
+ failed = true;
126
+ if (response.headersSent) {
127
+ state = "cut";
128
+ response.destroy();
129
+ return true;
130
+ }
131
+ state = "ended";
132
+ restore();
133
+ response
134
+ .writeHead(500, { "content-type": "text/plain; charset=utf-8" })
135
+ .end(body);
136
+ return true;
63
137
  },
64
- closed: closed.promise,
65
138
  };
66
139
  }
@@ -108,24 +108,44 @@ export function createSocketRouter(deps: SocketRouterDeps): SocketRouterEngine {
108
108
  entries.splice(entries.indexOf(entry), 1);
109
109
  };
110
110
 
111
+ /**
112
+ * One end, as a rule drives it. Playwright calls the rule's handlers from
113
+ * its own event dispatch, where a throw is an uncaught exception and a
114
+ * rejection an unhandled one — either surfacing in whichever test runs
115
+ * next. A handler that fails fails the rule instead, as `open` does.
116
+ */
111
117
  const peer = (
112
118
  route: WebSocketRoutePort,
119
+ onFailure: (error: unknown) => Promise<void>,
113
120
  close: (options: CloseOptions) => CloseOptions = (options) => options,
114
- ): SocketPeer => ({
115
- send: (data) =>
116
- route.send(typeof data === "string" ? data : toBuffer(data)),
117
- close: (options) => route.close(close(options)),
118
- onMessage: (handler) => route.onMessage(handler),
119
- onClose: (handler) => route.onClose(handler),
120
- });
121
+ ): SocketPeer => {
122
+ const guarded =
123
+ <Args extends unknown[]>(handler: (...args: Args) => void) =>
124
+ (...args: Args): void => {
125
+ try {
126
+ // Typed `void`, yet an async function is a valid handler.
127
+ const result: unknown = handler(...args);
128
+ if (result instanceof Promise) result.catch(onFailure);
129
+ } catch (error) {
130
+ void onFailure(error);
131
+ }
132
+ };
133
+ return {
134
+ send: (data) =>
135
+ route.send(typeof data === "string" ? data : toBuffer(data)),
136
+ close: (options) => route.close(close(options)),
137
+ onMessage: (handler) => route.onMessage(guarded(handler)),
138
+ onClose: (handler) => route.onClose(guarded(handler)),
139
+ };
140
+ };
121
141
 
122
142
  /**
123
- * A rule that throws is a bug in the test. Say which rule failed — in the
124
- * log, on the timeline, and in the reason the page's socket closes with —
125
- * and close both ends: the page's socket would otherwise sit open with
126
- * nobody answering it, and a server the rule connected would stay
127
- * connected. 1011 is a server's "internal error"; the server's end gets
128
- * 1000, as a script may not send 1011.
143
+ * A rule that throws, in `open` or in a handler it set, is a bug in the
144
+ * test. Say which rule failed — in the log, on the timeline, and in the
145
+ * reason the page's socket closes with — and close both ends: the page's
146
+ * socket would otherwise sit open with nobody answering it, and a server
147
+ * the rule connected would stay connected. 1011 is a server's "internal
148
+ * error"; the server's end gets 1000, as a script may not send 1011.
129
149
  */
130
150
  const fail = async (
131
151
  rule: SocketRule,
@@ -178,16 +198,25 @@ export function createSocketRouter(deps: SocketRouterDeps): SocketRouterEngine {
178
198
  // the rule's own replaces.
179
199
  route.onMessage(drop);
180
200
  let server: SocketPeer | undefined;
201
+ let failed = false;
202
+ // Once per socket, for whatever fails first. Marked before failing:
203
+ // the closes that follow are reported to the rule's handlers, and a
204
+ // broken one throws again.
205
+ const failOnce = async (error: unknown): Promise<void> => {
206
+ if (failed) return;
207
+ failed = true;
208
+ await fail(entry.rule, view, server, error);
209
+ };
181
210
  const view: SocketView = {
182
211
  correlation: deps.correlation(),
183
212
  url,
184
213
  context: attachment.id,
185
- page: peer(route),
214
+ page: peer(route, failOnce),
186
215
  connectToServer: () => {
187
216
  if (server === undefined) {
188
217
  const upstream = route.connectToServer();
189
218
  upstream.onMessage(drop);
190
- server = peer(upstream, forServer);
219
+ server = peer(upstream, failOnce, forServer);
191
220
  }
192
221
  return server;
193
222
  },
@@ -200,7 +229,7 @@ export function createSocketRouter(deps: SocketRouterDeps): SocketRouterEngine {
200
229
  } catch (error) {
201
230
  // Never rejected to Playwright, where it would surface as an
202
231
  // unhandled rejection in whichever test runs next.
203
- await fail(entry.rule, view, server, error);
232
+ await failOnce(error);
204
233
  }
205
234
  };
206
235
 
@@ -30,9 +30,11 @@ export interface StageOptions<Plugins extends readonly AnyWirePlugin[]> {
30
30
  */
31
31
  readonly pageFlushMs?: number;
32
32
  /**
33
- * How long a page may take to answer a flush or a command before a read
34
- * goes on without it — and Playwright to take the page hooks out before a
35
- * detach goes on. Default 2000 ms.
33
+ * How long a page may take to answer a flush or a command, or the
34
+ * browser to hand over a response's body, before a read goes on without
35
+ * it; how long a live stream's request may take to reach the stream
36
+ * server before the stream is given up; and how long Playwright may take
37
+ * to take the page hooks out before a detach goes on. Default 2000 ms.
36
38
  */
37
39
  readonly pageTimeoutMs?: number;
38
40
  /** TLS for live streams on https pages, and the bind host. */
@@ -79,9 +81,9 @@ export interface StageCore {
79
81
  /** The id frames use for this page. */
80
82
  pageId(page: PagePort): string;
81
83
  /**
82
- * Bring the timeline up to date: flush every page's buffer and wait for
83
- * the responses already back from the network. The reads below do it for
84
- * you.
84
+ * Bring the timeline up to date: flush every page's buffer, and wait for
85
+ * the responses already back from the network and the ends of the
86
+ * streams a stub closed. The reads below do it for you.
85
87
  */
86
88
  sync(): Promise<void>;
87
89
  frames(filter?: FrameFilter): Promise<Frame[]>;