@hue-run/sdk 0.1.3 → 0.1.5
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/MANAGED_TARGETS.md +84 -0
- package/README.md +39 -4
- package/dist/ai-sdk.js +10 -1
- package/dist/client.d.ts +11 -1
- package/dist/client.js +232 -97
- package/dist/config.d.ts +1 -1
- package/dist/config.js +18 -1
- package/dist/evals/json.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/managed.d.ts +65 -0
- package/dist/managed.js +537 -0
- package/dist/privacy.js +38 -20
- package/dist/safety.d.ts +8 -0
- package/dist/safety.js +179 -0
- package/dist/snapshot.d.ts +12 -0
- package/dist/snapshot.js +196 -0
- package/dist/transport.d.ts +6 -0
- package/dist/transport.js +192 -32
- package/dist/types.d.ts +27 -4
- package/package.json +7 -2
package/dist/config.js
CHANGED
|
@@ -3,6 +3,18 @@ export const MAX_CONTENT_BYTES = 256 * 1024;
|
|
|
3
3
|
export function validateOptions(options) {
|
|
4
4
|
if (typeof options.captureContent !== "boolean")
|
|
5
5
|
throw new TypeError("Choose captureContent explicitly: true or false");
|
|
6
|
+
if (options.enabled !== undefined && typeof options.enabled !== "boolean")
|
|
7
|
+
throw new TypeError("enabled must be a boolean");
|
|
8
|
+
if (options.enabled === false)
|
|
9
|
+
return {
|
|
10
|
+
captureContent: options.captureContent,
|
|
11
|
+
enabled: false,
|
|
12
|
+
apiKey: "",
|
|
13
|
+
serviceName: "hue-disabled",
|
|
14
|
+
baseUrl: "https://app.hue.run",
|
|
15
|
+
timeoutMillis: 10000,
|
|
16
|
+
maxQueueBytes: 8 * 1024 * 1024,
|
|
17
|
+
};
|
|
6
18
|
if (typeof options.apiKey !== "string" ||
|
|
7
19
|
!options.apiKey ||
|
|
8
20
|
options.apiKey.length > 4096 ||
|
|
@@ -28,5 +40,10 @@ export function validateOptions(options) {
|
|
|
28
40
|
const timeoutMillis = options.timeoutMillis ?? 10000;
|
|
29
41
|
if (!Number.isInteger(timeoutMillis) || timeoutMillis < 100 || timeoutMillis > 60000)
|
|
30
42
|
throw new TypeError("timeoutMillis must be 100–60000");
|
|
31
|
-
|
|
43
|
+
const maxQueueBytes = options.maxQueueBytes ?? 8 * 1024 * 1024;
|
|
44
|
+
if (!Number.isSafeInteger(maxQueueBytes) ||
|
|
45
|
+
maxQueueBytes < 1024 ||
|
|
46
|
+
maxQueueBytes > 64 * 1024 * 1024)
|
|
47
|
+
throw new TypeError("maxQueueBytes must be 1024–67108864");
|
|
48
|
+
return { ...options, baseUrl: url.origin, timeoutMillis, maxQueueBytes };
|
|
32
49
|
}
|
package/dist/evals/json.js
CHANGED
|
@@ -55,6 +55,7 @@ export function sourceDigest(source) {
|
|
|
55
55
|
}
|
|
56
56
|
export function uuid(value) {
|
|
57
57
|
if (typeof value !== "string" ||
|
|
58
|
+
value.length !== 36 ||
|
|
58
59
|
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value))
|
|
59
60
|
throw new TypeError("Expected a UUID");
|
|
60
61
|
return value;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createHue, HueClient, HueConnectionError, type ExistingHueProviders } from "./client.js";
|
|
1
|
+
export { createHue, createHueSafe, HueClient, HueConnectionError, type ExistingHueProviders, } from "./client.js";
|
|
2
2
|
export { createHueTransport, HueTransport, HueExportError } from "./transport.js";
|
|
3
3
|
export { HueTraceVerificationError } from "./receipt.js";
|
|
4
4
|
export type * from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { createHue, HueClient, HueConnectionError } from "./client.js";
|
|
1
|
+
export { createHue, createHueSafe, HueClient, HueConnectionError, } from "./client.js";
|
|
2
2
|
export { createHueTransport, HueTransport, HueExportError } from "./transport.js";
|
|
3
3
|
export { HueTraceVerificationError } from "./receipt.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { type Tracer } from "@opentelemetry/api";
|
|
2
|
+
import type { JsonValue } from "./types.js";
|
|
3
|
+
export interface ManagedInputFile {
|
|
4
|
+
artifactId: string;
|
|
5
|
+
filename: string;
|
|
6
|
+
contentType: string;
|
|
7
|
+
byteSize: number;
|
|
8
|
+
sha256: string;
|
|
9
|
+
role: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ManagedInvocation {
|
|
12
|
+
protocolVersion: 1;
|
|
13
|
+
executionId: string;
|
|
14
|
+
attempt: number;
|
|
15
|
+
input: JsonValue;
|
|
16
|
+
config: JsonValue;
|
|
17
|
+
inputFiles: ManagedInputFile[];
|
|
18
|
+
deadline: string;
|
|
19
|
+
traceparent: string;
|
|
20
|
+
}
|
|
21
|
+
export interface ManagedTargetContext {
|
|
22
|
+
executionId: string;
|
|
23
|
+
attempt: number;
|
|
24
|
+
input: JsonValue;
|
|
25
|
+
config: JsonValue;
|
|
26
|
+
inputFiles: Array<ManagedInputFile & {
|
|
27
|
+
data: Uint8Array;
|
|
28
|
+
}>;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
traceId: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ManagedOutputFile {
|
|
33
|
+
filename: string;
|
|
34
|
+
contentType: string;
|
|
35
|
+
data: Uint8Array;
|
|
36
|
+
primary?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface ManagedTargetResult {
|
|
39
|
+
state?: "succeeded" | "error" | "cancelled";
|
|
40
|
+
output?: JsonValue;
|
|
41
|
+
/** Public, safe error summary. Never include provider errors, credentials or stacks. */
|
|
42
|
+
error?: {
|
|
43
|
+
type: string;
|
|
44
|
+
message?: string;
|
|
45
|
+
};
|
|
46
|
+
files?: ManagedOutputFile[];
|
|
47
|
+
usage?: {
|
|
48
|
+
inputTokens?: number;
|
|
49
|
+
outputTokens?: number;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
export interface ManagedTargetOptions {
|
|
53
|
+
machineCredential: string;
|
|
54
|
+
baseUrl?: string;
|
|
55
|
+
target: (invocation: ManagedTargetContext) => Promise<ManagedTargetResult>;
|
|
56
|
+
/** Flush the application's existing trace AND log pipelines; do not shut them down. */
|
|
57
|
+
flushTelemetry: () => Promise<unknown>;
|
|
58
|
+
tracer?: Tracer;
|
|
59
|
+
/** Reserve finalization time inside the host's request limit. Default 90 seconds. */
|
|
60
|
+
maxExecutionMillis?: number;
|
|
61
|
+
/** Upload/checkpoint/flush grace after execution deadline, at most 30 seconds. */
|
|
62
|
+
finalizationMillis?: number;
|
|
63
|
+
}
|
|
64
|
+
/** A machine-authenticated, framework-neutral POST handler. Never retries the target. */
|
|
65
|
+
export declare function createManagedTargetHandler(options: ManagedTargetOptions): (request: Request) => Promise<Response>;
|
package/dist/managed.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { request as httpRequest } from "node:http";
|
|
3
|
+
import { request as httpsRequest } from "node:https";
|
|
4
|
+
import { context, ROOT_CONTEXT, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
5
|
+
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
6
|
+
import { validateOptions } from "./config.js";
|
|
7
|
+
import { json, uuid } from "./evals/json.js";
|
|
8
|
+
const MIB = 1024 * 1024;
|
|
9
|
+
const MAX_FILE = 25 * MIB;
|
|
10
|
+
const MAX_TOTAL = 64 * MIB;
|
|
11
|
+
const STATES = new Set([
|
|
12
|
+
"queued",
|
|
13
|
+
"dispatched",
|
|
14
|
+
"running",
|
|
15
|
+
"uncertain",
|
|
16
|
+
"checkpointed",
|
|
17
|
+
"completed",
|
|
18
|
+
"unsupported",
|
|
19
|
+
"error",
|
|
20
|
+
"cancelled",
|
|
21
|
+
"superseded",
|
|
22
|
+
]);
|
|
23
|
+
const propagator = new W3CTraceContextPropagator();
|
|
24
|
+
/** A machine-authenticated, framework-neutral POST handler. Never retries the target. */
|
|
25
|
+
export function createManagedTargetHandler(options) {
|
|
26
|
+
token(options.machineCredential);
|
|
27
|
+
const baseUrl = validateOptions({
|
|
28
|
+
apiKey: "managed-invocation",
|
|
29
|
+
serviceName: "managed-target",
|
|
30
|
+
captureContent: false,
|
|
31
|
+
baseUrl: options.baseUrl,
|
|
32
|
+
}).baseUrl;
|
|
33
|
+
const maxExecution = bounded(options.maxExecutionMillis ?? 90_000, 1, 90_000);
|
|
34
|
+
const grace = bounded(options.finalizationMillis ?? 30_000, 1, 30_000);
|
|
35
|
+
if (typeof options.target !== "function" || typeof options.flushTelemetry !== "function")
|
|
36
|
+
throw new TypeError("target and flushTelemetry are required");
|
|
37
|
+
const credential = createHash("sha256").update(`Bearer ${options.machineCredential}`).digest();
|
|
38
|
+
return async (request) => {
|
|
39
|
+
if (request.method !== "POST")
|
|
40
|
+
return reply(405, { error: "method_not_allowed" });
|
|
41
|
+
const supplied = request.headers.get("authorization") ?? "";
|
|
42
|
+
if (supplied.length > 8192 ||
|
|
43
|
+
!timingSafeEqual(credential, createHash("sha256").update(supplied).digest()))
|
|
44
|
+
return reply(401, { error: "authentication" });
|
|
45
|
+
const began = Date.now();
|
|
46
|
+
let invocation;
|
|
47
|
+
let invocationToken;
|
|
48
|
+
try {
|
|
49
|
+
invocationToken = token(request.headers.get("x-hue-invocation-token"));
|
|
50
|
+
const body = await readBytes(request, MIB, AbortSignal.timeout(5_000));
|
|
51
|
+
invocation = validateInvocation(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body)));
|
|
52
|
+
if (Date.parse(invocation.deadline) <= Date.now())
|
|
53
|
+
return reply(408, { error: "deadline_exceeded" });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return reply(400, { error: "invalid_invocation" });
|
|
57
|
+
}
|
|
58
|
+
const executionEnd = Math.min(Date.parse(invocation.deadline), began + maxExecution);
|
|
59
|
+
const finalEnd = Math.min(executionEnd + grace, began + maxExecution + grace);
|
|
60
|
+
const signal = AbortSignal.timeout(Math.max(1, executionEnd - Date.now()));
|
|
61
|
+
const api = new InvocationApi(baseUrl, invocationToken, invocation.executionId);
|
|
62
|
+
const uncertain = () => reply(503, { protocolVersion: 1, executionId: invocation.executionId, state: "uncertain" });
|
|
63
|
+
let claim;
|
|
64
|
+
try {
|
|
65
|
+
claim = object(await api.json("POST", "/claim", {}, executionEnd));
|
|
66
|
+
if (typeof claim.claimed !== "boolean" ||
|
|
67
|
+
claim.executionId !== invocation.executionId ||
|
|
68
|
+
!STATES.has(String(claim.state)))
|
|
69
|
+
throw new Error();
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return uncertain();
|
|
73
|
+
}
|
|
74
|
+
if (!claim.claimed)
|
|
75
|
+
return reply(409, {
|
|
76
|
+
protocolVersion: 1,
|
|
77
|
+
executionId: invocation.executionId,
|
|
78
|
+
state: claim.state,
|
|
79
|
+
});
|
|
80
|
+
// The application's existing provider owns this span and every child. No global setup.
|
|
81
|
+
const parent = propagator.extract(ROOT_CONTEXT, { traceparent: invocation.traceparent }, {
|
|
82
|
+
keys: (carrier) => Object.keys(carrier),
|
|
83
|
+
get: (carrier, key) => carrier[key],
|
|
84
|
+
});
|
|
85
|
+
const span = (options.tracer ?? trace.getTracer("@hue-run/sdk/managed")).startSpan("ai.managed_target", {
|
|
86
|
+
attributes: {
|
|
87
|
+
"hue.execution.id": invocation.executionId,
|
|
88
|
+
"hue.execution.attempt": invocation.attempt,
|
|
89
|
+
},
|
|
90
|
+
}, parent);
|
|
91
|
+
const spanContext = span.spanContext();
|
|
92
|
+
if (!span.isRecording() || spanContext.traceId !== invocation.traceparent.split("-")[1]) {
|
|
93
|
+
span.end();
|
|
94
|
+
return uncertain();
|
|
95
|
+
}
|
|
96
|
+
let result;
|
|
97
|
+
try {
|
|
98
|
+
const inputFiles = [];
|
|
99
|
+
for (const file of invocation.inputFiles) {
|
|
100
|
+
const data = await api.bytes(`/inputs/${file.artifactId}`, file.byteSize, executionEnd);
|
|
101
|
+
if (data.byteLength !== file.byteSize || sha256(data) !== file.sha256)
|
|
102
|
+
throw new Error("input_integrity");
|
|
103
|
+
inputFiles.push({ ...file, data });
|
|
104
|
+
}
|
|
105
|
+
// Synchronous input hashing or a busy event loop can exhaust the budget
|
|
106
|
+
// before the abort timer runs. Do not start a paid callback in that gap.
|
|
107
|
+
if (Date.now() >= executionEnd)
|
|
108
|
+
throw new Error("deadline");
|
|
109
|
+
signal.throwIfAborted();
|
|
110
|
+
result = await within(context.with(trace.setSpan(parent, span), () => options.target({
|
|
111
|
+
executionId: invocation.executionId,
|
|
112
|
+
attempt: invocation.attempt,
|
|
113
|
+
input: invocation.input,
|
|
114
|
+
config: invocation.config,
|
|
115
|
+
inputFiles,
|
|
116
|
+
signal,
|
|
117
|
+
traceId: spanContext.traceId,
|
|
118
|
+
})), signal);
|
|
119
|
+
validateResult(result);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// A timed-out callback may still be running. Do not claim a terminal outcome for it.
|
|
123
|
+
if (signal.aborted || Date.now() >= executionEnd) {
|
|
124
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
125
|
+
span.end();
|
|
126
|
+
return uncertain();
|
|
127
|
+
}
|
|
128
|
+
result = {
|
|
129
|
+
state: "error",
|
|
130
|
+
error: { type: "target_error", message: "The target or input validation failed." },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const artifactIds = [];
|
|
134
|
+
let primaryArtifactId;
|
|
135
|
+
let uploadFailed = false;
|
|
136
|
+
for (const [index, file] of (result.files ?? []).entries()) {
|
|
137
|
+
try {
|
|
138
|
+
const hash = sha256(file.data);
|
|
139
|
+
const reserved = object(await api.json("POST", "/files", {
|
|
140
|
+
idempotencyKey: `file:${index}:${hash}`,
|
|
141
|
+
filename: file.filename,
|
|
142
|
+
contentType: file.contentType,
|
|
143
|
+
byteSize: file.data.byteLength,
|
|
144
|
+
sha256: hash,
|
|
145
|
+
}, finalEnd, true));
|
|
146
|
+
const artifactId = uuid(reserved.artifactId);
|
|
147
|
+
if (reserved.state !== "ready") {
|
|
148
|
+
const uploadUrl = safeUploadUrl(reserved.uploadUrl);
|
|
149
|
+
const headers = uploadHeaders(reserved.headers, file.contentType);
|
|
150
|
+
// Never replay a signed write. Completion independently verifies the
|
|
151
|
+
// stored hash, so it can settle a lost successful acknowledgement.
|
|
152
|
+
try {
|
|
153
|
+
await api.upload(uploadUrl, file.data, headers, finalEnd);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
/* Verify below. */
|
|
157
|
+
}
|
|
158
|
+
await api.json("POST", `/files/${artifactId}/complete`, {}, finalEnd, true);
|
|
159
|
+
}
|
|
160
|
+
artifactIds.push(artifactId);
|
|
161
|
+
if (file.primary)
|
|
162
|
+
primaryArtifactId = artifactId;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
uploadFailed = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Preserve every successfully uploaded secondary even when another file failed.
|
|
169
|
+
if (uploadFailed)
|
|
170
|
+
result = {
|
|
171
|
+
...result,
|
|
172
|
+
state: "error",
|
|
173
|
+
error: {
|
|
174
|
+
type: "artifact_upload_failed",
|
|
175
|
+
message: "One or more output files could not be stored.",
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const state = result.state ?? "succeeded";
|
|
179
|
+
if (state !== "succeeded")
|
|
180
|
+
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
181
|
+
span.end();
|
|
182
|
+
const outcome = {
|
|
183
|
+
protocolVersion: 1,
|
|
184
|
+
executionId: invocation.executionId,
|
|
185
|
+
state,
|
|
186
|
+
...(Object.hasOwn(result, "output") ? { output: result.output } : {}),
|
|
187
|
+
...(result.error ? { error: result.error } : {}),
|
|
188
|
+
artifactIds,
|
|
189
|
+
...(primaryArtifactId ? { primaryArtifactId } : {}),
|
|
190
|
+
traceId: spanContext.traceId,
|
|
191
|
+
expectedSpanIds: [spanContext.spanId],
|
|
192
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
193
|
+
};
|
|
194
|
+
try {
|
|
195
|
+
await api.json("POST", "/outcome", outcome, finalEnd, true);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return uncertain();
|
|
199
|
+
}
|
|
200
|
+
const acknowledgement = {
|
|
201
|
+
protocolVersion: 1,
|
|
202
|
+
executionId: invocation.executionId,
|
|
203
|
+
state: "checkpointed",
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
const remaining = finalEnd - Date.now();
|
|
207
|
+
if (remaining <= 0)
|
|
208
|
+
throw new Error();
|
|
209
|
+
const flushed = await within(Promise.resolve().then(options.flushTelemetry), AbortSignal.timeout(remaining));
|
|
210
|
+
if (flushed === false)
|
|
211
|
+
throw new Error("flush_failed");
|
|
212
|
+
if (flushed &&
|
|
213
|
+
typeof flushed === "object" &&
|
|
214
|
+
("pendingSpans" in flushed || "pendingLogs" in flushed)) {
|
|
215
|
+
// Hue's failure counters are cumulative; its flush throws for new failures.
|
|
216
|
+
// Pending counters describe the current drain and must both be zero.
|
|
217
|
+
const report = flushed;
|
|
218
|
+
if (report.pendingSpans !== 0 || report.pendingLogs !== 0)
|
|
219
|
+
throw new Error("flush_pending");
|
|
220
|
+
}
|
|
221
|
+
await api.json("POST", "/telemetry", { expectedSpanIds: [spanContext.spanId], flushed: true }, finalEnd, true);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return reply(200, { ...acknowledgement, telemetry: "pending" });
|
|
225
|
+
}
|
|
226
|
+
return reply(200, { ...acknowledgement, telemetry: "flushed" });
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
class InvocationApi {
|
|
230
|
+
baseUrl;
|
|
231
|
+
invocationToken;
|
|
232
|
+
executionId;
|
|
233
|
+
constructor(baseUrl, invocationToken, executionId) {
|
|
234
|
+
this.baseUrl = baseUrl;
|
|
235
|
+
this.invocationToken = invocationToken;
|
|
236
|
+
this.executionId = executionId;
|
|
237
|
+
}
|
|
238
|
+
url(path) {
|
|
239
|
+
return `${this.baseUrl}/api/v1/managed-executions/${this.executionId}${path}`;
|
|
240
|
+
}
|
|
241
|
+
async json(method, path, value, deadline, retry = false) {
|
|
242
|
+
const body = JSON.stringify(json(value, MIB));
|
|
243
|
+
const bytes = await this.request(this.url(path), {
|
|
244
|
+
method,
|
|
245
|
+
headers: {
|
|
246
|
+
authorization: `Bearer ${this.invocationToken}`,
|
|
247
|
+
"content-type": "application/json",
|
|
248
|
+
},
|
|
249
|
+
body,
|
|
250
|
+
}, MIB, deadline, retry);
|
|
251
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
252
|
+
}
|
|
253
|
+
bytes(path, maximum, deadline) {
|
|
254
|
+
return this.request(this.url(path), { headers: { authorization: `Bearer ${this.invocationToken}` } }, maximum, deadline, false);
|
|
255
|
+
}
|
|
256
|
+
async upload(url, data, headers, deadline) {
|
|
257
|
+
const remaining = deadline - Date.now();
|
|
258
|
+
if (remaining <= 0)
|
|
259
|
+
throw new Error("deadline");
|
|
260
|
+
const signal = AbortSignal.timeout(remaining);
|
|
261
|
+
await new Promise((resolve, reject) => {
|
|
262
|
+
let activeResponse;
|
|
263
|
+
const finish = (error) => {
|
|
264
|
+
signal.removeEventListener("abort", abort);
|
|
265
|
+
if (error)
|
|
266
|
+
reject(error);
|
|
267
|
+
else
|
|
268
|
+
resolve();
|
|
269
|
+
};
|
|
270
|
+
const abort = () => {
|
|
271
|
+
activeResponse?.destroy();
|
|
272
|
+
request.destroy();
|
|
273
|
+
finish(new Error("Hue managed upload failed"));
|
|
274
|
+
};
|
|
275
|
+
// Signed writes must not be replayed by fetch, redirect handling or a
|
|
276
|
+
// reused-socket retry. A lost response is settled by Hue's verified read.
|
|
277
|
+
const request = (new URL(url).protocol === "https:" ? httpsRequest : httpRequest)(url, {
|
|
278
|
+
method: "PUT",
|
|
279
|
+
agent: false,
|
|
280
|
+
signal,
|
|
281
|
+
maxHeaderSize: 16 * 1024,
|
|
282
|
+
headers: { ...headers, "content-length": String(data.byteLength) },
|
|
283
|
+
}, (response) => {
|
|
284
|
+
activeResponse = response;
|
|
285
|
+
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300) {
|
|
286
|
+
response.destroy();
|
|
287
|
+
finish(new Error("Hue managed upload failed"));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
void (async () => {
|
|
291
|
+
let length = 0;
|
|
292
|
+
for await (const chunk of response) {
|
|
293
|
+
signal.throwIfAborted();
|
|
294
|
+
length += Buffer.byteLength(chunk);
|
|
295
|
+
if (length > MIB)
|
|
296
|
+
throw new Error("Upload response too large");
|
|
297
|
+
}
|
|
298
|
+
signal.throwIfAborted();
|
|
299
|
+
finish();
|
|
300
|
+
})().catch(() => {
|
|
301
|
+
response.destroy();
|
|
302
|
+
finish(new Error("Hue managed upload failed"));
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
request.once("error", () => finish(new Error("Hue managed upload failed")));
|
|
306
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
307
|
+
request.end(Buffer.from(data));
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
async request(url, init, maximum, deadline, retry) {
|
|
311
|
+
for (let attempt = 0;; attempt++) {
|
|
312
|
+
const remaining = deadline - Date.now();
|
|
313
|
+
if (remaining <= 0)
|
|
314
|
+
throw new Error("deadline");
|
|
315
|
+
try {
|
|
316
|
+
const signal = AbortSignal.timeout(remaining);
|
|
317
|
+
const response = await fetch(url, { ...init, redirect: "error", signal });
|
|
318
|
+
if (!response.ok) {
|
|
319
|
+
await response.body?.cancel();
|
|
320
|
+
if (![429, 500, 502, 503, 504].includes(response.status))
|
|
321
|
+
throw new PermanentError();
|
|
322
|
+
throw new Error();
|
|
323
|
+
}
|
|
324
|
+
return await readBytes(response, maximum, signal);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (!retry ||
|
|
328
|
+
attempt >= 1 ||
|
|
329
|
+
error instanceof PermanentError ||
|
|
330
|
+
Date.now() + 100 >= deadline)
|
|
331
|
+
throw new Error("Hue managed request failed");
|
|
332
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
class PermanentError extends Error {
|
|
338
|
+
}
|
|
339
|
+
function reply(status, body) {
|
|
340
|
+
return Response.json(body, { status, headers: { "cache-control": "no-store" } });
|
|
341
|
+
}
|
|
342
|
+
function sha256(data) {
|
|
343
|
+
return createHash("sha256").update(data).digest("hex");
|
|
344
|
+
}
|
|
345
|
+
function token(value) {
|
|
346
|
+
if (typeof value !== "string" || !value || value.length > 8192 || /[\s\u0000]/u.test(value))
|
|
347
|
+
throw new TypeError("Invalid credential");
|
|
348
|
+
return value;
|
|
349
|
+
}
|
|
350
|
+
function bounded(value, minimum, maximum) {
|
|
351
|
+
if (typeof value !== "number" ||
|
|
352
|
+
!Number.isSafeInteger(value) ||
|
|
353
|
+
value < minimum ||
|
|
354
|
+
value > maximum)
|
|
355
|
+
throw new TypeError("Invalid integer");
|
|
356
|
+
return value;
|
|
357
|
+
}
|
|
358
|
+
function object(value) {
|
|
359
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
360
|
+
throw new TypeError("Expected object");
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
function filename(value) {
|
|
364
|
+
if (typeof value !== "string" ||
|
|
365
|
+
!value.trim() ||
|
|
366
|
+
value.length > 255 ||
|
|
367
|
+
/[\x00-\x1f\x7f/\\]/u.test(value) ||
|
|
368
|
+
value === "." ||
|
|
369
|
+
value === "..")
|
|
370
|
+
throw new TypeError("Invalid filename");
|
|
371
|
+
return value;
|
|
372
|
+
}
|
|
373
|
+
function shortString(value, maximum = 255) {
|
|
374
|
+
if (typeof value !== "string" ||
|
|
375
|
+
!value ||
|
|
376
|
+
value.length > maximum ||
|
|
377
|
+
/[\x00-\x1f\x7f]/u.test(value))
|
|
378
|
+
throw new TypeError("Invalid string");
|
|
379
|
+
return value;
|
|
380
|
+
}
|
|
381
|
+
function validateInvocation(value) {
|
|
382
|
+
const v = object(json(value, MIB));
|
|
383
|
+
if (Object.keys(v).some((key) => ![
|
|
384
|
+
"protocolVersion",
|
|
385
|
+
"executionId",
|
|
386
|
+
"attempt",
|
|
387
|
+
"input",
|
|
388
|
+
"config",
|
|
389
|
+
"inputFiles",
|
|
390
|
+
"deadline",
|
|
391
|
+
"traceparent",
|
|
392
|
+
].includes(key)))
|
|
393
|
+
throw new TypeError("Unknown invocation field");
|
|
394
|
+
if (v.protocolVersion !== 1 || !Object.hasOwn(v, "input") || !Object.hasOwn(v, "config"))
|
|
395
|
+
throw new TypeError("Invalid protocol");
|
|
396
|
+
uuid(v.executionId);
|
|
397
|
+
bounded(v.attempt, 1, Number.MAX_SAFE_INTEGER);
|
|
398
|
+
if (typeof v.deadline !== "string" ||
|
|
399
|
+
v.deadline.trim() !== v.deadline ||
|
|
400
|
+
!/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?Z$/.test(v.deadline) ||
|
|
401
|
+
!Number.isFinite(Date.parse(v.deadline)))
|
|
402
|
+
throw new TypeError("Invalid deadline");
|
|
403
|
+
if (typeof v.traceparent !== "string" ||
|
|
404
|
+
v.traceparent.length !== 55 ||
|
|
405
|
+
!/^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/.test(v.traceparent) ||
|
|
406
|
+
/^00-0{32}-/.test(v.traceparent) ||
|
|
407
|
+
/-0{16}-01$/.test(v.traceparent))
|
|
408
|
+
throw new TypeError("A sampled W3C traceparent is required");
|
|
409
|
+
if (!Array.isArray(v.inputFiles) || v.inputFiles.length > 16)
|
|
410
|
+
throw new TypeError("Invalid files");
|
|
411
|
+
let total = 0;
|
|
412
|
+
const ids = new Set();
|
|
413
|
+
for (const raw of v.inputFiles) {
|
|
414
|
+
const file = object(raw);
|
|
415
|
+
const id = uuid(file.artifactId);
|
|
416
|
+
if (Object.keys(file).some((key) => !["artifactId", "filename", "contentType", "byteSize", "sha256", "role"].includes(key)))
|
|
417
|
+
throw new TypeError("Unknown file field");
|
|
418
|
+
if (ids.has(id))
|
|
419
|
+
throw new TypeError("Duplicate input file");
|
|
420
|
+
ids.add(id);
|
|
421
|
+
filename(file.filename);
|
|
422
|
+
shortString(file.contentType);
|
|
423
|
+
shortString(file.role, 64);
|
|
424
|
+
total += bounded(file.byteSize, 0, MAX_FILE);
|
|
425
|
+
if (typeof file.sha256 !== "string" ||
|
|
426
|
+
file.sha256.length !== 64 ||
|
|
427
|
+
!/^[0-9a-f]{64}$/.test(file.sha256))
|
|
428
|
+
throw new TypeError("Invalid hash");
|
|
429
|
+
}
|
|
430
|
+
if (total > MAX_TOTAL)
|
|
431
|
+
throw new TypeError("Input files too large");
|
|
432
|
+
return v;
|
|
433
|
+
}
|
|
434
|
+
function validateResult(result) {
|
|
435
|
+
object(result);
|
|
436
|
+
if (result.state && !["succeeded", "error", "cancelled"].includes(result.state))
|
|
437
|
+
throw new TypeError("Invalid outcome");
|
|
438
|
+
if (Object.hasOwn(result, "output"))
|
|
439
|
+
json(result.output);
|
|
440
|
+
if (result.error) {
|
|
441
|
+
shortString(result.error.type, 64);
|
|
442
|
+
if (!/^[a-z][a-z0-9_]{0,63}$/.test(result.error.type))
|
|
443
|
+
throw new TypeError("Invalid error type");
|
|
444
|
+
if (result.error.message !== undefined)
|
|
445
|
+
shortString(result.error.message, 1000);
|
|
446
|
+
}
|
|
447
|
+
if (result.usage)
|
|
448
|
+
for (const count of Object.values(result.usage))
|
|
449
|
+
bounded(count, 0, Number.MAX_SAFE_INTEGER);
|
|
450
|
+
if (result.files !== undefined && (!Array.isArray(result.files) || result.files.length > 16))
|
|
451
|
+
throw new TypeError("Invalid files");
|
|
452
|
+
let total = 0;
|
|
453
|
+
let primary = 0;
|
|
454
|
+
for (const file of result.files ?? []) {
|
|
455
|
+
filename(file.filename);
|
|
456
|
+
shortString(file.contentType);
|
|
457
|
+
if (!(file.data instanceof Uint8Array))
|
|
458
|
+
throw new TypeError("File data must be bytes");
|
|
459
|
+
total += bounded(file.data.byteLength, 0, MAX_FILE);
|
|
460
|
+
if (file.primary !== undefined && typeof file.primary !== "boolean")
|
|
461
|
+
throw new TypeError("Invalid primary");
|
|
462
|
+
if (file.primary)
|
|
463
|
+
primary++;
|
|
464
|
+
}
|
|
465
|
+
if (total > MAX_TOTAL || primary > 1)
|
|
466
|
+
throw new TypeError("Invalid output files");
|
|
467
|
+
}
|
|
468
|
+
function safeUploadUrl(value) {
|
|
469
|
+
if (typeof value !== "string" || value.length > 8192 || /[\x00-\x20\x7f]/u.test(value))
|
|
470
|
+
throw new TypeError("Invalid upload URL");
|
|
471
|
+
const url = new URL(value);
|
|
472
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
473
|
+
if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) ||
|
|
474
|
+
url.username ||
|
|
475
|
+
url.password ||
|
|
476
|
+
url.hash)
|
|
477
|
+
throw new TypeError("Invalid upload URL");
|
|
478
|
+
// Validate without rewriting the provider's signed capability.
|
|
479
|
+
return value;
|
|
480
|
+
}
|
|
481
|
+
function uploadHeaders(value, contentType) {
|
|
482
|
+
const headers = { "content-type": contentType };
|
|
483
|
+
if (value === undefined || value === null)
|
|
484
|
+
return headers;
|
|
485
|
+
for (const [name, raw] of Object.entries(object(value))) {
|
|
486
|
+
const lower = name.toLowerCase();
|
|
487
|
+
const value = shortString(raw);
|
|
488
|
+
if (lower === "content-type" && value === contentType)
|
|
489
|
+
headers[lower] = value;
|
|
490
|
+
else if (lower === "x-vercel-blob-access" && value === "private")
|
|
491
|
+
headers[lower] = value;
|
|
492
|
+
else
|
|
493
|
+
throw new TypeError("Unsupported upload header");
|
|
494
|
+
}
|
|
495
|
+
return headers;
|
|
496
|
+
}
|
|
497
|
+
async function within(promise, signal) {
|
|
498
|
+
signal.throwIfAborted();
|
|
499
|
+
let abort;
|
|
500
|
+
try {
|
|
501
|
+
return await Promise.race([
|
|
502
|
+
promise,
|
|
503
|
+
new Promise((_, reject) => {
|
|
504
|
+
abort = () => reject(new Error("Deadline exceeded"));
|
|
505
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
506
|
+
}),
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
509
|
+
finally {
|
|
510
|
+
signal.removeEventListener("abort", abort);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async function readBytes(source, maximum, signal) {
|
|
514
|
+
const declared = source.headers.get("content-length");
|
|
515
|
+
if (declared !== null && (!/^\d+$/.test(declared) || Number(declared) > maximum))
|
|
516
|
+
throw new Error("Oversized body");
|
|
517
|
+
if (!source.body)
|
|
518
|
+
return new Uint8Array();
|
|
519
|
+
const reader = source.body.getReader();
|
|
520
|
+
const chunks = [];
|
|
521
|
+
let size = 0;
|
|
522
|
+
try {
|
|
523
|
+
while (true) {
|
|
524
|
+
const next = await within(reader.read(), signal);
|
|
525
|
+
if (next.done)
|
|
526
|
+
break;
|
|
527
|
+
size += next.value.byteLength;
|
|
528
|
+
if (size > maximum)
|
|
529
|
+
throw new Error("Oversized body");
|
|
530
|
+
chunks.push(next.value);
|
|
531
|
+
}
|
|
532
|
+
return Buffer.concat(chunks, size);
|
|
533
|
+
}
|
|
534
|
+
finally {
|
|
535
|
+
void reader.cancel().catch(() => { });
|
|
536
|
+
}
|
|
537
|
+
}
|