@aglyn/aglyn 1.0.0-beta.224 → 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.
Files changed (40) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/advertising-tag-mounts.js +9 -1
  3. package/src/lib/app-utils/advertising-tag-mounts.js.map +1 -1
  4. package/src/lib/app-utils/after-response.d.ts +32 -0
  5. package/src/lib/app-utils/after-response.js +98 -0
  6. package/src/lib/app-utils/after-response.js.map +1 -0
  7. package/src/lib/app-utils/api-adapter.d.ts +10 -0
  8. package/src/lib/app-utils/api-adapter.js +18 -0
  9. package/src/lib/app-utils/api-adapter.js.map +1 -1
  10. package/src/lib/app-utils/binding-token-catalog.js +5 -0
  11. package/src/lib/app-utils/binding-token-catalog.js.map +1 -1
  12. package/src/lib/app-utils/collection-entries.d.ts +21 -0
  13. package/src/lib/app-utils/collection-entries.js +20 -1
  14. package/src/lib/app-utils/collection-entries.js.map +1 -1
  15. package/src/lib/app-utils/crm.d.ts +33 -5
  16. package/src/lib/app-utils/crm.js +59 -12
  17. package/src/lib/app-utils/crm.js.map +1 -1
  18. package/src/lib/app-utils/docs-index.generated.js +12 -12
  19. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  20. package/src/lib/app-utils/faq-page.d.ts +53 -0
  21. package/src/lib/app-utils/faq-page.js +265 -0
  22. package/src/lib/app-utils/faq-page.js.map +1 -0
  23. package/src/lib/app-utils/health-report.d.ts +10 -1
  24. package/src/lib/app-utils/health-report.js +12 -2
  25. package/src/lib/app-utils/health-report.js.map +1 -1
  26. package/src/lib/app-utils/llms-txt.d.ts +55 -38
  27. package/src/lib/app-utils/llms-txt.js +135 -6
  28. package/src/lib/app-utils/llms-txt.js.map +1 -1
  29. package/src/lib/app-utils/operator-alerts.js +19 -0
  30. package/src/lib/app-utils/operator-alerts.js.map +1 -1
  31. package/src/lib/app-utils/page-idle.d.ts +77 -0
  32. package/src/lib/app-utils/page-idle.js +129 -0
  33. package/src/lib/app-utils/page-idle.js.map +1 -0
  34. package/src/lib/app-utils/page-markdown.js +20 -0
  35. package/src/lib/app-utils/page-markdown.js.map +1 -1
  36. package/src/lib/app-utils/server.d.ts +1 -0
  37. package/src/lib/app-utils/server.js +3 -0
  38. package/src/lib/app-utils/server.js.map +1 -1
  39. package/src/lib/types/nodes.d.ts +27 -0
  40. package/src/lib/types/nodes.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/aglyn",
3
- "version": "1.0.0-beta.224",
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.224",
41
- "@aglyn/shared-data-mdi": "1.0.0-beta.224",
42
- "@aglyn/shared-data-types": "1.0.0-beta.224",
43
- "@aglyn/shared-util-email": "1.0.0-beta.224",
44
- "@aglyn/shared-util-first-touch": "1.0.0-beta.224",
45
- "@aglyn/shared-util-http": "1.0.0-beta.224",
46
- "@aglyn/shared-util-logger": "1.0.0-beta.224",
47
- "@aglyn/shared-util-timestamp": "1.0.0-beta.224",
48
- "@aglyn/shared-util-tools": "1.0.0-beta.224",
49
- "@aglyn/shared-util-vendor": "1.0.0-beta.224",
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",
@@ -25,6 +25,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
25
25
  // `site-analytics-independence.spec.ts` walks and which must stay independent
26
26
  // of the site-plugin gate.
27
27
  import { ADVERTISING_TAG_ATTRIBUTE, restoreAdvertisingTags, revokeAdvertisingTags } from "./advertising-tags.js";
28
+ import { usePageIdle } from "./page-idle.js";
28
29
  import { VISITOR_CONSENT_CHANGED_EVENT } from "./visitor-consent.js";
29
30
  import Script from "next/script";
30
31
  import { Fragment, useEffect, useRef } from "react";
@@ -83,7 +84,14 @@ export default function AdvertisingTagMounts({ active, tags, resolve, nonce, sha
83
84
  }, [
84
85
  active
85
86
  ]);
86
- if (!active || tags.length === 0) return null;
87
+ // Nothing is injected before the page has loaded and gone idle (AGL-3581).
88
+ // The withdrawal listener above is NOT behind this: a visitor who refuses
89
+ // before the page idles has nothing to tear down, and one who refuses after
90
+ // needs the listener already there. Every surface reads the same store, so
91
+ // a page that mounts its own loader flips in the same render as this one
92
+ // and `sharedLibraries` still describes it (AGL-2681).
93
+ const pageIsIdle = usePageIdle();
94
+ if (!active || tags.length === 0 || !pageIsIdle) return null;
87
95
  return /*#__PURE__*/ _jsx(_Fragment, {
88
96
  children: tags.map(({ vendor, accountId })=>// A PAIR per vendor, inline boot first and library second — the same
89
97
  // shape as the GA `ga-init` / `ga-src` pair, and for the same reason:
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/advertising-tag-mounts.tsx"],"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// NO `'use client'` here, and it is a lint rule rather than a preference\n// (AGL-52): a directive inside `@aglyn/aglyn` makes the bundler split a\n// duplicate module graph, and the second canvas/emitter singleton renders the\n// tenant site blank. Every component that mounts this one carries the\n// directive itself, which is where the client boundary belongs.\n//\n// Deep app-utils modules, never the `@aglyn/aglyn` barrel (AGL-1550): this\n// file is reached from `site-analytics.tsx`, whose import closure\n// `site-analytics-independence.spec.ts` walks and which must stay independent\n// of the site-plugin gate.\nimport {\n ADVERTISING_TAG_ATTRIBUTE,\n type ResolvedAdvertisingTag,\n restoreAdvertisingTags,\n revokeAdvertisingTags,\n} from './advertising-tags'\nimport { VISITOR_CONSENT_CHANGED_EVENT } from './visitor-consent'\nimport Script from 'next/script'\nimport { Fragment, useEffect, useRef, type ReactElement } from 'react'\n\n/**\n * The MOUNT and the WITHDRAWAL for consent-gated advertising tags, with no\n * opinion about where the verdict came from.\n *\n * ## Why this is a shared component and not a second copy per surface\n *\n * Aglyn runs advertising tags on three first-party surfaces and they resolve\n * consent through three different mechanisms: the tenant runtime reads a host\n * document and a per-host record, the console reads the platform record its\n * own posture machinery wrote, and the docs site reads the registrable-domain\n * mirror of that record. Those are genuinely different questions.\n *\n * What is NOT different is what happens once the answer is known: mount an\n * inline boot and a library per vendor, and — the half that is easy to forget\n * and impossible to retrofit — stop them the moment the answer changes. A\n * second copy of that half is how one surface comes to keep firing after\n * consent is withdrawn on another, because the copy that was not updated\n * still looks exactly like the one that was.\n *\n * So the verdict is a PROP and the machinery is shared. Each surface answers\n * its own question with its own resolver; none of them owns a teardown.\n *\n * ## Why it renders even when the answer is no\n *\n * Because the withdrawal path needs a listener. A visitor who accepts and then\n * turns advertising off must stop being tracked in THAT pageview, and by then\n * the vendor library has executed — React dropping the `<Script>` does not\n * unload it (AGL-1608). So this component stays mounted whenever the surface\n * participates at all, and subscribes to\n * {@link VISITOR_CONSENT_CHANGED_EVENT}; the teardown runs from the event,\n * synchronously with the visitor's click, rather than waiting on a re-render.\n *\n * Both paths run and they agree, which is deliberate: the render gate is what\n * keeps the tag out of a fresh pageview, the listener is what removes one that\n * is already there, and neither can do the other's job.\n */\nexport interface AdvertisingTagMountsProps {\n /**\n * Whether this surface participates in the gate AT ALL.\n *\n * False installs nothing — no listener, no scripts. That is the clause that\n * keeps the tenant runtime's teardown off a customer's site: we did not load\n * their pixel, we do not know what basis it runs on, and reaching into their\n * page to kill it would be its own breach. `revokeAdvertisingTags` is\n * additionally attribute-scoped, so there are two independent scopes.\n */\n readonly active: boolean\n /** The verdict for THIS render: the tags that may exist right now. */\n readonly tags: readonly ResolvedAdvertisingTag[]\n /**\n * Re-read the verdict from live state, for the withdrawal listener.\n *\n * A callback rather than the `tags` prop, because the listener fires from\n * the visitor's own click in the same tick as the record is written — the\n * props for the current render are by definition the state before it.\n */\n readonly resolve: () => readonly ResolvedAdvertisingTag[]\n /**\n * The request's CSP nonce, stamped onto BOTH elements of every pair.\n *\n * A surface that enforces a nonce'd `script-src` refuses an inline script\n * that does not carry it, and the boot half of every pair is inline. Next's\n * `<Script>` stamps a nonce only when one reaches it: the automatic path\n * reads `HeadManagerContext`, and the App Router's client provider carries\n * no nonce at all — so a pair mounted after hydration, which is the only\n * time this component ever mounts one, is unnonced unless the caller hands\n * the value down. An explicit prop wins inside `next/script`\n * (`restProps.nonce || nonce`), which is what makes this the one door.\n *\n * The failure without it is silent and lopsided: the library beside the\n * boot has a `src` the policy allows, so the vendor's code loads and runs\n * against an account nobody configured, and the conversions this surface\n * sends are lost with nothing in the page but a CSP violation.\n *\n * Absent on a surface that sends no `script-src`: nothing is stamped, and\n * nothing is refused.\n */\n readonly nonce?: string\n /**\n * Libraries the HOST PAGE mounts itself, named by the needle a vendor\n * declares in `sharesLibrary` (AGL-2681).\n *\n * {@link sharedLibraryPresent} reads the document at render time, and that\n * is the right instrument for a loader some other party put there. It is\n * the wrong one for a loader the SAME render is about to create. On the\n * tenant the GA pair and this component both wait on `consent.ready`, so\n * the first render that may emit either emits both — GA's `<Script>` is not\n * in the document yet when this component looks, the check honestly says\n * \"absent\", and the visitor downloads `gtag.js` twice: once as ours with\n * `?id=AW-…`, once as gtag's own destination fetch for the `config` that\n * follows. Measured on `aglyn.com/solutions/small-business` with\n * advertising granted: two 147 KiB loaders for one account.\n *\n * A page that KNOWS it renders the library says so here, from the same\n * condition that renders it, and the document check stays as the fallback\n * for loaders it does not know about. Naming the needle rather than passing\n * a boolean keeps the declaration per library: a page that mounts gtag has\n * said nothing about the Meta pixel.\n */\n readonly sharedLibraries?: readonly string[]\n}\n\n/**\n * Is a library matching `needle` already in the document?\n *\n * Read at RENDER time rather than in an effect: the decision is whether to\n * emit a `<Script>` at all, and by the time an effect could answer, Next has\n * already appended it. `document` is guarded because this component renders on\n * the server too, where nothing is mounted and the honest answer is \"no\" — the\n * client render then re-evaluates with the real document.\n *\n * Blind to a loader the current render is creating alongside this one — see\n * `sharedLibraries` on the props for the case that needs the page to say so.\n */\nexport function sharedLibraryPresent(needle: string): boolean {\n if (typeof document === 'undefined') return false\n try {\n return Boolean(document.querySelector(`script[src*=\"${needle}\"]`))\n } catch {\n // A hostile or absent DOM: assume nothing is mounted, which mounts our\n // own copy — the cost is a duplicate fetch, never a missing tag.\n return false\n }\n}\n\nexport default function AdvertisingTagMounts({\n active,\n tags,\n resolve,\n nonce,\n sharedLibraries,\n}: AdvertisingTagMountsProps): ReactElement | null {\n /** Declared by the page, or found in the document: either means \"ride it\". */\n const libraryProvided = (needle: string): boolean =>\n (sharedLibraries?.includes(needle) ?? false) || sharedLibraryPresent(needle)\n /*\n * The resolver is held in a ref rather than listed as an effect dependency.\n *\n * Every caller passes a closure over its own live consent state, so the\n * function identity changes on every render; depending on it would tear the\n * listener down and re-install it each time, and a withdrawal that landed in\n * that window would find no subscriber. The ref makes the subscription's\n * lifetime `active`, which is the only thing that genuinely changes it,\n * while the callback it invokes is always the newest one.\n */\n const resolveRef = useRef(resolve)\n resolveRef.current = resolve\n\n useEffect(() => {\n if (!active) return undefined\n const sync = () => {\n if (resolveRef.current().length === 0) {\n revokeAdvertisingTags()\n } else {\n // Symmetric: a visitor who withdrew and changed their mind inside one\n // pageview would otherwise stay un-tracked until they navigated,\n // because a re-rendered `<Script>` cannot re-execute a library the\n // browser already ran.\n restoreAdvertisingTags()\n }\n }\n window.addEventListener(VISITOR_CONSENT_CHANGED_EVENT, sync)\n return () => window.removeEventListener(VISITOR_CONSENT_CHANGED_EVENT, sync)\n }, [active])\n\n if (!active || tags.length === 0) return null\n\n return (\n <>\n {tags.map(({ vendor, accountId }) => (\n // A PAIR per vendor, inline boot first and library second — the same\n // shape as the GA `ga-init` / `ga-src` pair, and for the same reason:\n // the boot defines the vendor's queue shim and declares the consent\n // state, so nothing the library later drains was queued under a state\n // nobody chose. Both elements carry the teardown's scope marker; only\n // elements carrying it are ever revoked, removed or cookie-swept.\n <Fragment key={vendor.id}>\n <Script\n id={`ad-tag-${vendor.id}-init`}\n strategy=\"afterInteractive\"\n nonce={nonce}\n {...{ [ADVERTISING_TAG_ATTRIBUTE]: vendor.id }}\n >\n {vendor.bootSnippet ? vendor.bootSnippet(accountId) : ''}\n </Script>\n {/* Skipped when another loader already brought this library in\n (AGL-1152). Google Ads shares `gtag.js` with the GA4 measurement\n id and with a GTM container, so a surface with both configured\n would fetch it twice and define `gtag()` twice — and the boot\n above would be the second voice in a consent conversation the\n first one already had. One library, several `config` calls, is\n how gtag carries several products. The page's own declaration is\n consulted first (AGL-2681): the document cannot yet show a loader\n this same render is creating. */}\n {vendor.sharesLibrary && libraryProvided(vendor.sharesLibrary) ? null : (\n <Script\n id={`ad-tag-${vendor.id}-src`}\n strategy=\"afterInteractive\"\n nonce={nonce}\n {...{ [ADVERTISING_TAG_ATTRIBUTE]: vendor.id }}\n // `scriptSrcFor` where the vendor has one: gtag reads the\n // container out of the loader's query, so the copy we bring\n // ourselves has to name the account. See `scriptSrcFor`.\n src={\n vendor.scriptSrcFor\n ? vendor.scriptSrcFor(accountId)\n : vendor.scriptSrc\n }\n />\n )}\n </Fragment>\n ))}\n </>\n )\n}\n"],"names":["ADVERTISING_TAG_ATTRIBUTE","restoreAdvertisingTags","revokeAdvertisingTags","VISITOR_CONSENT_CHANGED_EVENT","Script","Fragment","useEffect","useRef","sharedLibraryPresent","needle","document","Boolean","querySelector","AdvertisingTagMounts","active","tags","resolve","nonce","sharedLibraries","libraryProvided","includes","resolveRef","current","undefined","sync","length","window","addEventListener","removeEventListener","map","vendor","accountId","id","strategy","bootSnippet","sharesLibrary","src","scriptSrcFor","scriptSrc"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,yEAAyE;AACzE,wEAAwE;AACxE,8EAA8E;AAC9E,sEAAsE;AACtE,gEAAgE;AAChE,EAAE;AACF,2EAA2E;AAC3E,kEAAkE;AAClE,8EAA8E;AAC9E,2BAA2B;AAC3B,SACEA,yBAAyB,EAEzBC,sBAAsB,EACtBC,qBAAqB,QAChB,wBAAoB;AAC3B,SAASC,6BAA6B,QAAQ,uBAAmB;AACjE,OAAOC,YAAY,cAAa;AAChC,SAASC,QAAQ,EAAEC,SAAS,EAAEC,MAAM,QAA2B,QAAO;AAwGtE;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,qBAAqBC,MAAc;IACjD,IAAI,OAAOC,aAAa,aAAa,OAAO;IAC5C,IAAI;QACF,OAAOC,QAAQD,SAASE,aAAa,CAAC,CAAC,aAAa,EAAEH,OAAO,EAAE,CAAC;IAClE,EAAE,eAAM;QACN,uEAAuE;QACvE,iEAAiE;QACjE,OAAO;IACT;AACF;AAEA,eAAe,SAASI,qBAAqB,EAC3CC,MAAM,EACNC,IAAI,EACJC,OAAO,EACPC,KAAK,EACLC,eAAe,EACW;IAC1B,4EAA4E,GAC5E,MAAMC,kBAAkB,CAACV;;eACvB,SAACS,mCAAAA,gBAAiBE,QAAQ,CAACX,0BAAW,UAAUD,qBAAqBC;;IACvE;;;;;;;;;GASC,GACD,MAAMY,aAAad,OAAOS;IAC1BK,WAAWC,OAAO,GAAGN;IAErBV,UAAU;QACR,IAAI,CAACQ,QAAQ,OAAOS;QACpB,MAAMC,OAAO;YACX,IAAIH,WAAWC,OAAO,GAAGG,MAAM,KAAK,GAAG;gBACrCvB;YACF,OAAO;gBACL,sEAAsE;gBACtE,iEAAiE;gBACjE,mEAAmE;gBACnE,uBAAuB;gBACvBD;YACF;QACF;QACAyB,OAAOC,gBAAgB,CAACxB,+BAA+BqB;QACvD,OAAO,IAAME,OAAOE,mBAAmB,CAACzB,+BAA+BqB;IACzE,GAAG;QAACV;KAAO;IAEX,IAAI,CAACA,UAAUC,KAAKU,MAAM,KAAK,GAAG,OAAO;IAEzC,qBACE;kBACGV,KAAKc,GAAG,CAAC,CAAC,EAAEC,MAAM,EAAEC,SAAS,EAAE,GAC9B,qEAAqE;YACrE,sEAAsE;YACtE,oEAAoE;YACpE,sEAAsE;YACtE,sEAAsE;YACtE,kEAAkE;0BAClE,MAAC1B;;kCACC,KAACD;wBACC4B,IAAI,CAAC,OAAO,EAAEF,OAAOE,EAAE,CAAC,KAAK,CAAC;wBAC9BC,UAAS;wBACThB,OAAOA;wBACD,CAACjB,0BAA0B,EAAE8B,OAAOE,EAAE;kCAE3CF,OAAOI,WAAW,GAAGJ,OAAOI,WAAW,CAACH,aAAa;;oBAWvDD,OAAOK,aAAa,IAAIhB,gBAAgBW,OAAOK,aAAa,IAAI,qBAC/D,KAAC/B;wBACC4B,IAAI,CAAC,OAAO,EAAEF,OAAOE,EAAE,CAAC,IAAI,CAAC;wBAC7BC,UAAS;wBACThB,OAAOA;wBACD,CAACjB,0BAA0B,EAAE8B,OAAOE,EAAE;wBAC5C,0DAA0D;wBAC1D,4DAA4D;wBAC5D,yDAAyD;wBACzDI,KACEN,OAAOO,YAAY,GACfP,OAAOO,YAAY,CAACN,aACpBD,OAAOQ,SAAS;;;eA9BbR,OAAOE,EAAE;;AAsChC"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/advertising-tag-mounts.tsx"],"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// NO `'use client'` here, and it is a lint rule rather than a preference\n// (AGL-52): a directive inside `@aglyn/aglyn` makes the bundler split a\n// duplicate module graph, and the second canvas/emitter singleton renders the\n// tenant site blank. Every component that mounts this one carries the\n// directive itself, which is where the client boundary belongs.\n//\n// Deep app-utils modules, never the `@aglyn/aglyn` barrel (AGL-1550): this\n// file is reached from `site-analytics.tsx`, whose import closure\n// `site-analytics-independence.spec.ts` walks and which must stay independent\n// of the site-plugin gate.\nimport {\n ADVERTISING_TAG_ATTRIBUTE,\n type ResolvedAdvertisingTag,\n restoreAdvertisingTags,\n revokeAdvertisingTags,\n} from './advertising-tags'\nimport { usePageIdle } from './page-idle'\nimport { VISITOR_CONSENT_CHANGED_EVENT } from './visitor-consent'\nimport Script from 'next/script'\nimport { Fragment, useEffect, useRef, type ReactElement } from 'react'\n\n/**\n * The MOUNT and the WITHDRAWAL for consent-gated advertising tags, with no\n * opinion about where the verdict came from.\n *\n * ## Why this is a shared component and not a second copy per surface\n *\n * Aglyn runs advertising tags on three first-party surfaces and they resolve\n * consent through three different mechanisms: the tenant runtime reads a host\n * document and a per-host record, the console reads the platform record its\n * own posture machinery wrote, and the docs site reads the registrable-domain\n * mirror of that record. Those are genuinely different questions.\n *\n * What is NOT different is what happens once the answer is known: mount an\n * inline boot and a library per vendor, and — the half that is easy to forget\n * and impossible to retrofit — stop them the moment the answer changes. A\n * second copy of that half is how one surface comes to keep firing after\n * consent is withdrawn on another, because the copy that was not updated\n * still looks exactly like the one that was.\n *\n * So the verdict is a PROP and the machinery is shared. Each surface answers\n * its own question with its own resolver; none of them owns a teardown.\n *\n * ## Why it renders even when the answer is no\n *\n * Because the withdrawal path needs a listener. A visitor who accepts and then\n * turns advertising off must stop being tracked in THAT pageview, and by then\n * the vendor library has executed — React dropping the `<Script>` does not\n * unload it (AGL-1608). So this component stays mounted whenever the surface\n * participates at all, and subscribes to\n * {@link VISITOR_CONSENT_CHANGED_EVENT}; the teardown runs from the event,\n * synchronously with the visitor's click, rather than waiting on a re-render.\n *\n * Both paths run and they agree, which is deliberate: the render gate is what\n * keeps the tag out of a fresh pageview, the listener is what removes one that\n * is already there, and neither can do the other's job.\n */\nexport interface AdvertisingTagMountsProps {\n /**\n * Whether this surface participates in the gate AT ALL.\n *\n * False installs nothing — no listener, no scripts. That is the clause that\n * keeps the tenant runtime's teardown off a customer's site: we did not load\n * their pixel, we do not know what basis it runs on, and reaching into their\n * page to kill it would be its own breach. `revokeAdvertisingTags` is\n * additionally attribute-scoped, so there are two independent scopes.\n */\n readonly active: boolean\n /** The verdict for THIS render: the tags that may exist right now. */\n readonly tags: readonly ResolvedAdvertisingTag[]\n /**\n * Re-read the verdict from live state, for the withdrawal listener.\n *\n * A callback rather than the `tags` prop, because the listener fires from\n * the visitor's own click in the same tick as the record is written — the\n * props for the current render are by definition the state before it.\n */\n readonly resolve: () => readonly ResolvedAdvertisingTag[]\n /**\n * The request's CSP nonce, stamped onto BOTH elements of every pair.\n *\n * A surface that enforces a nonce'd `script-src` refuses an inline script\n * that does not carry it, and the boot half of every pair is inline. Next's\n * `<Script>` stamps a nonce only when one reaches it: the automatic path\n * reads `HeadManagerContext`, and the App Router's client provider carries\n * no nonce at all — so a pair mounted after hydration, which is the only\n * time this component ever mounts one, is unnonced unless the caller hands\n * the value down. An explicit prop wins inside `next/script`\n * (`restProps.nonce || nonce`), which is what makes this the one door.\n *\n * The failure without it is silent and lopsided: the library beside the\n * boot has a `src` the policy allows, so the vendor's code loads and runs\n * against an account nobody configured, and the conversions this surface\n * sends are lost with nothing in the page but a CSP violation.\n *\n * Absent on a surface that sends no `script-src`: nothing is stamped, and\n * nothing is refused.\n */\n readonly nonce?: string\n /**\n * Libraries the HOST PAGE mounts itself, named by the needle a vendor\n * declares in `sharesLibrary` (AGL-2681).\n *\n * {@link sharedLibraryPresent} reads the document at render time, and that\n * is the right instrument for a loader some other party put there. It is\n * the wrong one for a loader the SAME render is about to create. On the\n * tenant the GA pair and this component both wait on `consent.ready`, so\n * the first render that may emit either emits both — GA's `<Script>` is not\n * in the document yet when this component looks, the check honestly says\n * \"absent\", and the visitor downloads `gtag.js` twice: once as ours with\n * `?id=AW-…`, once as gtag's own destination fetch for the `config` that\n * follows. Measured on `aglyn.com/solutions/small-business` with\n * advertising granted: two 147 KiB loaders for one account.\n *\n * A page that KNOWS it renders the library says so here, from the same\n * condition that renders it, and the document check stays as the fallback\n * for loaders it does not know about. Naming the needle rather than passing\n * a boolean keeps the declaration per library: a page that mounts gtag has\n * said nothing about the Meta pixel.\n */\n readonly sharedLibraries?: readonly string[]\n}\n\n/**\n * Is a library matching `needle` already in the document?\n *\n * Read at RENDER time rather than in an effect: the decision is whether to\n * emit a `<Script>` at all, and by the time an effect could answer, Next has\n * already appended it. `document` is guarded because this component renders on\n * the server too, where nothing is mounted and the honest answer is \"no\" — the\n * client render then re-evaluates with the real document.\n *\n * Blind to a loader the current render is creating alongside this one — see\n * `sharedLibraries` on the props for the case that needs the page to say so.\n */\nexport function sharedLibraryPresent(needle: string): boolean {\n if (typeof document === 'undefined') return false\n try {\n return Boolean(document.querySelector(`script[src*=\"${needle}\"]`))\n } catch {\n // A hostile or absent DOM: assume nothing is mounted, which mounts our\n // own copy — the cost is a duplicate fetch, never a missing tag.\n return false\n }\n}\n\nexport default function AdvertisingTagMounts({\n active,\n tags,\n resolve,\n nonce,\n sharedLibraries,\n}: AdvertisingTagMountsProps): ReactElement | null {\n /** Declared by the page, or found in the document: either means \"ride it\". */\n const libraryProvided = (needle: string): boolean =>\n (sharedLibraries?.includes(needle) ?? false) || sharedLibraryPresent(needle)\n /*\n * The resolver is held in a ref rather than listed as an effect dependency.\n *\n * Every caller passes a closure over its own live consent state, so the\n * function identity changes on every render; depending on it would tear the\n * listener down and re-install it each time, and a withdrawal that landed in\n * that window would find no subscriber. The ref makes the subscription's\n * lifetime `active`, which is the only thing that genuinely changes it,\n * while the callback it invokes is always the newest one.\n */\n const resolveRef = useRef(resolve)\n resolveRef.current = resolve\n\n useEffect(() => {\n if (!active) return undefined\n const sync = () => {\n if (resolveRef.current().length === 0) {\n revokeAdvertisingTags()\n } else {\n // Symmetric: a visitor who withdrew and changed their mind inside one\n // pageview would otherwise stay un-tracked until they navigated,\n // because a re-rendered `<Script>` cannot re-execute a library the\n // browser already ran.\n restoreAdvertisingTags()\n }\n }\n window.addEventListener(VISITOR_CONSENT_CHANGED_EVENT, sync)\n return () => window.removeEventListener(VISITOR_CONSENT_CHANGED_EVENT, sync)\n }, [active])\n\n // Nothing is injected before the page has loaded and gone idle (AGL-3581).\n // The withdrawal listener above is NOT behind this: a visitor who refuses\n // before the page idles has nothing to tear down, and one who refuses after\n // needs the listener already there. Every surface reads the same store, so\n // a page that mounts its own loader flips in the same render as this one\n // and `sharedLibraries` still describes it (AGL-2681).\n const pageIsIdle = usePageIdle()\n\n if (!active || tags.length === 0 || !pageIsIdle) return null\n\n return (\n <>\n {tags.map(({ vendor, accountId }) => (\n // A PAIR per vendor, inline boot first and library second — the same\n // shape as the GA `ga-init` / `ga-src` pair, and for the same reason:\n // the boot defines the vendor's queue shim and declares the consent\n // state, so nothing the library later drains was queued under a state\n // nobody chose. Both elements carry the teardown's scope marker; only\n // elements carrying it are ever revoked, removed or cookie-swept.\n <Fragment key={vendor.id}>\n <Script\n id={`ad-tag-${vendor.id}-init`}\n strategy=\"afterInteractive\"\n nonce={nonce}\n {...{ [ADVERTISING_TAG_ATTRIBUTE]: vendor.id }}\n >\n {vendor.bootSnippet ? vendor.bootSnippet(accountId) : ''}\n </Script>\n {/* Skipped when another loader already brought this library in\n (AGL-1152). Google Ads shares `gtag.js` with the GA4 measurement\n id and with a GTM container, so a surface with both configured\n would fetch it twice and define `gtag()` twice — and the boot\n above would be the second voice in a consent conversation the\n first one already had. One library, several `config` calls, is\n how gtag carries several products. The page's own declaration is\n consulted first (AGL-2681): the document cannot yet show a loader\n this same render is creating. */}\n {vendor.sharesLibrary && libraryProvided(vendor.sharesLibrary) ? null : (\n <Script\n id={`ad-tag-${vendor.id}-src`}\n strategy=\"afterInteractive\"\n nonce={nonce}\n {...{ [ADVERTISING_TAG_ATTRIBUTE]: vendor.id }}\n // `scriptSrcFor` where the vendor has one: gtag reads the\n // container out of the loader's query, so the copy we bring\n // ourselves has to name the account. See `scriptSrcFor`.\n src={\n vendor.scriptSrcFor\n ? vendor.scriptSrcFor(accountId)\n : vendor.scriptSrc\n }\n />\n )}\n </Fragment>\n ))}\n </>\n )\n}\n"],"names":["ADVERTISING_TAG_ATTRIBUTE","restoreAdvertisingTags","revokeAdvertisingTags","usePageIdle","VISITOR_CONSENT_CHANGED_EVENT","Script","Fragment","useEffect","useRef","sharedLibraryPresent","needle","document","Boolean","querySelector","AdvertisingTagMounts","active","tags","resolve","nonce","sharedLibraries","libraryProvided","includes","resolveRef","current","undefined","sync","length","window","addEventListener","removeEventListener","pageIsIdle","map","vendor","accountId","id","strategy","bootSnippet","sharesLibrary","src","scriptSrcFor","scriptSrc"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,yEAAyE;AACzE,wEAAwE;AACxE,8EAA8E;AAC9E,sEAAsE;AACtE,gEAAgE;AAChE,EAAE;AACF,2EAA2E;AAC3E,kEAAkE;AAClE,8EAA8E;AAC9E,2BAA2B;AAC3B,SACEA,yBAAyB,EAEzBC,sBAAsB,EACtBC,qBAAqB,QAChB,wBAAoB;AAC3B,SAASC,WAAW,QAAQ,iBAAa;AACzC,SAASC,6BAA6B,QAAQ,uBAAmB;AACjE,OAAOC,YAAY,cAAa;AAChC,SAASC,QAAQ,EAAEC,SAAS,EAAEC,MAAM,QAA2B,QAAO;AAwGtE;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,qBAAqBC,MAAc;IACjD,IAAI,OAAOC,aAAa,aAAa,OAAO;IAC5C,IAAI;QACF,OAAOC,QAAQD,SAASE,aAAa,CAAC,CAAC,aAAa,EAAEH,OAAO,EAAE,CAAC;IAClE,EAAE,eAAM;QACN,uEAAuE;QACvE,iEAAiE;QACjE,OAAO;IACT;AACF;AAEA,eAAe,SAASI,qBAAqB,EAC3CC,MAAM,EACNC,IAAI,EACJC,OAAO,EACPC,KAAK,EACLC,eAAe,EACW;IAC1B,4EAA4E,GAC5E,MAAMC,kBAAkB,CAACV;;eACvB,SAACS,mCAAAA,gBAAiBE,QAAQ,CAACX,0BAAW,UAAUD,qBAAqBC;;IACvE;;;;;;;;;GASC,GACD,MAAMY,aAAad,OAAOS;IAC1BK,WAAWC,OAAO,GAAGN;IAErBV,UAAU;QACR,IAAI,CAACQ,QAAQ,OAAOS;QACpB,MAAMC,OAAO;YACX,IAAIH,WAAWC,OAAO,GAAGG,MAAM,KAAK,GAAG;gBACrCxB;YACF,OAAO;gBACL,sEAAsE;gBACtE,iEAAiE;gBACjE,mEAAmE;gBACnE,uBAAuB;gBACvBD;YACF;QACF;QACA0B,OAAOC,gBAAgB,CAACxB,+BAA+BqB;QACvD,OAAO,IAAME,OAAOE,mBAAmB,CAACzB,+BAA+BqB;IACzE,GAAG;QAACV;KAAO;IAEX,2EAA2E;IAC3E,0EAA0E;IAC1E,4EAA4E;IAC5E,2EAA2E;IAC3E,yEAAyE;IACzE,uDAAuD;IACvD,MAAMe,aAAa3B;IAEnB,IAAI,CAACY,UAAUC,KAAKU,MAAM,KAAK,KAAK,CAACI,YAAY,OAAO;IAExD,qBACE;kBACGd,KAAKe,GAAG,CAAC,CAAC,EAAEC,MAAM,EAAEC,SAAS,EAAE,GAC9B,qEAAqE;YACrE,sEAAsE;YACtE,oEAAoE;YACpE,sEAAsE;YACtE,sEAAsE;YACtE,kEAAkE;0BAClE,MAAC3B;;kCACC,KAACD;wBACC6B,IAAI,CAAC,OAAO,EAAEF,OAAOE,EAAE,CAAC,KAAK,CAAC;wBAC9BC,UAAS;wBACTjB,OAAOA;wBACD,CAAClB,0BAA0B,EAAEgC,OAAOE,EAAE;kCAE3CF,OAAOI,WAAW,GAAGJ,OAAOI,WAAW,CAACH,aAAa;;oBAWvDD,OAAOK,aAAa,IAAIjB,gBAAgBY,OAAOK,aAAa,IAAI,qBAC/D,KAAChC;wBACC6B,IAAI,CAAC,OAAO,EAAEF,OAAOE,EAAE,CAAC,IAAI,CAAC;wBAC7BC,UAAS;wBACTjB,OAAOA;wBACD,CAAClB,0BAA0B,EAAEgC,OAAOE,EAAE;wBAC5C,0DAA0D;wBAC1D,4DAA4D;wBAC5D,yDAAyD;wBACzDI,KACEN,OAAOO,YAAY,GACfP,OAAOO,YAAY,CAACN,aACpBD,OAAOQ,SAAS;;;eA9BbR,OAAOE,EAAE;;AAsChC"}
@@ -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