@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.
- package/CLI.md +10 -10
- package/README.md +45 -18
- package/dist/attachment.d.ts +51 -7
- package/dist/attachment.js +141 -33
- package/dist/blobs.d.ts +22 -5
- package/dist/blobs.js +186 -71
- package/dist/client.d.ts +9 -4
- package/dist/client.js +46 -15
- package/dist/inline-files.d.ts +5 -4
- package/dist/inline-files.js +6 -5
- package/dist/json-edits.js +26 -7
- package/dist/large-media.d.ts +106 -0
- package/dist/large-media.js +414 -0
- package/dist/snapshot.d.ts +2 -0
- package/dist/snapshot.js +19 -1
- package/dist/transport.d.ts +1 -0
- package/dist/transport.js +25 -15
- package/dist/types.d.ts +10 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/json-edits.js
CHANGED
|
@@ -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
|
|
158
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/snapshot.d.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/dist/transport.d.ts
CHANGED
|
@@ -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. */
|