ccdeck 3.44.0 → 3.45.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 (44) hide show
  1. package/README.md +14 -5
  2. package/THIRD_PARTY_NOTICES.md +27 -0
  3. package/dist/web/assets/BrowserWatchModal-Cbzf3TWa.js +1 -0
  4. package/dist/web/assets/IntegrationModal-BnnBr0a3.js +5 -0
  5. package/dist/web/assets/RadioPanel-BIqrMLQN.js +1 -0
  6. package/dist/web/assets/{TrafficRadar-Ch0LVdSl.js → TrafficRadar-D1gZCIwX.js} +1 -1
  7. package/dist/web/assets/{UsageHistoryModal-Du0ZkIJ1.js → UsageHistoryModal-DAhplVju.js} +1 -1
  8. package/dist/web/assets/app-BW3NYUYB.js +318 -0
  9. package/dist/web/assets/app-C5RY_wZ4.css +1 -0
  10. package/dist/web/assets/{index-BQbOfxbL.js → index-r8Ymr47Q.js} +1 -1
  11. package/dist/web/index.html +3 -3
  12. package/package.json +1 -1
  13. package/release-notes.json +18 -0
  14. package/src/server/account-notify.mjs +68 -6
  15. package/src/server/account-watch.mjs +61 -2
  16. package/src/server/claude-accounts.mjs +12 -2
  17. package/src/server/deck-prefs.mjs +15 -5
  18. package/src/server/feature-use.mjs +11 -1
  19. package/src/server/forge-http.mjs +514 -0
  20. package/src/server/forge-public-fetch.mjs +228 -0
  21. package/src/server/forge-routes.mjs +154 -0
  22. package/src/server/github-api.mjs +1369 -0
  23. package/src/server/gitlab-api.mjs +1271 -0
  24. package/src/server/index.mjs +6 -2
  25. package/src/server/integrations/cache.mjs +42 -0
  26. package/src/server/integrations/errors.mjs +167 -0
  27. package/src/server/integrations/forge.mjs +76 -0
  28. package/src/server/integrations/jira.mjs +1026 -0
  29. package/src/server/integrations/providers.mjs +11 -0
  30. package/src/server/integrations/proxy.mjs +314 -0
  31. package/src/server/integrations/registry.mjs +142 -0
  32. package/src/server/integrations/routes.mjs +498 -0
  33. package/src/server/integrations/store.mjs +226 -0
  34. package/src/server/lan-deck.mjs +7 -0
  35. package/src/server/lan-elsewhere.d.mts +30 -0
  36. package/src/server/lan-elsewhere.mjs +137 -0
  37. package/src/server/org-telemetry.mjs +173 -0
  38. package/src/server/pinned-build.mjs +3 -0
  39. package/src/server/request-gates.mjs +19 -0
  40. package/src/server/usage-day.mjs +2 -0
  41. package/dist/web/assets/BrowserWatchModal-DMIUygWz.js +0 -1
  42. package/dist/web/assets/RadioPanel-DNB_AHSs.js +0 -1
  43. package/dist/web/assets/app-CQIstkMF.js +0 -318
  44. package/dist/web/assets/app-Ch-0CYlK.css +0 -1
@@ -0,0 +1,514 @@
1
+ // The one road the GitHub and GitLab clients take to their forge (#2179).
2
+ //
3
+ // github-api.mjs and gitlab-api.mjs say WHAT to ask; this file is HOW, and it
4
+ // is where the promises about the token are kept, so they are kept once:
5
+ //
6
+ // THE TOKEN GOES ONLY WHERE IT WAS GIVEN FOR. A transport is made for one
7
+ // connection with the origins that connection's provider allowed — GitLab's
8
+ // own instance, api.github.com — and the token is attached to a request only
9
+ // when its URL is on one of them. Redirects are followed by hand, never by
10
+ // fetch, so a hop to another origin (GitHub sends a job's log to a signed
11
+ // blob-storage URL) goes out WITHOUT it. A token never rides to a host the
12
+ // user did not type.
13
+ //
14
+ // NOR DOES THE DECK GO WHERE A FORGE POINTS IT. A JSON or GraphQL read follows
15
+ // a redirect only on the connection's own origins; a job log's tail may leave
16
+ // them for a public https host only. No forge can send the deck to this
17
+ // machine, the LAN or a cloud metadata address (off-origin redirects) — not by
18
+ // naming one, and not by a public name whose DNS answers one: a request to a
19
+ // host off the connection's own goes through forge-public-fetch.mjs, whose
20
+ // socket connects only to an address it resolved and checked.
21
+ //
22
+ // NOTHING HERE SAYS THE TOKEN. Errors carry the forge's own words and the
23
+ // status; never a header, never the request. The messages are diagnostic —
24
+ // the panel picks its sentence from `kind`.
25
+ //
26
+ // ONE VOCABULARY FOR FAILURE. Every failure leaves as a ForgeError whose kind
27
+ // is one of forge-types.ts's ForgeErrorKind, because each kind has a different
28
+ // recovery on screen: auth and expired send the user to the connection,
29
+ // permission names the missing scope, unreachable and tls are about the
30
+ // network or the instance, rate-limit says when to try again.
31
+ import { cappedText } from "./provider-status.mjs";
32
+ import { isPrivateHost } from "./integrations/proxy.mjs";
33
+ import { NOT_PUBLIC, publicFetch } from "./forge-public-fetch.mjs";
34
+
35
+ const DEFAULT_TIMEOUT_MS = 20_000;
36
+ // A page of fifty merge requests with their reviewers is ~200 KB; a GraphQL
37
+ // detail query with every discussion, well under 2 MB. Sixteen is room for a
38
+ // big one without letting a wrong address stream a disk image into memory.
39
+ const DEFAULT_MAX_BYTES = 16 << 20;
40
+ const MAX_REDIRECTS = 3;
41
+ // The forge's own error body is read for its message only.
42
+ const ERROR_BODY_BYTES = 64 << 10;
43
+ // Lines a log tail keeps beyond what it was asked for, while it streams.
44
+ const TAIL_SLACK = 32;
45
+ // An avatar is a few KB, a pasted screenshot a few MB.
46
+ const ASSET_MAX_BYTES = 8 << 20;
47
+ // Raster images only: an SVG served from the deck's own origin and opened as
48
+ // a page could run script there.
49
+ // The four the integrations core's asset route passes on (#2178).
50
+ const RASTER = /^image\/(?:png|jpeg|gif|webp)$/;
51
+
52
+ /** A failure, in the panel's vocabulary. */
53
+ export class ForgeError extends Error {
54
+ /**
55
+ * @param {string} kind
56
+ * @param {string} message
57
+ * @param {{ status?: number, retryAfter?: number }} [extra]
58
+ */
59
+ constructor(kind, message, extra = {}) {
60
+ super(message);
61
+ this.name = "ForgeError";
62
+ this.kind = kind;
63
+ if (extra.status) this.status = extra.status;
64
+ if (Number.isFinite(extra.retryAfter)) this.retryAfter = extra.retryAfter;
65
+ }
66
+
67
+ toJSON() {
68
+ return {
69
+ kind: this.kind,
70
+ message: this.message,
71
+ ...(this.status ? { status: this.status } : {}),
72
+ ...(Number.isFinite(this.retryAfter) ? { retryAfter: this.retryAfter } : {}),
73
+ };
74
+ }
75
+ }
76
+
77
+ /** Any thrown thing as the route answers it. A non-ForgeError is a bug of
78
+ * ours, and says so without passing its message through. */
79
+ export function forgeErrorInfo(err) {
80
+ if (err instanceof ForgeError) return err.toJSON();
81
+ return { kind: "upstream", message: "unexpected error reading the forge" };
82
+ }
83
+
84
+ // What Node's fetch (undici) puts in `cause.code` when the certificate is the
85
+ // problem. ERR_TLS_* and ERR_SSL_* are matched by prefix below.
86
+ const TLS_CODES = new Set([
87
+ "CERT_HAS_EXPIRED", "CERT_NOT_YET_VALID", "CERT_REVOKED", "CERT_UNTRUSTED", "CERT_REJECTED",
88
+ "DEPTH_ZERO_SELF_SIGNED_CERT", "SELF_SIGNED_CERT_IN_CHAIN", "UNABLE_TO_GET_ISSUER_CERT",
89
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "HOSTNAME_MISMATCH",
90
+ ]);
91
+
92
+ /** The first error code anywhere down an error's causes — undici wraps the
93
+ * socket's error in `cause`, and a dual-stack connect in an AggregateError. */
94
+ function errorCode(err, depth = 0) {
95
+ if (!err || depth > 4) return null;
96
+ if (typeof err.code === "string" && err.code !== "UND_ERR") return err.code;
97
+ if (Array.isArray(err.errors)) {
98
+ for (const e of err.errors) {
99
+ const c = errorCode(e, depth + 1);
100
+ if (c) return c;
101
+ }
102
+ }
103
+ return errorCode(err.cause, depth + 1);
104
+ }
105
+
106
+ /** A request that got no answer at all, as a ForgeError. */
107
+ export function networkError(err, origin) {
108
+ if (err instanceof ForgeError) return err;
109
+ if (err?.name === "TimeoutError" || err?.name === "AbortError") {
110
+ return new ForgeError("unreachable", `${origin} did not answer in time`);
111
+ }
112
+ const code = errorCode(err);
113
+ if (code && (TLS_CODES.has(code) || /^ERR_(?:TLS|SSL)_/.test(code))) {
114
+ return new ForgeError("tls", `${origin}: the certificate is not trusted (${code})`);
115
+ }
116
+ return new ForgeError("unreachable", `${origin}: ${code ?? "no answer"}`);
117
+ }
118
+
119
+ /**
120
+ * Seconds until the forge will answer again, from whichever header it sent:
121
+ * Retry-After (seconds or a date), GitHub's x-ratelimit-reset or GitLab's
122
+ * RateLimit-Reset (both epoch seconds). Undefined when it said nothing.
123
+ */
124
+ export function retryAfterSeconds(headers, nowMs) {
125
+ const get = (name) => headers?.get?.(name) ?? null;
126
+ const retry = get("retry-after");
127
+ if (retry != null) {
128
+ const s = Number(retry);
129
+ if (Number.isFinite(s)) return Math.max(0, Math.ceil(s));
130
+ const at = Date.parse(retry);
131
+ if (Number.isFinite(at)) return Math.max(0, Math.ceil((at - nowMs) / 1000));
132
+ }
133
+ const reset = Number(get("x-ratelimit-reset") ?? get("ratelimit-reset"));
134
+ if (Number.isFinite(reset) && reset > 0) return Math.max(0, Math.ceil(reset - nowMs / 1000));
135
+ return undefined;
136
+ }
137
+
138
+ /** The forge's own sentence out of an error body: GitHub says `message`,
139
+ * GitLab `message` or `error_description` or `error`. */
140
+ function bodyMessage(body, fallback) {
141
+ if (body && typeof body === "object") {
142
+ const m = body.error_description ?? body.message ?? body.error;
143
+ if (typeof m === "string" && m.trim()) return m.trim().slice(0, 300);
144
+ // GitLab sends validation failures as { message: { field: [..] } }.
145
+ if (m && typeof m === "object") return JSON.stringify(m).slice(0, 300);
146
+ }
147
+ if (typeof body === "string" && body.trim() && !/^\s*</.test(body)) return body.trim().slice(0, 300);
148
+ return fallback;
149
+ }
150
+
151
+ /** A response that is not 2xx, as a ForgeError. Reads (a capped piece of) the
152
+ * body for the forge's message. */
153
+ export async function httpError(res, nowMs) {
154
+ let body = null;
155
+ try {
156
+ const text = await cappedText(res, ERROR_BODY_BYTES);
157
+ try { body = JSON.parse(text); } catch { body = text; }
158
+ } catch { /* the status alone will do */ }
159
+ const status = res.status;
160
+ const message = bodyMessage(body, `http ${status}`);
161
+ const retryAfter = retryAfterSeconds(res.headers, nowMs);
162
+ if (status === 401) {
163
+ // GitLab: { error: "invalid_token", error_description: "Token is expired…" }.
164
+ return new ForgeError(/expired/i.test(message) ? "expired" : "auth", message, { status });
165
+ }
166
+ if (status === 429) return new ForgeError("rate-limit", message, { status, retryAfter });
167
+ if (status === 403) {
168
+ // GitHub answers an exhausted limit with 403, not 429: the remaining
169
+ // count says which 403 this is, and its secondary limit says so in words.
170
+ const limited = res.headers?.get?.("x-ratelimit-remaining") === "0"
171
+ || /rate limit/i.test(message)
172
+ || res.headers?.get?.("retry-after") != null;
173
+ return limited
174
+ ? new ForgeError("rate-limit", message, { status, retryAfter })
175
+ : new ForgeError("permission", message, { status });
176
+ }
177
+ if (status === 404) return new ForgeError("not-found", message, { status });
178
+ // A self-hosted instance that is down usually answers through its proxy.
179
+ if (status === 502 || status === 503 || status === 504) return new ForgeError("unreachable", message, { status });
180
+ return new ForgeError("upstream", message, { status });
181
+ }
182
+
183
+ /** The ForgeError for a GraphQL answer with errors and no data. GitHub types
184
+ * its errors; GitLab says it in words. */
185
+ export function graphqlError(errors) {
186
+ const list = Array.isArray(errors) ? errors : [];
187
+ const message = list.map(e => e?.message).filter(m => typeof m === "string").join("; ").slice(0, 300)
188
+ || "the forge answered without data";
189
+ const types = new Set(list.map(e => e?.type ?? e?.extensions?.code).filter(Boolean));
190
+ if (types.has("RATE_LIMITED") || /rate limit/i.test(message)) return new ForgeError("rate-limit", message);
191
+ if (types.has("INSUFFICIENT_SCOPES") || types.has("FORBIDDEN") || /insufficient.scope|not have permission/i.test(message)) {
192
+ return new ForgeError("permission", message);
193
+ }
194
+ if (types.has("NOT_FOUND") || /does not exist|could not resolve/i.test(message)) return new ForgeError("not-found", message);
195
+ return new ForgeError("upstream", message);
196
+ }
197
+
198
+ /** The URL of the next page from a Link header, or null. */
199
+ export function nextLink(headers) {
200
+ const link = headers?.get?.("link");
201
+ if (!link) return null;
202
+ for (const part of link.split(",")) {
203
+ const m = /<([^>]+)>\s*;\s*rel="?next"?/.exec(part);
204
+ if (m) return m[1];
205
+ }
206
+ return null;
207
+ }
208
+
209
+ /** An ISO time as epoch ms, or null. */
210
+ export function epoch(s) {
211
+ if (typeof s !== "string" || !s) return null;
212
+ const t = Date.parse(s);
213
+ return Number.isFinite(t) ? t : null;
214
+ }
215
+
216
+ /** Run `fn` over `items` with at most `limit` at once, in order. Used where a
217
+ * forge needs one call per row (GitLab's ahead/behind per branch). */
218
+ export async function mapLimit(items, limit, fn) {
219
+ const out = new Array(items.length);
220
+ let next = 0;
221
+ const worker = async () => {
222
+ while (next < items.length) {
223
+ const i = next++;
224
+ out[i] = await fn(items[i], i);
225
+ }
226
+ };
227
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
228
+ return out;
229
+ }
230
+
231
+ // CSI and OSC escape sequences, and the two-byte ones — what CI runners colour
232
+ // and move the cursor with.
233
+ const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]/g;
234
+ // GitLab's collapsible-section markers: `section_start:1700000000:step_script[collapsed=true]`.
235
+ const GITLAB_SECTION = /section_(?:start|end):\d+:[A-Za-z0-9_.-]+(?:\[[^\]]*\])?/g;
236
+
237
+ /**
238
+ * One raw log line as a person reads it. A carriage return inside a line is a
239
+ * progress bar redrawn — the text after the last one is what the terminal
240
+ * showed — and GitLab puts its section markers before one too.
241
+ */
242
+ export function cleanLogLine(line) {
243
+ let s = String(line ?? "").replace(/\r+$/, "");
244
+ const cr = s.lastIndexOf("\r");
245
+ if (cr >= 0) s = s.slice(cr + 1);
246
+ return s.replace(GITLAB_SECTION, "").replace(ANSI, "").trimEnd();
247
+ }
248
+
249
+ /**
250
+ * A transport for one connection.
251
+ *
252
+ * @param {object} opts
253
+ * @param {string} opts.token
254
+ * @param {(token: string) => Record<string, string>} opts.authHeaders how this forge wants the token
255
+ * @param {string[]} opts.allowedOrigins where the token may go
256
+ * @param {typeof fetch} [opts.fetchImpl] every request to the allowed origins, and every `allow`ed one
257
+ * @param {typeof fetch} [opts.publicFetchImpl] every other request — a redirect hop off the
258
+ * allowed origins. Connects only to public addresses (forge-public-fetch.mjs).
259
+ * @param {() => number} [opts.now]
260
+ * @param {number} [opts.timeoutMs]
261
+ * @param {number} [opts.maxBytes]
262
+ */
263
+ export function createForgeTransport({
264
+ token,
265
+ authHeaders,
266
+ allowedOrigins,
267
+ fetchImpl = (...a) => fetch(...a),
268
+ publicFetchImpl = publicFetch(),
269
+ now = () => Date.now(),
270
+ timeoutMs = DEFAULT_TIMEOUT_MS,
271
+ maxBytes = DEFAULT_MAX_BYTES,
272
+ }) {
273
+ const origins = new Set((allowedOrigins ?? []).map(o => new URL(o).origin));
274
+
275
+ function headersFor(url, extra) {
276
+ const h = { accept: "application/json", "user-agent": "ccdeck", ...extra };
277
+ if (token && origins.has(new URL(url).origin)) Object.assign(h, authHeaders(token));
278
+ return h;
279
+ }
280
+
281
+ /** Whether a redirect may take a read to `next` (off-origin redirects): on the
282
+ * connection's own origins, always; elsewhere only for `offOrigin:
283
+ * "public"` (a job log's signed blob address), and then only a public
284
+ * https host — never plain http, credentials in the address, this machine
285
+ * or a private network. A forge can thus never point the deck at the
286
+ * machine it runs on, the LAN or a cloud metadata address. */
287
+ function mayFollow(next, offOrigin) {
288
+ if (origins.has(next.origin)) return true;
289
+ if (offOrigin !== "public") return false;
290
+ return next.protocol === "https:" && !next.username && !next.password && !isPrivateHost(next.hostname);
291
+ }
292
+
293
+ /** One request, redirects followed by hand: the token is re-decided at
294
+ * every hop, so a hop off the allowed origins drops it. `allow`, when
295
+ * given, is asked about the address and every hop after it; otherwise a
296
+ * hop must pass mayFollow, which refuses any off the connection's origins
297
+ * unless `offOrigin` is "public" — and such a hop is made by
298
+ * publicFetchImpl, which refuses a name that resolves to a private
299
+ * address. `signal` gives up early, as well as the timeout. */
300
+ async function send(url, { method = "GET", headers, body, timeout = timeoutMs, allow, offOrigin = "refuse", signal: caller } = {}) {
301
+ let target = String(url);
302
+ const signal = caller ? anySignal(caller, AbortSignal.timeout(timeout)) : AbortSignal.timeout(timeout);
303
+ for (let hop = 0; ; hop++) {
304
+ if (allow && !allow(new URL(target))) {
305
+ throw new ForgeError("permission", `${new URL(target).origin} is not a host this connection reads from`, { status: 403 });
306
+ }
307
+ const origin = new URL(target).origin;
308
+ const road = allow || origins.has(origin) ? fetchImpl : publicFetchImpl;
309
+ let res;
310
+ try {
311
+ res = await road(target, { method, headers: headersFor(target, headers), body, redirect: "manual", signal });
312
+ } catch (err) {
313
+ if (errorCode(err) === NOT_PUBLIC) {
314
+ throw new ForgeError("permission", `${origin} is not a host this connection reads from`, { status: 403 });
315
+ }
316
+ throw networkError(err, origin);
317
+ }
318
+ const location = res.status >= 300 && res.status < 400 ? res.headers?.get?.("location") : null;
319
+ if (!location) return res;
320
+ res.body?.cancel?.().catch(() => {});
321
+ if (hop >= MAX_REDIRECTS) throw new ForgeError("upstream", "too many redirects", { status: res.status });
322
+ const next = new URL(location, target);
323
+ if (!allow && !mayFollow(next, offOrigin)) {
324
+ // A read of the API sent off it is not the API answering (a sign-in
325
+ // page on another host, a proxy): as a 2xx that is not JSON reads.
326
+ throw offOrigin === "public"
327
+ ? new ForgeError("permission", `${next.origin} is not a host this connection reads from`, { status: 403 })
328
+ : new ForgeError("incompatible", `${new URL(target).origin} sent this read to ${next.origin}, not to this connection's address`, { status: res.status });
329
+ }
330
+ target = next.toString();
331
+ // A redirect is a GET from here on (303, and what browsers do for 302).
332
+ if (method !== "GET" && res.status !== 307 && res.status !== 308) {
333
+ method = "GET";
334
+ body = undefined;
335
+ }
336
+ }
337
+ }
338
+
339
+ /** GET (or send) and parse JSON. A 2xx that is not JSON is the wrong
340
+ * address — a login page, a proxy's splash — and says so as incompatible. */
341
+ async function json(url, opts = {}) {
342
+ const res = await send(url, opts);
343
+ if (!res.ok) throw await httpError(res, now());
344
+ const type = res.headers?.get?.("content-type") ?? "";
345
+ if (!/json/i.test(type)) {
346
+ res.body?.cancel?.().catch(() => {});
347
+ throw new ForgeError("incompatible", `${new URL(url).origin} answered ${type || "without a content type"}, not the API`, { status: res.status });
348
+ }
349
+ let text;
350
+ try {
351
+ text = await cappedText(res, maxBytes);
352
+ } catch (err) {
353
+ throw new ForgeError("upstream", err?.message === "body too large" ? "answer too large" : "answer cut off", { status: res.status });
354
+ }
355
+ try {
356
+ return { data: JSON.parse(text), headers: res.headers, status: res.status };
357
+ } catch {
358
+ throw new ForgeError("upstream", "unreadable JSON", { status: res.status });
359
+ }
360
+ }
361
+
362
+ /** POST a GraphQL query. Errors WITH data are returned for the caller to
363
+ * weigh (a field the token cannot see); errors without data throw. */
364
+ async function graphql(url, query, variables = {}) {
365
+ const { data: body } = await json(url, {
366
+ method: "POST",
367
+ headers: { "content-type": "application/json" },
368
+ body: JSON.stringify({ query, variables }),
369
+ });
370
+ const errors = Array.isArray(body?.errors) ? body.errors : [];
371
+ if (!body?.data) throw graphqlError(errors);
372
+ return { data: body.data, errors };
373
+ }
374
+
375
+ /**
376
+ * The last `lines` lines of a text resource — a job's log. Asks for only the
377
+ * end first (a suffix Range); a server that ignores Range sends it all, and
378
+ * that is streamed with only the last lines kept, up to `maxBytes`.
379
+ */
380
+ async function tail(url, { lines = 80, tailBytes = 256 << 10, maxBytes: cap = 64 << 20, clean = cleanLogLine } = {}) {
381
+ // GitHub answers a job's log with a redirect to a signed blob address on
382
+ // another host: a tail may follow one, to a public https host only.
383
+ const res = await send(url, { headers: { accept: "text/plain, */*", range: `bytes=-${tailBytes}` }, timeout: 60_000, offOrigin: "public" });
384
+ if (!res.ok) throw await httpError(res, now());
385
+ // A suffix range starts mid-line: the first line is a fragment.
386
+ let fragment = res.status === 206;
387
+ let kept = [];
388
+ // Blank lines (once cleaned) since the last real one. They are kept only
389
+ // when a real line follows them: the blank lines a log ends on are not
390
+ // shown, and must never take the place of real ones — however many there
391
+ // are, and wherever the body was cut into chunks.
392
+ let blanks = 0;
393
+ let dropped = false;
394
+ let carry = "";
395
+ let bytes = 0;
396
+ const decoder = new TextDecoder();
397
+ const keep = (raw) => {
398
+ if (fragment) {
399
+ fragment = false;
400
+ dropped = true;
401
+ return;
402
+ }
403
+ const line = clean(raw);
404
+ if (line === "") {
405
+ blanks++;
406
+ return;
407
+ }
408
+ if (blanks > 0) {
409
+ // More than the trim below keeps could never be shown.
410
+ const room = lines + TAIL_SLACK;
411
+ if (blanks > room) dropped = true;
412
+ for (let i = Math.min(blanks, room); i > 0; i--) kept.push("");
413
+ blanks = 0;
414
+ }
415
+ kept.push(line);
416
+ // Trimmed with room to spare while it streams.
417
+ if (kept.length > lines * 2 + TAIL_SLACK) {
418
+ kept = kept.slice(-(lines + TAIL_SLACK));
419
+ dropped = true;
420
+ }
421
+ };
422
+ const take = (text, final) => {
423
+ const parts = (carry + text).split("\n");
424
+ carry = final ? "" : parts.pop();
425
+ for (const p of parts) keep(p);
426
+ };
427
+ const reader = res.body?.getReader?.();
428
+ if (!reader) {
429
+ take(await res.text(), true);
430
+ } else {
431
+ for (;;) {
432
+ const { done, value } = await reader.read();
433
+ if (done) break;
434
+ bytes += value.byteLength;
435
+ if (bytes > cap) {
436
+ reader.cancel().catch(() => {});
437
+ throw new ForgeError("upstream", "log too large to read the end of");
438
+ }
439
+ take(decoder.decode(value, { stream: true }), false);
440
+ }
441
+ take(decoder.decode(), true);
442
+ }
443
+ let out = kept;
444
+ if (out.length > lines) {
445
+ out = out.slice(-lines);
446
+ dropped = true;
447
+ }
448
+ return { lines: out, truncated: dropped };
449
+ }
450
+
451
+ /**
452
+ * An image for the panel — an avatar, a screenshot in a description — for
453
+ * the deck to pass on from its own origin. `allow` names the hosts the
454
+ * forge serves images from and is asked again at every redirect, so the
455
+ * deck is never sent off to fetch an arbitrary address; the token rides
456
+ * only where it always may. What comes back must be a raster image under
457
+ * `maxBytes`.
458
+ *
459
+ * @returns {Promise<{ contentType: string, bytes: Buffer }>}
460
+ */
461
+ async function asset(url, { allow, maxBytes: cap = ASSET_MAX_BYTES, signal }) {
462
+ const res = await send(url, { headers: { accept: "image/webp,image/png,image/jpeg,image/gif;q=0.9,*/*;q=0.1" }, timeout: 15_000, allow, signal });
463
+ if (!res.ok) throw await httpError(res, now());
464
+ const type = (res.headers?.get?.("content-type") ?? "").split(";")[0].trim().toLowerCase();
465
+ const declared = Number(res.headers?.get?.("content-length") ?? NaN);
466
+ if (!RASTER.test(type) || declared > cap) {
467
+ res.body?.cancel?.().catch(() => {});
468
+ throw RASTER.test(type)
469
+ ? new ForgeError("upstream", "image too large", { status: res.status })
470
+ : new ForgeError("incompatible", `not an image (${type || "no content type"})`, { status: res.status });
471
+ }
472
+ try {
473
+ return { contentType: type, bytes: await cappedBytes(res, cap) };
474
+ } catch (err) {
475
+ throw new ForgeError("upstream", err?.message === "body too large" ? "image too large" : "image cut off", { status: res.status });
476
+ }
477
+ }
478
+
479
+ return { send, json, graphql, tail, asset };
480
+ }
481
+
482
+ /** Aborted when either is: AbortSignal.any where Node has it (20.3+). */
483
+ function anySignal(a, b) {
484
+ if (typeof AbortSignal.any === "function") return AbortSignal.any([a, b]);
485
+ const controller = new AbortController();
486
+ for (const s of [a, b]) {
487
+ if (s.aborted) controller.abort(s.reason);
488
+ else s.addEventListener("abort", () => controller.abort(s.reason), { once: true });
489
+ }
490
+ return controller.signal;
491
+ }
492
+
493
+ /** The body as bytes, refused past `max` while it is still arriving. */
494
+ async function cappedBytes(res, max) {
495
+ const reader = res.body?.getReader?.();
496
+ if (!reader) {
497
+ const buf = Buffer.from(await res.arrayBuffer());
498
+ if (buf.byteLength > max) throw new Error("body too large");
499
+ return buf;
500
+ }
501
+ const chunks = [];
502
+ let bytes = 0;
503
+ for (;;) {
504
+ const { done, value } = await reader.read();
505
+ if (done) break;
506
+ bytes += value.byteLength;
507
+ if (bytes > max) {
508
+ reader.cancel().catch(() => {});
509
+ throw new Error("body too large");
510
+ }
511
+ chunks.push(Buffer.from(value.buffer, value.byteOffset, value.byteLength));
512
+ }
513
+ return Buffer.concat(chunks);
514
+ }