@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.
@@ -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
+ }