@smithers-orchestrator/server 0.16.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/LICENSE +21 -0
- package/package.json +46 -0
- package/src/ConnectRequest.ts +17 -0
- package/src/EventFrame.ts +7 -0
- package/src/GatewayAuthConfig.ts +26 -0
- package/src/GatewayDefaults.ts +3 -0
- package/src/GatewayOptions.ts +13 -0
- package/src/GatewayTokenGrant.ts +5 -0
- package/src/GatewayWebhookConfig.ts +10 -0
- package/src/GatewayWebhookRunConfig.ts +4 -0
- package/src/GatewayWebhookSignalConfig.ts +6 -0
- package/src/HelloResponse.ts +18 -0
- package/src/RequestFrame.ts +6 -0
- package/src/ResponseFrame.ts +10 -0
- package/src/ServeOptions.ts +11 -0
- package/src/ServerOptions.ts +8 -0
- package/src/gateway.js +3402 -0
- package/src/gatewayRoutes/DiffSummary.ts +6 -0
- package/src/gatewayRoutes/GetNodeDiffRouteResult.ts +23 -0
- package/src/gatewayRoutes/NODE_OUTPUT_MAX_BYTES.js +1 -0
- package/src/gatewayRoutes/NODE_OUTPUT_WARN_BYTES.js +1 -0
- package/src/gatewayRoutes/NodeOutputResponse.ts +22 -0
- package/src/gatewayRoutes/NodeOutputRouteError.js +14 -0
- package/src/gatewayRoutes/getDevToolsSnapshot.js +428 -0
- package/src/gatewayRoutes/getNodeDiff.js +609 -0
- package/src/gatewayRoutes/getNodeOutput.js +504 -0
- package/src/gatewayRoutes/jumpToFrame.js +84 -0
- package/src/gatewayRoutes/streamDevTools.js +525 -0
- package/src/index.d.ts +953 -0
- package/src/index.js +1240 -0
- package/src/serve.js +315 -0
- package/src/smithersRuntime.js +63 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { diffSnapshots } from "@smithers-orchestrator/devtools";
|
|
3
|
+
import { runPromise } from "../smithersRuntime.js";
|
|
4
|
+
import {
|
|
5
|
+
DevToolsRouteError,
|
|
6
|
+
getDevToolsSnapshotRoute,
|
|
7
|
+
validateFromSeqInput,
|
|
8
|
+
validateRunId,
|
|
9
|
+
} from "./getDevToolsSnapshot.js";
|
|
10
|
+
|
|
11
|
+
/** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
|
|
12
|
+
/** @typedef {import("@smithers-orchestrator/protocol/devtools").DevToolsEvent} DevToolsEvent */
|
|
13
|
+
/** @typedef {import("@smithers-orchestrator/protocol/devtools").DevToolsSnapshot} DevToolsSnapshot */
|
|
14
|
+
/** @typedef {import("@smithers-orchestrator/devtools/snapshotSerializer").SnapshotSerializerWarning} SnapshotSerializerWarning */
|
|
15
|
+
|
|
16
|
+
export const DEVTOOLS_REBASELINE_INTERVAL = 50;
|
|
17
|
+
export const DEVTOOLS_BACKPRESSURE_LIMIT = 1_000;
|
|
18
|
+
export const DEVTOOLS_POLL_INTERVAL_MS = 25;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {number} timeMs
|
|
22
|
+
* @returns {Promise<void>}
|
|
23
|
+
*/
|
|
24
|
+
function delay(timeMs) {
|
|
25
|
+
return new Promise((resolve) => setTimeout(resolve, timeMs));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {DevToolsEvent} event
|
|
30
|
+
* @returns {number}
|
|
31
|
+
*/
|
|
32
|
+
function estimateEventSize(event) {
|
|
33
|
+
return Buffer.byteLength(JSON.stringify(event), "utf8");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wrap a promise-returning function in an Effect tracing span with attributes.
|
|
38
|
+
*
|
|
39
|
+
* @template T
|
|
40
|
+
* @param {string} spanName
|
|
41
|
+
* @param {Record<string, unknown>} attrs
|
|
42
|
+
* @param {() => Promise<T>} run
|
|
43
|
+
* @returns {Promise<T>}
|
|
44
|
+
*/
|
|
45
|
+
async function withSpan(spanName, attrs, run) {
|
|
46
|
+
return runPromise(
|
|
47
|
+
Effect.promise(() => run()).pipe(Effect.withSpan(spanName, { attributes: attrs })),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {SmithersDb} adapter
|
|
53
|
+
* @param {string} runId
|
|
54
|
+
* @returns {Promise<{ frameNo: number } | null>}
|
|
55
|
+
*/
|
|
56
|
+
async function getLastFrameSpan(adapter, runId) {
|
|
57
|
+
return withSpan(
|
|
58
|
+
"db.frames.get",
|
|
59
|
+
{ runId, op: "getLastFrame" },
|
|
60
|
+
() => adapter.getLastFrame(runId),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class AsyncEventQueue {
|
|
65
|
+
items = [];
|
|
66
|
+
waiters = [];
|
|
67
|
+
closed = false;
|
|
68
|
+
error = null;
|
|
69
|
+
maxItems;
|
|
70
|
+
/**
|
|
71
|
+
* @param {number} maxItems
|
|
72
|
+
*/
|
|
73
|
+
constructor(maxItems) {
|
|
74
|
+
this.maxItems = maxItems;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* @param {DevToolsEvent} value
|
|
78
|
+
*/
|
|
79
|
+
push(value) {
|
|
80
|
+
if (this.closed || this.error) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (this.items.length >= this.maxItems) {
|
|
84
|
+
this.fail(new DevToolsRouteError("BackpressureDisconnect", "Subscriber event queue exceeded 1000 buffered events."));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (this.waiters.length > 0) {
|
|
88
|
+
const waiter = this.waiters.shift();
|
|
89
|
+
waiter?.({ value, done: false });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this.items.push(value);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* @param {Error} error
|
|
96
|
+
*/
|
|
97
|
+
fail(error) {
|
|
98
|
+
if (this.closed || this.error) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.error = error;
|
|
102
|
+
while (this.waiters.length > 0) {
|
|
103
|
+
const waiter = this.waiters.shift();
|
|
104
|
+
waiter?.(Promise.reject(error));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
close() {
|
|
108
|
+
if (this.closed) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.closed = true;
|
|
112
|
+
while (this.waiters.length > 0) {
|
|
113
|
+
const waiter = this.waiters.shift();
|
|
114
|
+
waiter?.({ value: undefined, done: true });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* @returns {Promise<IteratorResult<DevToolsEvent>>}
|
|
119
|
+
*/
|
|
120
|
+
async next() {
|
|
121
|
+
if (this.error) {
|
|
122
|
+
throw this.error;
|
|
123
|
+
}
|
|
124
|
+
if (this.items.length > 0) {
|
|
125
|
+
const value = this.items.shift();
|
|
126
|
+
return { value, done: false };
|
|
127
|
+
}
|
|
128
|
+
if (this.closed) {
|
|
129
|
+
return { value: undefined, done: true };
|
|
130
|
+
}
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
this.waiters.push((result) => {
|
|
133
|
+
if (result && typeof result === "object" && "then" in result) {
|
|
134
|
+
result.then(resolve, reject);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
resolve(/** @type {IteratorResult<DevToolsEvent>} */ (result));
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @param {{ kind: "snapshot"; snapshot: DevToolsSnapshot } | { kind: "delta"; snapshot: DevToolsSnapshot; previous: DevToolsSnapshot }} input
|
|
145
|
+
* @returns {Promise<DevToolsEvent>}
|
|
146
|
+
*/
|
|
147
|
+
async function makeEvent(input) {
|
|
148
|
+
if (input.kind === "snapshot") {
|
|
149
|
+
return {
|
|
150
|
+
version: 1,
|
|
151
|
+
kind: "snapshot",
|
|
152
|
+
snapshot: input.snapshot,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const delta = await withSpan(
|
|
156
|
+
"devtools.diffSnapshots",
|
|
157
|
+
{
|
|
158
|
+
runId: input.snapshot.runId,
|
|
159
|
+
baseSeq: input.previous.seq,
|
|
160
|
+
seq: input.snapshot.seq,
|
|
161
|
+
},
|
|
162
|
+
async () => diffSnapshots(input.previous, input.snapshot),
|
|
163
|
+
);
|
|
164
|
+
return {
|
|
165
|
+
version: 1,
|
|
166
|
+
kind: "delta",
|
|
167
|
+
delta: {
|
|
168
|
+
version: 1,
|
|
169
|
+
baseSeq: delta.baseSeq,
|
|
170
|
+
seq: delta.seq,
|
|
171
|
+
ops: delta.ops,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {{
|
|
178
|
+
* adapter: SmithersDb;
|
|
179
|
+
* runId: string;
|
|
180
|
+
* fromSeq?: number;
|
|
181
|
+
* subscriberId?: string;
|
|
182
|
+
* pollIntervalMs?: number;
|
|
183
|
+
* maxBufferedEvents?: number;
|
|
184
|
+
* signal?: AbortSignal;
|
|
185
|
+
* invalidateSnapshot?: () => boolean;
|
|
186
|
+
* onWarning?: (warning: SnapshotSerializerWarning) => void;
|
|
187
|
+
* onLog?: (level: "debug" | "info" | "warn" | "error", message: string, fields: Record<string, unknown>) => void;
|
|
188
|
+
* onEvent?: (event: DevToolsEvent, stats: { bytes: number; durationMs: number; opCount?: number; frameNo?: number }) => void;
|
|
189
|
+
* onClose?: (summary: { eventsDelivered: number; durationMs: number; errorCode?: string }) => void;
|
|
190
|
+
* }} input
|
|
191
|
+
* @returns {AsyncIterable<DevToolsEvent>}
|
|
192
|
+
*/
|
|
193
|
+
export async function* streamDevToolsRoute(input) {
|
|
194
|
+
const runId = validateRunId(input.runId);
|
|
195
|
+
const pollIntervalMs = Number.isFinite(input.pollIntervalMs)
|
|
196
|
+
? Math.max(1, Math.floor(input.pollIntervalMs))
|
|
197
|
+
: DEVTOOLS_POLL_INTERVAL_MS;
|
|
198
|
+
const maxBufferedEvents = Number.isFinite(input.maxBufferedEvents)
|
|
199
|
+
? Math.max(1, Math.floor(input.maxBufferedEvents))
|
|
200
|
+
: DEVTOOLS_BACKPRESSURE_LIMIT;
|
|
201
|
+
validateFromSeqInput(input.fromSeq);
|
|
202
|
+
const startedAt = Date.now();
|
|
203
|
+
let eventsDelivered = 0;
|
|
204
|
+
let lastSnapshot = null;
|
|
205
|
+
let lastSeenSeq = 0;
|
|
206
|
+
/** Per-subscriber counter: number of delta events since the last snapshot. */
|
|
207
|
+
let eventsSinceSnapshot = 0;
|
|
208
|
+
let producerErrorCode = undefined;
|
|
209
|
+
const queue = new AsyncEventQueue(maxBufferedEvents);
|
|
210
|
+
let cancelled = false;
|
|
211
|
+
input.onLog?.("info", "devtools stream subscribed", {
|
|
212
|
+
runId,
|
|
213
|
+
fromSeq: input.fromSeq ?? null,
|
|
214
|
+
subscriberId: input.subscriberId ?? null,
|
|
215
|
+
});
|
|
216
|
+
/**
|
|
217
|
+
* @param {DevToolsEvent} event
|
|
218
|
+
* @param {number} started
|
|
219
|
+
*/
|
|
220
|
+
const publish = (event, started) => {
|
|
221
|
+
queue.push(event);
|
|
222
|
+
const durationMs = Date.now() - started;
|
|
223
|
+
if (event.kind === "snapshot") {
|
|
224
|
+
eventsSinceSnapshot = 0;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
eventsSinceSnapshot += 1;
|
|
228
|
+
}
|
|
229
|
+
if (!input.onEvent && !input.onLog) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
let measuredBytes = 0;
|
|
233
|
+
let hasMeasuredBytes = false;
|
|
234
|
+
const bytes = () => {
|
|
235
|
+
if (!hasMeasuredBytes) {
|
|
236
|
+
measuredBytes = estimateEventSize(event);
|
|
237
|
+
hasMeasuredBytes = true;
|
|
238
|
+
}
|
|
239
|
+
return measuredBytes;
|
|
240
|
+
};
|
|
241
|
+
if (event.kind === "snapshot") {
|
|
242
|
+
input.onLog?.("debug", "devtools snapshot emitted", {
|
|
243
|
+
runId,
|
|
244
|
+
seq: event.snapshot.seq,
|
|
245
|
+
frameNo: event.snapshot.frameNo,
|
|
246
|
+
bytes: bytes(),
|
|
247
|
+
durationMs,
|
|
248
|
+
});
|
|
249
|
+
input.onEvent?.(event, {
|
|
250
|
+
bytes: bytes(),
|
|
251
|
+
durationMs,
|
|
252
|
+
frameNo: event.snapshot.frameNo,
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
input.onLog?.("debug", "devtools delta emitted", {
|
|
257
|
+
runId,
|
|
258
|
+
seq: event.delta.seq,
|
|
259
|
+
opCount: event.delta.ops.length,
|
|
260
|
+
bytes: bytes(),
|
|
261
|
+
durationMs,
|
|
262
|
+
});
|
|
263
|
+
input.onEvent?.(event, {
|
|
264
|
+
bytes: bytes(),
|
|
265
|
+
durationMs,
|
|
266
|
+
opCount: event.delta.ops.length,
|
|
267
|
+
});
|
|
268
|
+
};
|
|
269
|
+
/**
|
|
270
|
+
* @returns {boolean}
|
|
271
|
+
*/
|
|
272
|
+
const shouldStop = () => cancelled || Boolean(input.signal?.aborted) || Boolean(queue.error);
|
|
273
|
+
/**
|
|
274
|
+
* Capture a snapshot wrapped in an Effect.withSpan. Lets tracing backends
|
|
275
|
+
* attribute snapshot work to a child span of the stream.
|
|
276
|
+
*
|
|
277
|
+
* @param {number} frameNo
|
|
278
|
+
* @returns {Promise<DevToolsSnapshot>}
|
|
279
|
+
*/
|
|
280
|
+
const captureSnapshot = async (frameNo) => withSpan(
|
|
281
|
+
"devtools.captureSnapshot",
|
|
282
|
+
{ runId, frameNo },
|
|
283
|
+
() => getDevToolsSnapshotRoute({
|
|
284
|
+
adapter: input.adapter,
|
|
285
|
+
runId,
|
|
286
|
+
frameNo,
|
|
287
|
+
onWarning: input.onWarning,
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
const producer = withSpan(
|
|
291
|
+
"devtools.streamDevTools",
|
|
292
|
+
{
|
|
293
|
+
runId,
|
|
294
|
+
fromSeq: input.fromSeq ?? null,
|
|
295
|
+
subscriberId: input.subscriberId ?? null,
|
|
296
|
+
},
|
|
297
|
+
async () => {
|
|
298
|
+
try {
|
|
299
|
+
const latestFrame = await getLastFrameSpan(input.adapter, runId);
|
|
300
|
+
if (!latestFrame) {
|
|
301
|
+
// Zero-frame run: fromSeq > 0 is in the future relative to the
|
|
302
|
+
// current seq (which is 0). Reject before emitting anything.
|
|
303
|
+
if (typeof input.fromSeq === "number" && input.fromSeq > 0) {
|
|
304
|
+
throw new DevToolsRouteError("SeqOutOfRange", `fromSeq ${input.fromSeq} is newer than current seq 0.`);
|
|
305
|
+
}
|
|
306
|
+
const emptySnapshot = await captureSnapshot(0);
|
|
307
|
+
publish(/** @type {DevToolsEvent} */ ({
|
|
308
|
+
version: 1,
|
|
309
|
+
kind: "snapshot",
|
|
310
|
+
snapshot: emptySnapshot,
|
|
311
|
+
}), Date.now());
|
|
312
|
+
lastSnapshot = emptySnapshot;
|
|
313
|
+
lastSeenSeq = emptySnapshot.seq;
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
const latestSeq = latestFrame.frameNo;
|
|
317
|
+
if (input.fromSeq !== undefined && input.fromSeq > latestSeq) {
|
|
318
|
+
throw new DevToolsRouteError("SeqOutOfRange", `fromSeq ${input.fromSeq} is newer than current seq ${latestSeq}.`);
|
|
319
|
+
}
|
|
320
|
+
if (input.fromSeq === undefined) {
|
|
321
|
+
const snapshot = await captureSnapshot(latestSeq);
|
|
322
|
+
publish(await makeEvent({ kind: "snapshot", snapshot }), Date.now());
|
|
323
|
+
lastSnapshot = snapshot;
|
|
324
|
+
lastSeenSeq = snapshot.seq;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
const fromSeq = input.fromSeq;
|
|
328
|
+
const baseSeq = Math.max(0, Math.floor(fromSeq / DEVTOOLS_REBASELINE_INTERVAL) * DEVTOOLS_REBASELINE_INTERVAL);
|
|
329
|
+
const initialSeq = Math.min(baseSeq, latestSeq);
|
|
330
|
+
let initialSnapshot = null;
|
|
331
|
+
try {
|
|
332
|
+
initialSnapshot = await captureSnapshot(initialSeq);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
if (error instanceof DevToolsRouteError && error.code === "FrameOutOfRange") {
|
|
336
|
+
input.onLog?.("warn", "devtools fromSeq gap forced re-baseline", {
|
|
337
|
+
runId,
|
|
338
|
+
fromSeq,
|
|
339
|
+
requestedBaseSeq: initialSeq,
|
|
340
|
+
latestSeq,
|
|
341
|
+
});
|
|
342
|
+
initialSnapshot = await captureSnapshot(latestSeq);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
publish(await makeEvent({ kind: "snapshot", snapshot: initialSnapshot }), Date.now());
|
|
349
|
+
lastSnapshot = initialSnapshot;
|
|
350
|
+
lastSeenSeq = initialSnapshot.seq;
|
|
351
|
+
for (let seq = lastSeenSeq + 1; seq <= latestSeq; seq += 1) {
|
|
352
|
+
if (shouldStop()) {
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
const started = Date.now();
|
|
356
|
+
let nextSnapshot = null;
|
|
357
|
+
try {
|
|
358
|
+
nextSnapshot = await captureSnapshot(seq);
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
if (error instanceof DevToolsRouteError && error.code === "FrameOutOfRange") {
|
|
362
|
+
// Mid-replay gap: the DB no longer has the
|
|
363
|
+
// requested intermediate frame (possibly pruned
|
|
364
|
+
// or rewound). Log, emit the latest available
|
|
365
|
+
// snapshot, reset the replay state, and break.
|
|
366
|
+
input.onLog?.("warn", "devtools replay gap forced re-baseline", {
|
|
367
|
+
runId,
|
|
368
|
+
missingSeq: seq,
|
|
369
|
+
latestSeq,
|
|
370
|
+
});
|
|
371
|
+
const latestAvailable = await getLastFrameSpan(input.adapter, runId);
|
|
372
|
+
const rebaselineSeq = latestAvailable?.frameNo ?? lastSeenSeq;
|
|
373
|
+
const rebaseline = await captureSnapshot(rebaselineSeq);
|
|
374
|
+
publish(await makeEvent({ kind: "snapshot", snapshot: rebaseline }), started);
|
|
375
|
+
lastSnapshot = rebaseline;
|
|
376
|
+
lastSeenSeq = rebaseline.seq;
|
|
377
|
+
break;
|
|
378
|
+
}
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
const deltaEvent = await makeEvent({
|
|
382
|
+
kind: "delta",
|
|
383
|
+
snapshot: nextSnapshot,
|
|
384
|
+
previous: lastSnapshot,
|
|
385
|
+
});
|
|
386
|
+
const snapshotEvent = /** @type {DevToolsEvent} */ ({
|
|
387
|
+
version: 1,
|
|
388
|
+
kind: "snapshot",
|
|
389
|
+
snapshot: nextSnapshot,
|
|
390
|
+
});
|
|
391
|
+
const invalidated = input.invalidateSnapshot?.() ?? false;
|
|
392
|
+
const shouldSnapshot =
|
|
393
|
+
invalidated ||
|
|
394
|
+
eventsSinceSnapshot + 1 >= DEVTOOLS_REBASELINE_INTERVAL ||
|
|
395
|
+
estimateEventSize(deltaEvent) >= estimateEventSize(snapshotEvent);
|
|
396
|
+
publish(shouldSnapshot ? snapshotEvent : deltaEvent, started);
|
|
397
|
+
lastSnapshot = nextSnapshot;
|
|
398
|
+
lastSeenSeq = seq;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
while (!shouldStop()) {
|
|
403
|
+
if (shouldStop()) {
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
const latest = await getLastFrameSpan(input.adapter, runId);
|
|
407
|
+
if (latest && latest.frameNo < lastSeenSeq) {
|
|
408
|
+
const started = Date.now();
|
|
409
|
+
input.onLog?.("info", "devtools rewind detected; forcing re-baseline snapshot", {
|
|
410
|
+
runId,
|
|
411
|
+
previousSeq: lastSeenSeq,
|
|
412
|
+
latestSeq: latest.frameNo,
|
|
413
|
+
});
|
|
414
|
+
const rewindSnapshot = await captureSnapshot(latest.frameNo);
|
|
415
|
+
publish(/** @type {DevToolsEvent} */ ({
|
|
416
|
+
version: 1,
|
|
417
|
+
kind: "snapshot",
|
|
418
|
+
snapshot: rewindSnapshot,
|
|
419
|
+
}), started);
|
|
420
|
+
lastSnapshot = rewindSnapshot;
|
|
421
|
+
lastSeenSeq = rewindSnapshot.seq;
|
|
422
|
+
}
|
|
423
|
+
if (latest && latest.frameNo > lastSeenSeq && lastSnapshot) {
|
|
424
|
+
for (let seq = lastSeenSeq + 1; seq <= latest.frameNo; seq += 1) {
|
|
425
|
+
if (shouldStop()) {
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
const started = Date.now();
|
|
429
|
+
let nextSnapshot = null;
|
|
430
|
+
try {
|
|
431
|
+
nextSnapshot = await captureSnapshot(seq);
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
if (error instanceof DevToolsRouteError && error.code === "FrameOutOfRange") {
|
|
435
|
+
input.onLog?.("warn", "devtools live gap forced re-baseline", {
|
|
436
|
+
runId,
|
|
437
|
+
missingSeq: seq,
|
|
438
|
+
latestSeq: latest.frameNo,
|
|
439
|
+
});
|
|
440
|
+
const rebaselineTarget = await getLastFrameSpan(input.adapter, runId);
|
|
441
|
+
const rebaselineSeq = rebaselineTarget?.frameNo ?? lastSeenSeq;
|
|
442
|
+
const rebaseline = await captureSnapshot(rebaselineSeq);
|
|
443
|
+
publish(/** @type {DevToolsEvent} */ ({
|
|
444
|
+
version: 1,
|
|
445
|
+
kind: "snapshot",
|
|
446
|
+
snapshot: rebaseline,
|
|
447
|
+
}), started);
|
|
448
|
+
lastSnapshot = rebaseline;
|
|
449
|
+
lastSeenSeq = rebaseline.seq;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
const deltaEvent = await makeEvent({
|
|
455
|
+
kind: "delta",
|
|
456
|
+
snapshot: nextSnapshot,
|
|
457
|
+
previous: lastSnapshot,
|
|
458
|
+
});
|
|
459
|
+
const snapshotEvent = /** @type {DevToolsEvent} */ ({
|
|
460
|
+
version: 1,
|
|
461
|
+
kind: "snapshot",
|
|
462
|
+
snapshot: nextSnapshot,
|
|
463
|
+
});
|
|
464
|
+
const invalidated = input.invalidateSnapshot?.() ?? false;
|
|
465
|
+
const shouldSnapshot =
|
|
466
|
+
invalidated ||
|
|
467
|
+
eventsSinceSnapshot + 1 >= DEVTOOLS_REBASELINE_INTERVAL ||
|
|
468
|
+
estimateEventSize(deltaEvent) >= estimateEventSize(snapshotEvent);
|
|
469
|
+
publish(shouldSnapshot ? snapshotEvent : deltaEvent, started);
|
|
470
|
+
lastSnapshot = nextSnapshot;
|
|
471
|
+
lastSeenSeq = seq;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (shouldStop()) {
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
await delay(pollIntervalMs);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
producerErrorCode = error?.code ?? undefined;
|
|
482
|
+
queue.fail(error instanceof Error ? error : new Error(String(error)));
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
queue.close();
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
);
|
|
489
|
+
// Keep a reference so the finally block can await it.
|
|
490
|
+
void producer.catch(() => { });
|
|
491
|
+
try {
|
|
492
|
+
while (true) {
|
|
493
|
+
const next = await queue.next();
|
|
494
|
+
if (next.done) {
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
eventsDelivered += 1;
|
|
498
|
+
yield next.value;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
cancelled = true;
|
|
503
|
+
queue.close();
|
|
504
|
+
const closeWaitMs = Math.max(25, pollIntervalMs * 4);
|
|
505
|
+
try {
|
|
506
|
+
await Promise.race([producer, delay(closeWaitMs)]);
|
|
507
|
+
}
|
|
508
|
+
catch { }
|
|
509
|
+
if (!producerErrorCode && queue.error?.code) {
|
|
510
|
+
producerErrorCode = queue.error.code;
|
|
511
|
+
}
|
|
512
|
+
const durationMs = Date.now() - startedAt;
|
|
513
|
+
input.onLog?.("info", "devtools stream unsubscribed", {
|
|
514
|
+
runId,
|
|
515
|
+
subscriberId: input.subscriberId ?? null,
|
|
516
|
+
eventsDelivered,
|
|
517
|
+
durationMs,
|
|
518
|
+
});
|
|
519
|
+
input.onClose?.({
|
|
520
|
+
eventsDelivered,
|
|
521
|
+
durationMs,
|
|
522
|
+
errorCode: producerErrorCode,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|