@aglyn/aglyn 1.0.0-beta.225 → 1.0.0-beta.226

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/aglyn",
3
- "version": "1.0.0-beta.225",
3
+ "version": "1.0.0-beta.226",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -37,16 +37,16 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "dependencies": {
40
- "@aglyn/shared-data-enums": "1.0.0-beta.225",
41
- "@aglyn/shared-data-mdi": "1.0.0-beta.225",
42
- "@aglyn/shared-data-types": "1.0.0-beta.225",
43
- "@aglyn/shared-util-email": "1.0.0-beta.225",
44
- "@aglyn/shared-util-first-touch": "1.0.0-beta.225",
45
- "@aglyn/shared-util-http": "1.0.0-beta.225",
46
- "@aglyn/shared-util-logger": "1.0.0-beta.225",
47
- "@aglyn/shared-util-timestamp": "1.0.0-beta.225",
48
- "@aglyn/shared-util-tools": "1.0.0-beta.225",
49
- "@aglyn/shared-util-vendor": "1.0.0-beta.225",
40
+ "@aglyn/shared-data-enums": "1.0.0-beta.226",
41
+ "@aglyn/shared-data-mdi": "1.0.0-beta.226",
42
+ "@aglyn/shared-data-types": "1.0.0-beta.226",
43
+ "@aglyn/shared-util-email": "1.0.0-beta.226",
44
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.226",
45
+ "@aglyn/shared-util-http": "1.0.0-beta.226",
46
+ "@aglyn/shared-util-logger": "1.0.0-beta.226",
47
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.226",
48
+ "@aglyn/shared-util-tools": "1.0.0-beta.226",
49
+ "@aglyn/shared-util-vendor": "1.0.0-beta.226",
50
50
  "@data-driven-forms/react-form-renderer": "^4.2.0",
51
51
  "@msgpack/msgpack": "^3.1.3",
52
52
  "@types/unist": "^3.0.3",
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** Next's `after()`, as the callers use it. */
18
+ export type AfterResponse = (task: () => Promise<void>) => void;
19
+ /**
20
+ * Next's `after()`, loaded the first time it is asked for; `null` where
21
+ * `next/server` cannot be loaded or has no `after`. One load per process.
22
+ */
23
+ export declare function loadAfterResponse(label?: string): Promise<AfterResponse | null>;
24
+ /**
25
+ * Runs `task` once the response has been sent, through Next's `after()`.
26
+ * Resolves `false` where there is no request to run after — a script, a
27
+ * spec — and the task is then not run at all. Never rejects. `label`
28
+ * prefixes what is logged, the caller's own (`[media-cdn]`).
29
+ */
30
+ export declare function scheduleAfterResponse(task: () => Promise<void>, label: string): Promise<boolean>;
31
+ /** Test seam: forget the loaded `after()` and what was logged. */
32
+ export declare function resetAfterResponseForTests(): void;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*==========================================
17
+ * WORK RUN AFTER THE RESPONSE: NEXT'S `after()`, LOADED LAZILY.
18
+ *
19
+ * A serverless invocation is frozen the moment its response is sent, and
20
+ * work scheduled any other way does not run (AGL-2327). `after()` is the
21
+ * one way to keep it. Three callers use it: the media CDN's lazy
22
+ * regeneration, the deliverability check at capture, and
23
+ * `runLegacyHandler` (`api-adapter.ts`), which hands it whatever a
24
+ * streaming handler is still awaiting when its body closes.
25
+ *
26
+ * ## Loaded, not imported
27
+ *
28
+ * `next/server` evaluates web `Request` classes at load, which a jsdom spec
29
+ * reaching one of `tenant-data-admin`'s writers cannot, and the modules
30
+ * that schedule work ride every writer's import. So it is loaded the first time
31
+ * work is scheduled.
32
+ *
33
+ * ## Through `import()`, never `require()`
34
+ *
35
+ * This package is an ES module. Next 16's Turbopack build compiled the
36
+ * `const loaded = require('next/server')` both callers once used into a
37
+ * hoisted `.after` read and left the body naming `loaded`, a binding that
38
+ * no longer existed: every call threw `ReferenceError: loaded is not
39
+ * defined`, the `catch` around it answered "no request", and neither the
40
+ * media CDN's regeneration (AGL-3486) nor the deliverability check at
41
+ * capture (AGL-3328) ever ran in production. A spec cannot see this:
42
+ * `jest.mock('next/server')` answers `require` and `import()` alike.
43
+ * `after-response.spec.ts` holds this package and `tenant-data-admin`
44
+ * to `import()`.
45
+ *
46
+ * ## Every drop is said once
47
+ *
48
+ * A task that could not be scheduled is work that silently did not
49
+ * happen. The first time each caller's task is dropped for a given reason
50
+ * it is logged; after that the same reason stays quiet, so a script
51
+ * writing five hundred rows outside a request says it once, not five
52
+ * hundred times. A task that runs and fails is logged every time.
53
+ *==========================================*/ /** Next's `after()`, as the callers use it. */ let afterResponseLoad = null;
54
+ const logged = new Set();
55
+ function logOnce(label, reason, message, error) {
56
+ const key = `${label}|${reason}`;
57
+ if (logged.has(key)) return;
58
+ logged.add(key);
59
+ if (error === undefined) console.error(`${label} ${message}`);
60
+ else console.error(`${label} ${message}`, error);
61
+ }
62
+ /**
63
+ * Next's `after()`, loaded the first time it is asked for; `null` where
64
+ * `next/server` cannot be loaded or has no `after`. One load per process.
65
+ */ export function loadAfterResponse(label = '[after-response]') {
66
+ afterResponseLoad != null ? afterResponseLoad : afterResponseLoad = import("next/server").then((loaded)=>typeof loaded.after === 'function' ? loaded.after : null, (error)=>{
67
+ logOnce(label, 'load', 'next/server could not be loaded', error);
68
+ return null;
69
+ });
70
+ return afterResponseLoad;
71
+ }
72
+ /**
73
+ * Runs `task` once the response has been sent, through Next's `after()`.
74
+ * Resolves `false` where there is no request to run after — a script, a
75
+ * spec — and the task is then not run at all. Never rejects. `label`
76
+ * prefixes what is logged, the caller's own (`[media-cdn]`).
77
+ */ export async function scheduleAfterResponse(task, label) {
78
+ const after = await loadAfterResponse(label);
79
+ if (!after) {
80
+ logOnce(label, 'unavailable', 'after() is unavailable; the task was not scheduled');
81
+ return false;
82
+ }
83
+ try {
84
+ after(()=>task().catch((error)=>{
85
+ console.error(`${label} after-response task failed`, error);
86
+ }));
87
+ return true;
88
+ } catch (error) {
89
+ logOnce(label, 'refused', 'after() refused the task; it was not scheduled', error);
90
+ return false;
91
+ }
92
+ }
93
+ /** Test seam: forget the loaded `after()` and what was logged. */ export function resetAfterResponseForTests() {
94
+ afterResponseLoad = null;
95
+ logged.clear();
96
+ }
97
+
98
+ //# sourceMappingURL=after-response.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/after-response.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*==========================================\n * WORK RUN AFTER THE RESPONSE: NEXT'S `after()`, LOADED LAZILY.\n *\n * A serverless invocation is frozen the moment its response is sent, and\n * work scheduled any other way does not run (AGL-2327). `after()` is the\n * one way to keep it. Three callers use it: the media CDN's lazy\n * regeneration, the deliverability check at capture, and\n * `runLegacyHandler` (`api-adapter.ts`), which hands it whatever a\n * streaming handler is still awaiting when its body closes.\n *\n * ## Loaded, not imported\n *\n * `next/server` evaluates web `Request` classes at load, which a jsdom spec\n * reaching one of `tenant-data-admin`'s writers cannot, and the modules\n * that schedule work ride every writer's import. So it is loaded the first time\n * work is scheduled.\n *\n * ## Through `import()`, never `require()`\n *\n * This package is an ES module. Next 16's Turbopack build compiled the\n * `const loaded = require('next/server')` both callers once used into a\n * hoisted `.after` read and left the body naming `loaded`, a binding that\n * no longer existed: every call threw `ReferenceError: loaded is not\n * defined`, the `catch` around it answered \"no request\", and neither the\n * media CDN's regeneration (AGL-3486) nor the deliverability check at\n * capture (AGL-3328) ever ran in production. A spec cannot see this:\n * `jest.mock('next/server')` answers `require` and `import()` alike.\n * `after-response.spec.ts` holds this package and `tenant-data-admin`\n * to `import()`.\n *\n * ## Every drop is said once\n *\n * A task that could not be scheduled is work that silently did not\n * happen. The first time each caller's task is dropped for a given reason\n * it is logged; after that the same reason stays quiet, so a script\n * writing five hundred rows outside a request says it once, not five\n * hundred times. A task that runs and fails is logged every time.\n *==========================================*/\n\n/** Next's `after()`, as the callers use it. */\nexport type AfterResponse = (task: () => Promise<void>) => void\n\nlet afterResponseLoad: Promise<AfterResponse | null> | null = null\n\nconst logged = new Set<string>()\n\nfunction logOnce(label: string, reason: string, message: string, error?: unknown): void {\n const key = `${label}|${reason}`\n if (logged.has(key)) return\n logged.add(key)\n if (error === undefined) console.error(`${label} ${message}`)\n else console.error(`${label} ${message}`, error)\n}\n\n/**\n * Next's `after()`, loaded the first time it is asked for; `null` where\n * `next/server` cannot be loaded or has no `after`. One load per process.\n */\nexport function loadAfterResponse(label = '[after-response]'): Promise<AfterResponse | null> {\n afterResponseLoad ??= import('next/server').then(\n (loaded: unknown): AfterResponse | null =>\n typeof (loaded as { after?: unknown }).after === 'function'\n ? (loaded as { after: AfterResponse }).after\n : null,\n (error: unknown): null => {\n logOnce(label, 'load', 'next/server could not be loaded', error)\n return null\n },\n )\n return afterResponseLoad\n}\n\n/**\n * Runs `task` once the response has been sent, through Next's `after()`.\n * Resolves `false` where there is no request to run after — a script, a\n * spec — and the task is then not run at all. Never rejects. `label`\n * prefixes what is logged, the caller's own (`[media-cdn]`).\n */\nexport async function scheduleAfterResponse(task: () => Promise<void>, label: string): Promise<boolean> {\n const after = await loadAfterResponse(label)\n if (!after) {\n logOnce(label, 'unavailable', 'after() is unavailable; the task was not scheduled')\n return false\n }\n try {\n after(() =>\n task().catch((error: unknown) => {\n console.error(`${label} after-response task failed`, error)\n }),\n )\n return true\n } catch (error) {\n logOnce(label, 'refused', 'after() refused the task; it was not scheduled', error)\n return false\n }\n}\n\n/** Test seam: forget the loaded `after()` and what was logged. */\nexport function resetAfterResponseForTests(): void {\n afterResponseLoad = null\n logged.clear()\n}\n"],"names":["afterResponseLoad","logged","Set","logOnce","label","reason","message","error","key","has","add","undefined","console","loadAfterResponse","then","loaded","after","scheduleAfterResponse","task","catch","resetAfterResponseForTests","clear"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;4CAqC4C,GAE5C,6CAA6C,GAG7C,IAAIA,oBAA0D;AAE9D,MAAMC,SAAS,IAAIC;AAEnB,SAASC,QAAQC,KAAa,EAAEC,MAAc,EAAEC,OAAe,EAAEC,KAAe;IAC9E,MAAMC,MAAM,GAAGJ,MAAM,CAAC,EAAEC,QAAQ;IAChC,IAAIJ,OAAOQ,GAAG,CAACD,MAAM;IACrBP,OAAOS,GAAG,CAACF;IACX,IAAID,UAAUI,WAAWC,QAAQL,KAAK,CAAC,GAAGH,MAAM,CAAC,EAAEE,SAAS;SACvDM,QAAQL,KAAK,CAAC,GAAGH,MAAM,CAAC,EAAEE,SAAS,EAAEC;AAC5C;AAEA;;;CAGC,GACD,OAAO,SAASM,kBAAkBT,QAAQ,kBAAkB;IAC1DJ,4BAAAA,oBAAAA,oBAAsB,MAAM,CAAC,eAAec,IAAI,CAC9C,CAACC,SACC,OAAO,AAACA,OAA+BC,KAAK,KAAK,aAC7C,AAACD,OAAoCC,KAAK,GAC1C,MACN,CAACT;QACCJ,QAAQC,OAAO,QAAQ,mCAAmCG;QAC1D,OAAO;IACT;IAEF,OAAOP;AACT;AAEA;;;;;CAKC,GACD,OAAO,eAAeiB,sBAAsBC,IAAyB,EAAEd,KAAa;IAClF,MAAMY,QAAQ,MAAMH,kBAAkBT;IACtC,IAAI,CAACY,OAAO;QACVb,QAAQC,OAAO,eAAe;QAC9B,OAAO;IACT;IACA,IAAI;QACFY,MAAM,IACJE,OAAOC,KAAK,CAAC,CAACZ;gBACZK,QAAQL,KAAK,CAAC,GAAGH,MAAM,2BAA2B,CAAC,EAAEG;YACvD;QAEF,OAAO;IACT,EAAE,OAAOA,OAAO;QACdJ,QAAQC,OAAO,WAAW,kDAAkDG;QAC5E,OAAO;IACT;AACF;AAEA,gEAAgE,GAChE,OAAO,SAASa;IACdpB,oBAAoB;IACpBC,OAAOoB,KAAK;AACd"}
@@ -43,5 +43,15 @@ export declare function pluginRequestFromWeb(request: Request, params?: Record<s
43
43
  * streams gets its `Response` back at the first chunk while it keeps writing;
44
44
  * if it fails after that, the body fails with it (see
45
45
  * {@link PluginResponseCollector}).
46
+ *
47
+ * ## What a streaming handler does after its last byte
48
+ *
49
+ * The request ends when the streamed body closes, and the platform may
50
+ * freeze the instance then, while the handler is still awaiting what it
51
+ * started: the media CDN's serve count and bandwidth evaluation. A write
52
+ * frozen in flight resumes on the instance's next request and fails there
53
+ * with a 60-second deadline, so the serve goes uncounted. The rest of such
54
+ * a handler is therefore handed to `after()`, which keeps the invocation
55
+ * alive until it settles.
46
56
  */
47
57
  export declare function runLegacyHandler(handler: LegacyApiHandler, request: Request, params?: Record<string, string | string[]>): Promise<Response>;
@@ -15,7 +15,9 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { Writable } from "node:stream";
18
+ import { loadAfterResponse, scheduleAfterResponse } from "./after-response.js";
18
19
  import { readClientIp } from "./request-ip.js";
20
+ /** What this adapter's `after()` drops are logged under. */ const AFTER_RESPONSE_LABEL = '[api-adapter]';
19
21
  /**
20
22
  * App Router ↔ node-style handler adapter (AGL-407). The plugin API contract
21
23
  * (`PluginApiHandler`) and a few shared handlers (`serveMediaCdn`,
@@ -337,12 +339,25 @@ import { readClientIp } from "./request-ip.js";
337
339
  * streams gets its `Response` back at the first chunk while it keeps writing;
338
340
  * if it fails after that, the body fails with it (see
339
341
  * {@link PluginResponseCollector}).
342
+ *
343
+ * ## What a streaming handler does after its last byte
344
+ *
345
+ * The request ends when the streamed body closes, and the platform may
346
+ * freeze the instance then, while the handler is still awaiting what it
347
+ * started: the media CDN's serve count and bandwidth evaluation. A write
348
+ * frozen in flight resumes on the instance's next request and fails there
349
+ * with a 60-second deadline, so the serve goes uncounted. The rest of such
350
+ * a handler is therefore handed to `after()`, which keeps the invocation
351
+ * alive until it settles.
340
352
  */ export async function runLegacyHandler(handler, request, params = {}) {
353
+ // Loaded ahead of the handler, so `after()` is in hand by the first chunk.
354
+ void loadAfterResponse(AFTER_RESPONSE_LABEL);
341
355
  const req = await pluginRequestFromWeb(request, params);
342
356
  const res = new PluginResponseCollector();
343
357
  const failure = {
344
358
  failed: false
345
359
  };
360
+ let settled = false;
346
361
  const handled = (async ()=>{
347
362
  try {
348
363
  await handler(req, res);
@@ -354,6 +369,8 @@ import { readClientIp } from "./request-ip.js";
354
369
  }
355
370
  failure.failed = true;
356
371
  failure.error = error;
372
+ } finally{
373
+ settled = true;
357
374
  }
358
375
  })();
359
376
  await Promise.race([
@@ -361,6 +378,7 @@ import { readClientIp } from "./request-ip.js";
361
378
  res.firstChunkWritten
362
379
  ]);
363
380
  if (failure.failed) throw failure.error;
381
+ if (!settled) void scheduleAfterResponse(()=>handled, AFTER_RESPONSE_LABEL);
364
382
  return res.toResponse();
365
383
  }
366
384
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/api-adapter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Writable } from 'node:stream'\nimport type { PluginApiRequest, PluginApiResponse } from './api-plugins'\nimport { readClientIp } from './request-ip'\n\n/**\n * A node-style API handler runnable through {@link runLegacyHandler}. Both\n * the framework-light `PluginApiHandler` and the shared handlers typed with\n * `NextApiRequest`/`NextApiResponse` (serveMediaCdn/servePluginFetch) satisfy\n * it — their parameter types differ (contravariance), so the boundary is\n * intentionally loose. The runtime shapes we pass (below) cover what each\n * handler actually touches.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type LegacyApiHandler = (req: any, res: any) => unknown\n\n/**\n * App Router ↔ node-style handler adapter (AGL-407). The plugin API contract\n * (`PluginApiHandler`) and a few shared handlers (`serveMediaCdn`,\n * `servePluginFetch`) are deliberately framework-light `(req, res)` functions\n * — that structural shape is what keeps plugins decoupled from any Next\n * router. This module lets an App Router `route.ts` invoke them from a Web\n * `Request`, so the tenant's API surface moves to the App Router with **zero\n * changes to plugin handlers** (they still run unchanged on the console's\n * Pages Router too). The response collector is a real `Writable`, so a\n * handler that pipes a read stream into `res` streams its body to the client\n * rather than handing it over whole — see {@link PluginResponseCollector}.\n */\n\n/**\n * The address a node-style handler reads as `req.socket.remoteAddress`.\n *\n * There is no real socket behind an App Router `Request`, so this stands in\n * for one — which makes it a client-address reader wearing a socket's name,\n * and every plugin handler that falls back to `req.socket?.remoteAddress`\n * inherits whatever it decides. It goes through the shared reader for exactly\n * that reason: the fallback has to be the same trusted hop as the header\n * reading it falls back FROM, or a handler could be steered onto a\n * caller-supplied value by omitting a header.\n *\n * `undefined` rather than a placeholder when nothing is readable — node leaves\n * `remoteAddress` undefined on a destroyed socket, so handlers already have to\n * cope with its absence.\n */\nfunction clientIp(headers: Headers): string | undefined {\n return readClientIp(headers) ?? undefined\n}\n\n/** Parse a `Cookie` header into a flat record. */\nfunction parseCookies(headers: Headers): Record<string, string> {\n const raw = headers.get('cookie')\n if (!raw) return {}\n const out: Record<string, string> = {}\n for (const pair of raw.split(';')) {\n const index = pair.indexOf('=')\n if (index < 0) continue\n const key = pair.slice(0, index).trim()\n if (key) out[key] = decodeURIComponent(pair.slice(index + 1).trim())\n }\n return out\n}\n\n/**\n * Builds a `PluginApiRequest` from a Web `Request` plus the App Router route\n * `params` (awaited by the caller). Body parsing mirrors Next's default\n * body parser: JSON for `application/json`, form fields for urlencoded,\n * raw text otherwise; GET/HEAD carry no body.\n */\nexport async function pluginRequestFromWeb(\n request: Request,\n params: Record<string, string | string[]> = {},\n): Promise<PluginApiRequest> {\n const url = new URL(request.url)\n const query: Record<string, string | string[]> = { ...params }\n for (const key of url.searchParams.keys()) {\n if (key in query) continue\n const all = url.searchParams.getAll(key)\n query[key] = all.length > 1 ? all : (all[0] ?? '')\n }\n\n const method = request.method ?? 'GET'\n let body: unknown\n let rawBody: string | undefined\n if (method !== 'GET' && method !== 'HEAD') {\n const raw = await request.text()\n rawBody = raw || undefined\n if (raw) {\n const contentType = request.headers.get('content-type') ?? ''\n if (contentType.includes('application/json')) {\n try {\n body = JSON.parse(raw)\n } catch {\n body = raw\n }\n } else if (contentType.includes('application/x-www-form-urlencoded')) {\n body = Object.fromEntries(new URLSearchParams(raw))\n } else {\n body = raw\n }\n }\n }\n\n const headers: Record<string, string> = {}\n request.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n return {\n method,\n query,\n body,\n rawBody,\n headers,\n cookies: parseCookies(request.headers),\n socket: { remoteAddress: clientIp(request.headers) },\n }\n}\n\ntype WriteCallback = (error?: Error | null) => void\n\n/** A promise and the function that settles it. */\nfunction signal(): { promise: Promise<void>; resolve: () => void } {\n let resolve: () => void = () => undefined\n const promise = new Promise<void>((settle) => {\n resolve = settle\n })\n return { promise, resolve }\n}\n\n/** Statuses a `Response` may not carry a body on (Fetch §2.2.4). */\nconst NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304])\n\n/**\n * A `PluginApiResponse` that turns a node-style handler's output into a Web\n * `Response`, in one of two shapes.\n *\n * - **A complete body** — `json`, `send`, `redirect`, or `end()` with nothing\n * written. It is kept whole and becomes a buffered `Response` once the\n * handler returns, which is the shape every plugin handler relies on.\n * - **A streamed body** — anything written through the `Writable` side:\n * `write()`, `stream.pipe(res)`, `pipeline(source, res)`. The `Response` is\n * handed back at the FIRST chunk, carrying the status and headers set by\n * then, and its body is a `ReadableStream` the client pulls one chunk at a\n * time (AGL-2810).\n *\n * ## Why a streamed body is never collected\n *\n * The media CDN pipes whole Storage objects into `res`. Collected, each\n * request held its entire file in function memory — briefly twice, while the\n * chunks were concatenated — and sent nothing until the last byte had been\n * read, so a player's opening `bytes=0-` on a large video pulled the whole\n * file before playback could start.\n *\n * ## Backpressure\n *\n * The body stream holds one chunk. A write is acknowledged only when the\n * client has taken the chunk before it, so `pipe`/`pipeline` pause the source\n * while the client is slow and a response never holds more than a few chunks\n * in memory, whatever the size of the file behind it.\n *\n * ## Failure after the first chunk\n *\n * Once the status line is gone the only honest signal left is to fail the\n * body. `destroy(error)` errors the stream, so the client sees a broken\n * transfer. Closing it instead would present a truncated file as complete —\n * which, for a video player, is a corrupt file it has no reason to doubt.\n *\n * ## A client that stops reading\n *\n * Canceling the body destroys this writer, and `pipeline` answers a\n * destination that closed early by destroying its source. An abandoned\n * response therefore stops reading from Storage instead of leaving the read\n * open behind a client that has gone.\n */\nclass PluginResponseCollector extends Writable implements PluginApiResponse {\n private statusCode = 200\n private readonly outHeaders: Record<string, string | number | readonly string[]> =\n {}\n /** What `json`/`send` produced, when nothing was streamed. */\n private completeBody: Buffer | null = null\n private body: ReadableStream<Uint8Array> | null = null\n private controller: ReadableStreamDefaultController<Uint8Array> | null = null\n /** The acknowledgement for the chunk the client has not taken yet. */\n private awaitingPull: WriteCallback | null = null\n /** Set by `_final`: the body ended, so a later teardown is not a failure. */\n private bodyEnded = false\n private readonly ended = signal()\n private readonly firstChunk = signal()\n headersSent = false\n\n constructor() {\n super()\n this.once('finish', this.ended.resolve)\n // `close` as well as `finish`: a writer destroyed before it wrote or\n // ended must still let `toResponse` return rather than wait forever.\n this.once('close', this.ended.resolve)\n // A streamed failure reaches the client through the body (`_destroy`).\n // Without a listener, `destroy(error)` would also emit an `error` event\n // nothing handles, and an unhandled `error` takes the process down.\n this.on('error', () => undefined)\n }\n\n /** True once a chunk has been written, so the `Response` is committed. */\n get streaming(): boolean {\n return this.body !== null\n }\n\n /** Settles at the first streamed chunk. */\n get firstChunkWritten(): Promise<void> {\n return this.firstChunk.promise\n }\n\n override _write(\n chunk: unknown,\n encoding: BufferEncoding,\n callback: WriteCallback,\n ): void {\n this.headersSent = true\n const controller = this.controller ?? this.openBody()\n try {\n controller.enqueue(\n chunk instanceof Uint8Array ? chunk : Buffer.from(String(chunk), encoding),\n )\n } catch (error) {\n // The client already canceled or the body already failed: the write\n // fails the way a write to a closed socket does.\n callback(error instanceof Error ? error : new Error(String(error)))\n return\n }\n if ((controller.desiredSize ?? 0) > 0) callback()\n else this.awaitingPull = callback\n }\n\n override _final(callback: WriteCallback): void {\n this.bodyEnded = true\n try {\n this.controller?.close()\n } catch {\n // Canceled by the client; nothing is waiting for the end.\n }\n callback()\n }\n\n override _destroy(error: Error | null, callback: WriteCallback): void {\n this.awaitingPull = null\n if (this.controller && !this.bodyEnded) {\n try {\n this.controller.error(\n error ?? new Error('Response body closed before it ended'),\n )\n } catch {\n // Already closed or errored.\n }\n }\n callback(error)\n }\n\n /** Commits the response: the status and headers set so far are final. */\n private openBody(): ReadableStreamDefaultController<Uint8Array> {\n const started: { controller?: ReadableStreamDefaultController<Uint8Array> } =\n {}\n this.body = new ReadableStream<Uint8Array>(\n {\n start: (controller) => {\n started.controller = controller\n },\n pull: () => {\n const acknowledge = this.awaitingPull\n this.awaitingPull = null\n acknowledge?.()\n },\n cancel: () => {\n this.awaitingPull = null\n this.destroy()\n },\n },\n { highWaterMark: 1 },\n )\n // `start` runs synchronously inside the constructor (Streams §4.2.4).\n if (!started.controller) {\n throw new Error('ReadableStream did not start synchronously')\n }\n this.controller = started.controller\n this.firstChunk.resolve()\n return started.controller\n }\n\n status(code: number): this {\n this.statusCode = code\n return this\n }\n\n setHeader(name: string, value: string | number | readonly string[]): void {\n this.outHeaders[name.toLowerCase()] = value\n }\n\n removeHeader(name: string): void {\n delete this.outHeaders[name.toLowerCase()]\n }\n\n json(body: unknown): void {\n if (this.outHeaders['content-type'] === undefined) {\n this.setHeader('content-type', 'application/json; charset=utf-8')\n }\n this.endWith(Buffer.from(JSON.stringify(body)))\n }\n\n send(body: unknown): void {\n if (body === undefined || body === null) return void this.end()\n if (Buffer.isBuffer(body)) return void this.endWith(body)\n if (typeof body === 'string') return void this.endWith(Buffer.from(body))\n return this.json(body)\n }\n\n redirect(statusOrUrl: number | string, maybeUrl?: string): void {\n const status = typeof statusOrUrl === 'number' ? statusOrUrl : 302\n const location = typeof statusOrUrl === 'number' ? (maybeUrl ?? '') : statusOrUrl\n this.statusCode = status\n this.setHeader('location', location)\n this.end()\n }\n\n /**\n * Ends the response with a complete body. After a streamed chunk the body\n * is already a stream, so the bytes can only join it.\n */\n private endWith(body: Buffer): void {\n if (this.body) {\n this.end(body)\n return\n }\n this.completeBody = body\n this.end()\n }\n\n /**\n * The Web `Response`, as soon as it is decided: at the first streamed chunk,\n * or once the handler has ended the response with a complete body.\n */\n async toResponse(): Promise<Response> {\n await Promise.race([this.ended.promise, this.firstChunk.promise])\n const headers = new Headers()\n for (const [key, value] of Object.entries(this.outHeaders)) {\n if (Array.isArray(value)) {\n for (const item of value) headers.append(key, String(item))\n } else {\n headers.set(key, String(value))\n }\n }\n const bodyless = NULL_BODY_STATUSES.has(this.statusCode)\n if (this.body) {\n if (bodyless) {\n void this.body.cancel()\n return new Response(null, { status: this.statusCode, headers })\n }\n return new Response(this.body, { status: this.statusCode, headers })\n }\n const body = this.completeBody\n return new Response(\n bodyless || !body || body.length === 0 ? null : (body as BodyInit),\n { status: this.statusCode, headers },\n )\n }\n}\n\n/**\n * Runs a node-style `(req, res)` handler against a Web `Request` and returns\n * the Web `Response` it produced. The entry point for App Router `route.ts`\n * files that dispatch to plugin handlers or the shared `serveMediaCdn` /\n * `servePluginFetch` handlers. Runs on the Node.js runtime (streams,\n * firebase-admin) — not edge.\n *\n * A handler that responds with a complete body is awaited to the end, and a\n * throw before it responds rejects exactly as it always has. A handler that\n * streams gets its `Response` back at the first chunk while it keeps writing;\n * if it fails after that, the body fails with it (see\n * {@link PluginResponseCollector}).\n */\nexport async function runLegacyHandler(\n handler: LegacyApiHandler,\n request: Request,\n params: Record<string, string | string[]> = {},\n): Promise<Response> {\n const req = await pluginRequestFromWeb(request, params)\n const res = new PluginResponseCollector()\n const failure: { error?: unknown; failed: boolean } = { failed: false }\n const handled = (async (): Promise<void> => {\n try {\n await handler(req, res)\n } catch (error) {\n if (res.streaming) {\n // The status line is already out, so only the body can carry this.\n res.destroy(error instanceof Error ? error : new Error(String(error)))\n return\n }\n failure.failed = true\n failure.error = error\n }\n })()\n await Promise.race([handled, res.firstChunkWritten])\n if (failure.failed) throw failure.error\n return res.toResponse()\n}\n"],"names":["Writable","readClientIp","clientIp","headers","undefined","parseCookies","raw","get","out","pair","split","index","indexOf","key","slice","trim","decodeURIComponent","pluginRequestFromWeb","request","params","url","URL","query","searchParams","keys","all","getAll","length","method","body","rawBody","text","contentType","includes","JSON","parse","Object","fromEntries","URLSearchParams","forEach","value","cookies","socket","remoteAddress","signal","resolve","promise","Promise","settle","NULL_BODY_STATUSES","Set","PluginResponseCollector","streaming","firstChunkWritten","firstChunk","_write","chunk","encoding","callback","controller","headersSent","openBody","enqueue","Uint8Array","Buffer","from","String","error","Error","desiredSize","awaitingPull","_final","bodyEnded","close","_destroy","started","ReadableStream","start","pull","acknowledge","cancel","destroy","highWaterMark","status","code","statusCode","setHeader","name","outHeaders","toLowerCase","removeHeader","json","endWith","stringify","send","end","isBuffer","redirect","statusOrUrl","maybeUrl","location","completeBody","toResponse","race","ended","Headers","entries","Array","isArray","item","append","set","bodyless","has","Response","once","on","runLegacyHandler","handler","req","res","failure","failed","handled"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/api-adapter.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { Writable } from 'node:stream'\nimport { loadAfterResponse, scheduleAfterResponse } from './after-response'\nimport type { PluginApiRequest, PluginApiResponse } from './api-plugins'\nimport { readClientIp } from './request-ip'\n\n/** What this adapter's `after()` drops are logged under. */\nconst AFTER_RESPONSE_LABEL = '[api-adapter]'\n\n/**\n * A node-style API handler runnable through {@link runLegacyHandler}. Both\n * the framework-light `PluginApiHandler` and the shared handlers typed with\n * `NextApiRequest`/`NextApiResponse` (serveMediaCdn/servePluginFetch) satisfy\n * it — their parameter types differ (contravariance), so the boundary is\n * intentionally loose. The runtime shapes we pass (below) cover what each\n * handler actually touches.\n */\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\nexport type LegacyApiHandler = (req: any, res: any) => unknown\n\n/**\n * App Router ↔ node-style handler adapter (AGL-407). The plugin API contract\n * (`PluginApiHandler`) and a few shared handlers (`serveMediaCdn`,\n * `servePluginFetch`) are deliberately framework-light `(req, res)` functions\n * — that structural shape is what keeps plugins decoupled from any Next\n * router. This module lets an App Router `route.ts` invoke them from a Web\n * `Request`, so the tenant's API surface moves to the App Router with **zero\n * changes to plugin handlers** (they still run unchanged on the console's\n * Pages Router too). The response collector is a real `Writable`, so a\n * handler that pipes a read stream into `res` streams its body to the client\n * rather than handing it over whole — see {@link PluginResponseCollector}.\n */\n\n/**\n * The address a node-style handler reads as `req.socket.remoteAddress`.\n *\n * There is no real socket behind an App Router `Request`, so this stands in\n * for one — which makes it a client-address reader wearing a socket's name,\n * and every plugin handler that falls back to `req.socket?.remoteAddress`\n * inherits whatever it decides. It goes through the shared reader for exactly\n * that reason: the fallback has to be the same trusted hop as the header\n * reading it falls back FROM, or a handler could be steered onto a\n * caller-supplied value by omitting a header.\n *\n * `undefined` rather than a placeholder when nothing is readable — node leaves\n * `remoteAddress` undefined on a destroyed socket, so handlers already have to\n * cope with its absence.\n */\nfunction clientIp(headers: Headers): string | undefined {\n return readClientIp(headers) ?? undefined\n}\n\n/** Parse a `Cookie` header into a flat record. */\nfunction parseCookies(headers: Headers): Record<string, string> {\n const raw = headers.get('cookie')\n if (!raw) return {}\n const out: Record<string, string> = {}\n for (const pair of raw.split(';')) {\n const index = pair.indexOf('=')\n if (index < 0) continue\n const key = pair.slice(0, index).trim()\n if (key) out[key] = decodeURIComponent(pair.slice(index + 1).trim())\n }\n return out\n}\n\n/**\n * Builds a `PluginApiRequest` from a Web `Request` plus the App Router route\n * `params` (awaited by the caller). Body parsing mirrors Next's default\n * body parser: JSON for `application/json`, form fields for urlencoded,\n * raw text otherwise; GET/HEAD carry no body.\n */\nexport async function pluginRequestFromWeb(\n request: Request,\n params: Record<string, string | string[]> = {},\n): Promise<PluginApiRequest> {\n const url = new URL(request.url)\n const query: Record<string, string | string[]> = { ...params }\n for (const key of url.searchParams.keys()) {\n if (key in query) continue\n const all = url.searchParams.getAll(key)\n query[key] = all.length > 1 ? all : (all[0] ?? '')\n }\n\n const method = request.method ?? 'GET'\n let body: unknown\n let rawBody: string | undefined\n if (method !== 'GET' && method !== 'HEAD') {\n const raw = await request.text()\n rawBody = raw || undefined\n if (raw) {\n const contentType = request.headers.get('content-type') ?? ''\n if (contentType.includes('application/json')) {\n try {\n body = JSON.parse(raw)\n } catch {\n body = raw\n }\n } else if (contentType.includes('application/x-www-form-urlencoded')) {\n body = Object.fromEntries(new URLSearchParams(raw))\n } else {\n body = raw\n }\n }\n }\n\n const headers: Record<string, string> = {}\n request.headers.forEach((value, key) => {\n headers[key] = value\n })\n\n return {\n method,\n query,\n body,\n rawBody,\n headers,\n cookies: parseCookies(request.headers),\n socket: { remoteAddress: clientIp(request.headers) },\n }\n}\n\ntype WriteCallback = (error?: Error | null) => void\n\n/** A promise and the function that settles it. */\nfunction signal(): { promise: Promise<void>; resolve: () => void } {\n let resolve: () => void = () => undefined\n const promise = new Promise<void>((settle) => {\n resolve = settle\n })\n return { promise, resolve }\n}\n\n/** Statuses a `Response` may not carry a body on (Fetch §2.2.4). */\nconst NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304])\n\n/**\n * A `PluginApiResponse` that turns a node-style handler's output into a Web\n * `Response`, in one of two shapes.\n *\n * - **A complete body** — `json`, `send`, `redirect`, or `end()` with nothing\n * written. It is kept whole and becomes a buffered `Response` once the\n * handler returns, which is the shape every plugin handler relies on.\n * - **A streamed body** — anything written through the `Writable` side:\n * `write()`, `stream.pipe(res)`, `pipeline(source, res)`. The `Response` is\n * handed back at the FIRST chunk, carrying the status and headers set by\n * then, and its body is a `ReadableStream` the client pulls one chunk at a\n * time (AGL-2810).\n *\n * ## Why a streamed body is never collected\n *\n * The media CDN pipes whole Storage objects into `res`. Collected, each\n * request held its entire file in function memory — briefly twice, while the\n * chunks were concatenated — and sent nothing until the last byte had been\n * read, so a player's opening `bytes=0-` on a large video pulled the whole\n * file before playback could start.\n *\n * ## Backpressure\n *\n * The body stream holds one chunk. A write is acknowledged only when the\n * client has taken the chunk before it, so `pipe`/`pipeline` pause the source\n * while the client is slow and a response never holds more than a few chunks\n * in memory, whatever the size of the file behind it.\n *\n * ## Failure after the first chunk\n *\n * Once the status line is gone the only honest signal left is to fail the\n * body. `destroy(error)` errors the stream, so the client sees a broken\n * transfer. Closing it instead would present a truncated file as complete —\n * which, for a video player, is a corrupt file it has no reason to doubt.\n *\n * ## A client that stops reading\n *\n * Canceling the body destroys this writer, and `pipeline` answers a\n * destination that closed early by destroying its source. An abandoned\n * response therefore stops reading from Storage instead of leaving the read\n * open behind a client that has gone.\n */\nclass PluginResponseCollector extends Writable implements PluginApiResponse {\n private statusCode = 200\n private readonly outHeaders: Record<string, string | number | readonly string[]> =\n {}\n /** What `json`/`send` produced, when nothing was streamed. */\n private completeBody: Buffer | null = null\n private body: ReadableStream<Uint8Array> | null = null\n private controller: ReadableStreamDefaultController<Uint8Array> | null = null\n /** The acknowledgement for the chunk the client has not taken yet. */\n private awaitingPull: WriteCallback | null = null\n /** Set by `_final`: the body ended, so a later teardown is not a failure. */\n private bodyEnded = false\n private readonly ended = signal()\n private readonly firstChunk = signal()\n headersSent = false\n\n constructor() {\n super()\n this.once('finish', this.ended.resolve)\n // `close` as well as `finish`: a writer destroyed before it wrote or\n // ended must still let `toResponse` return rather than wait forever.\n this.once('close', this.ended.resolve)\n // A streamed failure reaches the client through the body (`_destroy`).\n // Without a listener, `destroy(error)` would also emit an `error` event\n // nothing handles, and an unhandled `error` takes the process down.\n this.on('error', () => undefined)\n }\n\n /** True once a chunk has been written, so the `Response` is committed. */\n get streaming(): boolean {\n return this.body !== null\n }\n\n /** Settles at the first streamed chunk. */\n get firstChunkWritten(): Promise<void> {\n return this.firstChunk.promise\n }\n\n override _write(\n chunk: unknown,\n encoding: BufferEncoding,\n callback: WriteCallback,\n ): void {\n this.headersSent = true\n const controller = this.controller ?? this.openBody()\n try {\n controller.enqueue(\n chunk instanceof Uint8Array ? chunk : Buffer.from(String(chunk), encoding),\n )\n } catch (error) {\n // The client already canceled or the body already failed: the write\n // fails the way a write to a closed socket does.\n callback(error instanceof Error ? error : new Error(String(error)))\n return\n }\n if ((controller.desiredSize ?? 0) > 0) callback()\n else this.awaitingPull = callback\n }\n\n override _final(callback: WriteCallback): void {\n this.bodyEnded = true\n try {\n this.controller?.close()\n } catch {\n // Canceled by the client; nothing is waiting for the end.\n }\n callback()\n }\n\n override _destroy(error: Error | null, callback: WriteCallback): void {\n this.awaitingPull = null\n if (this.controller && !this.bodyEnded) {\n try {\n this.controller.error(\n error ?? new Error('Response body closed before it ended'),\n )\n } catch {\n // Already closed or errored.\n }\n }\n callback(error)\n }\n\n /** Commits the response: the status and headers set so far are final. */\n private openBody(): ReadableStreamDefaultController<Uint8Array> {\n const started: { controller?: ReadableStreamDefaultController<Uint8Array> } =\n {}\n this.body = new ReadableStream<Uint8Array>(\n {\n start: (controller) => {\n started.controller = controller\n },\n pull: () => {\n const acknowledge = this.awaitingPull\n this.awaitingPull = null\n acknowledge?.()\n },\n cancel: () => {\n this.awaitingPull = null\n this.destroy()\n },\n },\n { highWaterMark: 1 },\n )\n // `start` runs synchronously inside the constructor (Streams §4.2.4).\n if (!started.controller) {\n throw new Error('ReadableStream did not start synchronously')\n }\n this.controller = started.controller\n this.firstChunk.resolve()\n return started.controller\n }\n\n status(code: number): this {\n this.statusCode = code\n return this\n }\n\n setHeader(name: string, value: string | number | readonly string[]): void {\n this.outHeaders[name.toLowerCase()] = value\n }\n\n removeHeader(name: string): void {\n delete this.outHeaders[name.toLowerCase()]\n }\n\n json(body: unknown): void {\n if (this.outHeaders['content-type'] === undefined) {\n this.setHeader('content-type', 'application/json; charset=utf-8')\n }\n this.endWith(Buffer.from(JSON.stringify(body)))\n }\n\n send(body: unknown): void {\n if (body === undefined || body === null) return void this.end()\n if (Buffer.isBuffer(body)) return void this.endWith(body)\n if (typeof body === 'string') return void this.endWith(Buffer.from(body))\n return this.json(body)\n }\n\n redirect(statusOrUrl: number | string, maybeUrl?: string): void {\n const status = typeof statusOrUrl === 'number' ? statusOrUrl : 302\n const location = typeof statusOrUrl === 'number' ? (maybeUrl ?? '') : statusOrUrl\n this.statusCode = status\n this.setHeader('location', location)\n this.end()\n }\n\n /**\n * Ends the response with a complete body. After a streamed chunk the body\n * is already a stream, so the bytes can only join it.\n */\n private endWith(body: Buffer): void {\n if (this.body) {\n this.end(body)\n return\n }\n this.completeBody = body\n this.end()\n }\n\n /**\n * The Web `Response`, as soon as it is decided: at the first streamed chunk,\n * or once the handler has ended the response with a complete body.\n */\n async toResponse(): Promise<Response> {\n await Promise.race([this.ended.promise, this.firstChunk.promise])\n const headers = new Headers()\n for (const [key, value] of Object.entries(this.outHeaders)) {\n if (Array.isArray(value)) {\n for (const item of value) headers.append(key, String(item))\n } else {\n headers.set(key, String(value))\n }\n }\n const bodyless = NULL_BODY_STATUSES.has(this.statusCode)\n if (this.body) {\n if (bodyless) {\n void this.body.cancel()\n return new Response(null, { status: this.statusCode, headers })\n }\n return new Response(this.body, { status: this.statusCode, headers })\n }\n const body = this.completeBody\n return new Response(\n bodyless || !body || body.length === 0 ? null : (body as BodyInit),\n { status: this.statusCode, headers },\n )\n }\n}\n\n/**\n * Runs a node-style `(req, res)` handler against a Web `Request` and returns\n * the Web `Response` it produced. The entry point for App Router `route.ts`\n * files that dispatch to plugin handlers or the shared `serveMediaCdn` /\n * `servePluginFetch` handlers. Runs on the Node.js runtime (streams,\n * firebase-admin) — not edge.\n *\n * A handler that responds with a complete body is awaited to the end, and a\n * throw before it responds rejects exactly as it always has. A handler that\n * streams gets its `Response` back at the first chunk while it keeps writing;\n * if it fails after that, the body fails with it (see\n * {@link PluginResponseCollector}).\n *\n * ## What a streaming handler does after its last byte\n *\n * The request ends when the streamed body closes, and the platform may\n * freeze the instance then, while the handler is still awaiting what it\n * started: the media CDN's serve count and bandwidth evaluation. A write\n * frozen in flight resumes on the instance's next request and fails there\n * with a 60-second deadline, so the serve goes uncounted. The rest of such\n * a handler is therefore handed to `after()`, which keeps the invocation\n * alive until it settles.\n */\nexport async function runLegacyHandler(\n handler: LegacyApiHandler,\n request: Request,\n params: Record<string, string | string[]> = {},\n): Promise<Response> {\n // Loaded ahead of the handler, so `after()` is in hand by the first chunk.\n void loadAfterResponse(AFTER_RESPONSE_LABEL)\n const req = await pluginRequestFromWeb(request, params)\n const res = new PluginResponseCollector()\n const failure: { error?: unknown; failed: boolean } = { failed: false }\n let settled = false\n const handled = (async (): Promise<void> => {\n try {\n await handler(req, res)\n } catch (error) {\n if (res.streaming) {\n // The status line is already out, so only the body can carry this.\n res.destroy(error instanceof Error ? error : new Error(String(error)))\n return\n }\n failure.failed = true\n failure.error = error\n } finally {\n settled = true\n }\n })()\n await Promise.race([handled, res.firstChunkWritten])\n if (failure.failed) throw failure.error\n if (!settled) void scheduleAfterResponse(() => handled, AFTER_RESPONSE_LABEL)\n return res.toResponse()\n}\n"],"names":["Writable","loadAfterResponse","scheduleAfterResponse","readClientIp","AFTER_RESPONSE_LABEL","clientIp","headers","undefined","parseCookies","raw","get","out","pair","split","index","indexOf","key","slice","trim","decodeURIComponent","pluginRequestFromWeb","request","params","url","URL","query","searchParams","keys","all","getAll","length","method","body","rawBody","text","contentType","includes","JSON","parse","Object","fromEntries","URLSearchParams","forEach","value","cookies","socket","remoteAddress","signal","resolve","promise","Promise","settle","NULL_BODY_STATUSES","Set","PluginResponseCollector","streaming","firstChunkWritten","firstChunk","_write","chunk","encoding","callback","controller","headersSent","openBody","enqueue","Uint8Array","Buffer","from","String","error","Error","desiredSize","awaitingPull","_final","bodyEnded","close","_destroy","started","ReadableStream","start","pull","acknowledge","cancel","destroy","highWaterMark","status","code","statusCode","setHeader","name","outHeaders","toLowerCase","removeHeader","json","endWith","stringify","send","end","isBuffer","redirect","statusOrUrl","maybeUrl","location","completeBody","toResponse","race","ended","Headers","entries","Array","isArray","item","append","set","bodyless","has","Response","once","on","runLegacyHandler","handler","req","res","failure","failed","settled","handled"],"mappings":"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"}
@@ -125,6 +125,11 @@
125
125
  label: 'Featured video',
126
126
  description: 'The featured video’s source — a library film, a video file link or ' + 'a video host’s link — for a Video element.'
127
127
  },
128
+ {
129
+ token: '{{entry.coverVideoDuration}}',
130
+ label: 'Featured video length',
131
+ description: 'How long the featured video runs, in seconds, for a Video element’s ' + 'duration field. Blank when the entry does not say.'
132
+ },
128
133
  {
129
134
  token: '{{entry.category}}',
130
135
  label: 'Category',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/binding-token-catalog.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Browsable data-placeholder catalogs for the designer's insert picker\n * (AGL-583). Hand-typing `{{entry.title}}` stays the advanced path; these\n * catalogs give every token a friendly label + description so editors can\n * browse and insert instead of memorizing the grammar.\n *\n * The token STRINGS are owned elsewhere — `collectionEntryTokens`\n * (collection-entries.ts) and the tenant compose pipeline resolve them —\n * this module only names them for pickers. The spec cross-checks the entry\n * catalog against the resolver so the two can never drift.\n */\nexport interface BindingTokenCatalogEntry {\n /** The literal token inserted into the prop, e.g. `{{entry.title}}`. */\n token: string\n /** Friendly picker label, e.g. `Title`. */\n label: string\n /** One-line description shown as the option's secondary text. */\n description?: string\n}\n\n/**\n * `{{entry.*}}` tokens (AGL-105/551/582) — resolve per entry inside a\n * Collection entries block and page-wide on entry-template screens.\n * Mirrors {@link collectionEntryTokens}; the spec enforces the mirror.\n */\nexport const ENTRY_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n { token: '{{entry.title}}', label: 'Title', description: 'The entry headline.' },\n {\n token: '{{entry.excerpt}}',\n label: 'Excerpt',\n description: 'Short summary text.',\n },\n {\n token: '{{entry.body}}',\n label: 'Body',\n description: 'The full entry body.',\n },\n {\n token: '{{entry.url}}',\n label: 'Link URL',\n description: 'Auto-route to the entry page.',\n },\n {\n token: '{{entry.date}}',\n label: 'Published date',\n description: 'Formatted publish date.',\n },\n /*\n Beside the readable date on purpose: a designer reaching for \"the date\" in\n a Video element's Publication date field has to see that the readable one\n is the wrong pick there, and why.\n */\n {\n token: '{{entry.publishedAt}}',\n label: 'Publish timestamp',\n description:\n 'The publish date and time in ISO 8601, for a field that needs a ' +\n 'machine-readable date, such as a Video element’s publication date.',\n },\n {\n token: '{{entry.author}}',\n label: 'Author',\n description: 'The byline set on the entry.',\n },\n {\n token: '{{entry.authorBio}}',\n label: 'Author bio',\n description: 'Blurb from the author’s record.',\n },\n {\n token: '{{entry.authorImage}}',\n label: 'Author portrait',\n description: 'Portrait or logo from the author’s record.',\n },\n {\n token: '{{entry.authorUrl}}',\n label: 'Author link',\n description: 'The author’s own page.',\n },\n {\n token: '{{entry.authorPageUrl}}',\n label: 'Author page',\n description:\n 'This author’s page on this site — everything they wrote, across every ' +\n 'collection. Separate from Author link, which is their own site.',\n },\n {\n token: '{{entry.slug}}',\n label: 'Slug',\n description: 'URL-safe entry identifier.',\n },\n /*\n Named \"Its collection…\" rather than \"Collection…\", which is what these\n describe and also exactly what the `{{collection.*}}` entries below are\n called. The picker groups options by heading, so the two sets sit apart —\n but a designer scanning it reads the LABEL, and two options reading\n \"Collection name\" three lines apart is a choice made by guessing. The\n pronoun is doing real work: on the one page where both resolve, they mean\n different collections.\n */\n {\n token: '{{entry.collection}}',\n label: 'Its collection',\n description:\n 'Which section this entry belongs to. Worth binding on a listing that ' +\n 'MIXES collections — an author page — where a card otherwise cannot ' +\n 'tell a release note from an essay.',\n },\n {\n token: '{{entry.collectionSlug}}',\n label: 'Its collection slug',\n description: 'That collection’s URL segment.',\n },\n {\n token: '{{entry.collectionUrl}}',\n label: 'Its collection link',\n description: 'The listing this entry belongs to.',\n },\n {\n token: '{{entry.coverImage}}',\n label: 'Cover image',\n description: 'Cover image URL.',\n },\n {\n token: '{{entry.coverVideo}}',\n label: 'Featured video',\n description:\n 'The featured video’s source — a library film, a video file link or ' +\n 'a video host’s link — for a Video element.',\n },\n {\n token: '{{entry.category}}',\n label: 'Category',\n description: 'The entry category.',\n },\n {\n token: '{{entry.tags}}',\n label: 'Tags',\n description: 'Tags, comma separated.',\n },\n {\n token: '{{entry.seoTitle}}',\n label: 'SEO title',\n description: 'Search title; falls back to Title.',\n },\n {\n token: '{{entry.seoDescription}}',\n label: 'SEO description',\n description: 'Meta description; falls back to Excerpt.',\n },\n]\n\n/**\n * `{{collection.*}}` and `{{pagination.*}}` tokens (AGL-551/1321/1386) —\n * resolve on collection list/entry template screens (see the tenant compose\n * pipeline's collection tokens).\n *\n * The category and pagination tokens all resolve to the empty string where\n * they do not apply, so they are safe to bind unconditionally: ONE list\n * screen serves the bare listing, every `/page/{n}` and every\n * `/category/{slug}`, and a template has no runtime conditional to vary\n * itself with.\n */\nexport const COLLECTION_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n {\n token: '{{collection.name}}',\n label: 'Collection name',\n description: 'Display name of the routed collection.',\n },\n {\n token: '{{collection.slug}}',\n label: 'Collection slug',\n description: 'URL slug of the routed collection.',\n },\n {\n token: '{{collection.category}}',\n label: 'Filtered category',\n description: 'Category the URL filtered on; empty when unfiltered.',\n },\n {\n token: '{{collection.categorySlug}}',\n label: 'Filtered category slug',\n description: 'That category’s URL segment; empty when unfiltered.',\n },\n {\n token: '{{pagination.page}}',\n label: 'Current page',\n description: 'Page number this URL is showing.',\n },\n {\n token: '{{pagination.totalPages}}',\n label: 'Total pages',\n // A listing bigger than one read cannot be counted honestly (AGL-3219):\n // the count and the entries were read at two different moments, and the\n // pager built from them disagreed with itself at the page boundary. It\n // still resolves, so a template binding it keeps rendering.\n description:\n 'Pages in the listing, after any category filter. Empty on a listing too large to count — bind the next link instead.',\n },\n {\n token: '{{pagination.prevUrl}}',\n label: 'Previous page link',\n description: 'Keeps the category; empty on the first page.',\n },\n {\n token: '{{pagination.nextUrl}}',\n label: 'Next page link',\n // The reliable \"is there more\" (AGL-3219): it is set from a read that\n // asked for one entry more than the page needed, so empty means there was\n // no such entry rather than that a total said so.\n description:\n 'Keeps the category; empty when there is nothing older to show.',\n },\n]\n\n/**\n * `{{author.*}}` tokens (AGL-2518) — resolve on an author's own page,\n * `/author/{slug}`.\n *\n * Empty everywhere else, like the category tokens above and for the same\n * reason: a template has no runtime conditional, so the tokens have to be the\n * thing that varies. A heading bound to `{{author.name}}` prints a name on an\n * author page and nothing anywhere else.\n *\n * The `{{pagination.*}}` tokens resolve HERE TOO, over this author's own\n * archive — deliberately the same four names a collection listing uses, so a\n * pager built once works on both.\n */\nexport const AUTHOR_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n {\n token: '{{author.name}}',\n label: 'Name',\n description: 'The byline this page collects.',\n },\n {\n token: '{{author.bio}}',\n label: 'Bio',\n description: 'Their blurb, from the author record.',\n },\n {\n token: '{{author.image}}',\n label: 'Portrait',\n description: 'Their portrait or logo, from the author record.',\n },\n {\n token: '{{author.jobTitle}}',\n label: 'Role',\n description: 'Their job title; empty for an Organization author.',\n },\n {\n token: '{{author.worksFor}}',\n label: 'Organization',\n description: 'Who they write for; empty for an Organization author.',\n },\n {\n token: '{{author.url}}',\n label: 'Their own site',\n description:\n 'The url on their record — a personal site, not this page. Empty when ' +\n 'they have none.',\n },\n {\n token: '{{author.pageUrl}}',\n label: 'This page',\n description: 'The canonical address of the page you are designing.',\n },\n {\n token: '{{author.entryCount}}',\n label: 'Post count',\n description: 'How many entries they have published, across every collection.',\n },\n {\n token: '{{author.entryCountLabel}}',\n label: 'Post count, worded',\n description:\n '\"1 post\" or \"12 posts\" — pluralized for you, because a template has no ' +\n 'conditional to do it with.',\n },\n]\n\n/**\n * The `{{item.*}}` token for one field of the record a repeat is rendering,\n * optionally hopping one reference to a field of the referenced record\n * (AGL-180). Always takes the stable field id — display names are labels\n * only, so renaming a field never breaks a binding (AGL-578).\n */\nexport function repeatItemToken(\n fieldId: string,\n targetFieldId?: string,\n): string {\n return targetFieldId\n ? `{{item.${fieldId}.${targetFieldId}}}`\n : `{{item.${fieldId}}}`\n}\n"],"names":["ENTRY_TOKEN_CATALOG","token","label","description","COLLECTION_TOKEN_CATALOG","AUTHOR_TOKEN_CATALOG","repeatItemToken","fieldId","targetFieldId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;CAUC,GAUD;;;;CAIC,GACD,OAAO,MAAMA,sBAA2D;IACtE;QAAEC,OAAO;QAAmBC,OAAO;QAASC,aAAa;IAAsB;IAC/E;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;;;;EAIA,GACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,qEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,2EACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;;;;;;;;EAQA,GACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,0EACA,wEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,wEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;CACD,CAAA;AAED;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,2BAAgE;IAC3E;QACEH,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACP,wEAAwE;QACxE,wEAAwE;QACxE,uEAAuE;QACvE,4DAA4D;QAC5DC,aACE;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACP,sEAAsE;QACtE,0EAA0E;QAC1E,kDAAkD;QAClDC,aACE;IACJ;CACD,CAAA;AAED;;;;;;;;;;;;CAYC,GACD,OAAO,MAAME,uBAA4D;IACvE;QACEJ,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,0EACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,4EACA;IACJ;CACD,CAAA;AAED;;;;;CAKC,GACD,OAAO,SAASG,gBACdC,OAAe,EACfC,aAAsB;IAEtB,OAAOA,gBACH,CAAC,OAAO,EAAED,QAAQ,CAAC,EAAEC,cAAc,EAAE,CAAC,GACtC,CAAC,OAAO,EAAED,QAAQ,EAAE,CAAC;AAC3B"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/binding-token-catalog.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Browsable data-placeholder catalogs for the designer's insert picker\n * (AGL-583). Hand-typing `{{entry.title}}` stays the advanced path; these\n * catalogs give every token a friendly label + description so editors can\n * browse and insert instead of memorizing the grammar.\n *\n * The token STRINGS are owned elsewhere — `collectionEntryTokens`\n * (collection-entries.ts) and the tenant compose pipeline resolve them —\n * this module only names them for pickers. The spec cross-checks the entry\n * catalog against the resolver so the two can never drift.\n */\nexport interface BindingTokenCatalogEntry {\n /** The literal token inserted into the prop, e.g. `{{entry.title}}`. */\n token: string\n /** Friendly picker label, e.g. `Title`. */\n label: string\n /** One-line description shown as the option's secondary text. */\n description?: string\n}\n\n/**\n * `{{entry.*}}` tokens (AGL-105/551/582) — resolve per entry inside a\n * Collection entries block and page-wide on entry-template screens.\n * Mirrors {@link collectionEntryTokens}; the spec enforces the mirror.\n */\nexport const ENTRY_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n { token: '{{entry.title}}', label: 'Title', description: 'The entry headline.' },\n {\n token: '{{entry.excerpt}}',\n label: 'Excerpt',\n description: 'Short summary text.',\n },\n {\n token: '{{entry.body}}',\n label: 'Body',\n description: 'The full entry body.',\n },\n {\n token: '{{entry.url}}',\n label: 'Link URL',\n description: 'Auto-route to the entry page.',\n },\n {\n token: '{{entry.date}}',\n label: 'Published date',\n description: 'Formatted publish date.',\n },\n /*\n Beside the readable date on purpose: a designer reaching for \"the date\" in\n a Video element's Publication date field has to see that the readable one\n is the wrong pick there, and why.\n */\n {\n token: '{{entry.publishedAt}}',\n label: 'Publish timestamp',\n description:\n 'The publish date and time in ISO 8601, for a field that needs a ' +\n 'machine-readable date, such as a Video element’s publication date.',\n },\n {\n token: '{{entry.author}}',\n label: 'Author',\n description: 'The byline set on the entry.',\n },\n {\n token: '{{entry.authorBio}}',\n label: 'Author bio',\n description: 'Blurb from the author’s record.',\n },\n {\n token: '{{entry.authorImage}}',\n label: 'Author portrait',\n description: 'Portrait or logo from the author’s record.',\n },\n {\n token: '{{entry.authorUrl}}',\n label: 'Author link',\n description: 'The author’s own page.',\n },\n {\n token: '{{entry.authorPageUrl}}',\n label: 'Author page',\n description:\n 'This author’s page on this site — everything they wrote, across every ' +\n 'collection. Separate from Author link, which is their own site.',\n },\n {\n token: '{{entry.slug}}',\n label: 'Slug',\n description: 'URL-safe entry identifier.',\n },\n /*\n Named \"Its collection…\" rather than \"Collection…\", which is what these\n describe and also exactly what the `{{collection.*}}` entries below are\n called. The picker groups options by heading, so the two sets sit apart —\n but a designer scanning it reads the LABEL, and two options reading\n \"Collection name\" three lines apart is a choice made by guessing. The\n pronoun is doing real work: on the one page where both resolve, they mean\n different collections.\n */\n {\n token: '{{entry.collection}}',\n label: 'Its collection',\n description:\n 'Which section this entry belongs to. Worth binding on a listing that ' +\n 'MIXES collections — an author page — where a card otherwise cannot ' +\n 'tell a release note from an essay.',\n },\n {\n token: '{{entry.collectionSlug}}',\n label: 'Its collection slug',\n description: 'That collection’s URL segment.',\n },\n {\n token: '{{entry.collectionUrl}}',\n label: 'Its collection link',\n description: 'The listing this entry belongs to.',\n },\n {\n token: '{{entry.coverImage}}',\n label: 'Cover image',\n description: 'Cover image URL.',\n },\n {\n token: '{{entry.coverVideo}}',\n label: 'Featured video',\n description:\n 'The featured video’s source — a library film, a video file link or ' +\n 'a video host’s link — for a Video element.',\n },\n {\n token: '{{entry.coverVideoDuration}}',\n label: 'Featured video length',\n description:\n 'How long the featured video runs, in seconds, for a Video element’s ' +\n 'duration field. Blank when the entry does not say.',\n },\n {\n token: '{{entry.category}}',\n label: 'Category',\n description: 'The entry category.',\n },\n {\n token: '{{entry.tags}}',\n label: 'Tags',\n description: 'Tags, comma separated.',\n },\n {\n token: '{{entry.seoTitle}}',\n label: 'SEO title',\n description: 'Search title; falls back to Title.',\n },\n {\n token: '{{entry.seoDescription}}',\n label: 'SEO description',\n description: 'Meta description; falls back to Excerpt.',\n },\n]\n\n/**\n * `{{collection.*}}` and `{{pagination.*}}` tokens (AGL-551/1321/1386) —\n * resolve on collection list/entry template screens (see the tenant compose\n * pipeline's collection tokens).\n *\n * The category and pagination tokens all resolve to the empty string where\n * they do not apply, so they are safe to bind unconditionally: ONE list\n * screen serves the bare listing, every `/page/{n}` and every\n * `/category/{slug}`, and a template has no runtime conditional to vary\n * itself with.\n */\nexport const COLLECTION_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n {\n token: '{{collection.name}}',\n label: 'Collection name',\n description: 'Display name of the routed collection.',\n },\n {\n token: '{{collection.slug}}',\n label: 'Collection slug',\n description: 'URL slug of the routed collection.',\n },\n {\n token: '{{collection.category}}',\n label: 'Filtered category',\n description: 'Category the URL filtered on; empty when unfiltered.',\n },\n {\n token: '{{collection.categorySlug}}',\n label: 'Filtered category slug',\n description: 'That category’s URL segment; empty when unfiltered.',\n },\n {\n token: '{{pagination.page}}',\n label: 'Current page',\n description: 'Page number this URL is showing.',\n },\n {\n token: '{{pagination.totalPages}}',\n label: 'Total pages',\n // A listing bigger than one read cannot be counted honestly (AGL-3219):\n // the count and the entries were read at two different moments, and the\n // pager built from them disagreed with itself at the page boundary. It\n // still resolves, so a template binding it keeps rendering.\n description:\n 'Pages in the listing, after any category filter. Empty on a listing too large to count — bind the next link instead.',\n },\n {\n token: '{{pagination.prevUrl}}',\n label: 'Previous page link',\n description: 'Keeps the category; empty on the first page.',\n },\n {\n token: '{{pagination.nextUrl}}',\n label: 'Next page link',\n // The reliable \"is there more\" (AGL-3219): it is set from a read that\n // asked for one entry more than the page needed, so empty means there was\n // no such entry rather than that a total said so.\n description:\n 'Keeps the category; empty when there is nothing older to show.',\n },\n]\n\n/**\n * `{{author.*}}` tokens (AGL-2518) — resolve on an author's own page,\n * `/author/{slug}`.\n *\n * Empty everywhere else, like the category tokens above and for the same\n * reason: a template has no runtime conditional, so the tokens have to be the\n * thing that varies. A heading bound to `{{author.name}}` prints a name on an\n * author page and nothing anywhere else.\n *\n * The `{{pagination.*}}` tokens resolve HERE TOO, over this author's own\n * archive — deliberately the same four names a collection listing uses, so a\n * pager built once works on both.\n */\nexport const AUTHOR_TOKEN_CATALOG: readonly BindingTokenCatalogEntry[] = [\n {\n token: '{{author.name}}',\n label: 'Name',\n description: 'The byline this page collects.',\n },\n {\n token: '{{author.bio}}',\n label: 'Bio',\n description: 'Their blurb, from the author record.',\n },\n {\n token: '{{author.image}}',\n label: 'Portrait',\n description: 'Their portrait or logo, from the author record.',\n },\n {\n token: '{{author.jobTitle}}',\n label: 'Role',\n description: 'Their job title; empty for an Organization author.',\n },\n {\n token: '{{author.worksFor}}',\n label: 'Organization',\n description: 'Who they write for; empty for an Organization author.',\n },\n {\n token: '{{author.url}}',\n label: 'Their own site',\n description:\n 'The url on their record — a personal site, not this page. Empty when ' +\n 'they have none.',\n },\n {\n token: '{{author.pageUrl}}',\n label: 'This page',\n description: 'The canonical address of the page you are designing.',\n },\n {\n token: '{{author.entryCount}}',\n label: 'Post count',\n description: 'How many entries they have published, across every collection.',\n },\n {\n token: '{{author.entryCountLabel}}',\n label: 'Post count, worded',\n description:\n '\"1 post\" or \"12 posts\" — pluralized for you, because a template has no ' +\n 'conditional to do it with.',\n },\n]\n\n/**\n * The `{{item.*}}` token for one field of the record a repeat is rendering,\n * optionally hopping one reference to a field of the referenced record\n * (AGL-180). Always takes the stable field id — display names are labels\n * only, so renaming a field never breaks a binding (AGL-578).\n */\nexport function repeatItemToken(\n fieldId: string,\n targetFieldId?: string,\n): string {\n return targetFieldId\n ? `{{item.${fieldId}.${targetFieldId}}}`\n : `{{item.${fieldId}}}`\n}\n"],"names":["ENTRY_TOKEN_CATALOG","token","label","description","COLLECTION_TOKEN_CATALOG","AUTHOR_TOKEN_CATALOG","repeatItemToken","fieldId","targetFieldId"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;CAUC,GAUD;;;;CAIC,GACD,OAAO,MAAMA,sBAA2D;IACtE;QAAEC,OAAO;QAAmBC,OAAO;QAASC,aAAa;IAAsB;IAC/E;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;;;;EAIA,GACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,qEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,2EACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;;;;;;;;EAQA,GACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,0EACA,wEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,wEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,yEACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;CACD,CAAA;AAED;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,2BAAgE;IAC3E;QACEH,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACP,wEAAwE;QACxE,wEAAwE;QACxE,uEAAuE;QACvE,4DAA4D;QAC5DC,aACE;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACP,sEAAsE;QACtE,0EAA0E;QAC1E,kDAAkD;QAClDC,aACE;IACJ;CACD,CAAA;AAED;;;;;;;;;;;;CAYC,GACD,OAAO,MAAME,uBAA4D;IACvE;QACEJ,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,0EACA;IACJ;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aAAa;IACf;IACA;QACEF,OAAO;QACPC,OAAO;QACPC,aACE,4EACA;IACJ;CACD,CAAA;AAED;;;;;CAKC,GACD,OAAO,SAASG,gBACdC,OAAe,EACfC,aAAsB;IAEtB,OAAOA,gBACH,CAAC,OAAO,EAAED,QAAQ,CAAC,EAAEC,cAAc,EAAE,CAAC,GACtC,CAAC,OAAO,EAAED,QAAQ,EAAE,CAAC;AAC3B"}
@@ -317,6 +317,15 @@ export interface CollectionEntryRecord {
317
317
  * the built-in entry page plays it with no template at all.
318
318
  */
319
319
  coverVideo?: string;
320
+ /**
321
+ * How long {@link coverVideo} runs, in whole seconds (AGL-3584) — the unit
322
+ * the Video element's `durationSeconds` takes, because an author types 63
323
+ * and not 63000. The page's `VideoObject` publishes it as `duration`, which
324
+ * Google recommends, and nothing else knows it for a hosted player's link.
325
+ * Read through {@link collectionEntryVideoDurationSeconds}, so a value that
326
+ * is not a positive number is no duration at all.
327
+ */
328
+ coverVideoDuration?: number;
320
329
  /** Search-result title override (AGL-582); falls back to `title`. */
321
330
  seoTitle?: string;
322
331
  /** Meta description override (AGL-582); falls back to `excerpt`. */
@@ -444,6 +453,18 @@ export declare function collectionEntryAuthorValues(entry: CollectionEntryRecord
444
453
  pageUrl: string;
445
454
  links: ContentAuthorLink[];
446
455
  };
456
+ /**
457
+ * A featured video's stored length as whole seconds, or `undefined` when it
458
+ * names none (AGL-3584).
459
+ *
460
+ * The one reading of `coverVideoDuration` for every side that touches it: the
461
+ * console's save, the loader, the token and the built-in page. A number or a
462
+ * numeric string (a form field, an import) counts when it is finite and
463
+ * positive; it is rounded to the second, never down to `0`, because a stored
464
+ * `0` reads as "no duration" downstream — the rule `videoMediaProps` applies
465
+ * to a library film's own length.
466
+ */
467
+ export declare function collectionEntryVideoDurationSeconds(value: unknown): number | undefined;
447
468
  /**
448
469
  * The `{{entry.*}}` token map for one entry (AGL-105/551): substituted
449
470
  * globally on entry-template screens and per-clone inside the Collection
@@ -335,13 +335,28 @@ categories) {
335
335
  url: ((_ref4 = author == null ? void 0 : author.url) != null ? _ref4 : '').trim()
336
336
  };
337
337
  }
338
+ /**
339
+ * A featured video's stored length as whole seconds, or `undefined` when it
340
+ * names none (AGL-3584).
341
+ *
342
+ * The one reading of `coverVideoDuration` for every side that touches it: the
343
+ * console's save, the loader, the token and the built-in page. A number or a
344
+ * numeric string (a form field, an import) counts when it is finite and
345
+ * positive; it is rounded to the second, never down to `0`, because a stored
346
+ * `0` reads as "no duration" downstream — the rule `videoMediaProps` applies
347
+ * to a library film's own length.
348
+ */ export function collectionEntryVideoDurationSeconds(value) {
349
+ const seconds = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value.trim()) : Number.NaN;
350
+ if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
351
+ return Math.max(1, Math.round(seconds));
352
+ }
338
353
  /**
339
354
  * The `{{entry.*}}` token map for one entry (AGL-105/551): substituted
340
355
  * globally on entry-template screens and per-clone inside the Collection
341
356
  * entries block. `entry.url` resolves to the entry's auto-route so links
342
357
  * (`Read more`, titles) work without hardcoding the collection slug.
343
358
  */ export function collectionEntryTokens(entry, collectionSlug, categories, /** The site's zone (AGL-3237); UTC when a site has not named one. */ timeZone) {
344
- var _entry_collectionSlug, _entry_title, _entry_excerpt, _entry_body, _entry_coverImage, _entry_coverVideo, _entry_slug, _entry_slug1, _entry_collectionName;
359
+ var _entry_collectionSlug, _entry_title, _entry_excerpt, _entry_body, _entry_coverImage, _entry_coverVideo, _collectionEntryVideoDurationSeconds, _entry_slug, _entry_slug1, _entry_collectionName;
345
360
  const meta = collectionEntryMetaValues(entry, categories, undefined, timeZone);
346
361
  const author = collectionEntryAuthorValues(entry);
347
362
  // The entry's OWN collection wins over the routed one (AGL-2518) — see
@@ -357,6 +372,10 @@ categories) {
357
372
  // into a Video element's source, and the element resolves a reference
358
373
  // against the site rendering it.
359
374
  'entry.coverVideo': (_entry_coverVideo = entry.coverVideo) != null ? _entry_coverVideo : '',
375
+ // Its length in seconds (AGL-3584), for the Video element's duration, so
376
+ // the watch page's `VideoObject` carries `duration`. `''` when unknown,
377
+ // which the element reads as no duration.
378
+ 'entry.coverVideoDuration': String((_collectionEntryVideoDurationSeconds = collectionEntryVideoDurationSeconds(entry.coverVideoDuration)) != null ? _collectionEntryVideoDurationSeconds : ''),
360
379
  'entry.slug': (_entry_slug = entry.slug) != null ? _entry_slug : '',
361
380
  'entry.url': `/${slug}/${(_entry_slug1 = entry.slug) != null ? _entry_slug1 : ''}`,
362
381
  // Which section this entry belongs to (AGL-2518). Worth binding only on a