@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/blobs.js
CHANGED
|
@@ -3,6 +3,7 @@ import { context } from "@opentelemetry/api";
|
|
|
3
3
|
import { suppressTracing } from "@opentelemetry/core";
|
|
4
4
|
import { isLoopbackHost } from "./config.js";
|
|
5
5
|
import { digestPart, hashInlineFiles, hashNestedInlineFiles, INLINE_FILE_LIMIT, INLINE_FILE_TEXT_PER_RECORD, isMessageKey, keepsPrefix, literalKeys, mediaBytes, mediaContentType, mediaIn, isTextMediaType, mayInlineFiles, isMediaContentType, MAX_INLINE_FILE_TEXT, placeable, placedMediaText, prefixPart, readMediaText, reencodeMedia, } from "./inline-files.js";
|
|
6
|
+
import { isByteSource, JoinedText, longFileBytes, longHead, placedLargeText, readLargeMediaText, } from "./large-media.js";
|
|
6
7
|
import { capLogBody, TRUNCATED_KEY, truncatedMarker, withTruncatedKeys } from "./privacy.js";
|
|
7
8
|
import { truncateUtf8 } from "./safety.js";
|
|
8
9
|
/**
|
|
@@ -99,40 +100,53 @@ export function blobPrefix(value) {
|
|
|
99
100
|
return Buffer.from(truncateUtf8(value, BLOB_PREFIX_BYTES), "utf8").toString("utf8");
|
|
100
101
|
}
|
|
101
102
|
/** A value's size in bytes as it is uploaded: a string's UTF-8 encoding, counted, not made. */
|
|
102
|
-
function sizeOf(value) {
|
|
103
|
-
|
|
103
|
+
export function sizeOf(value) {
|
|
104
|
+
if (typeof value === "string")
|
|
105
|
+
return Buffer.byteLength(value, "utf8");
|
|
106
|
+
return isByteSource(value) ? value.size : value.byteLength;
|
|
107
|
+
}
|
|
108
|
+
/** The bytes a value holds in memory: none for a source read as it is uploaded. */
|
|
109
|
+
export function heldBytesOf(value) {
|
|
110
|
+
return isByteSource(value) ? 0 : value.byteLength;
|
|
104
111
|
}
|
|
105
112
|
/** A string's UTF-8 encoding a slice at a time, never splitting a surrogate pair. */
|
|
106
113
|
function* utf8Chunks(text) {
|
|
114
|
+
// One buffer, reused: a reader is done with each slice before it asks for the next.
|
|
115
|
+
const buffer = Buffer.allocUnsafe(TEXT_CHUNK_UNITS * 3);
|
|
107
116
|
for (let start = 0; start < text.length;) {
|
|
108
117
|
let end = Math.min(text.length, start + TEXT_CHUNK_UNITS);
|
|
109
118
|
if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]))
|
|
110
119
|
end--;
|
|
111
|
-
yield
|
|
120
|
+
yield buffer.subarray(0, buffer.write(text.slice(start, end), "utf8"));
|
|
112
121
|
start = end;
|
|
113
122
|
}
|
|
114
123
|
}
|
|
115
124
|
/** The bytes an upload carries, a slice at a time: no second copy of a large value is made. */
|
|
116
|
-
function
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
125
|
+
function uploadChunks(value) {
|
|
126
|
+
if (isByteSource(value))
|
|
127
|
+
return value.chunks();
|
|
128
|
+
return (function* () {
|
|
129
|
+
if (typeof value === "string") {
|
|
130
|
+
yield* utf8Chunks(value);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (let start = 0; start < value.byteLength; start += TEXT_CHUNK_UNITS)
|
|
134
|
+
yield value.subarray(start, Math.min(value.byteLength, start + TEXT_CHUNK_UNITS));
|
|
135
|
+
})();
|
|
123
136
|
}
|
|
124
137
|
const nextTurn = () => new Promise((resolve) => setImmediate(resolve));
|
|
125
138
|
/** The SHA-256 (lowercase hex) and byte size of what an upload of `value` carries, yielding to
|
|
126
|
-
* the event loop between slices so a large value does not stall the application.
|
|
127
|
-
|
|
139
|
+
* the event loop between slices so a large value does not stall the application. Past
|
|
140
|
+
* `deadline`, it throws rather than read on. */
|
|
141
|
+
export async function digestOf(value, deadline = Infinity) {
|
|
128
142
|
const hash = createHash("sha256");
|
|
129
143
|
let size = 0;
|
|
130
|
-
|
|
131
|
-
for (const chunk of uploadChunks(value)) {
|
|
144
|
+
for await (const chunk of uploadChunks(value)) {
|
|
132
145
|
hash.update(chunk);
|
|
133
146
|
size += chunk.byteLength;
|
|
134
|
-
|
|
135
|
-
|
|
147
|
+
await nextTurn();
|
|
148
|
+
if (Date.now() >= deadline)
|
|
149
|
+
throw new Error("The upload ran out of time");
|
|
136
150
|
}
|
|
137
151
|
return { sha256: hash.digest("hex"), size };
|
|
138
152
|
}
|
|
@@ -258,9 +272,12 @@ export class BlobUploader {
|
|
|
258
272
|
if (digest.size > MAX_BLOB_BYTES)
|
|
259
273
|
return { ok: false, reason: "tooLarge" };
|
|
260
274
|
const identity = `${traceId}:${digest.sha256}`;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
275
|
+
for (let known = this.outcomes.get(identity); known; known = this.outcomes.get(identity)) {
|
|
276
|
+
const shared = await known.promise;
|
|
277
|
+
// Another reference's file changed since it was recorded: this one tries its own bytes.
|
|
278
|
+
if (shared.ok || !shared.changed)
|
|
279
|
+
return shared;
|
|
280
|
+
}
|
|
264
281
|
if (this.outcomes.size >= 4096) {
|
|
265
282
|
const oldest = [...this.outcomes].find(([, item]) => item.settled)?.[0];
|
|
266
283
|
if (oldest === undefined)
|
|
@@ -308,6 +325,8 @@ export class BlobUploader {
|
|
|
308
325
|
const put = await this.put(grant, value, digest.size, upload.deadline);
|
|
309
326
|
if (put === "stored")
|
|
310
327
|
break;
|
|
328
|
+
if (put === "changed")
|
|
329
|
+
return { ok: false, reason: "failed", changed: true };
|
|
311
330
|
if (this.closed)
|
|
312
331
|
return { ok: false, reason: "failed" };
|
|
313
332
|
const expired = put === "expired" || Date.now() >= grant.expiresAt;
|
|
@@ -464,14 +483,21 @@ export class BlobUploader {
|
|
|
464
483
|
grant: { url, headers, expiresAt: Number.isFinite(expiresAt) ? expiresAt : Infinity },
|
|
465
484
|
};
|
|
466
485
|
}
|
|
467
|
-
/** The PUT of the value's bytes, with exactly the headers Hue signed.
|
|
486
|
+
/** The PUT of the value's bytes, with exactly the headers Hue signed. A source that no longer
|
|
487
|
+
* holds its bytes (a file changed since it was recorded) ends the request before its body is
|
|
488
|
+
* complete, and fails the value without a retry. */
|
|
468
489
|
async put(grant, value, size, deadline) {
|
|
469
490
|
const reply = await this.request("PUT", grant.url, grant.headers, value, deadline, MAX_ERROR_RESPONSE_BYTES, size);
|
|
491
|
+
if (isByteSource(value) && value.changed)
|
|
492
|
+
return "changed";
|
|
470
493
|
if (!reply)
|
|
471
494
|
return "transient";
|
|
472
|
-
// 412: the object exists already (another upload of the same value won).
|
|
473
|
-
|
|
495
|
+
// 412: the object exists already (another upload of the same value won). A success answered
|
|
496
|
+
// before the whole body was written stores nothing that was sent.
|
|
497
|
+
if (reply.status === 412)
|
|
474
498
|
return "stored";
|
|
499
|
+
if (reply.status >= 200 && reply.status < 300)
|
|
500
|
+
return reply.complete ? "stored" : "failed";
|
|
475
501
|
if (reply.status === 403)
|
|
476
502
|
return reply.body.toString("utf8").includes("Request has expired") ? "expired" : "failed";
|
|
477
503
|
if (reply.status >= 500 || reply.status === 408 || reply.status === 429)
|
|
@@ -501,6 +527,8 @@ export class BlobUploader {
|
|
|
501
527
|
return undefined;
|
|
502
528
|
return new Promise((resolve) => {
|
|
503
529
|
let settled = false;
|
|
530
|
+
// Whether the whole body was handed to the request.
|
|
531
|
+
let ended = false;
|
|
504
532
|
let request;
|
|
505
533
|
const finish = (reply) => {
|
|
506
534
|
if (settled)
|
|
@@ -509,16 +537,20 @@ export class BlobUploader {
|
|
|
509
537
|
clearTimeout(timer);
|
|
510
538
|
if (request) {
|
|
511
539
|
this.requests.delete(request);
|
|
512
|
-
|
|
540
|
+
// An answer before the whole body was handed over ends the request: a store that
|
|
541
|
+
// neither reads nor closes would otherwise hold it, and its source, open.
|
|
542
|
+
if (!reply || !ended)
|
|
513
543
|
request.destroy();
|
|
514
544
|
}
|
|
515
545
|
resolve(reply);
|
|
516
546
|
};
|
|
517
547
|
const timer = setTimeout(() => finish(), Math.max(1, deadline - Date.now()));
|
|
548
|
+
// The bytes handed to the request: its body is complete once they are its length.
|
|
549
|
+
let sentBytes = 0;
|
|
550
|
+
const written = () => length === undefined || sentBytes === length;
|
|
518
551
|
const requestHeaders = { ...headers };
|
|
519
552
|
if (length === undefined)
|
|
520
|
-
requestHeaders["Content-Length"] =
|
|
521
|
-
typeof body === "string" ? Buffer.byteLength(body, "utf8") : body.byteLength;
|
|
553
|
+
requestHeaders["Content-Length"] = sizeOf(body);
|
|
522
554
|
try {
|
|
523
555
|
// Uploads are the SDK's own export traffic: an http instrumentation must not trace them.
|
|
524
556
|
request = context.with(suppressTracing(context.active()), () => http.request(url, { method, headers: requestHeaders, agent: false }, (response) => {
|
|
@@ -528,6 +560,7 @@ export class BlobUploader {
|
|
|
528
560
|
status: response.statusCode ?? 0,
|
|
529
561
|
headers: response.headers,
|
|
530
562
|
body: Buffer.concat(chunks),
|
|
563
|
+
complete: written(),
|
|
531
564
|
});
|
|
532
565
|
response.on("data", (chunk) => {
|
|
533
566
|
size += chunk.byteLength;
|
|
@@ -552,26 +585,41 @@ export class BlobUploader {
|
|
|
552
585
|
sent.on("error", () => finish());
|
|
553
586
|
sent.setTimeout(this.options.timeoutMillis, () => finish());
|
|
554
587
|
void (async () => {
|
|
555
|
-
if (length === undefined) {
|
|
588
|
+
if (length === undefined && !isByteSource(body)) {
|
|
589
|
+
ended = true;
|
|
556
590
|
sent.end(body);
|
|
557
591
|
return;
|
|
558
592
|
}
|
|
559
|
-
for
|
|
593
|
+
// A source or a text may reuse a slice's memory for the next: each slice is written out
|
|
594
|
+
// before the next is read. Bytes are views of memory that does not change.
|
|
595
|
+
const reused = !(body instanceof Uint8Array);
|
|
596
|
+
for await (const chunk of uploadChunks(body)) {
|
|
560
597
|
if (settled)
|
|
561
598
|
return;
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
599
|
+
sentBytes += chunk.byteLength;
|
|
600
|
+
await new Promise((proceed) => {
|
|
601
|
+
const done = () => {
|
|
602
|
+
sent.off("drain", done);
|
|
603
|
+
sent.off("close", done);
|
|
604
|
+
proceed();
|
|
605
|
+
};
|
|
606
|
+
sent.on("close", done);
|
|
607
|
+
if (reused) {
|
|
608
|
+
sent.write(chunk, () => done());
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (sent.write(chunk))
|
|
612
|
+
done();
|
|
613
|
+
else
|
|
569
614
|
sent.on("drain", done);
|
|
570
|
-
|
|
571
|
-
|
|
615
|
+
});
|
|
616
|
+
if (settled)
|
|
617
|
+
return;
|
|
572
618
|
}
|
|
573
|
-
if (
|
|
574
|
-
|
|
619
|
+
if (settled)
|
|
620
|
+
return;
|
|
621
|
+
ended = true;
|
|
622
|
+
sent.end();
|
|
575
623
|
})().catch(() => finish());
|
|
576
624
|
});
|
|
577
625
|
}
|
|
@@ -615,30 +663,46 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
615
663
|
* uploaded keeps its bytes inline, except in a recorded message: a span's own message keeps its
|
|
616
664
|
* digest, and a log record's keeps the digest of a file over the inline limit, as nested
|
|
617
665
|
* messages kept them before log records uploaded. */
|
|
618
|
-
const placeFiles = async (index, key, files) => {
|
|
666
|
+
const placeFiles = async (index, key, files, long) => {
|
|
619
667
|
const message = isMessageKey(key);
|
|
620
668
|
let changed = false;
|
|
621
669
|
await Promise.all(files.map((file, position) => {
|
|
622
|
-
|
|
623
|
-
|
|
670
|
+
// A file whose content is a long string is read from its place in the recorded text, a
|
|
671
|
+
// slice at a time: never decoded or copied whole.
|
|
672
|
+
const inPlace = long?.get(file.content);
|
|
673
|
+
const decoded = inPlace ? longFileBytes(file, inPlace) : mediaBytes(file);
|
|
674
|
+
const bytes = "source" in decoded ? decoded.source : decoded.bytes;
|
|
675
|
+
const size = sizeOf(bytes);
|
|
676
|
+
if (!placeable(file, decoded, size))
|
|
624
677
|
return Promise.resolve();
|
|
625
678
|
const contentType = mediaContentType(file, decoded);
|
|
626
679
|
const textual = isTextMediaType(contentType);
|
|
627
|
-
if (textual &&
|
|
680
|
+
if (textual && size <= INLINE_FILE_LIMIT)
|
|
628
681
|
return Promise.resolve();
|
|
629
682
|
const prefix = keepsPrefix(file, textual, decoded);
|
|
630
683
|
const partKey = `${key}#${prefix ? file.pointer : file.partPointer}`;
|
|
631
684
|
const slot = slots > 0 && isBlobKey(partKey) ? (slots--, true) : false;
|
|
632
685
|
return (async () => {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
686
|
+
// Hashing a long file takes an upload place, as hashing a large value does; one the
|
|
687
|
+
// export's upload budget cannot hash stays as recorded and is not uploaded.
|
|
688
|
+
let digest;
|
|
689
|
+
try {
|
|
690
|
+
digest = isByteSource(bytes)
|
|
691
|
+
? await uploader.limit(() => digestOf(bytes, upload.deadline))
|
|
692
|
+
: options.memo
|
|
693
|
+
? options.memo.digest(file, decoded, traceId)
|
|
694
|
+
: await digestOf(bytes);
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
fallback("failed");
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
636
700
|
const outcome = slot
|
|
637
|
-
? await uploader.store(traceId, partKey,
|
|
701
|
+
? await uploader.store(traceId, partKey, bytes, contentType, digest, upload)
|
|
638
702
|
: { ok: false, reason: "budget" };
|
|
639
703
|
if (outcome.ok) {
|
|
640
704
|
if (prefix)
|
|
641
|
-
prefixPart(file, blobPrefix(file.content));
|
|
705
|
+
prefixPart(file, blobPrefix(inPlace ? longHead(inPlace, BLOB_PREFIX_BYTES + 1) : file.content));
|
|
642
706
|
else
|
|
643
707
|
digestPart(file, digest);
|
|
644
708
|
changed = true;
|
|
@@ -649,7 +713,7 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
649
713
|
tally.uploaded++;
|
|
650
714
|
}
|
|
651
715
|
else {
|
|
652
|
-
if (message && (!collector.log ||
|
|
716
|
+
if (message && (!collector.log || size > INLINE_FILE_LIMIT)) {
|
|
653
717
|
digestPart(file, digest);
|
|
654
718
|
changed = true;
|
|
655
719
|
}
|
|
@@ -661,30 +725,59 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
661
725
|
};
|
|
662
726
|
const literal = literalKeys(attributes);
|
|
663
727
|
const parts = async (index, key, text) => {
|
|
664
|
-
//
|
|
665
|
-
if (!mayInlineFiles(key, text) ||
|
|
728
|
+
// Literal text holds no file.
|
|
729
|
+
if (!mayInlineFiles(key, text) || literal.has(key))
|
|
666
730
|
return text;
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
731
|
+
let media;
|
|
732
|
+
let large;
|
|
733
|
+
if (collector.large?.has(key)) {
|
|
734
|
+
// Held whole past the inspection bounds: its long strings were read in place, the JSON
|
|
735
|
+
// around them within the per-attribute bound.
|
|
736
|
+
media = large = largeReads.get(index);
|
|
671
737
|
}
|
|
672
738
|
else {
|
|
673
|
-
|
|
739
|
+
// Both bounds count UTF-16 code units, as admission does.
|
|
740
|
+
if (text.length > MAX_INLINE_FILE_TEXT)
|
|
674
741
|
return text;
|
|
675
|
-
inspected
|
|
742
|
+
if (collector.inspected) {
|
|
743
|
+
// A value admission skipped stays as recorded.
|
|
744
|
+
if (!collector.inspected.has(key))
|
|
745
|
+
return text;
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
if (inspected + text.length > INLINE_FILE_TEXT_PER_RECORD)
|
|
749
|
+
return text;
|
|
750
|
+
inspected += text.length;
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
media = readMediaText(key, text);
|
|
754
|
+
}
|
|
755
|
+
catch {
|
|
756
|
+
return text;
|
|
757
|
+
}
|
|
676
758
|
}
|
|
677
|
-
|
|
759
|
+
if (!media?.files.length)
|
|
760
|
+
return text;
|
|
761
|
+
if (!(await placeFiles(index, key, media.files, large?.long)))
|
|
762
|
+
return text;
|
|
763
|
+
return large ? await placedLargeText(large) : placedMediaText(media);
|
|
764
|
+
};
|
|
765
|
+
// The values read in place are scanned first, in candidate order, between turns of the event
|
|
766
|
+
// loop: places in `hue.blobs` are then taken in candidate order, as for any value.
|
|
767
|
+
const largeReads = new Map();
|
|
768
|
+
for (const [index, { key, value }] of collector.candidates.entries()) {
|
|
769
|
+
if (typeof value !== "string" ||
|
|
770
|
+
!collector.large?.has(key) ||
|
|
771
|
+
!mayInlineFiles(key, value) ||
|
|
772
|
+
literal.has(key))
|
|
773
|
+
continue;
|
|
678
774
|
try {
|
|
679
|
-
|
|
775
|
+
largeReads.set(index, await readLargeMediaText(key, value, options.longUnits));
|
|
680
776
|
}
|
|
681
777
|
catch {
|
|
682
|
-
|
|
778
|
+
// JSON that does not parse holds no file: the value stays as recorded.
|
|
683
779
|
}
|
|
684
|
-
|
|
685
|
-
return text;
|
|
686
|
-
return (await placeFiles(index, key, media.files)) ? placedMediaText(media) : text;
|
|
687
|
-
};
|
|
780
|
+
}
|
|
688
781
|
/** A log record's body with its files placed. Each file's content counts toward the record's
|
|
689
782
|
* inspection bounds, as an attribute's text does. */
|
|
690
783
|
const placeBody = async (body) => {
|
|
@@ -710,14 +803,22 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
710
803
|
return media.box.value;
|
|
711
804
|
};
|
|
712
805
|
const place = async (index, { key, value }) => {
|
|
713
|
-
const
|
|
714
|
-
|
|
806
|
+
const read = typeof value === "string" ? await parts(index, key, value) : value;
|
|
807
|
+
// Text joined from pieces of the recorded text is made one string only when it is kept
|
|
808
|
+
// inline; otherwise it is measured, hashed, uploaded and cut from its pieces.
|
|
809
|
+
const joined = read instanceof JoinedText ? read : undefined;
|
|
810
|
+
const size = joined ? joined.utf8Size() : sizeOf(read);
|
|
715
811
|
if (size <= valueBytes) {
|
|
716
|
-
result[key] =
|
|
812
|
+
result[key] = joined ? joined.text() : read;
|
|
717
813
|
return;
|
|
718
814
|
}
|
|
815
|
+
const placed = joined ? joined.source(size) : read;
|
|
719
816
|
listed.push({ order: index, key });
|
|
720
|
-
const cut = () =>
|
|
817
|
+
const cut = () => joined
|
|
818
|
+
? truncateUtf8(joined.head(valueBytes + 1), valueBytes)
|
|
819
|
+
: typeof placed === "string"
|
|
820
|
+
? truncateUtf8(placed, valueBytes)
|
|
821
|
+
: truncatedMarker(size);
|
|
721
822
|
// A log record's value is cut, as no log reader pages an uploaded text value.
|
|
722
823
|
if (collector.log) {
|
|
723
824
|
result[key] = cut();
|
|
@@ -733,13 +834,27 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
733
834
|
if (size > MAX_BLOB_BYTES)
|
|
734
835
|
return { ok: false, reason: "tooLarge" };
|
|
735
836
|
// Hashing a large value takes a slot as its upload does; the upload takes its own.
|
|
736
|
-
|
|
737
|
-
|
|
837
|
+
let digest;
|
|
838
|
+
try {
|
|
839
|
+
digest = await uploader.limit(() => digestOf(placed, upload.deadline));
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
return { ok: false, reason: "failed" };
|
|
843
|
+
}
|
|
844
|
+
const contentType = joined
|
|
845
|
+
? textContentType(joined.head(256) + joined.tail(256))
|
|
846
|
+
: typeof placed === "string"
|
|
847
|
+
? textContentType(placed)
|
|
848
|
+
: "application/octet-stream";
|
|
738
849
|
const outcome = await uploader.store(traceId, key, placed, contentType, digest, upload);
|
|
739
850
|
return outcome.ok ? { ...outcome, digest } : outcome;
|
|
740
851
|
})();
|
|
741
852
|
if (stored.ok) {
|
|
742
|
-
result[key] =
|
|
853
|
+
result[key] = joined
|
|
854
|
+
? blobPrefix(joined.head(BLOB_PREFIX_BYTES + 1))
|
|
855
|
+
: typeof placed === "string"
|
|
856
|
+
? blobPrefix(placed)
|
|
857
|
+
: blobPrefix(placed);
|
|
743
858
|
entries.push({
|
|
744
859
|
order: [index, Infinity],
|
|
745
860
|
entry: blobEntry(key, stored.digest.sha256, stored.digest.size, stored.contentType),
|
|
@@ -754,7 +869,7 @@ export async function offloadRecord(attributes, collector, options) {
|
|
|
754
869
|
const body = collector.body ? placeBody(collector.body.value) : undefined;
|
|
755
870
|
await Promise.all(collector.candidates.map((candidate, index) => place(index, candidate)));
|
|
756
871
|
await Promise.all((collector.attachments ?? []).map((file, position) => {
|
|
757
|
-
if (!file.bytes
|
|
872
|
+
if (!sizeOf(file.bytes))
|
|
758
873
|
return Promise.resolve();
|
|
759
874
|
const slot = slots > 0 ? (slots--, true) : false;
|
|
760
875
|
return (async () => {
|
package/dist/client.d.ts
CHANGED
|
@@ -166,10 +166,15 @@ export declare class HueClient {
|
|
|
166
166
|
/**
|
|
167
167
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
168
168
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
169
|
-
* and, when `captureContent` is true, `hue.file.name`. `data`
|
|
170
|
-
* uploaded only with `upload: true
|
|
171
|
-
*
|
|
172
|
-
*
|
|
169
|
+
* and, when `captureContent` is true, `hue.file.name`. `data` or the file at `path` is hashed and
|
|
170
|
+
* measured locally and uploaded only with `upload: true`: `data` and a file up to 25 MiB are
|
|
171
|
+
* held in memory until then, while a larger regular file, up to Hue's 1 GB upload limit, is
|
|
172
|
+
* hashed a slice at a time and read again from disk when it is uploaded, and is uploaded only if
|
|
173
|
+
* it still has the size and SHA-256 it was recorded with; a `path` that is no regular file, such
|
|
174
|
+
* as a named pipe, is refused without waiting on it. The event is metadata, so it is
|
|
175
|
+
* recorded in both capture modes. An invalid record, or one without an active span, is omitted
|
|
176
|
+
* and counted, never thrown; an invalid name alone is omitted and counted while the rest is
|
|
177
|
+
* recorded.
|
|
173
178
|
*/
|
|
174
179
|
recordFile(file: FileRecord, explicitContext?: Context): void;
|
|
175
180
|
/** The keys of each span's content values that were cut to Hue's value cap, for `hue.truncated`. */
|
package/dist/client.js
CHANGED
|
@@ -13,8 +13,8 @@ import { hostedServerAddresses, hostedToolActivity, hostedToolProvider, provider
|
|
|
13
13
|
import { toolCatalogSummary } from "./tool-definitions.js";
|
|
14
14
|
import { contentLimitsOf, encodeBoundedContent, encodeContent, noopSpan, safeSpan, spanSource, } from "./safety.js";
|
|
15
15
|
import { TRUNCATED_KEY, withTruncatedKeys } from "./privacy.js";
|
|
16
|
-
import { countFallback, MAX_BLOB_BYTES, newTally } from "./blobs.js";
|
|
17
|
-
import { attachmentPart, attachmentState, holdsAttachment, MAX_ATTACHMENT_DEPTH, } from "./attachment.js";
|
|
16
|
+
import { countFallback, MAX_BLOB_BYTES, newTally, sizeOf } from "./blobs.js";
|
|
17
|
+
import { attachmentPart, attachmentState, fileSource, holdsAttachment, MAX_ATTACHMENT_DEPTH, readPath, } from "./attachment.js";
|
|
18
18
|
import { createHueTransport, exportBudgetMillis, HueExportError, } from "./transport.js";
|
|
19
19
|
import { verifyTrace } from "./receipt.js";
|
|
20
20
|
import { sdkVersion } from "./version.js";
|
|
@@ -611,21 +611,22 @@ export class HueClient {
|
|
|
611
611
|
const files = [];
|
|
612
612
|
const tally = newTally();
|
|
613
613
|
for (const { state, part, depth } of placed) {
|
|
614
|
-
if (!part ||
|
|
614
|
+
if (!part || state.size === undefined || !state.sha256) {
|
|
615
615
|
this.instrumentationFailed(signal);
|
|
616
616
|
continue;
|
|
617
617
|
}
|
|
618
618
|
if (state.invalidName && captureContent)
|
|
619
619
|
this.instrumentationFailed(signal);
|
|
620
|
-
if (!this.transport.offloadsValues() || !state.
|
|
620
|
+
if (!this.transport.offloadsValues() || !state.size)
|
|
621
621
|
continue;
|
|
622
622
|
if (cut || depth > MAX_ATTACHMENT_DEPTH) {
|
|
623
623
|
countFallback(tally, "budget", traceId);
|
|
624
624
|
continue;
|
|
625
625
|
}
|
|
626
626
|
files.push({
|
|
627
|
-
|
|
628
|
-
|
|
627
|
+
// A file given by path over 25 MiB is read again from disk when it is uploaded.
|
|
628
|
+
bytes: state.file ? fileSource(state.file) : state.bytes,
|
|
629
|
+
digest: { sha256: state.sha256, size: state.size },
|
|
629
630
|
contentType: state.contentType,
|
|
630
631
|
...(state.name === undefined ? {} : { name: state.name }),
|
|
631
632
|
});
|
|
@@ -788,10 +789,15 @@ export class HueClient {
|
|
|
788
789
|
/**
|
|
789
790
|
* Adds a `hue.file` event to the active (or given) span for a file the work read, received or
|
|
790
791
|
* produced: `hue.file.sha256`, `hue.file.role`, `hue.file.media_type`, `hue.file.size` when known
|
|
791
|
-
* and, when `captureContent` is true, `hue.file.name`. `data`
|
|
792
|
-
* uploaded only with `upload: true
|
|
793
|
-
*
|
|
794
|
-
*
|
|
792
|
+
* and, when `captureContent` is true, `hue.file.name`. `data` or the file at `path` is hashed and
|
|
793
|
+
* measured locally and uploaded only with `upload: true`: `data` and a file up to 25 MiB are
|
|
794
|
+
* held in memory until then, while a larger regular file, up to Hue's 1 GB upload limit, is
|
|
795
|
+
* hashed a slice at a time and read again from disk when it is uploaded, and is uploaded only if
|
|
796
|
+
* it still has the size and SHA-256 it was recorded with; a `path` that is no regular file, such
|
|
797
|
+
* as a named pipe, is refused without waiting on it. The event is metadata, so it is
|
|
798
|
+
* recorded in both capture modes. An invalid record, or one without an active span, is omitted
|
|
799
|
+
* and counted, never thrown; an invalid name alone is omitted and counted while the rest is
|
|
800
|
+
* recorded.
|
|
795
801
|
*/
|
|
796
802
|
recordFile(file, explicitContext) {
|
|
797
803
|
if (!this.enabled || this.closed)
|
|
@@ -800,7 +806,7 @@ export class HueClient {
|
|
|
800
806
|
const span = trace.getSpan(explicitContext ?? this.storage.getStore()?.context ?? context.active());
|
|
801
807
|
if (!span?.isRecording())
|
|
802
808
|
throw new Error("File records require an active span");
|
|
803
|
-
const { role, mediaType, data, name } = file;
|
|
809
|
+
const { role, mediaType, data, name, path } = file;
|
|
804
810
|
if (role !== "input" && role !== "attachment" && role !== "output")
|
|
805
811
|
throw new TypeError("Invalid file role");
|
|
806
812
|
if (!isLabel(mediaType))
|
|
@@ -808,7 +814,31 @@ export class HueClient {
|
|
|
808
814
|
let sha256 = typeof file.sha256 === "string" ? file.sha256.toLowerCase() : file.sha256;
|
|
809
815
|
let byteSize = file.byteSize;
|
|
810
816
|
let bytes;
|
|
811
|
-
|
|
817
|
+
// A file given by path over 25 MiB: hashed now and read again from disk when it is uploaded.
|
|
818
|
+
let onDisk;
|
|
819
|
+
let given = data;
|
|
820
|
+
if (path !== undefined) {
|
|
821
|
+
if (data !== undefined)
|
|
822
|
+
throw new TypeError("A file takes data or a path, not both");
|
|
823
|
+
if (typeof path !== "string" || !path)
|
|
824
|
+
throw new TypeError("Invalid file path");
|
|
825
|
+
const read = readPath(path);
|
|
826
|
+
if (!read)
|
|
827
|
+
throw new RangeError("File must be a regular file within Hue's 1 GB upload limit");
|
|
828
|
+
if ("bytes" in read)
|
|
829
|
+
given = read.bytes;
|
|
830
|
+
else {
|
|
831
|
+
onDisk = read.file;
|
|
832
|
+
// A caller-supplied digest or size must describe the same bytes.
|
|
833
|
+
if ((sha256 !== undefined && sha256 !== onDisk.sha256) ||
|
|
834
|
+
(byteSize !== undefined && byteSize !== onDisk.size))
|
|
835
|
+
throw new TypeError("File digest or size does not match its data");
|
|
836
|
+
sha256 = onDisk.sha256;
|
|
837
|
+
byteSize = onDisk.size;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (given !== undefined) {
|
|
841
|
+
const data = given;
|
|
812
842
|
bytes =
|
|
813
843
|
typeof data === "string"
|
|
814
844
|
? (() => {
|
|
@@ -888,10 +918,11 @@ export class HueClient {
|
|
|
888
918
|
if (eventTime)
|
|
889
919
|
this.fileEventTimes.set(source, eventTime);
|
|
890
920
|
if (file.upload === true && this.transport.offloadsValues()) {
|
|
891
|
-
|
|
921
|
+
const upload = bytes ?? (onDisk ? fileSource(onDisk) : undefined);
|
|
922
|
+
if (!this.transport.stageMedia(span, upload && eventTime
|
|
892
923
|
? {
|
|
893
|
-
bytes,
|
|
894
|
-
digest: { sha256, size:
|
|
924
|
+
bytes: upload,
|
|
925
|
+
digest: { sha256, size: sizeOf(upload) },
|
|
895
926
|
contentType: mediaType,
|
|
896
927
|
eventTime,
|
|
897
928
|
...(isMediaName(name) ? { name } : {}),
|
package/dist/inline-files.d.ts
CHANGED
|
@@ -137,20 +137,21 @@ export declare function placedMediaText(media: MediaValue): string;
|
|
|
137
137
|
/** Each JSON-encoded string a placed file sits in, written again, innermost first: the string as
|
|
138
138
|
* recorded with only its placed files changed. */
|
|
139
139
|
export declare function reencodeMedia(media: MediaValue): void;
|
|
140
|
-
/** Whether a found file has bytes to place: nonempty, and a whole string's `data:`
|
|
141
|
-
|
|
140
|
+
/** Whether a found file has bytes to place: nonempty (`size` bytes), and a whole string's `data:`
|
|
141
|
+
* URL decodes. */
|
|
142
|
+
export declare function placeable(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded">, size: number): boolean;
|
|
142
143
|
/** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
|
|
143
144
|
* provider part, whose entry names the content) rather than its digest. A whole string, a file
|
|
144
145
|
* inside a JSON-encoded string, and a text file a part holds as bare base64 (a GenAI `blob`,
|
|
145
146
|
* Vertex `media` or AI SDK part, whose content is not the text itself) take the digest form. */
|
|
146
|
-
export declare function keepsPrefix(file: InlineFilePart, textual: boolean, decoded: InlineFileBytes): boolean;
|
|
147
|
+
export declare function keepsPrefix(file: InlineFilePart, textual: boolean, decoded: Pick<InlineFileBytes, "bareBase64">): boolean;
|
|
147
148
|
/** A file's bytes: a field its convention declares base64 decoded, any other content read as
|
|
148
149
|
* Hue's reader reads it, whatever the part's media type (`inlineFileBytes`): a `data:` URL by its
|
|
149
150
|
* own encoding, text that reads as base64 decoded (what the GenAI conventions, and
|
|
150
151
|
* `client.media.resolve`, write in a `blob` or `media` part for every media type), anything else
|
|
151
152
|
* as its UTF-8. */
|
|
152
153
|
export declare function mediaBytes(file: InlineFilePart): InlineFileBytes;
|
|
153
|
-
export declare function mediaContentType(file: InlineFilePart, decoded: InlineFileBytes): string;
|
|
154
|
+
export declare function mediaContentType(file: InlineFilePart, decoded: Pick<InlineFileBytes, "decoded" | "urlMediaType">): string;
|
|
154
155
|
export interface FileDigest {
|
|
155
156
|
sha256: string;
|
|
156
157
|
size: number;
|
package/dist/inline-files.js
CHANGED
|
@@ -707,9 +707,10 @@ export function reencodeMedia(media) {
|
|
|
707
707
|
});
|
|
708
708
|
}
|
|
709
709
|
}
|
|
710
|
-
/** Whether a found file has bytes to place: nonempty, and a whole string's `data:`
|
|
711
|
-
|
|
712
|
-
|
|
710
|
+
/** Whether a found file has bytes to place: nonempty (`size` bytes), and a whole string's `data:`
|
|
711
|
+
* URL decodes. */
|
|
712
|
+
export function placeable(file, decoded, size) {
|
|
713
|
+
return size > 0 && (!file.whole || decoded.decoded);
|
|
713
714
|
}
|
|
714
715
|
/** Whether an uploaded file keeps a 16 KiB prefix of its content in place (text media in a
|
|
715
716
|
* provider part, whose entry names the content) rather than its digest. A whole string, a file
|
|
@@ -841,7 +842,7 @@ export function digestMedia(media, nested, count, memo, traceId = "") {
|
|
|
841
842
|
let changed = false;
|
|
842
843
|
for (const file of media.files) {
|
|
843
844
|
const decoded = mediaBytes(file);
|
|
844
|
-
if (!placeable(file, decoded))
|
|
845
|
+
if (!placeable(file, decoded, decoded.bytes.byteLength))
|
|
845
846
|
continue;
|
|
846
847
|
if ((nested || isTextMediaType(mediaContentType(file, decoded))) &&
|
|
847
848
|
decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|
|
@@ -875,7 +876,7 @@ export function digestFormBytes(value) {
|
|
|
875
876
|
continue;
|
|
876
877
|
inspected += units;
|
|
877
878
|
const decoded = mediaBytes(file);
|
|
878
|
-
if (!placeable(file, decoded))
|
|
879
|
+
if (!placeable(file, decoded, decoded.bytes.byteLength))
|
|
879
880
|
continue;
|
|
880
881
|
if (isTextMediaType(mediaContentType(file, decoded)) &&
|
|
881
882
|
decoded.bytes.byteLength <= INLINE_FILE_LIMIT)
|