@devicerail/live-visualizer 0.1.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,341 @@
1
+ import { LiveTimelineError } from "./errors.js";
2
+ import { boundedJson, boundedText, deepFreeze } from "./sanitize.js";
3
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
4
+ function record(value, location) {
5
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
6
+ throw new LiveTimelineError("invalid_event", `${location} must be an object`);
7
+ }
8
+ return value;
9
+ }
10
+ function stringValue(value, location) {
11
+ if (typeof value !== "string") {
12
+ throw new LiveTimelineError("invalid_event", `${location} must be a string`);
13
+ }
14
+ return value;
15
+ }
16
+ function uuid(value, location) {
17
+ const text = stringValue(value, location);
18
+ if (!UUID_PATTERN.test(text)) {
19
+ throw new LiveTimelineError("invalid_event", `${location} must be a UUID`);
20
+ }
21
+ return text;
22
+ }
23
+ function safeInteger(value, location, minimum = 0) {
24
+ if (!Number.isSafeInteger(value) || value < minimum) {
25
+ throw new LiveTimelineError("invalid_event", `${location} must be a safe integer`);
26
+ }
27
+ return value;
28
+ }
29
+ function finiteNumber(value, location) {
30
+ if (typeof value !== "number" || !Number.isFinite(value)) {
31
+ throw new LiveTimelineError("invalid_event", `${location} must be finite`);
32
+ }
33
+ return value;
34
+ }
35
+ function evidence(asset, limits) {
36
+ const value = record(asset, "Evidence");
37
+ // URI is validated as a wire field, then deliberately discarded. It must
38
+ // never cross the live presentation boundary.
39
+ stringValue(value.uri, "Evidence.uri");
40
+ const sha256 = value.sha256;
41
+ return deepFreeze({
42
+ availability: "referenceOnly",
43
+ id: boundedText(stringValue(value.id, "Evidence.id"), limits.maxTextBytes),
44
+ mediaType: boundedText(stringValue(value.mediaType, "Evidence.mediaType"), limits.maxTextBytes),
45
+ ...(sha256 === undefined || sha256 === null
46
+ ? {}
47
+ : { sha256: boundedText(stringValue(sha256, "Evidence.sha256"), limits.maxTextBytes) }),
48
+ });
49
+ }
50
+ function evidenceList(value, limits, location) {
51
+ if (value === undefined)
52
+ return Object.freeze({ evidence: Object.freeze([]), omitted: 0 });
53
+ if (!Array.isArray(value)) {
54
+ throw new LiveTimelineError("invalid_event", `${location} must be an array`);
55
+ }
56
+ const kept = value.slice(0, limits.maxEvidencePerEvent).map((asset) => evidence(asset, limits));
57
+ return Object.freeze({
58
+ evidence: Object.freeze(kept),
59
+ omitted: Math.max(0, value.length - kept.length),
60
+ });
61
+ }
62
+ function observation(value, limits) {
63
+ const source = record(value, "Observation");
64
+ const viewport = record(source.viewport, "Observation.viewport");
65
+ const screenshotOmission = source.screenshotOmission;
66
+ if (screenshotOmission !== undefined &&
67
+ screenshotOmission !== null &&
68
+ screenshotOmission !== "policy" &&
69
+ screenshotOmission !== "protectedAction") {
70
+ throw new LiveTimelineError("invalid_event", "Observation.screenshotOmission is invalid");
71
+ }
72
+ if (screenshotOmission && source.screenshot !== undefined && source.screenshot !== null) {
73
+ throw new LiveTimelineError("invalid_event", "an omitted Observation cannot also expose screenshot Evidence");
74
+ }
75
+ const width = safeInteger(viewport.width, "Observation.viewport.width");
76
+ const height = safeInteger(viewport.height, "Observation.viewport.height");
77
+ if (width > 0xffff_ffff || height > 0xffff_ffff) {
78
+ throw new LiveTimelineError("invalid_event", "Observation viewport exceeds u32");
79
+ }
80
+ return deepFreeze({
81
+ capturedAtMs: safeInteger(source.capturedAtMs, "Observation.capturedAtMs"),
82
+ deviceId: boundedText(stringValue(source.deviceId, "Observation.deviceId"), limits.maxTextBytes),
83
+ id: boundedText(uuid(source.id, "Observation.id"), limits.maxTextBytes),
84
+ ...(source.screenshot === undefined || source.screenshot === null
85
+ ? {}
86
+ : { screenshot: evidence(source.screenshot, limits) }),
87
+ ...(screenshotOmission ? { screenshotOmission } : {}),
88
+ viewport: {
89
+ height,
90
+ scaleFactor: finiteNumber(viewport.scaleFactor, "Observation.viewport.scaleFactor"),
91
+ width,
92
+ },
93
+ });
94
+ }
95
+ function errorInfo(value, limits) {
96
+ const source = record(value, "ErrorInfo");
97
+ if (typeof source.retryable !== "boolean") {
98
+ throw new LiveTimelineError("invalid_event", "ErrorInfo.retryable must be boolean");
99
+ }
100
+ return deepFreeze({
101
+ code: boundedText(stringValue(source.code, "ErrorInfo.code"), limits.maxTextBytes),
102
+ ...(source.details === undefined || source.details === null
103
+ ? {}
104
+ : { details: boundedJson(source.details, limits) }),
105
+ message: boundedText(stringValue(source.message, "ErrorInfo.message"), limits.maxTextBytes),
106
+ retryable: source.retryable,
107
+ });
108
+ }
109
+ function completion(value, limits) {
110
+ const source = record(value, "Action outcome");
111
+ switch (source.outcome) {
112
+ case "succeeded": {
113
+ const result = record(source.result, "Action result");
114
+ uuid(result.callId, "Action result.callId");
115
+ if (!Object.hasOwn(result, "output")) {
116
+ throw new LiveTimelineError("invalid_event", "Action result.output is required");
117
+ }
118
+ const references = evidenceList(result.evidence, limits, "Action result.evidence");
119
+ return deepFreeze({
120
+ outcome: "succeeded",
121
+ ...(result.after === undefined || result.after === null
122
+ ? {}
123
+ : { after: observation(result.after, limits) }),
124
+ ...(result.before === undefined || result.before === null
125
+ ? {}
126
+ : { before: observation(result.before, limits) }),
127
+ evidence: references.evidence,
128
+ evidenceOmitted: references.omitted,
129
+ finishedAtMs: safeInteger(result.finishedAtMs, "Action result.finishedAtMs"),
130
+ output: boundedJson(result.output, limits),
131
+ startedAtMs: safeInteger(result.startedAtMs, "Action result.startedAtMs"),
132
+ });
133
+ }
134
+ case "failed":
135
+ case "cancelled":
136
+ return deepFreeze({
137
+ outcome: source.outcome,
138
+ error: errorInfo(source.error, limits),
139
+ });
140
+ case "timedOut":
141
+ return deepFreeze({
142
+ outcome: "timedOut",
143
+ error: errorInfo(source.error, limits),
144
+ timeoutMs: safeInteger(source.timeoutMs, "Action timeoutMs"),
145
+ });
146
+ default:
147
+ throw new LiveTimelineError("invalid_event", "Action outcome is unknown");
148
+ }
149
+ }
150
+ function actionStarted(value, limits) {
151
+ const source = record(value, "RecordedActionCall");
152
+ if (source.argumentsRedacted !== undefined && typeof source.argumentsRedacted !== "boolean") {
153
+ throw new LiveTimelineError("invalid_event", "RecordedActionCall.argumentsRedacted must be boolean");
154
+ }
155
+ return deepFreeze({
156
+ type: "actionStarted",
157
+ arguments: source.argumentsRedacted === true
158
+ ? { omitted: "protected" }
159
+ : boundedJson(source.arguments ?? null, limits),
160
+ callId: boundedText(uuid(source.id, "RecordedActionCall.id"), limits.maxTextBytes),
161
+ name: boundedText(stringValue(source.name, "RecordedActionCall.name"), limits.maxTextBytes),
162
+ });
163
+ }
164
+ function verdict(value, limits) {
165
+ const source = record(value, "Verdict");
166
+ if (source.status !== "pass" && source.status !== "fail" && source.status !== "unknown") {
167
+ throw new LiveTimelineError("invalid_event", "Verdict.status is invalid");
168
+ }
169
+ const references = evidenceList(source.evidence, limits, "Verdict.evidence");
170
+ return deepFreeze({
171
+ type: "verdictRecorded",
172
+ status: source.status,
173
+ summary: boundedText(stringValue(source.summary, "Verdict.summary"), limits.maxTextBytes),
174
+ evidence: references.evidence,
175
+ evidenceOmitted: references.omitted,
176
+ });
177
+ }
178
+ export function presentEvent(event, limits) {
179
+ const source = record(event, "TestEvent");
180
+ const payload = record(source.payload, "TestEvent.payload");
181
+ const sessionId = uuid(source.sessionId, "TestEvent.sessionId");
182
+ const common = {
183
+ atMs: safeInteger(source.atMs, "TestEvent.atMs"),
184
+ ...(source.deviceId === undefined || source.deviceId === null
185
+ ? {}
186
+ : {
187
+ deviceId: boundedText(stringValue(source.deviceId, "TestEvent.deviceId"), limits.maxTextBytes),
188
+ }),
189
+ eventId: boundedText(uuid(source.eventId, "TestEvent.eventId"), limits.maxTextBytes),
190
+ sequence: safeInteger(source.sequence, "TestEvent.sequence", 1),
191
+ sessionId,
192
+ };
193
+ let category;
194
+ let title;
195
+ let status;
196
+ let presentation;
197
+ switch (payload.type) {
198
+ case "sessionStarted":
199
+ category = "session";
200
+ title = "Session started";
201
+ presentation = { type: "sessionStarted" };
202
+ break;
203
+ case "sessionEnded":
204
+ if (payload.outcome !== "completed" &&
205
+ payload.outcome !== "failed" &&
206
+ payload.outcome !== "cancelled" &&
207
+ payload.outcome !== "shutdown") {
208
+ throw new LiveTimelineError("invalid_event", "Session outcome is invalid");
209
+ }
210
+ category = "session";
211
+ title = "Session ended";
212
+ status = payload.outcome[0]?.toUpperCase() + payload.outcome.slice(1);
213
+ presentation = deepFreeze({
214
+ type: "sessionEnded",
215
+ outcome: payload.outcome,
216
+ ...(payload.reason === undefined || payload.reason === null
217
+ ? {}
218
+ : {
219
+ reason: boundedText(stringValue(payload.reason, "Session end reason"), limits.maxTextBytes),
220
+ }),
221
+ });
222
+ break;
223
+ case "observationCaptured":
224
+ category = "observation";
225
+ title = "Observation captured";
226
+ presentation = deepFreeze({
227
+ type: "observationCaptured",
228
+ observation: observation(payload.observation, limits),
229
+ });
230
+ break;
231
+ case "actionStarted":
232
+ category = "action";
233
+ title = "Action started";
234
+ presentation = actionStarted(payload.call, limits);
235
+ break;
236
+ case "actionCompleted": {
237
+ category = "action";
238
+ title = "Action completed";
239
+ const callId = uuid(payload.callId, "Action callId");
240
+ const outcomeSource = record(payload.outcome, "Action outcome");
241
+ if (outcomeSource.outcome === "succeeded") {
242
+ const result = record(outcomeSource.result, "Action result");
243
+ if (uuid(result.callId, "Action result.callId") !== callId) {
244
+ throw new LiveTimelineError("invalid_event", "Action result callId does not match its completed event");
245
+ }
246
+ }
247
+ const completed = completion(payload.outcome, limits);
248
+ status =
249
+ completed.outcome === "timedOut"
250
+ ? "Timed out"
251
+ : completed.outcome[0]?.toUpperCase() + completed.outcome.slice(1);
252
+ presentation = deepFreeze({
253
+ type: "actionCompleted",
254
+ callId: boundedText(callId, limits.maxTextBytes),
255
+ completion: completed,
256
+ });
257
+ break;
258
+ }
259
+ case "mediaStreamStarted": {
260
+ const stream = record(payload.stream, "Media stream");
261
+ const streamId = uuid(stream.id, "Media stream.id");
262
+ const kind = stringValue(stream.kind, "Media stream.kind");
263
+ if (kind !== "screenshot" && kind !== "video") {
264
+ throw new LiveTimelineError("invalid_event", "Media stream.kind is invalid");
265
+ }
266
+ let viewport;
267
+ if (stream.viewport !== undefined && stream.viewport !== null) {
268
+ const sourceViewport = record(stream.viewport, "Media stream.viewport");
269
+ const width = safeInteger(sourceViewport.width, "Media stream.viewport.width", 1);
270
+ const height = safeInteger(sourceViewport.height, "Media stream.viewport.height", 1);
271
+ const scaleFactor = finiteNumber(sourceViewport.scaleFactor, "Media stream.viewport.scaleFactor");
272
+ if (width > 0xffff_ffff || height > 0xffff_ffff || scaleFactor <= 0) {
273
+ throw new LiveTimelineError("invalid_event", "Media stream viewport is invalid");
274
+ }
275
+ viewport = Object.freeze({ height, scaleFactor, width });
276
+ }
277
+ category = "media";
278
+ title = "Media stream started";
279
+ presentation = deepFreeze({
280
+ type: "mediaStreamStarted",
281
+ streamId: boundedText(streamId, limits.maxTextBytes),
282
+ kind,
283
+ mediaType: boundedText(stringValue(stream.mediaType, "Media stream.mediaType"), limits.maxTextBytes),
284
+ ...(viewport === undefined ? {} : { viewport }),
285
+ });
286
+ break;
287
+ }
288
+ case "mediaFrameCaptured": {
289
+ const frame = record(payload.frame, "Media frame");
290
+ category = "media";
291
+ title = "Media frame captured";
292
+ presentation = deepFreeze({
293
+ type: "mediaFrameCaptured",
294
+ streamId: boundedText(uuid(frame.streamId, "Media frame.streamId"), limits.maxTextBytes),
295
+ frameIndex: safeInteger(frame.frameIndex, "Media frame.frameIndex", 1),
296
+ keyFrame: frame.keyFrame === true,
297
+ ...(frame.durationMs === undefined || frame.durationMs === null
298
+ ? {}
299
+ : { durationMs: safeInteger(frame.durationMs, "Media frame.durationMs") }),
300
+ evidence: evidence(frame.evidence, limits),
301
+ });
302
+ break;
303
+ }
304
+ case "mediaStreamEnded":
305
+ category = "media";
306
+ title = "Media stream ended";
307
+ presentation = deepFreeze({
308
+ type: "mediaStreamEnded",
309
+ streamId: boundedText(uuid(payload.streamId, "Media stream terminal.streamId"), limits.maxTextBytes),
310
+ frameCount: safeInteger(payload.frameCount, "Media stream terminal.frameCount"),
311
+ });
312
+ break;
313
+ case "error":
314
+ category = "error";
315
+ title = "Error";
316
+ presentation = deepFreeze({
317
+ type: "error",
318
+ error: errorInfo(payload.error, limits),
319
+ });
320
+ break;
321
+ case "verdictRecorded": {
322
+ category = "verdict";
323
+ title = "Verdict recorded";
324
+ presentation = verdict(payload.verdict, limits);
325
+ if (presentation.type !== "verdictRecorded") {
326
+ throw new LiveTimelineError("invalid_event", "Verdict presentation is invalid");
327
+ }
328
+ status = presentation.status[0]?.toUpperCase() + presentation.status.slice(1);
329
+ break;
330
+ }
331
+ default:
332
+ throw new LiveTimelineError("invalid_event", "TestEvent payload type is unknown");
333
+ }
334
+ return deepFreeze({
335
+ ...common,
336
+ category,
337
+ presentation,
338
+ ...(status ? { status } : {}),
339
+ title,
340
+ });
341
+ }
@@ -0,0 +1,8 @@
1
+ import type { BoundedJson, BoundedText, LiveTimelineLimits } from "./types.js";
2
+ export declare function boundedText(value: string, maxBytes: number): BoundedText;
3
+ export declare function canonicalFingerprint(value: unknown, limits: LiveTimelineLimits): {
4
+ readonly canonical: string;
5
+ readonly fingerprint: string;
6
+ };
7
+ export declare function boundedJson(value: unknown, limits: LiveTimelineLimits): BoundedJson;
8
+ export declare function deepFreeze<T>(value: T): T;
@@ -0,0 +1,261 @@
1
+ import { createHash } from "node:crypto";
2
+ import { LiveTimelineError } from "./errors.js";
3
+ const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
4
+ function escapeCodePoint(codePoint) {
5
+ return `\\u{${codePoint.toString(16).padStart(4, "0")}}`;
6
+ }
7
+ function escapedCharacter(character, codePoint) {
8
+ const isControl = codePoint <= 0x1f ||
9
+ (codePoint >= 0x7f && codePoint <= 0x9f) ||
10
+ (codePoint >= 0xd800 && codePoint <= 0xdfff);
11
+ const isBidi = codePoint === 0x061c ||
12
+ codePoint === 0x200e ||
13
+ codePoint === 0x200f ||
14
+ (codePoint >= 0x202a && codePoint <= 0x202e) ||
15
+ (codePoint >= 0x2066 && codePoint <= 0x2069);
16
+ const isMarkup = character === "<" || character === ">" || character === "&";
17
+ return isControl || isBidi || isMarkup ? escapeCodePoint(codePoint) : character;
18
+ }
19
+ export function boundedText(value, maxBytes) {
20
+ let text = "";
21
+ let bytes = 0;
22
+ let truncated = false;
23
+ for (const character of value) {
24
+ const codePoint = character.codePointAt(0);
25
+ if (codePoint === undefined)
26
+ continue;
27
+ const escaped = escapedCharacter(character, codePoint);
28
+ const addition = Buffer.byteLength(escaped);
29
+ if (bytes + addition > maxBytes) {
30
+ truncated = true;
31
+ break;
32
+ }
33
+ text += escaped;
34
+ bytes += addition;
35
+ }
36
+ return Object.freeze({ text, truncated });
37
+ }
38
+ class CanonicalWriter {
39
+ #blocks = [];
40
+ #limit;
41
+ #bytes = 0;
42
+ #pending = [];
43
+ #pendingBytes = 0;
44
+ constructor(limit) {
45
+ this.#limit = limit;
46
+ }
47
+ push(value) {
48
+ const bytes = Buffer.byteLength(value);
49
+ if (this.#bytes + bytes > this.#limit) {
50
+ throw new LiveTimelineError("viewer_capacity_exceeded", "input event exceeds the canonical fingerprint byte limit", { details: { limitBytes: this.#limit, observedBytesAtLeast: this.#bytes + bytes } });
51
+ }
52
+ if (this.#pendingBytes + bytes > 8 * 1024 || this.#pending.length >= 256) {
53
+ this.#flush();
54
+ }
55
+ this.#pending.push(value);
56
+ this.#pendingBytes += bytes;
57
+ this.#bytes += bytes;
58
+ }
59
+ finish() {
60
+ this.#flush();
61
+ return this.#blocks.join("");
62
+ }
63
+ #flush() {
64
+ if (this.#pending.length === 0)
65
+ return;
66
+ this.#blocks.push(this.#pending.join(""));
67
+ this.#pending = [];
68
+ this.#pendingBytes = 0;
69
+ }
70
+ }
71
+ function jsonEncode(value) {
72
+ const encoded = JSON.stringify(value);
73
+ if (encoded === undefined) {
74
+ throw new LiveTimelineError("invalid_event", "input event contains a non-JSON value");
75
+ }
76
+ return encoded;
77
+ }
78
+ function writeJsonString(value, writer) {
79
+ writer.push('"');
80
+ let plain = "";
81
+ const flushPlain = () => {
82
+ if (plain.length === 0)
83
+ return;
84
+ writer.push(plain);
85
+ plain = "";
86
+ };
87
+ for (const character of value) {
88
+ const encoded = jsonEncode(character).slice(1, -1);
89
+ if (encoded === character) {
90
+ plain += character;
91
+ if (plain.length >= 1_024)
92
+ flushPlain();
93
+ }
94
+ else {
95
+ flushPlain();
96
+ writer.push(encoded);
97
+ }
98
+ }
99
+ flushPlain();
100
+ writer.push('"');
101
+ }
102
+ function canonicalVisit(value, writer, depth, maxDepth, ancestors) {
103
+ if (depth > maxDepth) {
104
+ throw new LiveTimelineError("invalid_event", "input event exceeds the JSON depth limit", {
105
+ details: { maxDepth },
106
+ });
107
+ }
108
+ if (value === null || typeof value === "boolean") {
109
+ writer.push(String(value));
110
+ return;
111
+ }
112
+ if (typeof value === "string") {
113
+ writeJsonString(value, writer);
114
+ return;
115
+ }
116
+ if (typeof value === "number") {
117
+ if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) {
118
+ throw new LiveTimelineError("invalid_event", "input event contains an unsafe number");
119
+ }
120
+ writer.push(jsonEncode(value));
121
+ return;
122
+ }
123
+ if (typeof value !== "object") {
124
+ throw new LiveTimelineError("invalid_event", "input event contains a non-JSON value");
125
+ }
126
+ const prototype = Object.getPrototypeOf(value);
127
+ const isArray = Array.isArray(value);
128
+ if ((isArray && prototype !== Array.prototype) ||
129
+ (!isArray && prototype !== Object.prototype && prototype !== null)) {
130
+ throw new LiveTimelineError("invalid_event", "input event contains a non-JSON prototype");
131
+ }
132
+ if (ancestors.has(value)) {
133
+ throw new LiveTimelineError("invalid_event", "input event contains a cycle");
134
+ }
135
+ ancestors.add(value);
136
+ if (isArray) {
137
+ if (Object.getOwnPropertySymbols(value).length > 0) {
138
+ throw new LiveTimelineError("invalid_event", "input event contains symbol keys");
139
+ }
140
+ writer.push("[");
141
+ for (let index = 0; index < value.length; index += 1) {
142
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
143
+ if (!descriptor) {
144
+ throw new LiveTimelineError("invalid_event", "input event contains a sparse array");
145
+ }
146
+ if (!("value" in descriptor) || !descriptor.enumerable) {
147
+ throw new LiveTimelineError("invalid_event", "input event contains array accessors");
148
+ }
149
+ if (index > 0)
150
+ writer.push(",");
151
+ canonicalVisit(descriptor.value, writer, depth + 1, maxDepth, ancestors);
152
+ }
153
+ writer.push("]");
154
+ }
155
+ else {
156
+ if (Object.getOwnPropertySymbols(value).length > 0) {
157
+ throw new LiveTimelineError("invalid_event", "input event contains symbol keys");
158
+ }
159
+ writer.push("{");
160
+ const record = value;
161
+ const keys = Object.keys(record).sort();
162
+ keys.forEach((key, index) => {
163
+ if (index > 0)
164
+ writer.push(",");
165
+ writeJsonString(key, writer);
166
+ writer.push(":");
167
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
168
+ if (!descriptor || !("value" in descriptor)) {
169
+ throw new LiveTimelineError("invalid_event", "input event contains an accessor or changed during fingerprinting");
170
+ }
171
+ canonicalVisit(descriptor.value, writer, depth + 1, maxDepth, ancestors);
172
+ });
173
+ writer.push("}");
174
+ }
175
+ ancestors.delete(value);
176
+ }
177
+ export function canonicalFingerprint(value, limits) {
178
+ const writer = new CanonicalWriter(limits.maxInputEventBytes);
179
+ canonicalVisit(value, writer, 0, limits.maxJsonDepth, new Set());
180
+ const canonical = writer.finish();
181
+ return Object.freeze({
182
+ canonical,
183
+ fingerprint: createHash("sha256").update(canonical, "utf8").digest("hex"),
184
+ });
185
+ }
186
+ function presentationValue(value, limits, depth, state) {
187
+ if (depth > limits.maxJsonDepth) {
188
+ state.truncated = true;
189
+ return "[depth limit]";
190
+ }
191
+ if (typeof value === "string") {
192
+ const text = boundedText(value, limits.maxTextBytes);
193
+ if (text.truncated)
194
+ state.truncated = true;
195
+ return text.text;
196
+ }
197
+ if (value === null || typeof value === "boolean" || typeof value === "number")
198
+ return value;
199
+ if (Array.isArray(value)) {
200
+ const count = Math.min(value.length, 128);
201
+ if (count < value.length)
202
+ state.truncated = true;
203
+ return value
204
+ .slice(0, count)
205
+ .map((child) => presentationValue(child, limits, depth + 1, state));
206
+ }
207
+ if (typeof value === "object") {
208
+ const entries = Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
209
+ const count = Math.min(entries.length, 128);
210
+ if (count < entries.length)
211
+ state.truncated = true;
212
+ const pairs = [];
213
+ const seen = new Set();
214
+ let collision = false;
215
+ for (const [rawKey, child] of entries.slice(0, count)) {
216
+ const labelled = DANGEROUS_KEYS.has(rawKey) ? `[unsafe-key:${rawKey}]` : rawKey;
217
+ const boundedKey = boundedText(labelled, limits.maxTextBytes);
218
+ if (boundedKey.truncated)
219
+ state.truncated = true;
220
+ if (seen.has(boundedKey.text))
221
+ collision = true;
222
+ seen.add(boundedKey.text);
223
+ pairs.push([
224
+ boundedKey.text,
225
+ presentationValue(child, limits, depth + 1, state),
226
+ ]);
227
+ }
228
+ if (collision) {
229
+ state.truncated = true;
230
+ const collisionSafe = Object.create(null);
231
+ collisionSafe["[collision-safe object entries]"] = pairs;
232
+ return collisionSafe;
233
+ }
234
+ const result = Object.create(null);
235
+ for (const [key, child] of pairs)
236
+ result[key] = child;
237
+ return result;
238
+ }
239
+ state.truncated = true;
240
+ return "[non-JSON value]";
241
+ }
242
+ export function boundedJson(value, limits) {
243
+ const state = { truncated: false };
244
+ const safe = presentationValue(value, limits, 0, state);
245
+ const json = jsonEncode(safe);
246
+ if (Buffer.byteLength(json) <= limits.maxJsonBytes) {
247
+ return Object.freeze({ json, truncated: state.truncated });
248
+ }
249
+ return Object.freeze({
250
+ json: jsonEncode(`[JSON exceeds ${limits.maxJsonBytes}-byte presentation limit]`),
251
+ truncated: true,
252
+ });
253
+ }
254
+ export function deepFreeze(value) {
255
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
256
+ Object.freeze(value);
257
+ for (const child of Object.values(value))
258
+ deepFreeze(child);
259
+ }
260
+ return value;
261
+ }