@hue-run/sdk 0.18.1 → 0.18.2

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.
@@ -28,6 +28,11 @@ function locate(text, wanted) {
28
28
  }
29
29
  };
30
30
  const stringEnd = (index) => {
31
+ // A string without escapes ends at the next quote, found natively: a long file's content
32
+ // is passed over without a step per character.
33
+ const quote = text.indexOf('"', index + 1);
34
+ if (quote !== -1 && !text.slice(index + 1, quote).includes("\\"))
35
+ return quote + 1;
31
36
  let at = index + 1;
32
37
  for (;;) {
33
38
  const code = text.charCodeAt(at);
@@ -150,32 +155,46 @@ export function applyJsonEdits(text, edits) {
150
155
  start: span.start,
151
156
  end: span.end,
152
157
  render: () => {
153
- const kept = members.filter((member) => !edit.remove.has(member.key));
154
158
  const first = members[0]?.start ?? span.end - 1;
155
159
  const close = members.length ? members.at(-1).end : span.end - 1;
156
160
  let out = text.slice(span.start, first);
157
- kept.forEach((member, position) => {
158
- const index = members.indexOf(member);
161
+ let kept = 0;
162
+ members.forEach((member, index) => {
163
+ if (edit.remove.has(member.key))
164
+ return;
159
165
  // The separator that preceded this member, comma included, unless it now comes first.
160
- if (position > 0)
166
+ if (kept++ > 0)
161
167
  out += text.slice(members[index - 1].end, member.start);
162
168
  out += render(member.start, member.end);
163
169
  });
164
170
  let appended = "";
165
171
  for (const [key, value] of edit.set)
166
172
  if (!lastOf.has(key) || edit.remove.has(key))
167
- appended += `${kept.length || appended ? "," : ""}${JSON.stringify(key)}:${value}`;
173
+ appended += `${kept || appended ? "," : ""}${JSON.stringify(key)}:${value}`;
168
174
  return out + appended + text.slice(close, span.end);
169
175
  },
170
176
  });
171
177
  }
172
178
  pieces.sort((a, b) => a.start - b.start || b.end - a.end);
173
179
  /** The text from `start` to `end` with the pieces within it rendered; a piece inside another
174
- * is rendered by the outer one. */
180
+ * is rendered by the outer one. Only the pieces that start within the range are visited. */
175
181
  const render = (start, end) => {
176
182
  let out = "";
177
183
  let at = start;
178
- for (const piece of pieces) {
184
+ // The first piece starting at or after `start`, found by halving.
185
+ let low = 0;
186
+ let high = pieces.length;
187
+ while (low < high) {
188
+ const middle = (low + high) >> 1;
189
+ if (pieces[middle].start < start)
190
+ low = middle + 1;
191
+ else
192
+ high = middle;
193
+ }
194
+ for (let index = low; index < pieces.length; index++) {
195
+ const piece = pieces[index];
196
+ if (piece.start >= end)
197
+ break;
179
198
  if (piece.start < at || piece.end > end)
180
199
  continue;
181
200
  out += text.slice(at, piece.start) + piece.render();
@@ -0,0 +1,106 @@
1
+ import { type InlineFilePart, type MediaValue } from "./inline-files.js";
2
+ /**
3
+ * Inline media in a value too long for the media inspection bounds (8 Mi UTF-16 code units for one
4
+ * attribute, 16 Mi for a record), read without parsing the files out of it. A span's own attribute
5
+ * held whole for upload is scanned once: every value string longer than {@link LONG_STRING_UNITS}
6
+ * that is recorded without escapes as strict base64 or a base64 `data:` URL is replaced, in a
7
+ * skeleton of the JSON text, by a short stand-in of the same kind, and the skeleton (within the
8
+ * 8 Mi bound) is read as Hue's reader reads the value. A file whose content is such a string is
9
+ * then decoded, hashed and uploaded a slice at a time from its place in the recorded text, never
10
+ * copied out of it whole; its placement edits the skeleton, and the strings no file replaced are
11
+ * written back from the recorded text. The scan reads at most {@link WINDOW_UNITS} of the text in
12
+ * one synchronous step. Mirrors the Python SDK's `_large_media.py`, so both SDKs write the same
13
+ * bytes.
14
+ */
15
+ /** A value string longer than this, in UTF-16 code units, is read in place when it is base64 or a
16
+ * base64 `data:` URL recorded without escapes. Longer than every label Hue's reader matches (256
17
+ * units), so a stand-in decides every test of the walk as the string itself does. */
18
+ export declare const LONG_STRING_UNITS: number;
19
+ /** The most of a value read in one synchronous step: the scan yields to the event loop between
20
+ * windows, and files are decoded and hashed a window at a time. */
21
+ export declare const WINDOW_UNITS: number;
22
+ /** A source of bytes read a slice at a time: an upload body that is never held whole. `chunks` may
23
+ * be called again for a retried upload, and starts fresh each time; a slice it yields may be
24
+ * reused for the next one, so a reader is done with each slice before it asks for the next. */
25
+ export interface ByteSource {
26
+ readonly size: number;
27
+ chunks(): Iterable<Uint8Array> | AsyncIterable<Uint8Array>;
28
+ /** Set when the latest read found the source no longer holds the bytes it was recorded with. */
29
+ changed?: boolean;
30
+ }
31
+ /** Whether a value is a {@link ByteSource}. */
32
+ export declare function isByteSource(value: unknown): value is ByteSource;
33
+ /** How the bytes of a long string are read from its text. */
34
+ type LongKind = "base64" | "dataBase64";
35
+ /** A long value string in place in the recorded text: its content is `text` from `start` to
36
+ * `end`, without escapes, and its data (after a `data:` URL's comma) starts at `data`. */
37
+ export interface LongText {
38
+ text: string;
39
+ start: number;
40
+ end: number;
41
+ data: number;
42
+ kind: LongKind;
43
+ /** A `data:` URL's header, between `data:` and its comma. */
44
+ header?: string;
45
+ }
46
+ /** A media value read in place, with the long strings its stand-ins name. Its `source` is the
47
+ * skeleton: placing its files edits the skeleton, and {@link placedLargeText} writes the long
48
+ * strings back. */
49
+ export interface LargeMediaValue extends MediaValue {
50
+ long: Map<string, LongText>;
51
+ /** What every stand-in's tag starts with, followed by its index in {@link order} as eight hex
52
+ * digits: one forward scan for it finds every stand-in left in a placed text. */
53
+ nonce: string;
54
+ order: string[];
55
+ }
56
+ /**
57
+ * An attribute's recorded text read for files as `readMediaText` reads it, its long base64 and
58
+ * base64 `data:` URL strings read in place (see {@link skeletonOf}). Undefined for text whose JSON
59
+ * around those strings is over the 8 Mi bound, or that reading finds nothing in. Throws for JSON
60
+ * that does not parse.
61
+ */
62
+ export declare function readLargeMediaText(key: string, text: string, longUnits?: number): Promise<LargeMediaValue | undefined>;
63
+ /**
64
+ * Text made of pieces of other strings, not yet joined into one: the recorded text with its files
65
+ * placed, whose long strings no file replaced are still pieces of the recorded text. It is read a
66
+ * window at a time, and made one string only when it is short enough to keep inline, so a long
67
+ * remainder is never held in two copies.
68
+ */
69
+ export declare class JoinedText {
70
+ private readonly pieces;
71
+ constructor(pieces: [string, number, number][]);
72
+ /** The text a window at a time, never between the halves of a surrogate pair. */
73
+ windows(): Generator<string>;
74
+ /** The first `units` code units. */
75
+ head(units: number): string;
76
+ /** The last `units` code units. */
77
+ tail(units: number): string;
78
+ /** The text as one string. */
79
+ text(): string;
80
+ /** The text's UTF-8 size, counted a window at a time. */
81
+ utf8Size(): number;
82
+ /** The text's UTF-8, a window at a time, into one buffer each read reuses. */
83
+ source(size: number): ByteSource;
84
+ }
85
+ /**
86
+ * The recorded text once the files of a value read in place are placed: the skeleton with the
87
+ * placements' edits, and each stand-in no placement replaced written back as the string it stands
88
+ * for. Every byte the placements do not change is the recorded text's. Text that keeps a long
89
+ * string is returned as its pieces ({@link JoinedText}), not copied into one string.
90
+ */
91
+ export declare function placedLargeText(media: LargeMediaValue): Promise<string | JoinedText>;
92
+ /** A long file's bytes as `mediaBytes` reads them, measured without being made: its size, whether
93
+ * it was decoded, the media type its `data:` URL names, and a source of its bytes. */
94
+ export interface LongFileBytes {
95
+ size: number;
96
+ decoded: boolean;
97
+ urlMediaType?: string;
98
+ bareBase64?: boolean;
99
+ source: ByteSource;
100
+ }
101
+ /** The bytes of a file whose content is a long string, as `mediaBytes` reads them: a field its
102
+ * convention declares base64 decoded, base64 text decoded (`bareBase64`), a base64 `data:` URL. */
103
+ export declare function longFileBytes(file: InlineFilePart, long: LongText): LongFileBytes;
104
+ /** The first `units` code units of a long string's content. */
105
+ export declare function longHead(long: LongText, units: number): string;
106
+ export {};
@@ -0,0 +1,414 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { MAX_INLINE_FILE_TEXT, mediaIn, placedMediaText, readerParses, readingOf, } from "./inline-files.js";
3
+ /**
4
+ * Inline media in a value too long for the media inspection bounds (8 Mi UTF-16 code units for one
5
+ * attribute, 16 Mi for a record), read without parsing the files out of it. A span's own attribute
6
+ * held whole for upload is scanned once: every value string longer than {@link LONG_STRING_UNITS}
7
+ * that is recorded without escapes as strict base64 or a base64 `data:` URL is replaced, in a
8
+ * skeleton of the JSON text, by a short stand-in of the same kind, and the skeleton (within the
9
+ * 8 Mi bound) is read as Hue's reader reads the value. A file whose content is such a string is
10
+ * then decoded, hashed and uploaded a slice at a time from its place in the recorded text, never
11
+ * copied out of it whole; its placement edits the skeleton, and the strings no file replaced are
12
+ * written back from the recorded text. The scan reads at most {@link WINDOW_UNITS} of the text in
13
+ * one synchronous step. Mirrors the Python SDK's `_large_media.py`, so both SDKs write the same
14
+ * bytes.
15
+ */
16
+ /** A value string longer than this, in UTF-16 code units, is read in place when it is base64 or a
17
+ * base64 `data:` URL recorded without escapes. Longer than every label Hue's reader matches (256
18
+ * units), so a stand-in decides every test of the walk as the string itself does. */
19
+ export const LONG_STRING_UNITS = 16 * 1024;
20
+ /** The most of a value read in one synchronous step: the scan yields to the event loop between
21
+ * windows, and files are decoded and hashed a window at a time. */
22
+ export const WINDOW_UNITS = 1024 * 1024;
23
+ /** Whether a value is a {@link ByteSource}. */
24
+ export function isByteSource(value) {
25
+ return (value !== null &&
26
+ typeof value === "object" &&
27
+ !(value instanceof Uint8Array) &&
28
+ typeof value.chunks === "function");
29
+ }
30
+ /** Base64 alphabet characters only, and the same padded to a multiple of four. */
31
+ const alphabet = /^[A-Za-z0-9+/]*$/;
32
+ const base64 = /^[A-Za-z0-9+/]*={0,2}$/;
33
+ // eslint-disable-next-line no-control-regex -- a JSON string literal holds no raw control character
34
+ const controlCharacter = /[\u0000-\u001f]/;
35
+ /** The longest `data:` URL header read in place, in code units: a string with a longer one is
36
+ * parsed with the rest of the value. */
37
+ const MAX_HEADER_UNITS = 4096;
38
+ const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
39
+ /** Whether a `data:` URL header's parameters, after its media type, include `base64`. */
40
+ function base64Header(header) {
41
+ const semicolon = header.indexOf(";");
42
+ return semicolon !== -1 && `${header.slice(semicolon)};`.includes(";base64;");
43
+ }
44
+ /** Whether `text` from `start` to `end` is strict base64 (padded to a multiple of four), checked a
45
+ * window at a time between turns of the event loop. */
46
+ async function strictBase64(text, start, end) {
47
+ if ((end - start) % 4 !== 0)
48
+ return false;
49
+ for (let at = start; at < end; at += WINDOW_UNITS) {
50
+ const stop = Math.min(end, at + WINDOW_UNITS);
51
+ // Padding only closes the whole string.
52
+ if (!(stop === end ? base64 : alphabet).test(text.slice(at, stop)))
53
+ return false;
54
+ if (stop < end)
55
+ await nextTurn();
56
+ }
57
+ return true;
58
+ }
59
+ /**
60
+ * How the content from `start` to `end` of `text` (a string literal's, without escapes, or with
61
+ * `raw` the whole text of a value that is no JSON) is read in place, if it is: strict base64, or a
62
+ * `data:` URL declaring `;base64` whose data is strict base64. Undefined for any other text, which
63
+ * is parsed with the rest of the value (a percent-escaped `data:` URL included); a string literal
64
+ * whose header holds a control character, which no JSON does, is left for the parse to refuse.
65
+ */
66
+ async function classify(text, start, end, raw = false) {
67
+ if (text.startsWith("data:", start)) {
68
+ // The comma is looked for within the string and the header's bound only.
69
+ const found = text
70
+ .slice(start + 5, Math.min(end, start + 5 + MAX_HEADER_UNITS + 1))
71
+ .indexOf(",");
72
+ if (found === -1)
73
+ return undefined;
74
+ const comma = start + 5 + found;
75
+ const header = text.slice(start + 5, comma);
76
+ if ((!raw && controlCharacter.test(header)) || !base64Header(header))
77
+ return undefined;
78
+ if (!(await strictBase64(text, comma + 1, end)))
79
+ return undefined;
80
+ return { text, start, end, data: comma + 1, kind: "dataBase64", header };
81
+ }
82
+ if (!(await strictBase64(text, start, end)))
83
+ return undefined;
84
+ return { text, start, end, data: start, kind: "base64" };
85
+ }
86
+ /** The stand-in for a long string in the skeleton: its kind kept (base64 and its first 16
87
+ * characters, which name an audio container; a `data:` URL's header), longer than any label, and
88
+ * unique by `tag`. */
89
+ function standIn(long, tag) {
90
+ const fill = `${tag}${"A".repeat(472)}`;
91
+ if (long.kind === "base64")
92
+ return `${long.text.slice(long.start, long.start + 16)}${fill}`;
93
+ return `data:${long.header},${fill}`;
94
+ }
95
+ /** The index past the whitespace JSON allows from `at`. */
96
+ function space(text, at) {
97
+ let index = at;
98
+ for (;;) {
99
+ const code = text.charCodeAt(index);
100
+ if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d)
101
+ index++;
102
+ else
103
+ return index;
104
+ }
105
+ }
106
+ /** Whether the quote at `at` is escaped: whether an odd run of backslashes ends just before it.
107
+ * A string's run stops at its opening quote. */
108
+ function escapedQuote(text, at) {
109
+ let start = at;
110
+ while (start > 0 && text.charCodeAt(start - 1) === 0x5c)
111
+ start--;
112
+ return (at - start) % 2 === 1;
113
+ }
114
+ /** Marks a search that reached the end of its window before its bound. */
115
+ const MORE = -2;
116
+ /** The first `character` of `text` from `from`, before `to`, within one window: its index, -1
117
+ * when there is none before `to`, or {@link MORE} when the window ended first. */
118
+ function within(text, character, from, to) {
119
+ const stop = Math.min(to, from + WINDOW_UNITS);
120
+ const found = text.slice(from, stop).indexOf(character);
121
+ if (found !== -1)
122
+ return from + found;
123
+ return stop === to ? -1 : MORE;
124
+ }
125
+ /** {@link within} on from window to window, a turn of the event loop between them. */
126
+ async function find(text, character, from, to) {
127
+ for (let at = from;; at += WINDOW_UNITS) {
128
+ const found = within(text, character, at, to);
129
+ if (found !== MORE)
130
+ return found;
131
+ await nextTurn();
132
+ }
133
+ }
134
+ /**
135
+ * The skeleton of JSON `text`: each value string longer than `longUnits` and recorded without
136
+ * escapes as base64 or a base64 `data:` URL replaced by its stand-in. Undefined once the skeleton
137
+ * is longer than {@link MAX_INLINE_FILE_TEXT}: the scan stops there, so the text it reads and the
138
+ * skeleton it makes stay within that bound however long the value is. A string the scan finds
139
+ * unterminated (text that is no JSON) ends the scan; the skeleton then holds the rest as recorded
140
+ * and fails to parse. The scan reads at most a window of the text in one synchronous step.
141
+ */
142
+ async function skeletonOf(text, longUnits) {
143
+ const nonce = randomBytes(16).toString("hex");
144
+ const long = new Map();
145
+ const replaced = [];
146
+ // The skeleton's length less the text's, up to where the scan stands.
147
+ let shorter = 0;
148
+ let at = 0;
149
+ let turn = 0;
150
+ // A search within one window, synchronous; past it, from window to window between turns.
151
+ const quote = (from) => within(text, '"', from, text.length);
152
+ for (;;) {
153
+ if (at - turn >= WINDOW_UNITS) {
154
+ turn = at;
155
+ await nextTurn();
156
+ }
157
+ let open = quote(at);
158
+ if (open === MORE)
159
+ open = await find(text, '"', at + WINDOW_UNITS, text.length);
160
+ if (open === -1)
161
+ break;
162
+ // The string's end: its first quote after an even run of backslashes, found quote by quote,
163
+ // not escape by escape.
164
+ let close = quote(open + 1);
165
+ if (close === MORE)
166
+ close = await find(text, '"', open + 1 + WINDOW_UNITS, text.length);
167
+ let escaped = false;
168
+ if (close !== -1) {
169
+ let slash = within(text, "\\", open + 1, close);
170
+ if (slash === MORE)
171
+ slash = await find(text, "\\", open + 1 + WINDOW_UNITS, close);
172
+ escaped = slash !== -1;
173
+ }
174
+ while (escaped && close !== -1) {
175
+ // A string with an escape is kept as recorded: once it passes the bound, so does the
176
+ // skeleton, and its run of backslashes is no longer than the bound.
177
+ if (close - shorter > MAX_INLINE_FILE_TEXT)
178
+ return undefined;
179
+ if (!escapedQuote(text, close))
180
+ break;
181
+ if (close - turn >= WINDOW_UNITS) {
182
+ turn = close;
183
+ await nextTurn();
184
+ }
185
+ const from = close + 1;
186
+ close = quote(from);
187
+ if (close === MORE)
188
+ close = await find(text, '"', from + WINDOW_UNITS, text.length);
189
+ }
190
+ if (close === -1)
191
+ break;
192
+ at = close + 1;
193
+ // Only a value: a key stays as it is.
194
+ const found = escaped || close - open - 1 <= longUnits || text.charCodeAt(space(text, at)) === 0x3a
195
+ ? undefined
196
+ : await classify(text, open + 1, close);
197
+ if (found) {
198
+ const stub = standIn(found, `${nonce}${long.size.toString(16).padStart(8, "0")}`);
199
+ long.set(stub, found);
200
+ replaced.push({ start: open + 1, end: close, stub });
201
+ shorter += close - open - 1 - stub.length;
202
+ }
203
+ if (at - shorter > MAX_INLINE_FILE_TEXT)
204
+ return undefined;
205
+ }
206
+ if (text.length - shorter > MAX_INLINE_FILE_TEXT)
207
+ return undefined;
208
+ const pieces = [];
209
+ let copied = 0;
210
+ for (const { start, end, stub } of replaced) {
211
+ pieces.push(text.slice(copied, start), stub);
212
+ copied = end;
213
+ }
214
+ pieces.push(text.slice(copied));
215
+ return { text: pieces.join(""), long, nonce, order: [...long.keys()] };
216
+ }
217
+ /**
218
+ * An attribute's recorded text read for files as `readMediaText` reads it, its long base64 and
219
+ * base64 `data:` URL strings read in place (see {@link skeletonOf}). Undefined for text whose JSON
220
+ * around those strings is over the 8 Mi bound, or that reading finds nothing in. Throws for JSON
221
+ * that does not parse.
222
+ */
223
+ export async function readLargeMediaText(key, text, longUnits = LONG_STRING_UNITS) {
224
+ const reading = readingOf(key);
225
+ /** Text that is no JSON is the file itself when it is one long base64 `data:` URL; other text
226
+ * is read as `readMediaText` reads it, within the 8 Mi bound. */
227
+ const whole = async () => {
228
+ const found = text.length > longUnits && text.startsWith("data:")
229
+ ? await classify(text, 0, text.length, true)
230
+ : undefined;
231
+ if (!found)
232
+ return text.length > MAX_INLINE_FILE_TEXT
233
+ ? undefined
234
+ : { ...mediaIn(text), long: new Map(), nonce: "", order: [] };
235
+ const nonce = randomBytes(16).toString("hex");
236
+ const stub = standIn(found, `${nonce}00000000`);
237
+ return { ...mediaIn(stub), long: new Map([[stub, found]]), nonce, order: [stub] };
238
+ };
239
+ const json = async (check) => {
240
+ const skeleton = await skeletonOf(text, longUnits);
241
+ if (!skeleton)
242
+ return undefined;
243
+ const parsed = JSON.parse(skeleton.text);
244
+ if (check && !check(parsed))
245
+ return undefined;
246
+ return readerParses(parsed)
247
+ ? {
248
+ ...mediaIn(parsed, skeleton.text),
249
+ long: skeleton.long,
250
+ nonce: skeleton.nonce,
251
+ order: skeleton.order,
252
+ }
253
+ : undefined;
254
+ };
255
+ switch (reading) {
256
+ case "json":
257
+ return json();
258
+ case "metadata":
259
+ return /^\s*[[{]/.test(text) ? json() : whole();
260
+ case "parts":
261
+ return /^\s*\[/.test(text)
262
+ ? json((parsed) => Array.isArray(parsed) &&
263
+ parsed.length > 0 &&
264
+ parsed.every((part) => part !== null &&
265
+ typeof part === "object" &&
266
+ !Array.isArray(part) &&
267
+ typeof part.type === "string"))
268
+ : undefined;
269
+ case "url":
270
+ return whole();
271
+ default:
272
+ return undefined;
273
+ }
274
+ }
275
+ /**
276
+ * Text made of pieces of other strings, not yet joined into one: the recorded text with its files
277
+ * placed, whose long strings no file replaced are still pieces of the recorded text. It is read a
278
+ * window at a time, and made one string only when it is short enough to keep inline, so a long
279
+ * remainder is never held in two copies.
280
+ */
281
+ export class JoinedText {
282
+ pieces;
283
+ constructor(pieces) {
284
+ this.pieces = pieces.filter(([, start, end]) => end > start);
285
+ }
286
+ /** The text a window at a time, never between the halves of a surrogate pair. */
287
+ *windows() {
288
+ for (const [text, start, end] of this.pieces)
289
+ for (let at = start; at < end;) {
290
+ let stop = Math.min(end, at + WINDOW_UNITS);
291
+ if (stop < end && /[\uD800-\uDBFF]/.test(text[stop - 1]))
292
+ stop--;
293
+ yield text.slice(at, stop);
294
+ at = stop;
295
+ }
296
+ }
297
+ /** The first `units` code units. */
298
+ head(units) {
299
+ let out = "";
300
+ for (const [text, start, end] of this.pieces) {
301
+ if (out.length >= units)
302
+ break;
303
+ out += text.slice(start, Math.min(end, start + units - out.length));
304
+ }
305
+ return out;
306
+ }
307
+ /** The last `units` code units. */
308
+ tail(units) {
309
+ let out = "";
310
+ for (let index = this.pieces.length - 1; index >= 0 && out.length < units; index--) {
311
+ const [text, start, end] = this.pieces[index];
312
+ out = text.slice(Math.max(start, end - (units - out.length)), end) + out;
313
+ }
314
+ return out;
315
+ }
316
+ /** The text as one string. */
317
+ text() {
318
+ return this.pieces.map(([text, start, end]) => text.slice(start, end)).join("");
319
+ }
320
+ /** The text's UTF-8 size, counted a window at a time. */
321
+ utf8Size() {
322
+ let size = 0;
323
+ for (const window of this.windows())
324
+ size += Buffer.byteLength(window, "utf8");
325
+ return size;
326
+ }
327
+ /** The text's UTF-8, a window at a time, into one buffer each read reuses. */
328
+ source(size) {
329
+ const windows = () => this.windows();
330
+ return {
331
+ size,
332
+ *chunks() {
333
+ const buffer = Buffer.allocUnsafe(WINDOW_UNITS * 3);
334
+ for (const window of windows())
335
+ yield buffer.subarray(0, buffer.write(window, "utf8"));
336
+ },
337
+ };
338
+ }
339
+ }
340
+ /**
341
+ * The recorded text once the files of a value read in place are placed: the skeleton with the
342
+ * placements' edits, and each stand-in no placement replaced written back as the string it stands
343
+ * for. Every byte the placements do not change is the recorded text's. Text that keeps a long
344
+ * string is returned as its pieces ({@link JoinedText}), not copied into one string.
345
+ */
346
+ export async function placedLargeText(media) {
347
+ const edited = placedMediaText(media);
348
+ if (!media.order.length)
349
+ return edited;
350
+ // One forward scan for the stand-ins' shared nonce finds each one left, in order, by the index
351
+ // its tag carries: linear in the text however many there are, a window at a time.
352
+ const pieces = [];
353
+ let from = 0;
354
+ let turn = 0;
355
+ for (let at = edited.indexOf(media.nonce); at !== -1; at = edited.indexOf(media.nonce, at + 1)) {
356
+ if (at - turn >= WINDOW_UNITS) {
357
+ turn = at;
358
+ await nextTurn();
359
+ }
360
+ const stub = media.order[Number.parseInt(edited.slice(at + 32, at + 40), 16)];
361
+ if (stub === undefined)
362
+ continue;
363
+ const start = at - stub.indexOf(media.nonce);
364
+ if (start < from || !edited.startsWith(stub, start))
365
+ continue;
366
+ const long = media.long.get(stub);
367
+ pieces.push([edited, from, start], [long.text, long.start, long.end]);
368
+ from = start + stub.length;
369
+ at = from - 1;
370
+ }
371
+ if (!pieces.length)
372
+ return edited;
373
+ pieces.push([edited, from, edited.length]);
374
+ return new JoinedText(pieces);
375
+ }
376
+ /** Base64 from `start` to `end` of `text` (strict, padded to a multiple of four), decoded a window
377
+ * at a time into one buffer each read reuses. */
378
+ function base64Source(text, start, end) {
379
+ const length = end - start;
380
+ const pad = (text.charCodeAt(end - 1) === 0x3d ? 1 : 0) + (text.charCodeAt(end - 2) === 0x3d ? 1 : 0);
381
+ return {
382
+ size: (length / 4) * 3 - pad,
383
+ *chunks() {
384
+ const buffer = Buffer.allocUnsafe((WINDOW_UNITS / 4) * 3);
385
+ for (let at = start; at < end; at += WINDOW_UNITS) {
386
+ const written = buffer.write(text.slice(at, Math.min(end, at + WINDOW_UNITS)), "base64");
387
+ yield buffer.subarray(0, written);
388
+ }
389
+ },
390
+ };
391
+ }
392
+ /** The bytes of a file whose content is a long string, as `mediaBytes` reads them: a field its
393
+ * convention declares base64 decoded, base64 text decoded (`bareBase64`), a base64 `data:` URL. */
394
+ export function longFileBytes(file, long) {
395
+ const source = base64Source(long.text, long.data, long.end);
396
+ if (long.kind === "base64")
397
+ return {
398
+ size: source.size,
399
+ decoded: true,
400
+ ...(file.base64 ? {} : { bareBase64: true }),
401
+ source,
402
+ };
403
+ const mediaType = long.header.split(";")[0].trim();
404
+ return {
405
+ size: source.size,
406
+ decoded: true,
407
+ ...(mediaType ? { urlMediaType: mediaType } : {}),
408
+ source,
409
+ };
410
+ }
411
+ /** The first `units` code units of a long string's content. */
412
+ export function longHead(long, units) {
413
+ return long.text.slice(long.start, Math.min(long.end, long.start + units));
414
+ }
@@ -32,6 +32,8 @@ export interface RecordSnapshot<T> {
32
32
  digested: ReadonlyMap<string, DigestedMessage>;
33
33
  /** The span's own message attributes admission inspected or left to export to inspect. */
34
34
  inspected: ReadonlySet<string>;
35
+ /** The span's own attributes held whole past the inspection bounds, read in place at export. */
36
+ large: ReadonlySet<string>;
35
37
  }
36
38
  export declare function snapshotSpan(source: ReadableSpan, limit: number, captureContent?: boolean, valueUnits?: number, hold?: HoldLimits, memo?: MediaDigestMemo): RecordSnapshot<ReadableSpan>;
37
39
  export declare function snapshotLog(source: ReadableLogRecord, limit: number, captureContent?: boolean, valueUnits?: number, hold?: HoldLimits, memo?: MediaDigestMemo): RecordSnapshot<ReadWriteLogRecord>;
package/dist/snapshot.js CHANGED
@@ -88,6 +88,9 @@ class Snapshot {
88
88
  /** The span's own message attributes inspected within the record's allowance, whether here or
89
89
  * left to export for a message held whole: export inspects only these. */
90
90
  inspectedKeys = new Set();
91
+ /** The span's own attributes held whole for upload that the inspection bounds left out: export
92
+ * reads their media in place. */
93
+ largeKeys = new Set();
91
94
  /**
92
95
  * In metadata-only mode the recorded messages, which export strips, are not copied at all. A
93
96
  * value string (an attribute's, an event's or link's attribute's, a log body or a status
@@ -145,6 +148,10 @@ class Snapshot {
145
148
  this.inspected += value.length;
146
149
  this.inspectedKeys.add(key);
147
150
  }
151
+ // Past those bounds, export reads its media in place, without parsing the files out of
152
+ // it; nothing is read here, on the application's thread.
153
+ else if (!this.log && !literal && isMediaKey(key))
154
+ this.largeKeys.add(key);
148
155
  return { value };
149
156
  }
150
157
  // Within the queued length: an inspected attribute holding inline media is held whole for
@@ -154,7 +161,16 @@ class Snapshot {
154
161
  if (!isMediaKey(key) || literal)
155
162
  return undefined;
156
163
  const digested = this.inlineFiles(key, value, true);
157
- if (digested === value || !this.holds(value.length * 2))
164
+ if (digested === value) {
165
+ // Over the cap but past the inspection bounds: export reads its media in place.
166
+ if (!this.log &&
167
+ !this.inspectedKeys.has(key) &&
168
+ (value.length > this.hold.valueBytes ||
169
+ Buffer.byteLength(value, "utf8") > this.hold.valueBytes))
170
+ this.largeKeys.add(key);
171
+ return undefined;
172
+ }
173
+ if (!this.holds(value.length * 2))
158
174
  return undefined;
159
175
  this.copy(digested, 1, "value", true);
160
176
  return { value };
@@ -545,6 +561,7 @@ export function snapshotSpan(source, limit, captureContent = true, valueUnits =
545
561
  held: snapshot.held,
546
562
  digested: snapshot.digested,
547
563
  inspected: snapshot.inspectedKeys,
564
+ large: snapshot.largeKeys,
548
565
  };
549
566
  }
550
567
  export function snapshotLog(source, limit, captureContent = true, valueUnits = Infinity, hold, memo) {
@@ -601,5 +618,6 @@ export function snapshotLog(source, limit, captureContent = true, valueUnits = I
601
618
  held: snapshot.held,
602
619
  digested: new Map(),
603
620
  inspected: new Set(),
621
+ large: new Set(),
604
622
  };
605
623
  }
@@ -83,6 +83,7 @@ export declare class HueTransport {
83
83
  private admissionCuts;
84
84
  private admissionDigests;
85
85
  private admissionInspected;
86
+ private admissionLarge;
86
87
  /** Uploads values over the inline limit apart from their spans; absent when disabled. */
87
88
  private uploader?;
88
89
  /** Bytes of values over the inline limit queued spans hold whole for upload, and each span's. */