@specific.dev/spectest 0.24.0 → 0.27.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.
Files changed (74) hide show
  1. package/dist/aws-sigv4.d.ts +42 -0
  2. package/dist/aws-sigv4.js +166 -0
  3. package/dist/browser.d.ts +314 -0
  4. package/dist/browser.js +1320 -0
  5. package/dist/components/email.d.ts +135 -0
  6. package/dist/components/email.js +271 -0
  7. package/dist/components/expo.d.ts +69 -0
  8. package/dist/components/expo.js +125 -0
  9. package/dist/components/index.d.ts +8 -0
  10. package/dist/components/index.js +18 -0
  11. package/dist/components/k3s.d.ts +143 -0
  12. package/dist/components/k3s.js +1067 -0
  13. package/dist/components/postgres.d.ts +93 -0
  14. package/dist/components/postgres.js +58 -0
  15. package/dist/components/replayFake.d.ts +169 -0
  16. package/dist/components/replayFake.js +738 -0
  17. package/dist/components/s3.d.ts +99 -0
  18. package/dist/components/s3.js +81 -0
  19. package/dist/components/supabase.d.ts +197 -0
  20. package/dist/components/supabase.js +1003 -0
  21. package/dist/daemon.d.ts +1 -0
  22. package/dist/daemon.js +4223 -0
  23. package/dist/ids.d.ts +2 -0
  24. package/{src/ids.ts → dist/ids.js} +46 -50
  25. package/dist/index.d.ts +1183 -0
  26. package/dist/index.js +769 -0
  27. package/dist/ingress.d.ts +114 -0
  28. package/dist/ingress.js +210 -0
  29. package/dist/inspect.d.ts +228 -0
  30. package/dist/inspect.js +429 -0
  31. package/dist/locator.d.ts +260 -0
  32. package/dist/locator.js +293 -0
  33. package/dist/mobile.d.ts +71 -0
  34. package/dist/mobile.js +65 -0
  35. package/dist/record-secrets.d.ts +9 -0
  36. package/{src/record-secrets.ts → dist/record-secrets.js} +13 -15
  37. package/dist/recorder.d.ts +516 -0
  38. package/dist/recorder.js +219 -0
  39. package/dist/redis.d.ts +54 -0
  40. package/dist/redis.js +126 -0
  41. package/dist/replay-bundle.d.ts +38 -0
  42. package/{src/replay-bundle.ts → dist/replay-bundle.js} +29 -47
  43. package/dist/resolver.d.ts +1 -0
  44. package/dist/resolver.js +309 -0
  45. package/dist/s3.d.ts +89 -0
  46. package/dist/s3.js +198 -0
  47. package/dist/sql.d.ts +74 -0
  48. package/dist/sql.js +151 -0
  49. package/dist/terminal.d.ts +161 -0
  50. package/dist/terminal.js +538 -0
  51. package/package.json +24 -9
  52. package/src/browser.ts +0 -1807
  53. package/src/components/email.ts +0 -398
  54. package/src/components/expo.ts +0 -167
  55. package/src/components/index.ts +0 -63
  56. package/src/components/k3s.ts +0 -1312
  57. package/src/components/postgres.ts +0 -105
  58. package/src/components/replayFake.ts +0 -848
  59. package/src/components/s3.ts +0 -132
  60. package/src/components/supabase.ts +0 -1299
  61. package/src/daemon.ts +0 -4969
  62. package/src/index.ts +0 -2350
  63. package/src/ingress.ts +0 -288
  64. package/src/inspect.ts +0 -673
  65. package/src/locator.ts +0 -594
  66. package/src/mobile.ts +0 -133
  67. package/src/recorder.ts +0 -817
  68. package/src/redis.ts +0 -202
  69. package/src/resolver.ts +0 -351
  70. package/src/s3.ts +0 -333
  71. package/src/sql.ts +0 -243
  72. package/src/terminal.ts +0 -740
  73. package/src/vendor/rrweb-plugin-console-record.umd.js +0 -521
  74. package/src/vendor/rrweb-record.min.js +0 -5061
@@ -0,0 +1,219 @@
1
+ // Per-test event recorder. The daemon installs a fresh recorder before
2
+ // running a test case and drains it afterwards. While a recorder is
3
+ // active, instrumented sites (the SDK's `expect`, the daemon's wrapped
4
+ // `ctx.exec`, and the test-scoped `fetch` wrapper) push structured
5
+ // events into it.
6
+ //
7
+ // When no test is running (production code paths, eval, etc.) the
8
+ // current recorder is `null` and the `record*` helpers are no-ops, so
9
+ // callers can invoke them unconditionally.
10
+ import { clearPendingNullish } from "./inspect.js";
11
+ const OUTPUT_SNIPPET_BYTES = 256 * 1024;
12
+ class Recorder {
13
+ events = [];
14
+ seq = 0;
15
+ start = Date.now();
16
+ /** Claim a seq + start offset now; pass the result to `push` at finish. */
17
+ reserve() {
18
+ return { seq: this.seq++, tOffsetMs: Date.now() - this.start };
19
+ }
20
+ push(ev, reservation) {
21
+ // A new op invalidates any pending nullish-leaf note: a note must not
22
+ // survive past the op it belongs to and get adopted by a later, unrelated
23
+ // `expect`. The assertion event itself is exempt — its `expect()` already
24
+ // adopted (and consumed) the note before recording.
25
+ if (ev.kind !== "assertion")
26
+ clearPendingNullish();
27
+ const seq = reservation?.seq ?? this.seq++;
28
+ this.events.push({
29
+ ...ev,
30
+ seq,
31
+ tOffsetMs: reservation?.tOffsetMs ?? Date.now() - this.start,
32
+ });
33
+ return seq;
34
+ }
35
+ drain() {
36
+ return this.events;
37
+ }
38
+ /** Index of the next event that would be pushed. Use with `truncate`
39
+ * to drop everything pushed since this index. The seq counter does
40
+ * not roll back — discarded seqs leave gaps. */
41
+ eventCount() {
42
+ return this.events.length;
43
+ }
44
+ /** Drop events with index >= `toLen`. The seq counter is unaffected
45
+ * (so seqs already handed out remain unique even though the events
46
+ * they named are gone). */
47
+ truncate(toLen) {
48
+ if (toLen < this.events.length) {
49
+ this.events.length = toLen;
50
+ }
51
+ }
52
+ /** Stamp `parentSeq` onto every event at index >= `startIdx`. Used
53
+ * by `ctx.poll` to group the kept iteration's events under the
54
+ * resulting wait event. */
55
+ markChildren(startIdx, parentSeq) {
56
+ for (let i = startIdx; i < this.events.length; i++) {
57
+ const ev = this.events[i];
58
+ if (ev.seq === parentSeq)
59
+ continue;
60
+ ev.parentSeq = parentSeq;
61
+ }
62
+ }
63
+ /** Shallow-merge `patch` into the already-recorded event with this
64
+ * `seq`. Lets an instrumentation site enrich or reclassify an event
65
+ * after the fact — e.g. the k3s component upgrades the generic `http`
66
+ * event its API call produced into a Kubernetes-specific `kube` event
67
+ * once it has parsed the request. Searches from the end since the
68
+ * target is almost always the most recent event. No-op when no event
69
+ * carries that seq. */
70
+ annotate(seq, patch) {
71
+ for (let i = this.events.length - 1; i >= 0; i--) {
72
+ const ev = this.events[i];
73
+ if (ev.seq === seq) {
74
+ Object.assign(ev, patch);
75
+ return;
76
+ }
77
+ }
78
+ }
79
+ /** Drop the single event carrying this `seq`. Used to retract an event
80
+ * that an instrumentation site recorded but then decided is noise — e.g.
81
+ * the dynamic kube client's internal discovery GET. Caller must ensure
82
+ * nothing references the seq (no child events/assertions hang off it).
83
+ * The seq counter does not roll back, so the seq stays retired. Searches
84
+ * from the end since the target is almost always the most recent event. */
85
+ remove(seq) {
86
+ for (let i = this.events.length - 1; i >= 0; i--) {
87
+ if (this.events[i].seq === seq) {
88
+ this.events.splice(i, 1);
89
+ return;
90
+ }
91
+ }
92
+ }
93
+ }
94
+ let current = null;
95
+ // Counter-based pause: `pauseRecording`/`resumeRecording` nest safely
96
+ // (a nested `ctx.poll` inside another `ctx.poll`'s predicate stays
97
+ // suppressed until its own resume balances out). Active record* sites
98
+ // no-op while `paused > 0`, so http/exec/db/etc. inside a paused
99
+ // region produce no events and the returned seq is `undefined` —
100
+ // which means the daemon's fetch wrapper also skips installing the
101
+ // inspector proxy on the Response.
102
+ let paused = 0;
103
+ export function pauseRecording() {
104
+ paused += 1;
105
+ }
106
+ export function resumeRecording() {
107
+ paused = Math.max(0, paused - 1);
108
+ }
109
+ function active() {
110
+ return current !== null && paused === 0;
111
+ }
112
+ export function startRecording() {
113
+ current = new Recorder();
114
+ paused = 0;
115
+ }
116
+ export function stopRecording() {
117
+ if (!current)
118
+ return [];
119
+ const evs = current.drain();
120
+ current = null;
121
+ paused = 0;
122
+ return evs;
123
+ }
124
+ export function isRecording() {
125
+ return current !== null;
126
+ }
127
+ /** Reserve an ordering slot at the start of an op so nested events it triggers
128
+ * (which finish — and record — first) still sort after it. Hand the returned
129
+ * reservation to the matching `record*` call at op finish. Returns `undefined`
130
+ * when nothing is recording, in which case `record*` falls back to allocating
131
+ * the seq at record time. */
132
+ export function reserveEvent() {
133
+ return active() ? current.reserve() : undefined;
134
+ }
135
+ export function recordExec(ev, reservation) {
136
+ return active() ? current.push({ kind: "exec", ...ev }, reservation) : undefined;
137
+ }
138
+ export function recordAssertion(ev) {
139
+ return active() ? current.push({ kind: "assertion", ...ev }) : undefined;
140
+ }
141
+ export function recordHttp(ev, reservation) {
142
+ return active() ? current.push({ kind: "http", ...ev }, reservation) : undefined;
143
+ }
144
+ export function recordBrowser(ev, reservation) {
145
+ return active() ? current.push({ kind: "browser", ...ev }, reservation) : undefined;
146
+ }
147
+ export function recordDb(ev, reservation) {
148
+ return active() ? current.push({ kind: "db", ...ev }, reservation) : undefined;
149
+ }
150
+ export function recordRedis(ev, reservation) {
151
+ return active() ? current.push({ kind: "redis", ...ev }, reservation) : undefined;
152
+ }
153
+ export function recordS3(ev, reservation) {
154
+ return active() ? current.push({ kind: "s3", ...ev }, reservation) : undefined;
155
+ }
156
+ export function recordTerminal(ev, reservation) {
157
+ return active() ? current.push({ kind: "terminal", ...ev }, reservation) : undefined;
158
+ }
159
+ export function recordTerminalStep(ev, reservation) {
160
+ return active() ? current.push({ kind: "terminal-step", ...ev }, reservation) : undefined;
161
+ }
162
+ export function recordFake(ev, reservation) {
163
+ return active() ? current.push({ kind: "fake", ...ev }, reservation) : undefined;
164
+ }
165
+ export function recordEnv(ev, reservation) {
166
+ return active() ? current.push({ kind: "env", ...ev }, reservation) : undefined;
167
+ }
168
+ export function recordEmail(ev, reservation) {
169
+ return active() ? current.push({ kind: "email", ...ev }, reservation) : undefined;
170
+ }
171
+ export function recordWait(ev, reservation) {
172
+ // Use `current` directly (not `active()`) so a `recordWait` at the
173
+ // end of a `ctx.poll` block lands even though the surrounding code
174
+ // just resumed from `paused`. Pushing this event is the whole point
175
+ // of the poll primitive.
176
+ return current?.push({ kind: "wait", ...ev }, reservation);
177
+ }
178
+ /** Number of events the active recorder has accumulated. Returns 0
179
+ * when no recorder is active. */
180
+ export function recorderEventCount() {
181
+ return current?.eventCount() ?? 0;
182
+ }
183
+ /** Drop events with index >= `toLen` from the active recorder. */
184
+ export function recorderTruncate(toLen) {
185
+ current?.truncate(toLen);
186
+ }
187
+ /** Stamp `parentSeq` onto every event from `startIdx` onward, so the
188
+ * UI groups them under the parent in the timeline. */
189
+ export function recorderMarkChildren(startIdx, parentSeq) {
190
+ current?.markChildren(startIdx, parentSeq);
191
+ }
192
+ /** Shallow-merge `patch` into the active recorder's event with this
193
+ * `seq` (enrich/reclassify after the fact). No-op when nothing is
194
+ * recording or no event carries that seq. */
195
+ export function recorderAnnotate(seq, patch) {
196
+ current?.annotate(seq, patch);
197
+ }
198
+ /** Retract the active recorder's event with this `seq` (an instrumentation
199
+ * site recorded it, then decided it's noise). No-op when nothing is
200
+ * recording or no event carries that seq. */
201
+ export function recorderRemove(seq) {
202
+ current?.remove(seq);
203
+ }
204
+ /** Best-effort JSON-safe deep clone; falls back to `String(v)`. */
205
+ export function safeSerialize(v) {
206
+ if (v === undefined)
207
+ return undefined;
208
+ try {
209
+ return JSON.parse(JSON.stringify(v));
210
+ }
211
+ catch {
212
+ return String(v);
213
+ }
214
+ }
215
+ export function truncateUtf8(s) {
216
+ if (s.length <= OUTPUT_SNIPPET_BYTES)
217
+ return { value: s, truncated: false };
218
+ return { value: s.slice(0, OUTPUT_SNIPPET_BYTES), truncated: true };
219
+ }
@@ -0,0 +1,54 @@
1
+ import type { Wrapped } from "./inspect.js";
2
+ /**
3
+ * Structural type for the parts of `Bun.RedisClient` we use, declared locally
4
+ * so user projects don't need `@types/bun`. The real implementation comes from
5
+ * Bun at runtime via `globalThis.Bun.RedisClient`. Commands resolve to their
6
+ * reply; lifecycle methods (`connect`/`close`/`subscribe`/…) are passed through
7
+ * un-instrumented.
8
+ */
9
+ interface RawRedisClient {
10
+ send(command: string, args: string[]): Promise<unknown>;
11
+ close?(): void;
12
+ [method: string]: unknown;
13
+ }
14
+ /** The instrumented Redis surface — same shape as `Bun.RedisClient`, but every
15
+ * command resolves to a {@link Wrapped} reply recorded as a `redis` event.
16
+ * Indexable so every Bun command method is reachable without re-declaring the
17
+ * whole (large) surface; the common ones are typed for ergonomics. */
18
+ export interface RedisClientLike {
19
+ get(key: string): Promise<Wrapped<string | null>>;
20
+ set(key: string, value: string): Promise<Wrapped<string>>;
21
+ del(...keys: string[]): Promise<Wrapped<number>>;
22
+ exists(...keys: string[]): Promise<Wrapped<number>>;
23
+ incr(key: string): Promise<Wrapped<number>>;
24
+ hgetall(key: string): Promise<Wrapped<Record<string, string>>>;
25
+ /** Run an arbitrary command — `client.send("SET", ["k", "v"])`. */
26
+ send(command: string, args: string[]): Promise<Wrapped<unknown>>;
27
+ close(): void;
28
+ [method: string]: (...args: any[]) => any;
29
+ }
30
+ /** Options accepted by the {@link RedisClient} constructor beyond Bun's own. */
31
+ export interface RedisOptions {
32
+ /** Label shown on each recorded `redis` event (the timeline step's tag).
33
+ * Defaults to the connection URL's host (usually the service name). */
34
+ label?: string;
35
+ }
36
+ interface RedisConstructor {
37
+ new (url?: string, opts?: RedisOptions): RedisClientLike;
38
+ (url?: string, opts?: RedisOptions): RedisClientLike;
39
+ }
40
+ /**
41
+ * Open an instrumented Redis client against `url` (default: Bun's own
42
+ * `REDIS_URL`/`VALKEY_URL`). Usable with or without `new`. Requires the Bun
43
+ * runtime — it runs inside the spectest daemon.
44
+ */
45
+ export declare const RedisClient: RedisConstructor;
46
+ /**
47
+ * Proxy a `Bun.RedisClient` so each command method (and `send(...)`) emits a
48
+ * `redis` event when its promise settles and resolves to a {@link Wrapped}
49
+ * reply. Lifecycle methods and non-function properties pass through.
50
+ *
51
+ * Exported so a client built another way can opt into the same instrumentation.
52
+ */
53
+ export declare function instrumentRedis(raw: RawRedisClient, label: string): RedisClientLike;
54
+ export {};
package/dist/redis.js ADDED
@@ -0,0 +1,126 @@
1
+ // `RedisClient` — a drop-in for `Bun.RedisClient` (`Bun.redis`) that records
2
+ // every command on the test event log and returns its reply inspect-wrapped,
3
+ // so `expect(await client.get("k"))` links the assertion back to the command in
4
+ // the timeline (same provenance mechanism as the wrapped `fetch` / `SQL`). Swap
5
+ // `new Bun.RedisClient(url)` → `new RedisClient(url)` and nothing else changes
6
+ // at the call site; replies come back `Wrapped<T>` (that wrapper is what carries
7
+ // provenance), so `.unwrap()` before a `===` or before feeding a value onward.
8
+ //
9
+ // Reach for it in service `helpers` when you stand up a redis/valkey yourself,
10
+ // or directly in a test against a connection string a fake handed back.
11
+ import { recordRedis, reserveEvent, safeSerialize } from "./recorder.js";
12
+ import { wrap } from "./inspect.js";
13
+ // Long-lived / connection-management methods that don't map to a single
14
+ // request→reply step — passed straight through, never recorded.
15
+ const REDIS_PASSTHROUGH = new Set([
16
+ "connect",
17
+ "close",
18
+ "disconnect",
19
+ "duplicate",
20
+ "subscribe",
21
+ "unsubscribe",
22
+ "psubscribe",
23
+ "punsubscribe",
24
+ "ssubscribe",
25
+ "sunsubscribe",
26
+ "onclose",
27
+ "ref",
28
+ "unref",
29
+ ]);
30
+ const MAX_RESULT_BYTES = 2048;
31
+ /**
32
+ * Open an instrumented Redis client against `url` (default: Bun's own
33
+ * `REDIS_URL`/`VALKEY_URL`). Usable with or without `new`. Requires the Bun
34
+ * runtime — it runs inside the spectest daemon.
35
+ */
36
+ export const RedisClient = function RedisClient(url, opts) {
37
+ const bun = globalThis.Bun;
38
+ if (!bun?.RedisClient) {
39
+ throw new Error("RedisClient requires the Bun runtime (Bun >= 1.2.9) — it runs inside the spectest daemon.");
40
+ }
41
+ const raw = url !== undefined ? new bun.RedisClient(url, opts) : new bun.RedisClient();
42
+ return instrumentRedis(raw, opts?.label ?? hostLabel(url));
43
+ };
44
+ function hostLabel(url) {
45
+ if (!url)
46
+ return "redis";
47
+ try {
48
+ return new URL(url).hostname || "redis";
49
+ }
50
+ catch {
51
+ return "redis";
52
+ }
53
+ }
54
+ /**
55
+ * Proxy a `Bun.RedisClient` so each command method (and `send(...)`) emits a
56
+ * `redis` event when its promise settles and resolves to a {@link Wrapped}
57
+ * reply. Lifecycle methods and non-function properties pass through.
58
+ *
59
+ * Exported so a client built another way can opt into the same instrumentation.
60
+ */
61
+ export function instrumentRedis(raw, label) {
62
+ const run = (command, key, extra, invoke) => {
63
+ const started = Date.now();
64
+ const resv = reserveEvent();
65
+ return Promise.resolve(invoke()).then((value) => {
66
+ const { preview, truncated } = capValue(value);
67
+ const seq = recordRedis({
68
+ service: label,
69
+ command,
70
+ key,
71
+ args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
72
+ result: preview,
73
+ resultTruncated: truncated,
74
+ durationMs: Date.now() - started,
75
+ }, resv);
76
+ return wrap(value, seq);
77
+ }, (err) => {
78
+ const e = err;
79
+ recordRedis({
80
+ service: label,
81
+ command,
82
+ key,
83
+ args: extra.length > 0 ? extra.map(safeSerialize) : undefined,
84
+ durationMs: Date.now() - started,
85
+ error: e?.message ?? String(err),
86
+ }, resv);
87
+ throw err;
88
+ });
89
+ };
90
+ const handler = {
91
+ get(target, prop, receiver) {
92
+ if (typeof prop !== "string")
93
+ return Reflect.get(target, prop, receiver);
94
+ const v = Reflect.get(target, prop, receiver);
95
+ if (typeof v !== "function")
96
+ return v;
97
+ const fn = v;
98
+ if (REDIS_PASSTHROUGH.has(prop))
99
+ return fn.bind(target);
100
+ // `send(command, args)` carries the real command in its first arg.
101
+ if (prop === "send") {
102
+ return (command, args = []) => run(String(command).toUpperCase(), args[0], args.slice(1), () => fn.call(target, command, args));
103
+ }
104
+ return (...args) => run(prop.toUpperCase(), typeof args[0] === "string" ? args[0] : undefined, args.slice(1), () => fn.apply(target, args));
105
+ },
106
+ };
107
+ return new Proxy(raw, handler);
108
+ }
109
+ /** Best-effort JSON-safe preview of a reply, capped at {@link MAX_RESULT_BYTES}. */
110
+ function capValue(value) {
111
+ if (value === null || value === undefined)
112
+ return {};
113
+ const safe = safeSerialize(value);
114
+ const json = (() => {
115
+ try {
116
+ return JSON.stringify(safe);
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ })();
122
+ if (json !== undefined && json.length > MAX_RESULT_BYTES) {
123
+ return { preview: `${json.slice(0, MAX_RESULT_BYTES)}…`, truncated: true };
124
+ }
125
+ return { preview: safe };
126
+ }
@@ -0,0 +1,38 @@
1
+ /** Token an extracted asset is replaced with. Still a syntactically valid
2
+ * data: URI so anything that merely carries it along stays well-formed;
3
+ * it never renders (the viewer rehydrates before the events reach rrweb). */
4
+ export declare function assetToken(hash: string): string;
5
+ /**
6
+ * Serialize `sessions` and pull every large base64 data: URI out into a
7
+ * content-addressed asset table. Returns the deduplicated sessions as a
8
+ * JSON *string* (already serialized — splice it into the bundle document
9
+ * verbatim) plus the asset table.
10
+ */
11
+ export declare function extractReplayAssets(sessions: unknown): {
12
+ sessionsJson: string;
13
+ assets: Record<string, string>;
14
+ };
15
+ /**
16
+ * Encode a case's sessions as the gzipped replay-bundle document the
17
+ * control plane archives to S3 verbatim:
18
+ * `{ caseId, sessions: [...], assets: { <hash>: <dataUri> } }` — the
19
+ * shape of `storage.rs::CaseReplayBundle`.
20
+ */
21
+ export declare function encodeReplayBundle(caseId: string, sessions: unknown): Buffer;
22
+ /** Per-chunk raw-byte ceiling for `/replay-chunk` replies. Base64 inflates
23
+ * 4/3× and the JSON reply must clear the vm-agent's 16 MB response cap
24
+ * with headroom. The control plane's `fetch_replay_bundle` requests
25
+ * exactly this much per round trip. */
26
+ export declare const REPLAY_CHUNK_MAX_BYTES: number;
27
+ /**
28
+ * One `/replay-chunk` reply: base64 of `gz[offset, offset+limit)`, with
29
+ * offset/limit clamped to sane values (never more than
30
+ * `REPLAY_CHUNK_MAX_BYTES` raw bytes). An out-of-range offset yields an
31
+ * empty `b64`, which the control plane treats as an error — it only ever
32
+ * asks for offsets below the advertised total.
33
+ */
34
+ export declare function replayChunk(gz: Buffer, offsetIn: unknown, limitIn: unknown): {
35
+ total: number;
36
+ offset: number;
37
+ b64: string;
38
+ };
@@ -19,72 +19,58 @@
19
19
  // `web/case_viewer.rs::rehydrateAssets`). Only base64 URIs are extracted:
20
20
  // their charset needs no JSON escaping, so plain text substitution in
21
21
  // either direction cannot corrupt the surrounding JSON document.
22
-
23
22
  import { createHash } from "node:crypto";
24
23
  import { gzipSync } from "node:zlib";
25
-
26
24
  /** Minimum data: URI payload length (chars) worth extracting. Below this
27
25
  * the token + asset-table overhead rivals the URI itself. */
28
26
  const ASSET_MIN_BASE64_CHARS = 1024;
29
-
30
27
  /** Base64 `data:` URIs, ≥1 KiB of payload. Mediatype + parameters are
31
28
  * restricted to charsets that never need JSON escaping — a match taken
32
29
  * from serialized JSON text is therefore byte-identical to the logical
33
30
  * string value it sits inside. */
34
- const ASSET_RX = new RegExp(
35
- "data:[A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+(?:;[A-Za-z0-9.=+-]+)*;base64," +
36
- `[A-Za-z0-9+/=]{${ASSET_MIN_BASE64_CHARS},}`,
37
- "g",
38
- );
39
-
31
+ const ASSET_RX = new RegExp("data:[A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+(?:;[A-Za-z0-9.=+-]+)*;base64," +
32
+ `[A-Za-z0-9+/=]{${ASSET_MIN_BASE64_CHARS},}`, "g");
40
33
  /** Token an extracted asset is replaced with. Still a syntactically valid
41
34
  * data: URI so anything that merely carries it along stays well-formed;
42
35
  * it never renders (the viewer rehydrates before the events reach rrweb). */
43
- export function assetToken(hash: string): string {
44
- return `data:x-spectest-asset/${hash}`;
36
+ export function assetToken(hash) {
37
+ return `data:x-spectest-asset/${hash}`;
45
38
  }
46
-
47
39
  /**
48
40
  * Serialize `sessions` and pull every large base64 data: URI out into a
49
41
  * content-addressed asset table. Returns the deduplicated sessions as a
50
42
  * JSON *string* (already serialized — splice it into the bundle document
51
43
  * verbatim) plus the asset table.
52
44
  */
53
- export function extractReplayAssets(sessions: unknown): {
54
- sessionsJson: string;
55
- assets: Record<string, string>;
56
- } {
57
- const assets: Record<string, string> = {};
58
- const raw = JSON.stringify(sessions);
59
- const sessionsJson = raw.replace(ASSET_RX, (uri) => {
60
- // 16 hex chars (64 bits) of SHA-256: collision-safe at replay-asset
61
- // scale (dozens of assets per case), and short enough that a token is
62
- // negligible next to the URI it replaces.
63
- const hash = createHash("sha256").update(uri).digest("hex").slice(0, 16);
64
- assets[hash] = uri;
65
- return assetToken(hash);
66
- });
67
- return { sessionsJson, assets };
45
+ export function extractReplayAssets(sessions) {
46
+ const assets = {};
47
+ const raw = JSON.stringify(sessions);
48
+ const sessionsJson = raw.replace(ASSET_RX, (uri) => {
49
+ // 16 hex chars (64 bits) of SHA-256: collision-safe at replay-asset
50
+ // scale (dozens of assets per case), and short enough that a token is
51
+ // negligible next to the URI it replaces.
52
+ const hash = createHash("sha256").update(uri).digest("hex").slice(0, 16);
53
+ assets[hash] = uri;
54
+ return assetToken(hash);
55
+ });
56
+ return { sessionsJson, assets };
68
57
  }
69
-
70
58
  /**
71
59
  * Encode a case's sessions as the gzipped replay-bundle document the
72
60
  * control plane archives to S3 verbatim:
73
61
  * `{ caseId, sessions: [...], assets: { <hash>: <dataUri> } }` — the
74
62
  * shape of `storage.rs::CaseReplayBundle`.
75
63
  */
76
- export function encodeReplayBundle(caseId: string, sessions: unknown): Buffer {
77
- const { sessionsJson, assets } = extractReplayAssets(sessions);
78
- const doc = `{"caseId":${JSON.stringify(caseId)},"sessions":${sessionsJson},"assets":${JSON.stringify(assets)}}`;
79
- return gzipSync(Buffer.from(doc));
64
+ export function encodeReplayBundle(caseId, sessions) {
65
+ const { sessionsJson, assets } = extractReplayAssets(sessions);
66
+ const doc = `{"caseId":${JSON.stringify(caseId)},"sessions":${sessionsJson},"assets":${JSON.stringify(assets)}}`;
67
+ return gzipSync(Buffer.from(doc));
80
68
  }
81
-
82
69
  /** Per-chunk raw-byte ceiling for `/replay-chunk` replies. Base64 inflates
83
70
  * 4/3× and the JSON reply must clear the vm-agent's 16 MB response cap
84
71
  * with headroom. The control plane's `fetch_replay_bundle` requests
85
72
  * exactly this much per round trip. */
86
73
  export const REPLAY_CHUNK_MAX_BYTES = 6 * 1024 * 1024;
87
-
88
74
  /**
89
75
  * One `/replay-chunk` reply: base64 of `gz[offset, offset+limit)`, with
90
76
  * offset/limit clamped to sane values (never more than
@@ -92,17 +78,13 @@ export const REPLAY_CHUNK_MAX_BYTES = 6 * 1024 * 1024;
92
78
  * empty `b64`, which the control plane treats as an error — it only ever
93
79
  * asks for offsets below the advertised total.
94
80
  */
95
- export function replayChunk(
96
- gz: Buffer,
97
- offsetIn: unknown,
98
- limitIn: unknown,
99
- ): { total: number; offset: number; b64: string } {
100
- const offset = Math.max(0, Math.floor(Number(offsetIn) || 0));
101
- const wanted = Math.floor(Number(limitIn) || REPLAY_CHUNK_MAX_BYTES);
102
- const limit = Math.min(Math.max(1, wanted), REPLAY_CHUNK_MAX_BYTES);
103
- return {
104
- total: gz.length,
105
- offset,
106
- b64: gz.subarray(offset, offset + limit).toString("base64"),
107
- };
81
+ export function replayChunk(gz, offsetIn, limitIn) {
82
+ const offset = Math.max(0, Math.floor(Number(offsetIn) || 0));
83
+ const wanted = Math.floor(Number(limitIn) || REPLAY_CHUNK_MAX_BYTES);
84
+ const limit = Math.min(Math.max(1, wanted), REPLAY_CHUNK_MAX_BYTES);
85
+ return {
86
+ total: gz.length,
87
+ offset,
88
+ b64: gz.subarray(offset, offset + limit).toString("base64"),
89
+ };
108
90
  }
@@ -0,0 +1 @@
1
+ export {};