@heojeongbo/wirestage 0.1.1 → 0.3.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 +1 -1
- package/dist/features/rest/index.d.ts +1 -1
- package/dist/features/rest/index.d.ts.map +1 -1
- package/dist/features/rest/lib/calls.d.ts +7 -1
- package/dist/features/rest/lib/calls.d.ts.map +1 -1
- package/dist/features/rest/lib/calls.js +11 -0
- package/dist/features/rest/lib/calls.js.map +1 -1
- package/dist/features/rest/lib/create-rest.d.ts.map +1 -1
- package/dist/features/rest/lib/create-rest.js +22 -8
- package/dist/features/rest/lib/create-rest.js.map +1 -1
- package/dist/features/rest/lib/serve.d.ts +21 -0
- package/dist/features/rest/lib/serve.d.ts.map +1 -0
- package/dist/features/rest/lib/serve.js +223 -0
- package/dist/features/rest/lib/serve.js.map +1 -0
- package/dist/features/rest/model/types.d.ts +37 -1
- package/dist/features/rest/model/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/shared/api/http-server/index.d.ts +2 -0
- package/dist/shared/api/http-server/index.d.ts.map +1 -1
- package/dist/shared/api/http-server/index.js +1 -0
- package/dist/shared/api/http-server/index.js.map +1 -1
- package/dist/shared/api/http-server/serve.d.ts +29 -0
- package/dist/shared/api/http-server/serve.d.ts.map +1 -0
- package/dist/shared/api/http-server/serve.js +54 -0
- package/dist/shared/api/http-server/serve.js.map +1 -0
- package/dist/widgets/stage/index.d.ts +1 -1
- package/dist/widgets/stage/index.d.ts.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/reads.d.ts +4 -1
- package/dist/widgets/stage/lib/reads.d.ts.map +1 -1
- package/dist/widgets/stage/lib/reads.js +32 -1
- package/dist/widgets/stage/lib/reads.js.map +1 -1
- package/dist/widgets/stage/model/stage.d.ts +32 -1
- package/dist/widgets/stage/model/stage.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/features/rest/index.ts +2 -0
- package/src/features/rest/lib/calls.ts +27 -1
- package/src/features/rest/lib/create-rest.ts +28 -12
- package/src/features/rest/lib/serve.ts +338 -0
- package/src/features/rest/model/types.ts +40 -1
- package/src/index.ts +2 -0
- package/src/shared/api/http-server/index.ts +6 -0
- package/src/shared/api/http-server/serve.ts +112 -0
- package/src/widgets/stage/index.ts +2 -0
- package/src/widgets/stage/lib/create-stage.ts +2 -0
- package/src/widgets/stage/lib/reads.ts +44 -2
- package/src/widgets/stage/model/stage.ts +34 -1
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { FrameInit, Timeline } from "../../../entities/frame/index.js";
|
|
2
|
+
import {
|
|
3
|
+
type ServedRequest,
|
|
4
|
+
type ServedResponse,
|
|
5
|
+
serveHttp,
|
|
6
|
+
} from "../../../shared/api/http-server/index.js";
|
|
7
|
+
import {
|
|
8
|
+
type Arrivals,
|
|
9
|
+
createArrivals,
|
|
10
|
+
} from "../../../shared/lib/async/index.js";
|
|
11
|
+
import type { Clock } from "../../../shared/lib/clock/index.js";
|
|
12
|
+
import { WirestageError } from "../../../shared/lib/errors/index.js";
|
|
13
|
+
import type { ScopedLogger } from "../../../shared/lib/logger/index.js";
|
|
14
|
+
import type {
|
|
15
|
+
RestApi,
|
|
16
|
+
RestMethod,
|
|
17
|
+
RestReply,
|
|
18
|
+
RestRequest,
|
|
19
|
+
RestResolver,
|
|
20
|
+
RestServeOptions,
|
|
21
|
+
RestServer,
|
|
22
|
+
} from "../model/types.js";
|
|
23
|
+
import { filterCalls } from "./calls.js";
|
|
24
|
+
import { compilePath, type PathMatcher } from "./path.js";
|
|
25
|
+
|
|
26
|
+
export interface ServeDeps {
|
|
27
|
+
/** The protocol the server's frames are recorded under. */
|
|
28
|
+
readonly protocol: string;
|
|
29
|
+
readonly timeline: Timeline;
|
|
30
|
+
readonly clock: Clock;
|
|
31
|
+
readonly log: ScopedLogger;
|
|
32
|
+
readonly correlation: () => string;
|
|
33
|
+
/** Told when the server closes, so the plugin can forget it. */
|
|
34
|
+
readonly onClose: (server: RestServer) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Route {
|
|
38
|
+
readonly label: string;
|
|
39
|
+
readonly method: string | undefined;
|
|
40
|
+
readonly matcher: PathMatcher;
|
|
41
|
+
readonly resolver: RestResolver;
|
|
42
|
+
readonly match: ((request: RestRequest) => boolean) | undefined;
|
|
43
|
+
readonly requests: Arrivals<RestRequest>;
|
|
44
|
+
remaining: number | undefined;
|
|
45
|
+
hits: number;
|
|
46
|
+
active: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TEXT = { "content-type": "text/plain; charset=utf-8" };
|
|
50
|
+
|
|
51
|
+
const failureOf = (error: unknown): string =>
|
|
52
|
+
error instanceof Error ? error.message : String(error);
|
|
53
|
+
|
|
54
|
+
/** A served request as a route sees it, with that route's params. */
|
|
55
|
+
function restRequest(
|
|
56
|
+
request: ServedRequest,
|
|
57
|
+
correlation: string,
|
|
58
|
+
params: Record<string, string>,
|
|
59
|
+
): RestRequest {
|
|
60
|
+
const text = () => new TextDecoder().decode(request.body);
|
|
61
|
+
return {
|
|
62
|
+
correlation,
|
|
63
|
+
method: request.method,
|
|
64
|
+
url: request.url,
|
|
65
|
+
headers: request.headers,
|
|
66
|
+
page: undefined,
|
|
67
|
+
context: "",
|
|
68
|
+
resourceType: "other",
|
|
69
|
+
body: () => request.body,
|
|
70
|
+
text,
|
|
71
|
+
json: <T>() => JSON.parse(text()) as T,
|
|
72
|
+
params,
|
|
73
|
+
query: request.url.searchParams,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A served fake: routes, newest first, as the browser's are; every exchange
|
|
79
|
+
* on the timeline, the client's request as `network` and the answer as
|
|
80
|
+
* `server`.
|
|
81
|
+
*/
|
|
82
|
+
export async function serveRest(
|
|
83
|
+
deps: ServeDeps,
|
|
84
|
+
options: RestServeOptions,
|
|
85
|
+
): Promise<RestServer> {
|
|
86
|
+
const name = options.name ?? `${deps.protocol} server`;
|
|
87
|
+
/** Routes, oldest first. */
|
|
88
|
+
const routes: Route[] = [];
|
|
89
|
+
let url = "";
|
|
90
|
+
|
|
91
|
+
const record = (
|
|
92
|
+
init: Omit<FrameInit, "protocol" | "endpoint">,
|
|
93
|
+
path: string,
|
|
94
|
+
) =>
|
|
95
|
+
deps.timeline.append({
|
|
96
|
+
protocol: deps.protocol,
|
|
97
|
+
endpoint: path,
|
|
98
|
+
...init,
|
|
99
|
+
meta: { ...init.meta, server: url },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const retire = (route: Route) => {
|
|
103
|
+
if (!route.active) return;
|
|
104
|
+
route.active = false;
|
|
105
|
+
routes.splice(routes.indexOf(route), 1);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Carry a reply out; `undefined` when it passes the request on. */
|
|
109
|
+
const answer = async (
|
|
110
|
+
reply: RestReply,
|
|
111
|
+
response: ServedResponse,
|
|
112
|
+
path: string,
|
|
113
|
+
correlation: string,
|
|
114
|
+
): Promise<"answered" | undefined> => {
|
|
115
|
+
switch (reply.kind) {
|
|
116
|
+
case "response": {
|
|
117
|
+
const body = reply.body ?? "";
|
|
118
|
+
// Recorded before it is sent: a client that reads its answer and
|
|
119
|
+
// asks for `calls()` at once must find it there.
|
|
120
|
+
record(
|
|
121
|
+
{
|
|
122
|
+
kind: "response",
|
|
123
|
+
direction: "in",
|
|
124
|
+
source: "server",
|
|
125
|
+
data: body,
|
|
126
|
+
meta: { status: reply.status, headers: reply.headers },
|
|
127
|
+
correlation,
|
|
128
|
+
},
|
|
129
|
+
path,
|
|
130
|
+
);
|
|
131
|
+
await response.send(reply.status, reply.headers, body);
|
|
132
|
+
return "answered";
|
|
133
|
+
}
|
|
134
|
+
case "passthrough":
|
|
135
|
+
return undefined;
|
|
136
|
+
case "abort":
|
|
137
|
+
record(
|
|
138
|
+
{
|
|
139
|
+
kind: "fault",
|
|
140
|
+
direction: "none",
|
|
141
|
+
source: "server",
|
|
142
|
+
meta: { reason: "abort", error: reply.error },
|
|
143
|
+
correlation,
|
|
144
|
+
},
|
|
145
|
+
path,
|
|
146
|
+
);
|
|
147
|
+
response.reset();
|
|
148
|
+
return "answered";
|
|
149
|
+
case "delay":
|
|
150
|
+
await deps.clock.sleep(reply.ms);
|
|
151
|
+
return answer(reply.reply, response, path, correlation);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** A route's bug: logged, on the timeline, and the client gets a 500 saying so. */
|
|
156
|
+
const fail = async (
|
|
157
|
+
route: Route,
|
|
158
|
+
error: unknown,
|
|
159
|
+
response: ServedResponse,
|
|
160
|
+
path: string,
|
|
161
|
+
correlation: string,
|
|
162
|
+
) => {
|
|
163
|
+
deps.log.error("rule failed", { rule: route.label, server: name }, error);
|
|
164
|
+
record(
|
|
165
|
+
{
|
|
166
|
+
kind: "fault",
|
|
167
|
+
direction: "none",
|
|
168
|
+
source: "server",
|
|
169
|
+
meta: {
|
|
170
|
+
reason: "rule-error",
|
|
171
|
+
rule: route.label,
|
|
172
|
+
error: failureOf(error),
|
|
173
|
+
},
|
|
174
|
+
correlation,
|
|
175
|
+
},
|
|
176
|
+
path,
|
|
177
|
+
);
|
|
178
|
+
await response.send(
|
|
179
|
+
500,
|
|
180
|
+
TEXT,
|
|
181
|
+
`wirestage: rule "${route.label}" failed: ${failureOf(error)}`,
|
|
182
|
+
);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const handle = async (request: ServedRequest, response: ServedResponse) => {
|
|
186
|
+
const correlation = deps.correlation();
|
|
187
|
+
const path = request.url.pathname;
|
|
188
|
+
record(
|
|
189
|
+
{
|
|
190
|
+
kind: "request",
|
|
191
|
+
direction: "out",
|
|
192
|
+
source: "network",
|
|
193
|
+
data: request.body,
|
|
194
|
+
meta: {
|
|
195
|
+
method: request.method,
|
|
196
|
+
url: request.url.href,
|
|
197
|
+
headers: request.headers,
|
|
198
|
+
},
|
|
199
|
+
correlation,
|
|
200
|
+
},
|
|
201
|
+
path,
|
|
202
|
+
);
|
|
203
|
+
// The chain as it stands now: a route added while this one waits on a
|
|
204
|
+
// resolver takes the next request, not this one.
|
|
205
|
+
for (const route of [...routes].reverse()) {
|
|
206
|
+
if (!route.active) continue;
|
|
207
|
+
if (route.method !== undefined && route.method !== request.method)
|
|
208
|
+
continue;
|
|
209
|
+
const params = route.matcher.match(request.url);
|
|
210
|
+
if (params === undefined) continue;
|
|
211
|
+
const view = restRequest(request, correlation, params);
|
|
212
|
+
try {
|
|
213
|
+
if (route.match !== undefined && !route.match(view)) continue;
|
|
214
|
+
// Claimed before the resolver runs, which may wait: two requests
|
|
215
|
+
// at once cannot both take a route's last turn.
|
|
216
|
+
route.hits += 1;
|
|
217
|
+
if (route.remaining !== undefined && --route.remaining === 0)
|
|
218
|
+
retire(route);
|
|
219
|
+
route.requests.push(view);
|
|
220
|
+
const reply =
|
|
221
|
+
typeof route.resolver === "function"
|
|
222
|
+
? await route.resolver(view)
|
|
223
|
+
: route.resolver;
|
|
224
|
+
if ((await answer(reply, response, path, correlation)) !== undefined)
|
|
225
|
+
return;
|
|
226
|
+
} catch (error) {
|
|
227
|
+
await fail(route, error, response, path, correlation);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
record(
|
|
232
|
+
{
|
|
233
|
+
kind: "response",
|
|
234
|
+
direction: "in",
|
|
235
|
+
source: "server",
|
|
236
|
+
data: "",
|
|
237
|
+
meta: { status: 404, headers: TEXT, unmatched: true },
|
|
238
|
+
correlation,
|
|
239
|
+
},
|
|
240
|
+
path,
|
|
241
|
+
);
|
|
242
|
+
await response.send(
|
|
243
|
+
404,
|
|
244
|
+
TEXT,
|
|
245
|
+
`wirestage: ${name} has no route for ${request.method} ${path}`,
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const listening = await serveHttp(
|
|
250
|
+
{
|
|
251
|
+
name,
|
|
252
|
+
...(options.host === undefined ? {} : { host: options.host }),
|
|
253
|
+
...(options.port === undefined ? {} : { port: options.port }),
|
|
254
|
+
},
|
|
255
|
+
handle,
|
|
256
|
+
);
|
|
257
|
+
url = listening.url;
|
|
258
|
+
|
|
259
|
+
const on: RestApi["on"] = async (
|
|
260
|
+
method,
|
|
261
|
+
path,
|
|
262
|
+
resolver,
|
|
263
|
+
routeOptions = {},
|
|
264
|
+
) => {
|
|
265
|
+
if (
|
|
266
|
+
routeOptions.times !== undefined &&
|
|
267
|
+
(!Number.isInteger(routeOptions.times) || routeOptions.times < 1)
|
|
268
|
+
)
|
|
269
|
+
throw new WirestageError(
|
|
270
|
+
"INVALID_ARGUMENT",
|
|
271
|
+
`route "${method} ${String(path)}": times must be a positive integer, got ${routeOptions.times}`,
|
|
272
|
+
);
|
|
273
|
+
const label = `${method} ${String(path)}`;
|
|
274
|
+
const route: Route = {
|
|
275
|
+
label,
|
|
276
|
+
method: method === "*" ? undefined : method,
|
|
277
|
+
matcher: compilePath(path, options.baseURL),
|
|
278
|
+
resolver,
|
|
279
|
+
match: routeOptions.match,
|
|
280
|
+
requests: createArrivals<RestRequest>({
|
|
281
|
+
clock: deps.clock,
|
|
282
|
+
label: `${label} request to ${name}`,
|
|
283
|
+
}),
|
|
284
|
+
remaining: routeOptions.times,
|
|
285
|
+
hits: 0,
|
|
286
|
+
active: true,
|
|
287
|
+
};
|
|
288
|
+
routes.push(route);
|
|
289
|
+
return {
|
|
290
|
+
get hits() {
|
|
291
|
+
return route.hits;
|
|
292
|
+
},
|
|
293
|
+
get requests() {
|
|
294
|
+
return route.requests.items;
|
|
295
|
+
},
|
|
296
|
+
next: (waitOptions) => route.requests.next(waitOptions),
|
|
297
|
+
async remove() {
|
|
298
|
+
retire(route);
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const verb =
|
|
304
|
+
(method: RestMethod): RestApi["get"] =>
|
|
305
|
+
(path, resolver, routeOptions) =>
|
|
306
|
+
on(method, path, resolver, routeOptions);
|
|
307
|
+
|
|
308
|
+
let closing: Promise<void> | undefined;
|
|
309
|
+
const server: RestServer = {
|
|
310
|
+
url,
|
|
311
|
+
port: listening.port,
|
|
312
|
+
on,
|
|
313
|
+
get: verb("GET"),
|
|
314
|
+
post: verb("POST"),
|
|
315
|
+
put: verb("PUT"),
|
|
316
|
+
patch: verb("PATCH"),
|
|
317
|
+
delete: verb("DELETE"),
|
|
318
|
+
async calls(filter = {}) {
|
|
319
|
+
return filterCalls(
|
|
320
|
+
deps.timeline.frames({
|
|
321
|
+
protocol: deps.protocol,
|
|
322
|
+
where: (frame) => frame.meta["server"] === url,
|
|
323
|
+
}),
|
|
324
|
+
filter,
|
|
325
|
+
options.baseURL,
|
|
326
|
+
);
|
|
327
|
+
},
|
|
328
|
+
close() {
|
|
329
|
+
closing ??= (async () => {
|
|
330
|
+
for (const route of [...routes]) retire(route);
|
|
331
|
+
deps.onClose(server);
|
|
332
|
+
await listening.close();
|
|
333
|
+
})();
|
|
334
|
+
return closing;
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
return server;
|
|
338
|
+
}
|
|
@@ -25,6 +25,11 @@ export interface RestOptions {
|
|
|
25
25
|
readonly record?: boolean | UrlMatcher;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A request a route is deciding about. For a request a served fake received
|
|
30
|
+
* (`serve()`), `page` is undefined, `context` is empty and `resourceType` is
|
|
31
|
+
* `other`.
|
|
32
|
+
*/
|
|
28
33
|
export interface RestRequest extends HttpRequestView {
|
|
29
34
|
/** `:name` segments of the matched path. */
|
|
30
35
|
readonly params: Readonly<Record<string, string>>;
|
|
@@ -69,6 +74,38 @@ export interface RestCall {
|
|
|
69
74
|
readonly response: Frame | undefined;
|
|
70
75
|
/** The HTTP status; undefined for an aborted or unfinished request. */
|
|
71
76
|
readonly status: number | undefined;
|
|
77
|
+
/** The served fake that received it (its URL); undefined for a page's. */
|
|
78
|
+
readonly server: string | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RestServeOptions {
|
|
82
|
+
/** 0, the default, picks a free port. A fixed port that is taken fails, naming the server. */
|
|
83
|
+
readonly port?: number;
|
|
84
|
+
/** Default 127.0.0.1. */
|
|
85
|
+
readonly host?: string;
|
|
86
|
+
/** Names the server in errors, 404s and logs. Default `<plugin> server`. */
|
|
87
|
+
readonly name?: string;
|
|
88
|
+
/** A path prefix the server's routes are relative to. */
|
|
89
|
+
readonly baseURL?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A fake HTTP backend for clients outside the browser — a service under
|
|
94
|
+
* test, an agent — routed and recorded like the page's own requests. A route
|
|
95
|
+
* that passes a request through hands it to the next older route; with none
|
|
96
|
+
* left, the server answers 404.
|
|
97
|
+
*/
|
|
98
|
+
export interface RestServer
|
|
99
|
+
extends Pick<RestApi, "on" | "get" | "post" | "put" | "patch" | "delete"> {
|
|
100
|
+
readonly url: string;
|
|
101
|
+
readonly port: number;
|
|
102
|
+
/** This server's calls, oldest first. */
|
|
103
|
+
calls(filter?: {
|
|
104
|
+
readonly method?: string;
|
|
105
|
+
readonly path?: RestPath;
|
|
106
|
+
}): Promise<RestCall[]>;
|
|
107
|
+
/** Stop accepting and drop open connections. The stage closes it too. */
|
|
108
|
+
close(): Promise<void>;
|
|
72
109
|
}
|
|
73
110
|
|
|
74
111
|
export type RestPath = string | RegExp;
|
|
@@ -105,9 +142,11 @@ export interface RestApi {
|
|
|
105
142
|
resolver: RestResolver,
|
|
106
143
|
options?: RestRouteOptions,
|
|
107
144
|
): Promise<RestRoute>;
|
|
108
|
-
/** Recorded REST exchanges, oldest first. */
|
|
145
|
+
/** Recorded REST exchanges, oldest first: the pages' and served fakes'. */
|
|
109
146
|
calls(filter?: {
|
|
110
147
|
readonly method?: string;
|
|
111
148
|
readonly path?: RestPath;
|
|
112
149
|
}): Promise<RestCall[]>;
|
|
150
|
+
/** Serve routes on a port of their own, for clients outside the browser. */
|
|
151
|
+
serve(options?: RestServeOptions): Promise<RestServer>;
|
|
113
152
|
}
|
package/src/index.ts
CHANGED
|
@@ -102,9 +102,11 @@ export type { UrlMatcher } from "./shared/lib/match/index.js";
|
|
|
102
102
|
export { compileUrlMatcher } from "./shared/lib/match/index.js";
|
|
103
103
|
export type {
|
|
104
104
|
AttachOptions,
|
|
105
|
+
MarkOptions,
|
|
105
106
|
ReportOptions,
|
|
106
107
|
Stage,
|
|
107
108
|
StageCore,
|
|
109
|
+
StageCursor,
|
|
108
110
|
StageOptions,
|
|
109
111
|
StageReport,
|
|
110
112
|
} from "./widgets/stage/index.js";
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createServer,
|
|
3
|
+
type IncomingMessage,
|
|
4
|
+
type ServerResponse,
|
|
5
|
+
} from "node:http";
|
|
6
|
+
|
|
7
|
+
import { type Listening, type ListenOptions, listen } from "./listen.js";
|
|
8
|
+
|
|
9
|
+
/** A request a served fake received, read in full before it is handed on. */
|
|
10
|
+
export interface ServedRequest {
|
|
11
|
+
readonly method: string;
|
|
12
|
+
/** Absolute: the server's origin, then the path and query the client sent. */
|
|
13
|
+
readonly url: URL;
|
|
14
|
+
/** Lower-cased names; a repeated header's values joined with `, `. */
|
|
15
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
16
|
+
readonly body: Uint8Array;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The answer to a {@link ServedRequest}: one of the two, once. */
|
|
20
|
+
export interface ServedResponse {
|
|
21
|
+
/**
|
|
22
|
+
* Send a complete response. A header may carry several values separated
|
|
23
|
+
* by a newline (`set-cookie`). Resolves once it is written.
|
|
24
|
+
*/
|
|
25
|
+
send(
|
|
26
|
+
status: number,
|
|
27
|
+
headers: Readonly<Record<string, string>>,
|
|
28
|
+
body: Uint8Array | string,
|
|
29
|
+
): Promise<void>;
|
|
30
|
+
/** Drop the connection: no status, no body — a network failure. */
|
|
31
|
+
reset(): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RequestHandler = (
|
|
35
|
+
request: ServedRequest,
|
|
36
|
+
response: ServedResponse,
|
|
37
|
+
) => void | Promise<void>;
|
|
38
|
+
|
|
39
|
+
const headersOf = (message: IncomingMessage): Record<string, string> =>
|
|
40
|
+
Object.fromEntries(
|
|
41
|
+
Object.entries(message.headersDistinct as Record<string, string[]>).map(
|
|
42
|
+
([name, values]) => [name, values.join(", ")],
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const readBody = (message: IncomingMessage): Promise<Uint8Array> =>
|
|
47
|
+
new Promise((resolve, reject) => {
|
|
48
|
+
const chunks: Buffer[] = [];
|
|
49
|
+
message.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
50
|
+
message.once("end", () => resolve(new Uint8Array(Buffer.concat(chunks))));
|
|
51
|
+
message.once("error", reject);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function responderOf(response: ServerResponse): ServedResponse {
|
|
55
|
+
return {
|
|
56
|
+
send(status, headers, body) {
|
|
57
|
+
for (const [name, value] of Object.entries(headers))
|
|
58
|
+
response.setHeader(
|
|
59
|
+
name,
|
|
60
|
+
value.includes("\n") ? value.split("\n") : value,
|
|
61
|
+
);
|
|
62
|
+
response.statusCode = status;
|
|
63
|
+
return new Promise<void>((resolve) => {
|
|
64
|
+
// `close` too: a client that went away never sees `finish`.
|
|
65
|
+
response.once("close", () => resolve());
|
|
66
|
+
response.end(body);
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
reset() {
|
|
70
|
+
response.destroy();
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* An HTTP/1.1 server for clients outside the browser, handing each request,
|
|
77
|
+
* body read, to `handle`. The handler is the caller's to make safe; one that
|
|
78
|
+
* throws anyway gets a 500 rather than a client left waiting, and closing the
|
|
79
|
+
* server drops open connections at once (`listen`).
|
|
80
|
+
*/
|
|
81
|
+
export async function serveHttp(
|
|
82
|
+
options: Omit<ListenOptions, "protocol">,
|
|
83
|
+
handle: RequestHandler,
|
|
84
|
+
): Promise<Listening> {
|
|
85
|
+
const server = createServer((message, response) => {
|
|
86
|
+
const responder = responderOf(response);
|
|
87
|
+
readBody(message)
|
|
88
|
+
.then((body) =>
|
|
89
|
+
handle(
|
|
90
|
+
{
|
|
91
|
+
// Always set on a request an http.Server received.
|
|
92
|
+
method: message.method as string,
|
|
93
|
+
url: new URL(message.url as string, listening.url),
|
|
94
|
+
headers: headersOf(message),
|
|
95
|
+
body,
|
|
96
|
+
},
|
|
97
|
+
responder,
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
.catch((error: unknown) => {
|
|
101
|
+
if (response.headersSent) response.destroy();
|
|
102
|
+
else
|
|
103
|
+
void responder.send(
|
|
104
|
+
500,
|
|
105
|
+
{ "content-type": "text/plain; charset=utf-8" },
|
|
106
|
+
`wirestage: ${options.name} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
const listening = await listen(server, { ...options, protocol: "http" });
|
|
111
|
+
return listening;
|
|
112
|
+
}
|
|
@@ -41,6 +41,7 @@ const RESERVED = new Set<string>([
|
|
|
41
41
|
"frames",
|
|
42
42
|
"drain",
|
|
43
43
|
"mark",
|
|
44
|
+
"cursor",
|
|
44
45
|
"since",
|
|
45
46
|
"between",
|
|
46
47
|
"waitFor",
|
|
@@ -123,6 +124,7 @@ export function createStage<const Plugins extends readonly AnyWirePlugin[]>(
|
|
|
123
124
|
timeline,
|
|
124
125
|
clock,
|
|
125
126
|
sync,
|
|
127
|
+
pageId,
|
|
126
128
|
wait: {
|
|
127
129
|
timeout: options.wait?.timeout ?? 5_000,
|
|
128
130
|
interval: options.wait?.interval ?? 25,
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
Timeline,
|
|
5
5
|
} from "../../../entities/frame/index.js";
|
|
6
6
|
import type { WaitForOptions } from "../../../entities/plugin/index.js";
|
|
7
|
+
import type { PagePort } from "../../../shared/api/playwright/index.js";
|
|
7
8
|
import type { Clock } from "../../../shared/lib/clock/index.js";
|
|
8
9
|
import { WirestageError } from "../../../shared/lib/errors/index.js";
|
|
9
10
|
import type { StageCore } from "../model/stage.js";
|
|
@@ -13,6 +14,8 @@ export interface ReadsDeps {
|
|
|
13
14
|
readonly clock: Clock;
|
|
14
15
|
/** Flush every page buffer into the timeline. */
|
|
15
16
|
readonly sync: () => Promise<void>;
|
|
17
|
+
/** The id frames use for a page. */
|
|
18
|
+
readonly pageId: (page: PagePort) => string;
|
|
16
19
|
/** Defaults for `waitFor`, in milliseconds. */
|
|
17
20
|
readonly wait: { readonly timeout: number; readonly interval: number };
|
|
18
21
|
}
|
|
@@ -26,7 +29,14 @@ const REPORT_LIMIT = 1_000;
|
|
|
26
29
|
|
|
27
30
|
export type StageReads = Pick<
|
|
28
31
|
StageCore,
|
|
29
|
-
|
|
32
|
+
| "frames"
|
|
33
|
+
| "drain"
|
|
34
|
+
| "mark"
|
|
35
|
+
| "cursor"
|
|
36
|
+
| "since"
|
|
37
|
+
| "between"
|
|
38
|
+
| "waitFor"
|
|
39
|
+
| "report"
|
|
30
40
|
>;
|
|
31
41
|
|
|
32
42
|
/**
|
|
@@ -58,17 +68,49 @@ export function createReads(deps: ReadsDeps): StageReads {
|
|
|
58
68
|
await sync();
|
|
59
69
|
return drainCursor.take(filter);
|
|
60
70
|
},
|
|
61
|
-
async mark(name,
|
|
71
|
+
async mark(name, options = {}) {
|
|
62
72
|
await sync();
|
|
73
|
+
const { page, meta } = options;
|
|
63
74
|
return timeline.append({
|
|
64
75
|
protocol: "wirestage",
|
|
65
76
|
kind: "marker",
|
|
66
77
|
direction: "none",
|
|
67
78
|
endpoint: name,
|
|
68
79
|
source: "test",
|
|
80
|
+
...(page === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: { page: typeof page === "string" ? page : deps.pageId(page) }),
|
|
69
83
|
...(meta === undefined ? {} : { meta }),
|
|
70
84
|
});
|
|
71
85
|
},
|
|
86
|
+
cursor(base = {}) {
|
|
87
|
+
let position = 0;
|
|
88
|
+
const read = async (filter: FrameFilter) => {
|
|
89
|
+
await sync();
|
|
90
|
+
const where = filter.where;
|
|
91
|
+
return timeline.frames({
|
|
92
|
+
...base,
|
|
93
|
+
...filter,
|
|
94
|
+
after: Math.max(position, base.after ?? 0, filter.after ?? 0),
|
|
95
|
+
where: (frame) =>
|
|
96
|
+
(base.where === undefined || base.where(frame)) &&
|
|
97
|
+
(where === undefined || where(frame)),
|
|
98
|
+
});
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
async take(filter = {}) {
|
|
102
|
+
const frames = await read(filter);
|
|
103
|
+
// Past everything that arrived, not just what matched: the next
|
|
104
|
+
// take starts where this one looked.
|
|
105
|
+
position = Math.max(position, timeline.last()?.seq ?? 0);
|
|
106
|
+
return frames;
|
|
107
|
+
},
|
|
108
|
+
peek: (filter = {}) => read(filter),
|
|
109
|
+
get position() {
|
|
110
|
+
return position;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
},
|
|
72
114
|
async since(marker, filter) {
|
|
73
115
|
await sync();
|
|
74
116
|
const after = requireMarker(marker).seq;
|
|
@@ -55,6 +55,34 @@ export interface StageReport {
|
|
|
55
55
|
readonly text: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export interface MarkOptions {
|
|
59
|
+
/**
|
|
60
|
+
* The page the marker is about. The marker carries its id, so a read
|
|
61
|
+
* narrowed to that page (`{ page }`) includes it.
|
|
62
|
+
*/
|
|
63
|
+
readonly page?: PagePort | string;
|
|
64
|
+
/** Anything else worth keeping on the marker. */
|
|
65
|
+
readonly meta?: Readonly<Record<string, unknown>>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A reading position of its own on the timeline: what a helper that keeps a
|
|
70
|
+
* log per page, per channel or per check reads from, without moving the
|
|
71
|
+
* stage's `drain` or another cursor.
|
|
72
|
+
*/
|
|
73
|
+
export interface StageCursor {
|
|
74
|
+
/**
|
|
75
|
+
* Bring the timeline up to date, then take what reached it since this
|
|
76
|
+
* cursor last took — narrowed by the cursor's filter and `filter` — and
|
|
77
|
+
* move past all of it, matching or not.
|
|
78
|
+
*/
|
|
79
|
+
take(filter?: FrameFilter): Promise<Frame[]>;
|
|
80
|
+
/** The same frames as `take`, without moving. */
|
|
81
|
+
peek(filter?: FrameFilter): Promise<Frame[]>;
|
|
82
|
+
/** The `seq` the cursor has read up to; 0 before its first take. */
|
|
83
|
+
readonly position: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
export interface ReportOptions {
|
|
59
87
|
/** Frames kept, counting back from the newest. Default 1,000. */
|
|
60
88
|
readonly limit?: number;
|
|
@@ -90,7 +118,12 @@ export interface StageCore {
|
|
|
90
118
|
/** Frames since the previous `drain` (any filter), narrowed by `filter`. */
|
|
91
119
|
drain(filter?: FrameFilter): Promise<Frame[]>;
|
|
92
120
|
/** Put a named marker on the timeline, after everything that happened so far. */
|
|
93
|
-
mark(name: string,
|
|
121
|
+
mark(name: string, options?: MarkOptions): Promise<Frame>;
|
|
122
|
+
/**
|
|
123
|
+
* A cursor of its own, from the start of the timeline: every `take`
|
|
124
|
+
* returns what is new since the previous one.
|
|
125
|
+
*/
|
|
126
|
+
cursor(filter?: FrameFilter): StageCursor;
|
|
94
127
|
/** Frames after the most recent marker called `marker`. */
|
|
95
128
|
since(marker: string, filter?: FrameFilter): Promise<Frame[]>;
|
|
96
129
|
/** Frames between two markers, exclusive. */
|