@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.
- package/dist/entities/plugin/model/http.d.ts +12 -3
- package/dist/entities/plugin/model/http.d.ts.map +1 -1
- package/dist/entities/plugin/model/socket.d.ts +12 -7
- package/dist/entities/plugin/model/socket.d.ts.map +1 -1
- package/dist/entities/plugin/model/stream.d.ts +7 -4
- package/dist/entities/plugin/model/stream.d.ts.map +1 -1
- package/dist/features/sse/lib/create-sse.d.ts.map +1 -1
- package/dist/features/sse/lib/create-sse.js +2 -1
- package/dist/features/sse/lib/create-sse.js.map +1 -1
- package/dist/features/sse/model/types.d.ts +8 -2
- package/dist/features/sse/model/types.d.ts.map +1 -1
- package/dist/features/websocket/lib/proxy.d.ts.map +1 -1
- package/dist/features/websocket/lib/proxy.js +16 -3
- package/dist/features/websocket/lib/proxy.js.map +1 -1
- package/dist/features/websocket/model/types.d.ts +5 -1
- package/dist/features/websocket/model/types.d.ts.map +1 -1
- package/dist/shared/api/playwright/ports.d.ts +10 -1
- package/dist/shared/api/playwright/ports.d.ts.map +1 -1
- package/dist/shared/api/stream-server/stream-server.d.ts +3 -1
- package/dist/shared/api/stream-server/stream-server.d.ts.map +1 -1
- package/dist/shared/api/stream-server/stream-server.js +10 -3
- package/dist/shared/api/stream-server/stream-server.js.map +1 -1
- package/dist/widgets/stage/lib/create-stage.d.ts.map +1 -1
- package/dist/widgets/stage/lib/create-stage.js +2 -0
- package/dist/widgets/stage/lib/create-stage.js.map +1 -1
- package/dist/widgets/stage/lib/http-router.d.ts +11 -2
- package/dist/widgets/stage/lib/http-router.d.ts.map +1 -1
- package/dist/widgets/stage/lib/http-router.js +317 -76
- package/dist/widgets/stage/lib/http-router.js.map +1 -1
- package/dist/widgets/stage/lib/response-stream.d.ts +17 -3
- package/dist/widgets/stage/lib/response-stream.d.ts.map +1 -1
- package/dist/widgets/stage/lib/response-stream.js +88 -36
- package/dist/widgets/stage/lib/response-stream.js.map +1 -1
- package/dist/widgets/stage/lib/socket-router.d.ts.map +1 -1
- package/dist/widgets/stage/lib/socket-router.js +44 -15
- package/dist/widgets/stage/lib/socket-router.js.map +1 -1
- package/dist/widgets/stage/model/stage.d.ts +8 -6
- package/dist/widgets/stage/model/stage.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/entities/plugin/model/http.ts +12 -3
- package/src/entities/plugin/model/socket.ts +12 -7
- package/src/entities/plugin/model/stream.ts +7 -4
- package/src/features/sse/lib/create-sse.ts +2 -1
- package/src/features/sse/model/types.ts +8 -2
- package/src/features/websocket/lib/proxy.ts +15 -3
- package/src/features/websocket/model/types.ts +5 -1
- package/src/shared/api/playwright/ports.ts +7 -2
- package/src/shared/api/stream-server/stream-server.ts +11 -3
- package/src/widgets/stage/lib/create-stage.ts +2 -0
- package/src/widgets/stage/lib/http-router.ts +425 -91
- package/src/widgets/stage/lib/response-stream.ts +110 -37
- package/src/widgets/stage/lib/socket-router.ts +45 -16
- package/src/widgets/stage/model/stage.ts +8 -6
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
response.
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
124
|
-
* log, on the timeline, and in the
|
|
125
|
-
* and close both ends: the page's
|
|
126
|
-
* nobody answering it, and a server
|
|
127
|
-
* connected. 1011 is a server's "internal
|
|
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
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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[]>;
|