@hue-run/sdk 0.9.0 → 0.11.0

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.
Files changed (56) hide show
  1. package/CLI.md +240 -49
  2. package/ENVIRONMENTS.md +48 -9
  3. package/EVALUATIONS.md +41 -21
  4. package/README.md +70 -30
  5. package/dist/cli/eval-direct.d.ts +12 -1
  6. package/dist/cli/eval-direct.js +145 -33
  7. package/dist/cli/eval.d.ts +28 -1
  8. package/dist/cli/eval.js +504 -81
  9. package/dist/cli/listen.d.ts +76 -0
  10. package/dist/cli/listen.js +816 -0
  11. package/dist/cli/mcp.d.ts +65 -7
  12. package/dist/cli/mcp.js +271 -73
  13. package/dist/client.d.ts +7 -4
  14. package/dist/client.js +36 -10
  15. package/dist/evals/checkpoint.d.ts +15 -1
  16. package/dist/evals/checkpoint.js +34 -3
  17. package/dist/evals/client.d.ts +24 -4
  18. package/dist/evals/client.js +92 -38
  19. package/dist/evals/environment-target.d.ts +18 -3
  20. package/dist/evals/environment-target.js +22 -10
  21. package/dist/evals/exit-cleanup.d.ts +4 -0
  22. package/dist/evals/exit-cleanup.js +26 -0
  23. package/dist/evals/files.d.ts +69 -3
  24. package/dist/evals/files.js +255 -42
  25. package/dist/evals/json.js +116 -9
  26. package/dist/evals/local-worker.d.ts +21 -3
  27. package/dist/evals/local-worker.js +14 -0
  28. package/dist/evals/runner.d.ts +45 -0
  29. package/dist/evals/runner.js +199 -21
  30. package/dist/evals/scenarios.d.ts +7 -5
  31. package/dist/evals/scenarios.js +71 -14
  32. package/dist/evals/scorer-publication.js +58 -3
  33. package/dist/evals/simulation.d.ts +17 -4
  34. package/dist/evals/simulation.js +8 -0
  35. package/dist/evals/types.d.ts +48 -0
  36. package/dist/evals/verdicts.d.ts +26 -2
  37. package/dist/evals/verdicts.js +72 -9
  38. package/dist/evals.d.ts +4 -3
  39. package/dist/evals.js +2 -2
  40. package/dist/inline-files.d.ts +4 -0
  41. package/dist/inline-files.js +48 -17
  42. package/dist/provider-tools.d.ts +12 -1
  43. package/dist/provider-tools.js +192 -106
  44. package/dist/safety.d.ts +7 -1
  45. package/dist/safety.js +9 -7
  46. package/dist/setup/cli.js +31 -8
  47. package/dist/setup/peers.d.ts +13 -0
  48. package/dist/setup/peers.js +44 -0
  49. package/dist/snapshot.d.ts +2 -2
  50. package/dist/snapshot.js +47 -12
  51. package/dist/tool-definitions.d.ts +18 -0
  52. package/dist/tool-definitions.js +232 -0
  53. package/dist/transport.js +2 -2
  54. package/dist/version.d.ts +1 -1
  55. package/dist/version.js +1 -1
  56. package/package.json +1 -1
@@ -0,0 +1,816 @@
1
+ import { lookup as dnsLookup } from "node:dns";
2
+ import { readFile } from "node:fs/promises";
3
+ import { request as httpRequest } from "node:http";
4
+ import { request as httpsRequest } from "node:https";
5
+ import { isIP } from "node:net";
6
+ import { resolve } from "node:path";
7
+ import { parseArgs, parseEnv } from "node:util";
8
+ import { scrubCredentialText } from "../tool-definitions.js";
9
+ import { sdkVersion } from "../version.js";
10
+ import { envFileArgument, envFileOptions } from "./env-file.js";
11
+ import { parseHueOrigin } from "./login.js";
12
+ const DEFAULT_ORIGIN = "https://app.hue.run";
13
+ /** The longest a pull waits for a due delivery; the server answers empty at this bound. */
14
+ const PULL_WAIT_MS = 20_000;
15
+ /** Time allowed beyond the wait for Hue to answer a pull. */
16
+ const PULL_GRACE_MS = 15_000;
17
+ /** The most deliveries one pull leases; they are forwarded concurrently. */
18
+ const MAX_BATCH = 10;
19
+ /** How long a pulled delivery stays leased; after it Hue records a timeout. */
20
+ const LEASE_MS = 30_000;
21
+ /** Slack's acknowledgement window: a receiver that has not answered by then timed out. */
22
+ const FORWARD_TIMEOUT_MS = 3_000;
23
+ /** The most answer body an acknowledgement carries; only the URL verification reads it. */
24
+ const MAX_ACK_BODY_BYTES = 4_096;
25
+ const MAX_PULL_RESPONSE_BYTES = 16 * 1024 * 1024;
26
+ const MAX_ACK_RESPONSE_BYTES = 64 * 1024;
27
+ const MAX_DELIVERY_BODY_BYTES = 1024 * 1024;
28
+ const MAX_DELIVERY_HEADERS = 64;
29
+ const ACK_REQUEST_TIMEOUT_MS = 10_000;
30
+ const PULL_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
31
+ const ACK_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000, 4_000];
32
+ const MAX_RETRY_AFTER_MS = 60_000;
33
+ /** Deliveries whose answer is remembered, so a delivery Hue hands out again is not re-sent. */
34
+ const REMEMBERED_DELIVERIES = 1_024;
35
+ /** Connection-level headers the local request sets for itself. */
36
+ const HOP_BY_HOP = new Set([
37
+ "host",
38
+ "content-length",
39
+ "connection",
40
+ "keep-alive",
41
+ "proxy-authenticate",
42
+ "proxy-authorization",
43
+ "proxy-connection",
44
+ "te",
45
+ "trailer",
46
+ "transfer-encoding",
47
+ "upgrade",
48
+ ]);
49
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
50
+ const EVENT_ID = /^[A-Za-z0-9_-]{1,64}$/u;
51
+ const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$/u;
52
+ const HEADER_VALUE = /^[\t\x20-\x7e]{0,8192}$/u;
53
+ const WORLD_TOKEN = /^hue_world_[A-Za-z0-9_-]{1,1024}\.[A-Za-z0-9_-]{43}$/u;
54
+ const CONNECTION_KEY = /^hue_sk_[A-Za-z0-9_-]{1,512}$/u;
55
+ const EVENT_STATES = new Set(["pending", "acknowledged", "failed", "dropped"]);
56
+ export const LISTEN_USAGE = `Usage: hue listen --subscription ID --forward-to URL [--origin URL] [--env-path PATH]
57
+ [--credential world-token|connection-key] [--max N] [--allow-remote-forward]
58
+
59
+ Deliver a simulated world's events to a receiver on this machine. Hue's signed requests (Slack
60
+ Events API requests today) are pulled with the subscription's own credential, forwarded unchanged
61
+ to --forward-to, and acknowledged with the receiver's answer. Press Ctrl+C to stop: pulls stop,
62
+ deliveries in flight are finished and acknowledged, and nothing unanswered is acknowledged.
63
+
64
+ The credential is read from the environment, never from the command line:
65
+ HUE_WORLD_TOKEN the world token, for a subscription on one world
66
+ HUE_CONNECTION_KEY the connection key, for a subscription on a connection key
67
+ A project key (HUE_API_KEY, HUE_MCP_KEY) is refused.
68
+
69
+ Options:
70
+ --subscription ID The listen subscription to deliver (its id from Hue)
71
+ --forward-to URL The local receiver, for example http://localhost:3000/slack/events
72
+ --origin URL Hue origin (default HUE_BASE_URL, then ${DEFAULT_ORIGIN})
73
+ --env-path PATH Load a dotenv file first (--env-file also works when it exists)
74
+ --credential KIND world-token or connection-key, when both variables are set
75
+ --max N Deliveries leased per pull and forwarded at once, 1 to ${MAX_BATCH} (default ${MAX_BATCH})
76
+ --allow-remote-forward Allow a --forward-to host other than this machine, over https only
77
+ -h, --help Show this help`;
78
+ class UsageError extends Error {
79
+ }
80
+ /** A loopback address: 127.0.0.0/8, ::1, or 127.0.0.0/8 carried in IPv4-mapped IPv6. */
81
+ export function isLoopbackAddress(address) {
82
+ const unbracketed = address.replace(/^\[|\]$/gu, "").toLowerCase();
83
+ const version = isIP(unbracketed);
84
+ if (version === 4)
85
+ return unbracketed.startsWith("127.");
86
+ if (version !== 6)
87
+ return false;
88
+ if (unbracketed === "::1" || unbracketed === "0:0:0:0:0:0:0:1")
89
+ return true;
90
+ const mapped = /^(?:0{0,4}:){0,5}:?ffff:(\d+\.\d+\.\d+\.\d+)$/u.exec(unbracketed);
91
+ if (mapped)
92
+ return mapped[1].startsWith("127.");
93
+ const hex = /^(?:0{0,4}:){0,5}:?ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/u.exec(unbracketed);
94
+ return hex !== null && Number.parseInt(hex[1], 16) >> 8 === 127;
95
+ }
96
+ /**
97
+ * The receiver URL, or why it is refused: HTTP or HTTPS without credentials or fragment, on this
98
+ * machine (`localhost`, a `.localhost` name or a loopback address) unless `allowRemote`, and then
99
+ * HTTPS only off this machine.
100
+ */
101
+ export function parseForwardTarget(value, allowRemote) {
102
+ let url;
103
+ try {
104
+ url = new URL(value);
105
+ }
106
+ catch {
107
+ return "--forward-to must be an absolute URL such as http://localhost:3000/slack/events";
108
+ }
109
+ if (url.protocol !== "http:" && url.protocol !== "https:")
110
+ return "--forward-to must be an http or https URL";
111
+ if (url.username || url.password)
112
+ return "--forward-to must not carry credentials";
113
+ if (url.hash)
114
+ return "--forward-to must not carry a fragment";
115
+ if (!isLocalHostname(url.hostname)) {
116
+ if (!allowRemote)
117
+ return `--forward-to must name this machine (localhost or a loopback address); pass --allow-remote-forward to deliver to ${scrubCredentialText(url.hostname)}`;
118
+ // Each request carries the subscription's verification token and a valid signature.
119
+ if (url.protocol !== "https:")
120
+ return "--forward-to must use https for a host other than this machine, because each request carries the subscription's verification token and signature";
121
+ }
122
+ return url;
123
+ }
124
+ function isLocalHostname(hostname) {
125
+ const name = hostname.toLowerCase().replace(/\.$/u, "");
126
+ return name === "localhost" || name.endsWith(".localhost") || isLoopbackAddress(name);
127
+ }
128
+ /**
129
+ * A resolver that admits a name only when every address it resolves to is a loopback address, so
130
+ * a `localhost` name that resolves elsewhere reaches nothing. Address literals do not resolve;
131
+ * `parseForwardTarget` checked them.
132
+ */
133
+ function loopbackLookup(resolver = dnsLookup) {
134
+ return ((hostname, options, callback) => {
135
+ resolver(hostname, { ...options, all: true }, (error, addresses) => {
136
+ if (error)
137
+ return callback(error, options.all ? [] : "", 0);
138
+ if (addresses.length === 0 || !addresses.every((entry) => isLoopbackAddress(entry.address))) {
139
+ const refused = new Error(`${hostname} is not a loopback address`);
140
+ refused.code = "ENOTLOOPBACK";
141
+ return callback(refused, options.all ? [] : "", 0);
142
+ }
143
+ if (options.all)
144
+ callback(null, addresses);
145
+ else
146
+ callback(null, addresses[0].address, addresses[0].family);
147
+ });
148
+ });
149
+ }
150
+ function isTlsError(error) {
151
+ const code = error.code ?? "";
152
+ return (code.startsWith("ERR_TLS_") ||
153
+ code.startsWith("ERR_SSL_") ||
154
+ code.includes("CERT") ||
155
+ code === "DEPTH_ZERO_SELF_SIGNED_CERT" ||
156
+ code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" ||
157
+ code === "EPROTO");
158
+ }
159
+ /** Cuts text to at most `limit` UTF-8 bytes without splitting a character. */
160
+ function clampUtf8(text, limit) {
161
+ if (Buffer.byteLength(text, "utf8") <= limit)
162
+ return text;
163
+ let out = text.slice(0, limit);
164
+ while (Buffer.byteLength(out, "utf8") > limit)
165
+ out = out.slice(0, -1);
166
+ return out;
167
+ }
168
+ /**
169
+ * Sends one delivery to the receiver and reports its answer, or null when `signal` abandoned it.
170
+ * The body bytes and every header go out unchanged (the provider's signature covers them), no
171
+ * redirect is followed, and the answer counts only inside `timeoutMs`. Only a URL verification's
172
+ * successful answer body is read.
173
+ */
174
+ export function forwardDelivery(target, delivery, options = {}) {
175
+ const timeoutMs = options.timeoutMs ?? FORWARD_TIMEOUT_MS;
176
+ const body = Buffer.from(delivery.request.body, "utf8");
177
+ const headers = Object.create(null);
178
+ for (const [name, value] of Object.entries(delivery.request.headers))
179
+ if (!HOP_BY_HOP.has(name.toLowerCase()))
180
+ headers[name] = value;
181
+ headers["content-length"] = String(body.byteLength);
182
+ const started = performance.now();
183
+ const elapsed = () => Math.min(LEASE_MS, Math.max(0, Math.round(performance.now() - started)));
184
+ return new Promise((settle) => {
185
+ let request;
186
+ let settled = false;
187
+ const timer = setTimeout(() => finish({ outcome: "timeout", durationMs: elapsed() }), timeoutMs);
188
+ const abandon = () => finish(null);
189
+ const finish = (answer) => {
190
+ if (settled)
191
+ return;
192
+ settled = true;
193
+ clearTimeout(timer);
194
+ options.signal?.removeEventListener("abort", abandon);
195
+ request?.destroy();
196
+ settle(answer);
197
+ };
198
+ if (options.signal?.aborted)
199
+ return abandon();
200
+ options.signal?.addEventListener("abort", abandon, { once: true });
201
+ try {
202
+ request = (target.protocol === "https:" ? httpsRequest : httpRequest)(target, {
203
+ method: "POST",
204
+ headers,
205
+ agent: false,
206
+ // Plain HTTP only ever reaches a loopback address, whatever --allow-remote-forward says.
207
+ ...(options.allowRemote && target.protocol === "https:"
208
+ ? {}
209
+ : { lookup: loopbackLookup(options.resolver) }),
210
+ });
211
+ }
212
+ catch {
213
+ // A header Node refuses to send: nothing left this machine.
214
+ finish({ outcome: "connection_failed", durationMs: elapsed() });
215
+ return;
216
+ }
217
+ request.on("error", (error) => finish({
218
+ outcome: isTlsError(error) ? "tls_error" : "connection_failed",
219
+ durationMs: elapsed(),
220
+ }));
221
+ request.on("response", (response) => {
222
+ const status = response.statusCode ?? 0;
223
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
224
+ finish({ outcome: "connection_failed", durationMs: elapsed() });
225
+ return;
226
+ }
227
+ const noRetry = String(response.headers["x-slack-no-retry"] ?? "").trim() === "1";
228
+ const answered = (text) => finish({
229
+ outcome: "response",
230
+ status,
231
+ durationMs: elapsed(),
232
+ ...(noRetry ? { noRetry } : {}),
233
+ ...(text !== undefined ? { body: text } : {}),
234
+ });
235
+ // Only a successful handshake answer can verify, so only its body is read; a failing
236
+ // answer's page stays on this machine.
237
+ if (delivery.kind !== "url_verification" || status < 200 || status > 299) {
238
+ response.on("error", () => undefined);
239
+ answered();
240
+ return;
241
+ }
242
+ // The handshake's answer carries the challenge; read at most what an acknowledgement holds.
243
+ const chunks = [];
244
+ let size = 0;
245
+ const done = () => answered(clampUtf8(Buffer.concat(chunks).toString("utf8"), MAX_ACK_BODY_BYTES));
246
+ response.on("data", (chunk) => {
247
+ if (size >= MAX_ACK_BODY_BYTES)
248
+ return;
249
+ chunks.push(chunk.subarray(0, MAX_ACK_BODY_BYTES - size));
250
+ size += Math.min(chunk.byteLength, MAX_ACK_BODY_BYTES - size);
251
+ if (size >= MAX_ACK_BODY_BYTES)
252
+ done();
253
+ });
254
+ response.on("end", done);
255
+ response.on("error", done);
256
+ });
257
+ request.end(body);
258
+ });
259
+ }
260
+ /** One delivery from a pull answer, or null when it is not one this client can forward safely. */
261
+ export function parseDelivery(value, subscriptionId) {
262
+ if (!isObject(value))
263
+ return null;
264
+ const { deliveryId, subscriptionId: owner, eventId, kind, retryNum, leaseExpiresAt, request, } = value;
265
+ if (typeof deliveryId !== "string" || !UUID.test(deliveryId))
266
+ return null;
267
+ if (typeof owner !== "string" || owner.toLowerCase() !== subscriptionId.toLowerCase())
268
+ return null;
269
+ if (kind !== "url_verification" && kind !== "event_callback")
270
+ return null;
271
+ if (eventId !== null && (typeof eventId !== "string" || !EVENT_ID.test(eventId)))
272
+ return null;
273
+ if (!Number.isInteger(retryNum) || retryNum < 0 || retryNum > 100)
274
+ return null;
275
+ if (!isObject(request) || request.method !== "POST")
276
+ return null;
277
+ const { headers, body } = request;
278
+ if (typeof body !== "string" || Buffer.byteLength(body, "utf8") > MAX_DELIVERY_BODY_BYTES)
279
+ return null;
280
+ if (!isObject(headers))
281
+ return null;
282
+ const entries = Object.entries(headers);
283
+ if (entries.length > MAX_DELIVERY_HEADERS)
284
+ return null;
285
+ const copied = Object.create(null);
286
+ const names = new Set();
287
+ for (const [name, header] of entries) {
288
+ if (!HEADER_NAME.test(name) || typeof header !== "string" || !HEADER_VALUE.test(header))
289
+ return null;
290
+ // Two spellings of one header would leave the receiver to guess which is meant.
291
+ if (names.has(name.toLowerCase()))
292
+ return null;
293
+ names.add(name.toLowerCase());
294
+ copied[name] = header;
295
+ }
296
+ const leaseEnd = typeof leaseExpiresAt === "string" ? Date.parse(leaseExpiresAt) : Number.NaN;
297
+ return {
298
+ deliveryId: deliveryId.toLowerCase(),
299
+ eventId: eventId,
300
+ kind,
301
+ retryNum: retryNum,
302
+ leaseExpiresAt: Number.isFinite(leaseEnd) ? leaseEnd : null,
303
+ request: { headers: copied, body },
304
+ };
305
+ }
306
+ function isObject(value) {
307
+ return value !== null && typeof value === "object" && !Array.isArray(value);
308
+ }
309
+ /** Reads at most `limit` bytes of a response body; a larger one is cancelled and refused. */
310
+ async function readBounded(response, limit) {
311
+ if (!response.body)
312
+ return "";
313
+ const reader = response.body.getReader();
314
+ const chunks = [];
315
+ let size = 0;
316
+ for (;;) {
317
+ const { done, value } = await reader.read();
318
+ if (done)
319
+ break;
320
+ size += value.byteLength;
321
+ if (size > limit) {
322
+ await reader.cancel().catch(() => undefined);
323
+ throw new Error(`Hue answered more than ${limit} bytes`);
324
+ }
325
+ chunks.push(value);
326
+ }
327
+ return Buffer.concat(chunks).toString("utf8");
328
+ }
329
+ /** Hue's refusal code: the `x-hue-diagnostic` header, or the body's `diagnostic` or `code`. */
330
+ async function refusalCode(response) {
331
+ const header = response.headers.get("x-hue-diagnostic");
332
+ if (header && /^[a-z][a-z0-9_]{0,63}$/u.test(header))
333
+ return header;
334
+ try {
335
+ const parsed = JSON.parse(await readBounded(response, MAX_ACK_RESPONSE_BYTES));
336
+ if (!isObject(parsed))
337
+ return null;
338
+ const nested = isObject(parsed.error) ? parsed.error.code : undefined;
339
+ for (const candidate of [parsed.diagnostic, parsed.code, nested, parsed.error])
340
+ if (typeof candidate === "string" && /^[a-z][a-z0-9_]{0,63}$/u.test(candidate))
341
+ return candidate;
342
+ }
343
+ catch {
344
+ // An unreadable refusal is named by its status alone.
345
+ }
346
+ return null;
347
+ }
348
+ function retryAfterMs(response) {
349
+ const value = response.headers.get("retry-after");
350
+ if (!value || !/^\d{1,6}$/u.test(value.trim()))
351
+ return null;
352
+ return Math.min(MAX_RETRY_AFTER_MS, Number(value.trim()) * 1000);
353
+ }
354
+ function sleep(ms, signal) {
355
+ return new Promise((done) => {
356
+ if (signal?.aborted)
357
+ return done();
358
+ const timer = setTimeout(finish, ms);
359
+ function finish() {
360
+ clearTimeout(timer);
361
+ signal?.removeEventListener("abort", finish);
362
+ done();
363
+ }
364
+ signal?.addEventListener("abort", finish, { once: true });
365
+ });
366
+ }
367
+ function defaultOnInterrupt(listener) {
368
+ process.on("SIGINT", listener);
369
+ process.on("SIGTERM", listener);
370
+ return () => {
371
+ process.removeListener("SIGINT", listener);
372
+ process.removeListener("SIGTERM", listener);
373
+ };
374
+ }
375
+ function parseListenArguments(argv) {
376
+ return parseArgs({
377
+ args: argv,
378
+ allowPositionals: true,
379
+ strict: true,
380
+ options: {
381
+ subscription: { type: "string" },
382
+ "forward-to": { type: "string" },
383
+ origin: { type: "string" },
384
+ ...envFileOptions,
385
+ credential: { type: "string" },
386
+ max: { type: "string" },
387
+ "allow-remote-forward": { type: "boolean", default: false },
388
+ help: { type: "boolean", short: "h", default: false },
389
+ },
390
+ });
391
+ }
392
+ /**
393
+ * The credential that pulls: the subscription's world token or connection key from the
394
+ * environment. A project key is refused before any request; Hue refuses one too.
395
+ */
396
+ function selectCredential(env, choice) {
397
+ const worldToken = env.HUE_WORLD_TOKEN?.trim() || undefined;
398
+ const connectionKey = env.HUE_CONNECTION_KEY?.trim() || undefined;
399
+ if (choice !== undefined && choice !== "world-token" && choice !== "connection-key")
400
+ throw new UsageError("--credential must be world-token or connection-key");
401
+ let kind;
402
+ if (choice)
403
+ kind = choice;
404
+ else if (worldToken && connectionKey)
405
+ throw new UsageError("Both HUE_WORLD_TOKEN and HUE_CONNECTION_KEY are set; pass --credential world-token or --credential connection-key");
406
+ else if (worldToken)
407
+ kind = "world-token";
408
+ else if (connectionKey)
409
+ kind = "connection-key";
410
+ else
411
+ throw new UsageError("Set HUE_WORLD_TOKEN (a world's subscription) or HUE_CONNECTION_KEY (a connection key's subscription); hue listen never uses a project key");
412
+ const variable = kind === "world-token" ? "HUE_WORLD_TOKEN" : "HUE_CONNECTION_KEY";
413
+ const credential = kind === "world-token" ? worldToken : connectionKey;
414
+ if (!credential)
415
+ throw new UsageError(`${variable} is not set`);
416
+ for (const projectVariable of ["HUE_API_KEY", "HUE_MCP_KEY"])
417
+ if (env[projectVariable]?.trim() === credential)
418
+ throw new UsageError(`${variable} holds the project key in ${projectVariable}; hue listen pulls only with the subscription's world token or connection key`);
419
+ if (kind === "world-token" && !WORLD_TOKEN.test(credential))
420
+ throw new UsageError(credential.startsWith("hue_sk_")
421
+ ? "HUE_WORLD_TOKEN holds a key, not a world token; a connection key goes in HUE_CONNECTION_KEY"
422
+ : "HUE_WORLD_TOKEN is not a world token (hue_world_...)");
423
+ if (kind === "connection-key" && !CONNECTION_KEY.test(credential))
424
+ throw new UsageError(credential.startsWith("hue_world_")
425
+ ? "HUE_CONNECTION_KEY holds a world token; a world token goes in HUE_WORLD_TOKEN"
426
+ : "HUE_CONNECTION_KEY is not a connection key (hue_sk_...)");
427
+ return { credential, label: kind === "world-token" ? "world token" : "connection key" };
428
+ }
429
+ async function loadEnv(env, cwd, file) {
430
+ if (!file)
431
+ return env;
432
+ let text;
433
+ try {
434
+ text = await readFile(resolve(cwd, file), "utf8");
435
+ }
436
+ catch (error) {
437
+ throw new UsageError(`Unable to load ${scrubCredentialText(file)}: ${error.code ?? "unreadable"}`);
438
+ }
439
+ // As Node's --env-file does, a variable already in the environment keeps its value.
440
+ return { ...parseEnv(text), ...env };
441
+ }
442
+ function describeAnswer(answer) {
443
+ switch (answer.outcome) {
444
+ case "response":
445
+ return `${answer.status} in ${answer.durationMs} ms${answer.noRetry ? " (no retry)" : ""}`;
446
+ case "timeout":
447
+ return `no answer within ${answer.durationMs} ms`;
448
+ case "tls_error":
449
+ return "TLS error";
450
+ default:
451
+ return "connection failed";
452
+ }
453
+ }
454
+ function describeAck(result) {
455
+ switch (result.kind) {
456
+ case "recorded":
457
+ return result.state ? `Hue: ${result.state}` : "Hue: recorded";
458
+ case "lease_expired":
459
+ return "Hue: lease expired, recorded as a timeout";
460
+ case "revoked":
461
+ return "Hue: subscription revoked";
462
+ case "refused":
463
+ return `Hue refused the acknowledgement (HTTP ${result.status}${result.code ? ` ${result.code}` : ""})`;
464
+ default:
465
+ return "Hue did not answer the acknowledgement; unless it was recorded, the lease lapses into a timeout";
466
+ }
467
+ }
468
+ /**
469
+ * Runs `hue listen` and returns the process exit code: 0 stopped, 1 failed, 2 usage error, 130
470
+ * interrupted twice (acknowledgements abandoned). `argv` may start with the `listen` command word.
471
+ */
472
+ export async function runListenCommand(argv, io = {}) {
473
+ const stdout = io.stdout ?? process.stdout;
474
+ const stderr = io.stderr ?? process.stderr;
475
+ const cwd = io.cwd ?? process.cwd();
476
+ const fetchImpl = io.fetch ?? globalThis.fetch;
477
+ const forwardTimeoutMs = io.forwardTimeoutMs ?? FORWARD_TIMEOUT_MS;
478
+ const pullRetryDelays = io.retryDelaysMs ?? PULL_RETRY_DELAYS_MS;
479
+ const secrets = [];
480
+ // Every line loses the credential and control characters; text from elsewhere (error messages,
481
+ // the receiver URL) also loses anything shaped like a credential.
482
+ const clean = (text) => {
483
+ let out = text;
484
+ for (const secret of secrets)
485
+ out = out.replaceAll(secret, "[redacted]");
486
+ return out.replace(/[^\P{Cc}\n\t]|\p{Cf}/gu, " ");
487
+ };
488
+ const foreign = (text) => scrubCredentialText(text);
489
+ const out = (line) => void stdout.write(`${clean(line)}\n`);
490
+ const warn = (line) => void stderr.write(`${clean(line)}\n`);
491
+ let subscription;
492
+ let target;
493
+ let origin;
494
+ let credential;
495
+ let label;
496
+ let max;
497
+ let allowRemote;
498
+ try {
499
+ let parsed;
500
+ try {
501
+ parsed = parseListenArguments(argv);
502
+ }
503
+ catch (error) {
504
+ // Node's message repeats the option as typed, which may carry a pasted token.
505
+ throw new UsageError(foreign(error.message));
506
+ }
507
+ if (parsed.values.help) {
508
+ stdout.write(`${LISTEN_USAGE}\n`);
509
+ return 0;
510
+ }
511
+ const positionals = parsed.positionals[0] === "listen" ? parsed.positionals.slice(1) : parsed.positionals;
512
+ if (positionals.length > 0)
513
+ throw new UsageError(`Unexpected argument: ${foreign(positionals[0])}`);
514
+ const values = parsed.values;
515
+ const env = await loadEnv(io.env ?? process.env, cwd, envFileArgument(values, cwd));
516
+ ({ credential, label } = selectCredential(env, values.credential));
517
+ secrets.push(credential);
518
+ if (!values.subscription || !UUID.test(values.subscription))
519
+ throw new UsageError("--subscription must be the subscription's id (a UUID)");
520
+ subscription = values.subscription.toLowerCase();
521
+ if (!values["forward-to"])
522
+ throw new UsageError("--forward-to is required");
523
+ allowRemote = values["allow-remote-forward"];
524
+ const forward = parseForwardTarget(values["forward-to"], allowRemote);
525
+ if (typeof forward === "string")
526
+ throw new UsageError(forward);
527
+ target = forward;
528
+ const parsedOrigin = parseHueOrigin(values.origin ?? (env.HUE_BASE_URL?.trim() || DEFAULT_ORIGIN));
529
+ if (!parsedOrigin)
530
+ throw new UsageError("--origin must be an HTTPS origin such as https://app.hue.run (plain HTTP is accepted for loopback test servers only)");
531
+ origin = parsedOrigin;
532
+ max = Number(values.max ?? MAX_BATCH);
533
+ if (!/^\d{1,2}$/u.test(values.max ?? String(MAX_BATCH)) || max < 1 || max > MAX_BATCH)
534
+ throw new UsageError(`--max must be an integer from 1 to ${MAX_BATCH}`);
535
+ }
536
+ catch (error) {
537
+ // The credential may not be known yet, so each value the user typed was scrubbed by its shape
538
+ // where the message was built; the guidance around it is left as written.
539
+ warn(error.message);
540
+ stderr.write(`\n${LISTEN_USAGE}\n`);
541
+ return 2;
542
+ }
543
+ const base = `${origin}/api/v1/event-subscriptions/${subscription}/deliveries`;
544
+ const headers = {
545
+ authorization: `Bearer ${credential}`,
546
+ "content-type": "application/json",
547
+ accept: "application/json",
548
+ "user-agent": `hue-sdk-typescript/${sdkVersion} hue-listen`,
549
+ };
550
+ // The first stop request stops pulling and lets deliveries in flight finish; a second one
551
+ // abandons their acknowledgements, whose leases then lapse into timeouts.
552
+ const stopping = new AbortController();
553
+ const forced = new AbortController();
554
+ const removeInterrupt = (io.onInterrupt ?? defaultOnInterrupt)(() => {
555
+ if (!stopping.signal.aborted) {
556
+ stopping.abort();
557
+ warn("Stopping: finishing deliveries in flight (press Ctrl+C again to abandon them).");
558
+ }
559
+ else
560
+ forced.abort();
561
+ });
562
+ const remembered = new Map();
563
+ let fatal = null;
564
+ const pull = async (waitMs) => {
565
+ const request = new AbortController();
566
+ const abort = () => request.abort();
567
+ stopping.signal.addEventListener("abort", abort, { once: true });
568
+ const timer = setTimeout(abort, waitMs + PULL_GRACE_MS);
569
+ let response;
570
+ try {
571
+ response = await fetchImpl(`${base}/pull`, {
572
+ method: "POST",
573
+ headers,
574
+ body: JSON.stringify({ waitMs, max }),
575
+ redirect: "manual",
576
+ signal: request.signal,
577
+ });
578
+ }
579
+ catch (error) {
580
+ if (stopping.signal.aborted)
581
+ return { kind: "stopped" };
582
+ const cause = error.cause?.code;
583
+ return {
584
+ kind: "retry",
585
+ reason: request.signal.aborted
586
+ ? "no answer in time"
587
+ : `${foreign(error.message)}${typeof cause === "string" && /^[A-Z_]{1,32}$/u.test(cause) ? ` (${cause})` : ""}`,
588
+ retryAfterMs: null,
589
+ };
590
+ }
591
+ finally {
592
+ clearTimeout(timer);
593
+ // Once Hue answered, its deliveries are leased to this client: a stop request no longer
594
+ // cancels reading them, so they are forwarded and acknowledged rather than left to lapse.
595
+ stopping.signal.removeEventListener("abort", abort);
596
+ }
597
+ const receivedAt = performance.now();
598
+ const bodyTimer = setTimeout(abort, PULL_GRACE_MS);
599
+ // A second stop request abandons an answer still arriving; its leases lapse into timeouts.
600
+ forced.signal.addEventListener("abort", abort, { once: true });
601
+ if (forced.signal.aborted)
602
+ abort();
603
+ try {
604
+ return await readPull(response, receivedAt);
605
+ }
606
+ finally {
607
+ clearTimeout(bodyTimer);
608
+ forced.signal.removeEventListener("abort", abort);
609
+ }
610
+ };
611
+ const readPull = async (response, receivedAt) => {
612
+ const status = response.status;
613
+ if (status === 200) {
614
+ let parsed;
615
+ try {
616
+ parsed = JSON.parse(await readBounded(response, MAX_PULL_RESPONSE_BYTES));
617
+ }
618
+ catch (error) {
619
+ return { kind: "retry", reason: foreign(error.message), retryAfterMs: null };
620
+ }
621
+ const list = isObject(parsed) ? parsed.deliveries : undefined;
622
+ if (!Array.isArray(list))
623
+ return { kind: "fatal", message: "Hue answered a pull without deliveries" };
624
+ const deliveries = [];
625
+ const seen = new Set();
626
+ let skipped = 0;
627
+ let excess = 0;
628
+ for (const item of list) {
629
+ const delivery = parseDelivery(item, subscription);
630
+ if (!delivery)
631
+ skipped++;
632
+ else if (seen.has(delivery.deliveryId))
633
+ continue;
634
+ // Concurrency stays bounded by --max whatever the answer holds.
635
+ else if (deliveries.length >= max)
636
+ excess++;
637
+ else {
638
+ seen.add(delivery.deliveryId);
639
+ deliveries.push(delivery);
640
+ }
641
+ }
642
+ // A lease is measured on the server's clock: its stated end less the answer's `Date`, which
643
+ // is truncated to the second and so may be up to a second early.
644
+ const serverNow = Date.parse(response.headers.get("date") ?? "");
645
+ const leaseLeftMs = (delivery) => delivery.leaseExpiresAt === null || !Number.isFinite(serverNow)
646
+ ? LEASE_MS
647
+ : Math.min(LEASE_MS, Math.max(0, delivery.leaseExpiresAt - serverNow - 1_000));
648
+ return { kind: "deliveries", deliveries, skipped, excess, receivedAt, leaseLeftMs };
649
+ }
650
+ const code = await refusalCode(response);
651
+ const named = `HTTP ${status}${code ? ` ${code}` : ""}`;
652
+ if (status >= 300 && status <= 399)
653
+ return {
654
+ kind: "fatal",
655
+ message: `Hue answered the pull with a redirect (${named}); hue listen follows none. Check --origin.`,
656
+ };
657
+ if (status === 401)
658
+ return {
659
+ kind: "fatal",
660
+ message: `Hue refused the ${label} (${named}): it is not subscription ${subscription}'s credential, or it ended (a world token ends when its world seals). A project key never pulls.`,
661
+ };
662
+ if (status === 404)
663
+ return {
664
+ kind: "fatal",
665
+ message: `Hue has no subscription ${subscription} for this ${label} (${named}).`,
666
+ };
667
+ if (status === 409 && code === "pull_in_progress")
668
+ return {
669
+ kind: "retry",
670
+ reason: "another pull is open for this subscription (is another hue listen running?)",
671
+ retryAfterMs: retryAfterMs(response),
672
+ };
673
+ if (status === 409 && code === "not_listen")
674
+ return {
675
+ kind: "fatal",
676
+ message: `Subscription ${subscription} delivers to a request URL; hue listen needs a listen subscription.`,
677
+ };
678
+ if (status === 409 && code === "subscription_revoked")
679
+ return { kind: "fatal", message: `Subscription ${subscription} is revoked.` };
680
+ if (status === 429 || status >= 500)
681
+ return { kind: "retry", reason: named, retryAfterMs: retryAfterMs(response) };
682
+ return { kind: "fatal", message: `Hue refused the pull (${named}).` };
683
+ };
684
+ const acknowledge = async (delivery, answer, deadline) => {
685
+ for (let attempt = 0;; attempt++) {
686
+ // The first acknowledgement is always sent (Hue decides whether the lease still holds);
687
+ // repeats stop when the lease has ended.
688
+ const remaining = deadline - performance.now();
689
+ if (forced.signal.aborted || (attempt > 0 && remaining <= 0))
690
+ return { kind: "unanswered" };
691
+ const step = ACK_RETRY_DELAYS_MS[Math.min(attempt, ACK_RETRY_DELAYS_MS.length - 1)];
692
+ const request = new AbortController();
693
+ const abort = () => request.abort();
694
+ forced.signal.addEventListener("abort", abort, { once: true });
695
+ const timer = setTimeout(abort, Math.min(ACK_REQUEST_TIMEOUT_MS, Math.max(1_000, remaining)));
696
+ try {
697
+ const response = await fetchImpl(`${base}/${delivery.deliveryId}/ack`, {
698
+ method: "POST",
699
+ headers,
700
+ body: JSON.stringify(answer),
701
+ redirect: "manual",
702
+ signal: request.signal,
703
+ });
704
+ if (response.status >= 200 && response.status <= 299) {
705
+ let state = null;
706
+ try {
707
+ const parsed = JSON.parse(await readBounded(response, MAX_ACK_RESPONSE_BYTES));
708
+ const candidate = isObject(parsed)
709
+ ? (parsed.state ?? (isObject(parsed.event) ? parsed.event.state : undefined))
710
+ : undefined;
711
+ if (typeof candidate === "string" && EVENT_STATES.has(candidate))
712
+ state = candidate;
713
+ }
714
+ catch {
715
+ // The acknowledgement is recorded whatever the answer's body holds.
716
+ }
717
+ return { kind: "recorded", state };
718
+ }
719
+ const code = await refusalCode(response);
720
+ if (response.status === 409 && code === "lease_expired")
721
+ return { kind: "lease_expired" };
722
+ if (response.status === 409 && code === "subscription_revoked")
723
+ return { kind: "revoked" };
724
+ if (response.status !== 429 && response.status < 500)
725
+ return { kind: "refused", status: response.status, code };
726
+ // A `Retry-After` of 0 never turns the backoff into a busy loop.
727
+ await sleep(Math.min(Math.max(step, retryAfterMs(response) ?? 0), Math.max(0, remaining)), forced.signal);
728
+ }
729
+ catch {
730
+ // No answer: the same acknowledgement is safe to repeat until the lease ends.
731
+ await sleep(Math.min(step, Math.max(0, remaining)), forced.signal);
732
+ }
733
+ finally {
734
+ clearTimeout(timer);
735
+ forced.signal.removeEventListener("abort", abort);
736
+ }
737
+ }
738
+ };
739
+ const deliver = async (delivery, deadline) => {
740
+ // Hue may hand out a delivery again (a pull whose answer was retried); its request already
741
+ // reached the receiver, so the recorded answer is repeated and nothing is sent twice.
742
+ const previous = remembered.get(delivery.deliveryId);
743
+ const answer = previous ??
744
+ (await forwardDelivery(target, delivery, {
745
+ timeoutMs: forwardTimeoutMs,
746
+ allowRemote,
747
+ signal: forced.signal,
748
+ }));
749
+ const name = delivery.kind === "url_verification"
750
+ ? "url_verification"
751
+ : `${delivery.eventId ?? "event"}${delivery.retryNum ? ` retry ${delivery.retryNum}` : ""}`;
752
+ if (answer === null) {
753
+ out(`${new Date().toISOString()} ${name} -> abandoned; not acknowledged, the lease lapses into a timeout`);
754
+ return;
755
+ }
756
+ if (!previous) {
757
+ remembered.set(delivery.deliveryId, answer);
758
+ if (remembered.size > REMEMBERED_DELIVERIES)
759
+ remembered.delete(remembered.keys().next().value);
760
+ }
761
+ const result = await acknowledge(delivery, answer, deadline);
762
+ if (result.kind === "revoked")
763
+ fatal ??= `Subscription ${subscription} is revoked.`;
764
+ out(`${new Date().toISOString()} ${name}${previous ? " (repeated)" : ""} -> ${describeAnswer(answer)}; ${describeAck(result)}`);
765
+ };
766
+ out(`Forwarding subscription ${subscription} from ${foreign(origin)} to ${foreign(target.href)} using the ${label}. Press Ctrl+C to stop.`);
767
+ let failures = 0;
768
+ let ready = false;
769
+ try {
770
+ while (!stopping.signal.aborted && fatal === null) {
771
+ // The first pull answers at once, so a refused credential or subscription shows immediately.
772
+ const pulledAt = performance.now();
773
+ const result = await pull(ready ? PULL_WAIT_MS : 0);
774
+ if (result.kind === "stopped")
775
+ break;
776
+ if (result.kind === "fatal") {
777
+ fatal = result.message;
778
+ break;
779
+ }
780
+ if (result.kind === "retry") {
781
+ // `Retry-After` can lengthen the backoff, never shorten it.
782
+ const delay = Math.max(pullRetryDelays[Math.min(failures, pullRetryDelays.length - 1)], result.retryAfterMs ?? 0);
783
+ failures++;
784
+ warn(`Pull failed: ${result.reason}; retrying in ${Math.ceil(delay / 1000)} s.`);
785
+ await sleep(delay, stopping.signal);
786
+ continue;
787
+ }
788
+ failures = 0;
789
+ // A pull that answers empty at once, though it could wait, is not repeated at once.
790
+ if (ready && result.deliveries.length === 0 && performance.now() - pulledAt < 1_000)
791
+ await sleep(1_000, stopping.signal);
792
+ if (!ready) {
793
+ ready = true;
794
+ out("Ready: waiting for events.");
795
+ }
796
+ if (result.skipped)
797
+ warn(`Skipped ${result.skipped} delivery(ies) this version of @hue-run/sdk cannot forward; their leases lapse and Hue retries them.`);
798
+ if (result.excess)
799
+ warn(`Hue leased ${result.excess} more delivery(ies) than --max ${max}; they are not forwarded, their leases lapse and Hue retries them.`);
800
+ await Promise.all(result.deliveries.map((delivery) => deliver(delivery, result.receivedAt + result.leaseLeftMs(delivery))));
801
+ }
802
+ }
803
+ finally {
804
+ removeInterrupt();
805
+ }
806
+ if (fatal !== null) {
807
+ warn(`Error: ${fatal}`);
808
+ return 1;
809
+ }
810
+ if (forced.signal.aborted) {
811
+ warn("Interrupted.");
812
+ return 130;
813
+ }
814
+ out("Stopped.");
815
+ return 0;
816
+ }