@hue-run/sdk 0.18.2 → 0.19.1

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.
@@ -24,10 +24,18 @@ export declare const WINDOW_UNITS: number;
24
24
  * reused for the next one, so a reader is done with each slice before it asks for the next. */
25
25
  export interface ByteSource {
26
26
  readonly size: number;
27
+ /** The strings the source keeps alive while it is held, each whole; absent for one read from
28
+ * elsewhere. */
29
+ readonly held?: readonly HeldText[];
27
30
  chunks(): Iterable<Uint8Array> | AsyncIterable<Uint8Array>;
28
31
  /** Set when the latest read found the source no longer holds the bytes it was recorded with. */
29
32
  changed?: boolean;
30
33
  }
34
+ /** A string sources keep alive, as its two bytes a code unit: a slice read in place keeps the
35
+ * whole string, so the sources that read one share its {@link HeldText}, charged once. */
36
+ export interface HeldText {
37
+ readonly bytes: number;
38
+ }
31
39
  /** Whether a value is a {@link ByteSource}. */
32
40
  export declare function isByteSource(value: unknown): value is ByteSource;
33
41
  /** How the bytes of a long string are read from its text. */
@@ -42,6 +50,8 @@ export interface LongText {
42
50
  kind: LongKind;
43
51
  /** A `data:` URL's header, between `data:` and its comma. */
44
52
  header?: string;
53
+ /** `text` whole, shared by every long string read from it. */
54
+ held: HeldText;
45
55
  }
46
56
  /** A media value read in place, with the long strings its stand-ins name. Its `source` is the
47
57
  * skeleton: placing its files edits the skeleton, and {@link placedLargeText} writes the long
@@ -57,7 +67,8 @@ export interface LargeMediaValue extends MediaValue {
57
67
  * An attribute's recorded text read for files as `readMediaText` reads it, its long base64 and
58
68
  * base64 `data:` URL strings read in place (see {@link skeletonOf}). Undefined for text whose JSON
59
69
  * around those strings is over the 8 Mi bound, or that reading finds nothing in. Throws for JSON
60
- * that does not parse.
70
+ * that does not parse, except text read by JSON convention that is a `data:` URL, which is read
71
+ * as the text itself, as `readMediaText` reads it.
61
72
  */
62
73
  export declare function readLargeMediaText(key: string, text: string, longUnits?: number): Promise<LargeMediaValue | undefined>;
63
74
  /**
@@ -67,8 +78,10 @@ export declare function readLargeMediaText(key: string, text: string, longUnits?
67
78
  * remainder is never held in two copies.
68
79
  */
69
80
  export declare class JoinedText {
81
+ private readonly held;
70
82
  private readonly pieces;
71
- constructor(pieces: [string, number, number][]);
83
+ /** `held`: the strings the pieces are cut from, which the text keeps alive whole. */
84
+ constructor(pieces: [string, number, number][], held: readonly HeldText[]);
72
85
  /** The text a window at a time, never between the halves of a surrogate pair. */
73
86
  windows(): Generator<string>;
74
87
  /** The first `units` code units. */
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { MAX_INLINE_FILE_TEXT, mediaIn, placedMediaText, readerParses, readingOf, } from "./inline-files.js";
2
+ import { headerMediaType, MAX_INLINE_FILE_TEXT, mediaIn, placedMediaText, readerParses, readingOf, } from "./inline-files.js";
3
3
  /**
4
4
  * Inline media in a value too long for the media inspection bounds (8 Mi UTF-16 code units for one
5
5
  * attribute, 16 Mi for a record), read without parsing the files out of it. A span's own attribute
@@ -63,7 +63,7 @@ async function strictBase64(text, start, end) {
63
63
  * is parsed with the rest of the value (a percent-escaped `data:` URL included); a string literal
64
64
  * whose header holds a control character, which no JSON does, is left for the parse to refuse.
65
65
  */
66
- async function classify(text, start, end, raw = false) {
66
+ async function classify(text, start, end, held, raw = false) {
67
67
  if (text.startsWith("data:", start)) {
68
68
  // The comma is looked for within the string and the header's bound only.
69
69
  const found = text
@@ -77,11 +77,11 @@ async function classify(text, start, end, raw = false) {
77
77
  return undefined;
78
78
  if (!(await strictBase64(text, comma + 1, end)))
79
79
  return undefined;
80
- return { text, start, end, data: comma + 1, kind: "dataBase64", header };
80
+ return { text, start, end, data: comma + 1, kind: "dataBase64", header, held };
81
81
  }
82
82
  if (!(await strictBase64(text, start, end)))
83
83
  return undefined;
84
- return { text, start, end, data: start, kind: "base64" };
84
+ return { text, start, end, data: start, kind: "base64", held };
85
85
  }
86
86
  /** The stand-in for a long string in the skeleton: its kind kept (base64 and its first 16
87
87
  * characters, which name an audio container; a `data:` URL's header), longer than any label, and
@@ -139,7 +139,7 @@ async function find(text, character, from, to) {
139
139
  * unterminated (text that is no JSON) ends the scan; the skeleton then holds the rest as recorded
140
140
  * and fails to parse. The scan reads at most a window of the text in one synchronous step.
141
141
  */
142
- async function skeletonOf(text, longUnits) {
142
+ async function skeletonOf(text, longUnits, held) {
143
143
  const nonce = randomBytes(16).toString("hex");
144
144
  const long = new Map();
145
145
  const replaced = [];
@@ -193,7 +193,7 @@ async function skeletonOf(text, longUnits) {
193
193
  // Only a value: a key stays as it is.
194
194
  const found = escaped || close - open - 1 <= longUnits || text.charCodeAt(space(text, at)) === 0x3a
195
195
  ? undefined
196
- : await classify(text, open + 1, close);
196
+ : await classify(text, open + 1, close, held);
197
197
  if (found) {
198
198
  const stub = standIn(found, `${nonce}${long.size.toString(16).padStart(8, "0")}`);
199
199
  long.set(stub, found);
@@ -218,15 +218,17 @@ async function skeletonOf(text, longUnits) {
218
218
  * An attribute's recorded text read for files as `readMediaText` reads it, its long base64 and
219
219
  * base64 `data:` URL strings read in place (see {@link skeletonOf}). Undefined for text whose JSON
220
220
  * around those strings is over the 8 Mi bound, or that reading finds nothing in. Throws for JSON
221
- * that does not parse.
221
+ * that does not parse, except text read by JSON convention that is a `data:` URL, which is read
222
+ * as the text itself, as `readMediaText` reads it.
222
223
  */
223
224
  export async function readLargeMediaText(key, text, longUnits = LONG_STRING_UNITS) {
224
225
  const reading = readingOf(key);
226
+ const held = { bytes: text.length * 2 };
225
227
  /** Text that is no JSON is the file itself when it is one long base64 `data:` URL; other text
226
228
  * is read as `readMediaText` reads it, within the 8 Mi bound. */
227
229
  const whole = async () => {
228
230
  const found = text.length > longUnits && text.startsWith("data:")
229
- ? await classify(text, 0, text.length, true)
231
+ ? await classify(text, 0, text.length, held, true)
230
232
  : undefined;
231
233
  if (!found)
232
234
  return text.length > MAX_INLINE_FILE_TEXT
@@ -237,7 +239,7 @@ export async function readLargeMediaText(key, text, longUnits = LONG_STRING_UNIT
237
239
  return { ...mediaIn(stub), long: new Map([[stub, found]]), nonce, order: [stub] };
238
240
  };
239
241
  const json = async (check) => {
240
- const skeleton = await skeletonOf(text, longUnits);
242
+ const skeleton = await skeletonOf(text, longUnits, held);
241
243
  if (!skeleton)
242
244
  return undefined;
243
245
  const parsed = JSON.parse(skeleton.text);
@@ -254,7 +256,9 @@ export async function readLargeMediaText(key, text, longUnits = LONG_STRING_UNIT
254
256
  };
255
257
  switch (reading) {
256
258
  case "json":
257
- return json();
259
+ // No JSON text starts with `data:`: such text is the text itself, as `readMediaText` reads
260
+ // text that does not parse, and a whole `data:` URL is the file.
261
+ return text.startsWith("data:") ? whole() : json();
258
262
  case "metadata":
259
263
  return /^\s*[[{]/.test(text) ? json() : whole();
260
264
  case "parts":
@@ -279,8 +283,11 @@ export async function readLargeMediaText(key, text, longUnits = LONG_STRING_UNIT
279
283
  * remainder is never held in two copies.
280
284
  */
281
285
  export class JoinedText {
286
+ held;
282
287
  pieces;
283
- constructor(pieces) {
288
+ /** `held`: the strings the pieces are cut from, which the text keeps alive whole. */
289
+ constructor(pieces, held) {
290
+ this.held = held;
284
291
  this.pieces = pieces.filter(([, start, end]) => end > start);
285
292
  }
286
293
  /** The text a window at a time, never between the halves of a surrogate pair. */
@@ -329,6 +336,7 @@ export class JoinedText {
329
336
  const windows = () => this.windows();
330
337
  return {
331
338
  size,
339
+ held: this.held,
332
340
  *chunks() {
333
341
  const buffer = Buffer.allocUnsafe(WINDOW_UNITS * 3);
334
342
  for (const window of windows())
@@ -350,6 +358,7 @@ export async function placedLargeText(media) {
350
358
  // One forward scan for the stand-ins' shared nonce finds each one left, in order, by the index
351
359
  // its tag carries: linear in the text however many there are, a window at a time.
352
360
  const pieces = [];
361
+ const held = new Set([{ bytes: edited.length * 2 }]);
353
362
  let from = 0;
354
363
  let turn = 0;
355
364
  for (let at = edited.indexOf(media.nonce); at !== -1; at = edited.indexOf(media.nonce, at + 1)) {
@@ -365,21 +374,23 @@ export async function placedLargeText(media) {
365
374
  continue;
366
375
  const long = media.long.get(stub);
367
376
  pieces.push([edited, from, start], [long.text, long.start, long.end]);
377
+ held.add(long.held);
368
378
  from = start + stub.length;
369
379
  at = from - 1;
370
380
  }
371
381
  if (!pieces.length)
372
382
  return edited;
373
383
  pieces.push([edited, from, edited.length]);
374
- return new JoinedText(pieces);
384
+ return new JoinedText(pieces, [...held]);
375
385
  }
376
386
  /** 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) {
387
+ * at a time into one buffer each read reuses. `held` is `text` whole. */
388
+ function base64Source(text, start, end, held) {
379
389
  const length = end - start;
380
390
  const pad = (text.charCodeAt(end - 1) === 0x3d ? 1 : 0) + (text.charCodeAt(end - 2) === 0x3d ? 1 : 0);
381
391
  return {
382
392
  size: (length / 4) * 3 - pad,
393
+ held: [held],
383
394
  *chunks() {
384
395
  const buffer = Buffer.allocUnsafe((WINDOW_UNITS / 4) * 3);
385
396
  for (let at = start; at < end; at += WINDOW_UNITS) {
@@ -392,7 +403,7 @@ function base64Source(text, start, end) {
392
403
  /** The bytes of a file whose content is a long string, as `mediaBytes` reads them: a field its
393
404
  * convention declares base64 decoded, base64 text decoded (`bareBase64`), a base64 `data:` URL. */
394
405
  export function longFileBytes(file, long) {
395
- const source = base64Source(long.text, long.data, long.end);
406
+ const source = base64Source(long.text, long.data, long.end, long.held);
396
407
  if (long.kind === "base64")
397
408
  return {
398
409
  size: source.size,
@@ -400,7 +411,7 @@ export function longFileBytes(file, long) {
400
411
  ...(file.base64 ? {} : { bareBase64: true }),
401
412
  source,
402
413
  };
403
- const mediaType = long.header.split(";")[0].trim();
414
+ const mediaType = headerMediaType(long.header);
404
415
  return {
405
416
  size: source.size,
406
417
  decoded: true,
@@ -11,9 +11,11 @@ export type RecordValue = ReadableSpan | ReadableLogRecord;
11
11
  * them, reported like any refused request.
12
12
  */
13
13
  export declare const MAX_RATE_LIMIT_HOLD_MILLIS = 60000;
14
- /** How long one export may take with requests of `timeoutMillis` each: its uploads of values over
14
+ /** How long one export may take with requests of `timeoutMillis` each: hashing its values over
15
15
  * the inline limit, its requests, and a hold for a rate-limited receiver on top. Processors and
16
- * providers wait this long for a drain. */
16
+ * providers wait this long for a drain. A drain also waits for the uploads its exports queued,
17
+ * which run beside the requests and end within six timeouts and a minute of being queued, so they
18
+ * fit within the same budget. */
17
19
  export declare function exportBudgetMillis(timeoutMillis: number): number;
18
20
  /**
19
21
  * Rejection of {@link HueClient.flush} and {@link HueClient.shutdown}: telemetry was not fully
@@ -67,7 +69,11 @@ export declare class HueTransport {
67
69
  private logExporter;
68
70
  private closed;
69
71
  private shutdownPromise?;
70
- private flushPromise?;
72
+ /** The latest flush's send of its records; the next flush's send starts after it. */
73
+ private sendPromise?;
74
+ /** Set while a flush sends its records: the processors leave the uploads to that flush, which
75
+ * waits for them after its send, so no flush's send waits behind another's uploads. */
76
+ private sendingRecords;
71
77
  private live;
72
78
  private liveTimer?;
73
79
  private liveSpans;
@@ -89,6 +95,10 @@ export declare class HueTransport {
89
95
  /** Bytes of values over the inline limit queued spans hold whole for upload, and each span's. */
90
96
  private heldBytes;
91
97
  private held;
98
+ /** Bytes of each record's charge (its own held values and its staged files) that its queued
99
+ * uploads took over before the record was finished: charged to the uploads alone until they
100
+ * settle or the record is finished. */
101
+ private heldByUploads;
92
102
  private mediaMemo;
93
103
  private media;
94
104
  private logParts;
@@ -98,6 +108,11 @@ export declare class HueTransport {
98
108
  private mediaFinalizer;
99
109
  private uploaded;
100
110
  private uploadFallbacks;
111
+ /** Each export's queued uploads, until they all settle and are reported. */
112
+ private uploadReports;
113
+ /** Settles once every upload report registered so far has: each drain awaits this one promise,
114
+ * so a drain costs the same however many uploads are pending. */
115
+ private uploadsSettled;
101
116
  constructor(options: HueOptions);
102
117
  /**
103
118
  * Advisory records (placeholders) are admitted only while the queue is under a quarter of its
@@ -113,6 +128,15 @@ export declare class HueTransport {
113
128
  /** Holds the staged attachments of a log record being admitted with the queued record, within
114
129
  * the held-value budget; those the budget refuses are counted as not uploaded. */
115
130
  private admitLogParts;
131
+ /** What the held-value budget has room for: values queued records hold and bytes unsettled
132
+ * uploads hold share it. */
133
+ private heldRoom;
134
+ /** An upload `record`'s export queued, as it is queued: flushes and the processors wait for it
135
+ * from now on, and the `shared` bytes it holds of the record's own are charged to the upload
136
+ * alone, so the held-value budget never counts them twice while the export goes on. A charge
137
+ * `released` before the record is finished is handed back to the record, which still holds
138
+ * them. */
139
+ private uploadQueued;
116
140
  /** Registers a span whose staged files are released if it is never exported. */
117
141
  private ownMedia;
118
142
  private mediaIdentity;
@@ -136,15 +160,22 @@ export declare class HueTransport {
136
160
  private endedSpans;
137
161
  private countEnded;
138
162
  /**
139
- * Waits for the processors' and exporters' in-flight work; drain the providers first.
163
+ * Waits for the processors' and exporters' in-flight work, then for the uploads their exports
164
+ * queued; drain the providers first. Only the sends are serialized: a flush sends its records
165
+ * once an earlier flush sent its own, never after that flush's uploads.
140
166
  *
141
167
  * @throws HueExportError when a new non-warning issue was recorded since the previous observation.
142
168
  */
143
169
  flush(): Promise<ExportReport>;
144
- private flushOnce;
170
+ /** Sends what the processors and exporters hold, once the previous flush's send ended. */
171
+ private sendRecords;
172
+ /** Waits for the uploads queued so far; each ends within its lifetime, so this is bounded. */
173
+ private drainUploads;
174
+ private flushReport;
145
175
  /**
146
- * Flushes and stops the processors; records emitted afterwards are dropped and counted. In attach
147
- * mode call it after shutting down the application's providers.
176
+ * Flushes and stops the processors, waits for queued uploads, then ends the uploader; records
177
+ * emitted afterwards are dropped and counted. In attach mode call it after shutting down the
178
+ * application's providers.
148
179
  *
149
180
  * @throws HueExportError when the final flush observed new failures.
150
181
  */