@murumets-ee/frontend-cache 0.89.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,6 +59,23 @@ declare const DEFAULT_CHROME_ENTITIES: readonly ["site_chrome"];
59
59
  declare const DEFAULT_MENU_ENTITIES: {
60
60
  readonly menu: "handle";
61
61
  };
62
+ /**
63
+ * Entities holding a stored DETAIL TEMPLATE (block-library D009), and the field
64
+ * naming the entity each row draws. `@murumets-ee/content`'s `detail_template`
65
+ * by default.
66
+ *
67
+ * A template is not routable — nothing addresses it at a URL — so without this
68
+ * a publish of "Room detail" would send no tag at all, and every room page
69
+ * would keep the old layout until its route TTL expired. With it, a template
70
+ * write sends `entity:<target>`, the tag the frontend already mints onto every
71
+ * document page of that type.
72
+ *
73
+ * The collection tag, deliberately, and not a per-row one: one template draws
74
+ * EVERY row of its target, so every one of those pages changed.
75
+ */
76
+ declare const DEFAULT_TEMPLATE_ENTITIES: {
77
+ readonly detail_template: "target";
78
+ };
62
79
  /**
63
80
  * At most one request per second in the steady state — exactly the receiving
64
81
  * bucket's refill rate (`PURGE_RATE_LIMIT.refillPerSecond`), so a sustained
@@ -116,6 +133,11 @@ interface FrontendCacheConfig {
116
133
  readonly chromeEntities?: readonly string[];
117
134
  /** Entity name → the field holding its menu handle. Defaults to `{ menu: 'handle' }`. */
118
135
  readonly menuEntities?: Readonly<Record<string, string>>;
136
+ /**
137
+ * Entity name → the field holding the entity its rows draw. Defaults to
138
+ * `{ detail_template: 'target' }`. See {@link DEFAULT_TEMPLATE_ENTITIES}.
139
+ */
140
+ readonly templateEntities?: Readonly<Record<string, string>>;
119
141
  /**
120
142
  * Entities a page can EMBED in a block tree. Defaults to `['media']`.
121
143
  * See {@link DEFAULT_EMBEDDED_ENTITIES}.
@@ -133,6 +155,7 @@ interface ResolvedFrontendCacheConfig {
133
155
  readonly path: string;
134
156
  readonly chromeEntities: readonly string[];
135
157
  readonly menuEntities: Readonly<Record<string, string>>;
158
+ readonly templateEntities: Readonly<Record<string, string>>;
136
159
  readonly embeddedEntities: readonly string[];
137
160
  readonly flushIntervalMs: number;
138
161
  readonly minBatchMs: number;
@@ -185,5 +208,5 @@ type PurgeTarget = {
185
208
  */
186
209
  declare function resolvePurgeTarget(config: ResolvedFrontendCacheConfig): PurgeTarget;
187
210
  //#endregion
188
- export { DEFAULT_MAX_REQUESTS_PER_FLUSH as a, DEFAULT_MIN_BATCH_MS as c, FrontendCacheConfig as d, PurgeTarget as f, resolvePurgeTarget as g, resolveFrontendCacheConfig as h, DEFAULT_INVALIDATE_PATH as i, DEFAULT_REQUEST_TIMEOUT_MS as l, parseOrigin as m, DEFAULT_CHROME_ENTITIES as n, DEFAULT_MAX_TAGS_PER_REQUEST as o, ResolvedFrontendCacheConfig as p, DEFAULT_FLUSH_INTERVAL_MS as r, DEFAULT_MENU_ENTITIES as s, CACHE_INVALIDATION_TOKEN_ENV as t, FRONTEND_ORIGIN_ENV as u };
189
- //# sourceMappingURL=config-OULQXyhn.d.mts.map
211
+ export { resolvePurgeTarget as _, DEFAULT_MAX_REQUESTS_PER_FLUSH as a, DEFAULT_MIN_BATCH_MS as c, FRONTEND_ORIGIN_ENV as d, FrontendCacheConfig as f, resolveFrontendCacheConfig as g, parseOrigin as h, DEFAULT_INVALIDATE_PATH as i, DEFAULT_REQUEST_TIMEOUT_MS as l, ResolvedFrontendCacheConfig as m, DEFAULT_CHROME_ENTITIES as n, DEFAULT_MAX_TAGS_PER_REQUEST as o, PurgeTarget as p, DEFAULT_FLUSH_INTERVAL_MS as r, DEFAULT_MENU_ENTITIES as s, CACHE_INVALIDATION_TOKEN_ENV as t, DEFAULT_TEMPLATE_ENTITIES as u };
212
+ //# sourceMappingURL=config-zOH3aEri.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config-zOH3aEri.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;AA0BA;;;;AAAgC;AAShC;;;;AAAyC;AAGzC;;;;AAAoC;AAqBpC;;;;AAA+D;AAoC/D;;;;cArEa,mBAAA;AAqFb;;;;;AAOA;;AAPA,cA5Ea,4BAAA;;cAGA,uBAAA;AAuFb;;;;AAAiC;AAGjC;;;;AAAyC;AAiBzC;;;;AAA2C;AAG3C;;;;AAvBA,cAlEa,uBAAA;;cAoCA,qBAAA;EAAA,SAAmD,IAAA;AAAA;;;;;;;;;;;;;;;cAgBnD,yBAAA;EAAA,SAAkE,eAAA;AAAA;;;;;;cAOlE,yBAAA;AA0Jb;;;;;AAAA,cAnJa,oBAAA;;cAGA,4BAAA;;;AAkJqC;AAclD;;;;;;;;AAE8B;AAwK9B;;;cAzTa,8BAAA;;cAGA,0BAAA;AAAA,UAQI,mBAAA;EA0TiB;;;;;;;AAAkD;;;EAAlD,SA/SvB,OAAA;;WAEA,MAAA;;WAEA,KAAA;;WAEA,IAAA;;;;;WAKA,cAAA;;WAEA,YAAA,GAAe,QAAA,CAAS,MAAA;;;;;WAKxB,gBAAA,GAAmB,QAAA,CAAS,MAAA;;;;;WAK5B,gBAAA;EAAA,SACA,eAAA;EAAA,SACA,UAAA;EAAA,SACA,iBAAA;EAAA,SACA,mBAAA;EAAA,SACA,gBAAA;EAAA,SACA,cAAA;AAAA;AAAA,UAGM,2BAAA;EAAA,SACN,OAAA;EAAA,SACA,IAAA;EAAA,SACA,cAAA;EAAA,SACA,YAAA,EAAc,QAAA,CAAS,MAAA;EAAA,SACvB,gBAAA,EAAkB,QAAA,CAAS,MAAA;EAAA,SAC3B,gBAAA;EAAA,SACA,eAAA;EAAA,SACA,UAAA;EAAA,SACA,iBAAA;EAAA,SACA,mBAAA;EAAA,SACA,gBAAA;EAAA,SACA,cAAA;;WAEA,cAAA;EAAA,SACA,aAAA;;;;;;WAMA,QAAA;AAAA;;;;;;;;;iBAoDK,WAAA,CACd,GAAA;EACG,EAAA;EAAU,MAAA;AAAA;EAAqB,EAAA;EAAW,GAAA;AAAA;AAAA,iBAc/B,0BAAA,CACd,MAAA,EAAQ,mBAAA,GACP,2BAA2B;;KAwKlB,WAAA;EAAA,SACG,EAAA;EAAA,SAAmB,GAAA;EAAA,SAAsB,KAAA;AAAA;EAAA,SACzC,EAAA;EAAA,SAAoB,GAAA;AAAA;;;;;;;;;iBAUnB,kBAAA,CAAmB,MAAA,EAAQ,2BAAA,GAA8B,WAAW"}
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as DEFAULT_MAX_REQUESTS_PER_FLUSH, c as DEFAULT_MIN_BATCH_MS, d as FrontendCacheConfig, f as PurgeTarget, g as resolvePurgeTarget, h as resolveFrontendCacheConfig, i as DEFAULT_INVALIDATE_PATH, l as DEFAULT_REQUEST_TIMEOUT_MS, m as parseOrigin, n as DEFAULT_CHROME_ENTITIES, o as DEFAULT_MAX_TAGS_PER_REQUEST, p as ResolvedFrontendCacheConfig, r as DEFAULT_FLUSH_INTERVAL_MS, s as DEFAULT_MENU_ENTITIES, t as CACHE_INVALIDATION_TOKEN_ENV, u as FRONTEND_ORIGIN_ENV } from "./config-OULQXyhn.mjs";
1
+ import { _ as resolvePurgeTarget, a as DEFAULT_MAX_REQUESTS_PER_FLUSH, c as DEFAULT_MIN_BATCH_MS, d as FRONTEND_ORIGIN_ENV, f as FrontendCacheConfig, g as resolveFrontendCacheConfig, h as parseOrigin, i as DEFAULT_INVALIDATE_PATH, l as DEFAULT_REQUEST_TIMEOUT_MS, m as ResolvedFrontendCacheConfig, n as DEFAULT_CHROME_ENTITIES, o as DEFAULT_MAX_TAGS_PER_REQUEST, p as PurgeTarget, r as DEFAULT_FLUSH_INTERVAL_MS, s as DEFAULT_MENU_ENTITIES, t as CACHE_INVALIDATION_TOKEN_ENV, u as DEFAULT_TEMPLATE_ENTITIES } from "./config-zOH3aEri.mjs";
2
2
  import { EntityWriteObserver } from "@murumets-ee/entity/admin";
3
3
 
4
4
  //#region src/observer-slot.d.ts
@@ -148,5 +148,5 @@ declare function prioritiseTags(tags: readonly string[]): readonly string[];
148
148
  declare function parseRetryAfterSeconds(raw: string | null, fallback: number, max: number): number;
149
149
  declare function createPurgeSender(deps: PurgeSenderDeps): PurgeSender;
150
150
  //#endregion
151
- export { CACHE_INVALIDATION_TOKEN_ENV, CACHE_TAG_MAX_LENGTH, DEFAULT_CHROME_ENTITIES, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_INVALIDATE_PATH, DEFAULT_MAX_REQUESTS_PER_FLUSH, DEFAULT_MAX_TAGS_PER_REQUEST, DEFAULT_MENU_ENTITIES, DEFAULT_MIN_BATCH_MS, DEFAULT_REQUEST_TIMEOUT_MS, FRONTEND_ORIGIN_ENV, type FetchLike, type FrontendCacheConfig, MAX_TAGS_PER_EVENT, type PurgeLogger, type PurgeResponse, type PurgeScheduler, type PurgeSender, type PurgeSenderDeps, type PurgeTarget, type PurgeTimer, type ResolvedFrontendCacheConfig, type WriteTagInput, chromeTag, createPurgeSender, documentTag, entityTag, getActiveEntityWriteObserver, menuTag, normalizeTag, parseOrigin, parseRetryAfterSeconds, prioritiseTags, resolveFrontendCacheConfig, resolvePurgeTarget, setActiveEntityWriteObserver, tagsForWrite };
151
+ export { CACHE_INVALIDATION_TOKEN_ENV, CACHE_TAG_MAX_LENGTH, DEFAULT_CHROME_ENTITIES, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_INVALIDATE_PATH, DEFAULT_MAX_REQUESTS_PER_FLUSH, DEFAULT_MAX_TAGS_PER_REQUEST, DEFAULT_MENU_ENTITIES, DEFAULT_MIN_BATCH_MS, DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_TEMPLATE_ENTITIES, FRONTEND_ORIGIN_ENV, type FetchLike, type FrontendCacheConfig, MAX_TAGS_PER_EVENT, type PurgeLogger, type PurgeResponse, type PurgeScheduler, type PurgeSender, type PurgeSenderDeps, type PurgeTarget, type PurgeTimer, type ResolvedFrontendCacheConfig, type WriteTagInput, chromeTag, createPurgeSender, documentTag, entityTag, getActiveEntityWriteObserver, menuTag, normalizeTag, parseOrigin, parseRetryAfterSeconds, prioritiseTags, resolveFrontendCacheConfig, resolvePurgeTarget, setActiveEntityWriteObserver, tagsForWrite };
152
152
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -1 +1 @@
1
- import{C as e,E as t,S as n,T as r,_ as i,a,b as o,c as s,d as c,f as l,g as u,h as d,i as f,l as p,m,n as h,o as g,p as _,r as v,s as y,t as b,u as x,v as S,w as C,x as w,y as T}from"./sender-xWC7aWIl.mjs";export{m as CACHE_INVALIDATION_TOKEN_ENV,f as CACHE_TAG_MAX_LENGTH,d as DEFAULT_CHROME_ENTITIES,u as DEFAULT_FLUSH_INTERVAL_MS,i as DEFAULT_INVALIDATE_PATH,S as DEFAULT_MAX_REQUESTS_PER_FLUSH,T as DEFAULT_MAX_TAGS_PER_REQUEST,o as DEFAULT_MENU_ENTITIES,w as DEFAULT_MIN_BATCH_MS,n as DEFAULT_REQUEST_TIMEOUT_MS,e as FRONTEND_ORIGIN_ENV,a as MAX_TAGS_PER_EVENT,g as chromeTag,b as createPurgeSender,y as documentTag,s as entityTag,l as getActiveEntityWriteObserver,p as menuTag,x as normalizeTag,C as parseOrigin,h as parseRetryAfterSeconds,v as prioritiseTags,r as resolveFrontendCacheConfig,t as resolvePurgeTarget,_ as setActiveEntityWriteObserver,c as tagsForWrite};
1
+ import{C as e,D as t,E as n,S as r,T as i,_ as a,a as o,b as s,c,d as l,f as u,g as d,h as f,i as p,l as m,m as h,n as g,o as _,p as v,r as y,s as b,t as x,u as S,v as C,w,x as T,y as E}from"./sender-Kh6FZW48.mjs";export{h as CACHE_INVALIDATION_TOKEN_ENV,p as CACHE_TAG_MAX_LENGTH,f as DEFAULT_CHROME_ENTITIES,d as DEFAULT_FLUSH_INTERVAL_MS,a as DEFAULT_INVALIDATE_PATH,C as DEFAULT_MAX_REQUESTS_PER_FLUSH,E as DEFAULT_MAX_TAGS_PER_REQUEST,s as DEFAULT_MENU_ENTITIES,T as DEFAULT_MIN_BATCH_MS,r as DEFAULT_REQUEST_TIMEOUT_MS,e as DEFAULT_TEMPLATE_ENTITIES,w as FRONTEND_ORIGIN_ENV,o as MAX_TAGS_PER_EVENT,_ as chromeTag,x as createPurgeSender,b as documentTag,c as entityTag,u as getActiveEntityWriteObserver,m as menuTag,S as normalizeTag,i as parseOrigin,g as parseRetryAfterSeconds,y as prioritiseTags,n as resolveFrontendCacheConfig,t as resolvePurgeTarget,v as setActiveEntityWriteObserver,l as tagsForWrite};
package/dist/plugin.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { d as FrontendCacheConfig, p as ResolvedFrontendCacheConfig } from "./config-OULQXyhn.mjs";
1
+ import { f as FrontendCacheConfig, m as ResolvedFrontendCacheConfig } from "./config-zOH3aEri.mjs";
2
2
 
3
3
  //#region src/plugin.d.ts
4
4
  declare const FRONTEND_CACHE_PLUGIN_NAME = "@murumets-ee/frontend-cache";
package/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import{E as e,T as t,p as n,t as r}from"./sender-xWC7aWIl.mjs";import{definePlugin as i}from"@murumets-ee/core";const a=`@murumets-ee/frontend-cache`;function o(o){let s=t(o);return i({name:a,server:{config:s,onReady:async t=>{let i=t.logger.child({plugin:a});for(let e of s.problems)i.error({problem:e},`frontend-cache: configuration problem`);let o=e(s);o.ok?i.info({url:o.url},`frontend-cache: purge sender ready`):i.warn({reason:o.why},`frontend-cache: not configured — publishes will NOT purge the frontend, and edits will only appear once the page cache TTL expires`);let c=r({config:s,logger:i});n(e=>{c.notify(e)})}}})}export{a as FRONTEND_CACHE_PLUGIN_NAME,o as frontendCache};
1
+ import{D as e,E as t,p as n,t as r}from"./sender-Kh6FZW48.mjs";import{definePlugin as i}from"@murumets-ee/core";const a=`@murumets-ee/frontend-cache`;function o(o){let s=t(o);return i({name:a,server:{config:s,onReady:async t=>{let i=t.logger.child({plugin:a});for(let e of s.problems)i.error({problem:e},`frontend-cache: configuration problem`);let o=e(s);o.ok?i.info({url:o.url},`frontend-cache: purge sender ready`):i.warn({reason:o.why},`frontend-cache: not configured — publishes will NOT purge the frontend, and edits will only appear once the page cache TTL expires`);let c=r({config:s,logger:i});n(e=>{c.notify(e)})}}})}export{a as FRONTEND_CACHE_PLUGIN_NAME,o as frontendCache};
2
2
  //# sourceMappingURL=plugin.mjs.map
@@ -0,0 +1,2 @@
1
+ import{ENTITY_WRITE_OBSERVER_SLOT as e}from"@murumets-ee/entity/admin";const t=`FRONTEND_ORIGIN`,n=`CACHE_INVALIDATION_TOKEN`,r=`/api/invalidate`,i=[`site_chrome`],a=[`media`],o={menu:`handle`},s={detail_template:`target`},c=1e3,l=150,u=50,d=1,f=3e3;function p(e,t){return e===void 0?t:Number.isFinite(e)&&e>=1?Math.floor(e):t}function m(e){return h(process.env[e])}function h(e){if(e===void 0)return;let t=e.trim();return t===``?void 0:t}function g(e){let t;try{t=new URL(e)}catch{return{ok:!1,why:`not a valid absolute URL: ${JSON.stringify(e)}`}}return t.protocol!==`http:`&&t.protocol!==`https:`?{ok:!1,why:`scheme must be http or https, got ${t.protocol}`}:{ok:!0,origin:t.origin}}function _(e){let n=[],l=Array.isArray(e.locales)?e.locales:[],u=l.filter(e=>typeof e==`string`),d=u.map(e=>e.trim()).filter(e=>e!==``);u.length!==l.length&&n.push("`locales` must be an array of locale-code STRINGS — e.g. `['en', 'et']`, not `content()`'s `[{ code: 'en' }]` shape. Entries that were not strings have been ignored."),d.length===0&&n.push("no locales configured — chrome and menu purges are disabled, so a header or navigation edit will not appear until the page TTL expires. Pass the same `locales` list you pass to `content({ locales })`.");let m=e.origin;if(e.origin!==void 0){let r=g(e.origin);r.ok||(n.push(`\`origin\` is unusable — ${r.why}; ignoring it and falling back to ${t}`),m=void 0)}let _=e.path??`/api/invalidate`,v=typeof _==`string`&&_.startsWith(`/`);v||n.push(`\`path\` must start with "/", got ${JSON.stringify(_)} — falling back to ${r}`);let y=v?_:r,b=(Array.isArray(e.chromeEntities)?e.chromeEntities:void 0)?.filter(e=>typeof e==`string`&&e.trim()!==``).map(e=>e.trim());e.chromeEntities!==void 0&&b===void 0&&n.push("`chromeEntities` must be an array of entity-name strings — ignoring it");let x=(Array.isArray(e.embeddedEntities)?e.embeddedEntities:void 0)?.filter(e=>typeof e==`string`&&e.trim()!==``).map(e=>e.trim());e.embeddedEntities!==void 0&&x===void 0&&n.push("`embeddedEntities` must be an array of entity-name strings — ignoring it");let S=e.menuEntities,C=typeof S==`object`&&!!S&&!Array.isArray(S);S!==void 0&&!C&&n.push("`menuEntities` must be an object of `entityName: handleField` — ignoring it");let w=C?Object.fromEntries(Object.entries(S).filter(([,e])=>typeof e==`string`)):void 0,T=e.templateEntities,E=typeof T==`object`&&!!T&&!Array.isArray(T);T!==void 0&&!E&&n.push("`templateEntities` must be an object of `entityName: targetField` — ignoring it");let D;if(E){let e=Object.entries(T),t=e.filter(e=>typeof e[1]==`string`&&e[1].trim()!==``);t.length<e.length&&n.push("`templateEntities` has an entry whose target field is not a non-empty string — ignoring that entry"),D=e.length>0&&t.length===0?void 0:Object.fromEntries(t)}return{locales:d,path:y,chromeEntities:b??[...i],embeddedEntities:x??[...a],menuEntities:w??{...o},templateEntities:D??{...s},flushIntervalMs:p(e.flushIntervalMs,c),minBatchMs:p(e.minBatchMs,150),maxTagsPerRequest:p(e.maxTagsPerRequest,50),maxRequestsPerFlush:p(e.maxRequestsPerFlush,1),requestTimeoutMs:p(e.requestTimeoutMs,f),maxPendingTags:p(e.maxPendingTags,2e3),originOverride:h(m),tokenOverride:h(e.token),problems:n}}function v(e){let r=e.originOverride??m(`FRONTEND_ORIGIN`);if(r===void 0)return{ok:!1,why:`${t} is not set`};let i=g(r);if(!i.ok)return{ok:!1,why:i.why};let a=e.tokenOverride??m(`CACHE_INVALIDATION_TOKEN`);return a===void 0?{ok:!1,why:`${n} is not set`}:{ok:!0,url:`${i.origin}${e.path}`,token:a}}function y(t){if(t===void 0){delete globalThis[e];return}globalThis[e]=t}function b(){let t=globalThis[e];return typeof t==`function`?t:void 0}const x=128,S=64;function C(e){return e.trim().toLowerCase().replace(/[^a-z0-9:_-]+/g,`-`).replace(/-{2,}/g,`-`).slice(0,128).replace(/^[-:]+|[-:]+$/g,``)||void 0}function w(e){return C(e.replaceAll(`:`,`-`))}function T(e,...t){let n=t.map(w);if(!n.some(e=>e===void 0))return C(`${e}:${n.join(`:`)}`)}function E(e){return T(`entity`,e)}function D(e,t){return T(`doc`,e,t)}function O(e){return T(`chrome`,e)}function k(e,t){return T(`menu`,e,t)}function A(e,t){let n=e.routable?[E(e.entityName),D(e.entityName,e.id)]:[];if(!e.routable&&t.embeddedEntities.includes(e.entityName)&&n.push(D(e.entityName,e.id)),t.chromeEntities.includes(e.entityName))for(let e of t.locales)n.push(O(e));let r=t.menuEntities[e.entityName];if(r!==void 0){let i=e.row?.[r];if(typeof i==`string`&&i!==``)for(let e of t.locales)n.push(k(i,e))}let i=t.templateEntities[e.entityName];if(i!==void 0){let t=e.row?.[i];typeof t==`string`&&t!==``&&n.push(E(t))}let a=new Set;for(let e of n)if(e!==void 0){if(a.size>=64)break;a.add(e)}return[...a]}const j=(e,t)=>fetch(e,t),M=(e,t)=>{let n=setTimeout(e,t);return n.unref?.(),{clear:()=>{clearTimeout(n)}}};function N(e){let t=[],n=[];for(let r of e)r.startsWith(`doc:`)?n.push(r):t.push(r);return[...t,...n]}function P(e,t){let n=[];for(let r=0;r<e.length;r+=t)n.push(e.slice(r,r+t));return n}function F(e){return e instanceof Error?e.message:String(e)}function I(e){return e===0||e>=300&&e<400}function L(e,t,n){if(e===null)return t;let r=Number.parseInt(e.trim(),10);return!Number.isFinite(r)||r<0?t:Math.min(r,n)}function R(e){let{config:t,logger:n}=e,r=e.now??(()=>Date.now()),i=e.fetchImpl??j,a=e.scheduler??M,o=new Set,s,c=!1,l=-1/0,u=-1/0,d=!1,f=!1,p=Promise.resolve();function m(){return Math.max(l+t.flushIntervalMs,u)}function h(){f||s!==void 0||c||(s=a(()=>{s=void 0,C()},Math.max(t.minBatchMs,m()-r())))}function g(){for(let e of o)if(e.startsWith(`doc:`))return o.delete(e),!0;return!1}function _(e,r){d||(d=!0,n.warn({entity:e,pending:o.size,cap:t.maxPendingTags,dropped:r},`frontend-cache: pending purge-tag buffer is full; dropping tags until the next flush`))}function y(e){if(f)return;let n=N(A(e,t));if(n.length!==0){for(let r of n)if(!o.has(r)){if(o.size>=t.maxPendingTags&&(r.startsWith(`doc:`)||!g())){_(e.entityName,r);break}o.add(r)}h()}}async function b(e,a){let o;try{o=await i(e.url,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${e.token}`},body:JSON.stringify({tags:a}),signal:AbortSignal.timeout(t.requestTimeoutMs),redirect:`manual`})}catch(t){n.warn({url:e.url,tags:a.length,err:F(t)},`frontend-cache: purge request failed; the frontend keeps serving cached pages until their TTL expires`);return}if(o.ok){await o.text().catch(()=>``),n.debug({url:e.url,tags:a.length},`frontend-cache: purged`);return}if(o.status===429){let e=L(o.headers.get(`retry-after`),5,300);u=Math.max(u,r()+e*1e3)}let s;try{s=(await o.text()).slice(0,200)}catch(e){s=`<unreadable body: ${F(e)}>`}let c=o.status===401?`the frontend rejected the token — check that CACHE_INVALIDATION_TOKEN matches on both services`:o.status===429?`the frontend rate-limited this purge; this batch is dropped and the next is delayed`:o.status===503?`the frontend has no token configured, or route caching is off on that deployment`:I(o.status)?`the frontend redirected the purge, which is never legitimate — FRONTEND_ORIGIN probably names a scheme or host the site redirects away from`:`unexpected response`;n.warn({url:e.url,status:o.status,tags:a.length,detail:s},`frontend-cache: purge refused — ${c}`)}async function x(){if(o.size===0)return;l=r();let e=N([...o]);o.clear(),d=!1;let i=v(t);if(!i.ok){n.warn({reason:i.why,tags:e.length},`frontend-cache: not configured, so nothing was purged`);return}let a=t.maxTagsPerRequest*t.maxRequestsPerFlush,s=e.slice(0,a);if(e.length>s.length){let t=e.length-e.filter(e=>e.startsWith(`doc:`)).length,r=s.length>=t;n.warn({dropped:e.length-s.length,sent:s.length,lossless:r},r?`frontend-cache: purge batch capped; only document tags were shed, each already covered by its entity tag`:`frontend-cache: purge batch capped and BROAD tags were shed — some pages will stay cached until their TTL expires`)}let c=P(s,t.maxTagsPerRequest);for(let[e,a]of c.entries()){if(r()<u){n.warn({dropped:c.length-e,remainingTags:s.length-e*t.maxTagsPerRequest},`frontend-cache: rate limited mid-flush; abandoning the rest of this batch rather than spending more of the bucket`);break}await b(i,a)}}function S(e){return p=p.then(e,e).catch(e=>{try{n.warn({err:F(e)},`frontend-cache: purge flush failed unexpectedly`)}catch{}}),p}function C(){return c=!0,S(async()=>{try{if(r()<m())return;await x()}finally{c=!1,o.size>0&&h()}})}return{notify:y,flush:()=>S(x),stop(){f=!0,s!==void 0&&(s.clear(),s=void 0),o.clear()},get pendingCount(){return o.size}}}export{s as C,v as D,_ as E,f as S,g as T,r as _,S as a,o as b,E as c,A as d,b as f,c as g,i as h,x as i,k as l,n as m,L as n,O as o,y as p,N as r,D as s,R as t,C as u,d as v,t as w,l as x,u as y};
2
+ //# sourceMappingURL=sender-Kh6FZW48.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sender-Kh6FZW48.mjs","names":["OBSERVER_KEY"],"sources":["../src/config.ts","../src/observer-slot.ts","../src/tags.ts","../src/sender.ts"],"sourcesContent":["/**\n * Configuration for the purge sender.\n *\n * 🔴 **Nothing here throws.** `frontendCache()` is called from\n * `lumi.config.ts`, which the CLI loads through jiti at BUILD time — a throw\n * there takes down `lumi migrate`, `next build` and every other command that\n * touches the config, for a misconfiguration that only matters at runtime.\n * (See `gotcha_lumi_config_throw_at_build_time`.) So resolution collects\n * `problems` instead and the plugin logs them once at `onReady`.\n *\n * **A problem DEGRADES the sender; it does not silence it.** An unusable\n * `path` falls back to the default, non-string locale codes are dropped, a\n * nonsensical numeric knob takes its documented default, and an unusable\n * `origin` is the one case that genuinely stops sending — because there is\n * nowhere to send to. Recording a problem and then using the broken value\n * anyway is worse than either: it produced a URL that failed inside `fetch`\n * once per second, forever, with a warning nobody had asked for.\n *\n * Degrade rather than fail-closed is the right direction on this side of the\n * wire. The receiving endpoint fails closed because accepting an unauthorised\n * purge is a denial-of-service lever. Here the equivalent of \"fail closed\"\n * would be refusing an editor's publish because the frontend's URL has a typo,\n * which is absurd — the publish must land and the page catches up on TTL.\n */\n\n/** Environment variable holding the frontend's base origin, e.g. `http://front:4321`. */\nexport const FRONTEND_ORIGIN_ENV = 'FRONTEND_ORIGIN'\n\n/**\n * Environment variable holding the shared purge bearer token.\n *\n * Deliberately the same NAME the frontend reads, because it is the same secret\n * — compose sets it once and both services receive it. A second variable\n * nobody remembers to keep in sync is a purge that silently 401s.\n */\nexport const CACHE_INVALIDATION_TOKEN_ENV = 'CACHE_INVALIDATION_TOKEN'\n\n/** Default path of the frontend's invalidation endpoint. */\nexport const DEFAULT_INVALIDATE_PATH = '/api/invalidate'\n\n/**\n * The entity `@murumets-ee/content` uses for site chrome.\n *\n * A write to one of these emits `chromeTag(locale)` per locale — the tag that\n * tells the delivery cache every page's chrome changed.\n *\n * 🔴 This named the legacy single-row layout entity until PR30, and it had\n * been wrong since the chrome DOCUMENTS were introduced (F086). No app\n * overrides `chromeEntities`, so publishing a header emitted no purge at all\n * and the live site kept serving the old chrome — while a write to that layout\n * row, which supplied nothing, purged everything. The tag fired for the entity\n * that changed nothing and stayed silent for the two that changed everything,\n * and no gate could see it because the tests assert the tag's SHAPE, not its\n * entity set.\n *\n * What makes that unrepeatable is not vigilance: after PR30 there is ONE\n * chrome entity name, exported once from `@murumets-ee/content`, instead of a\n * list maintained in six places.\n */\nexport const DEFAULT_CHROME_ENTITIES = ['site_chrome'] as const\n\n/**\n * Entities whose rows a PAGE can embed inside a block tree, and which therefore\n * get a `doc:` tag on write even though nothing addresses them at a URL.\n *\n * `media` by default, because that is the whole of the case today: a page\n * renders an image from inside a block, the frontend mints\n * `doc:media:<id>` from the resolved tree's `refs`, and without this list the\n * matching purge is never sent — a media write is not `routable`, so\n * `tagsForWrite`'s deny-by-default gate produced NO tags at all and\n * re-publishing an image evicted nothing.\n *\n * Defaulting it ON is safe in the way `doc:order:<uuid>` would not be: a\n * resolved block tree already carries every media id it embeds on the public\n * wire, so the id is not new information to the frontend receiving the purge.\n * An operator adding an entity here is asserting the same about that entity.\n *\n * ⚠️ **The gate is the ENTITY NAME, not \"this row is embedded\", and the\n * difference is worth stating rather than leaving to be discovered.** Every\n * media write emits its document tag — including assets that are unused,\n * unpublished or admin-only and have never crossed the public wire. Knowing\n * otherwise would mean a reference query on the write path, which is the wrong\n * place to put one.\n *\n * So the receiver DOES learn the specific uuid and the write cadence of assets\n * it may never have been served — the same oracle `tagsForWrite`'s\n * deny-by-default comment rejects for `doc:order:<uuid>`, and it is not\n * softened by the collection tag, because this arm deliberately emits none.\n * What bounds it is that the destination is **Bearer-authenticated and\n * trusted**, not that there is nothing to learn. An operator adding an entity\n * here is accepting that trade for that entity.\n */\nexport const DEFAULT_EMBEDDED_ENTITIES = ['media'] as const\n\n/** `@murumets-ee/menus`' entity, and the field carrying its fetch key. */\nexport const DEFAULT_MENU_ENTITIES = { menu: 'handle' } as const\n\n/**\n * Entities holding a stored DETAIL TEMPLATE (block-library D009), and the field\n * naming the entity each row draws. `@murumets-ee/content`'s `detail_template`\n * by default.\n *\n * A template is not routable — nothing addresses it at a URL — so without this\n * a publish of \"Room detail\" would send no tag at all, and every room page\n * would keep the old layout until its route TTL expired. With it, a template\n * write sends `entity:<target>`, the tag the frontend already mints onto every\n * document page of that type.\n *\n * The collection tag, deliberately, and not a per-row one: one template draws\n * EVERY row of its target, so every one of those pages changed.\n */\nexport const DEFAULT_TEMPLATE_ENTITIES = { detail_template: 'target' } as const\n\n/**\n * At most one request per second in the steady state — exactly the receiving\n * bucket's refill rate (`PURGE_RATE_LIMIT.refillPerSecond`), so a sustained\n * write stream can never spend its 60-token burst.\n */\nexport const DEFAULT_FLUSH_INTERVAL_MS = 1000\n\n/**\n * How long the first event of a batch waits for company. Short enough that an\n * editor who publishes and immediately reloads has already lost more time to\n * the browser round trip.\n */\nexport const DEFAULT_MIN_BATCH_MS = 150\n\n/** The endpoint's own per-request cap (`MAX_PURGE_TAGS`). Sending more is a 400. */\nexport const DEFAULT_MAX_TAGS_PER_REQUEST = 50\n\n/**\n * Requests per flush. **One**, and the arithmetic is the argument.\n *\n * A flush happens at most once per `flushIntervalMs` (1s), and the receiving\n * bucket refills at exactly 1 token per second. One request per flush is\n * therefore the largest number that a SUSTAINED write stream can spend\n * indefinitely without draining the 60-token burst — at 4, a long import would\n * exhaust it in twenty seconds and every purge after that would 429.\n *\n * The cost of the cap is paid in document tags, which is why they are shed\n * first: `entity:E` is emitted alongside every `doc:E:<id>` and already evicts\n * all of them. Raise this knob only if a `lossless: false` warning says you\n * are shedding BROAD tags, which needs more than 50 distinct entities, locales\n * and menus touched inside one second.\n */\nexport const DEFAULT_MAX_REQUESTS_PER_FLUSH = 1\n\n/** A purge is a background nicety; it must never sit on a socket. */\nexport const DEFAULT_REQUEST_TIMEOUT_MS = 3000\n\n/**\n * Upper bound on tags buffered between flushes, so a runaway import cannot\n * grow the pending set without limit. Well above what a real batch produces.\n */\nexport const DEFAULT_MAX_PENDING_TAGS = 2000\n\nexport interface FrontendCacheConfig {\n /**\n * The locales this site serves — **the same list passed to\n * `content({ locales })`**.\n *\n * Required, and deliberately not inferred. `@murumets-ee/content` does not\n * publish its config through `PluginConfigRegistry`, so the alternatives were\n * a guess or a reach into another plugin's internals. A silently wrong\n * default here means chrome purges that miss a locale — a stale header on\n * one language only, which is precisely the kind of bug nobody reports.\n */\n readonly locales: readonly string[]\n /** Frontend base origin. Defaults to `process.env.FRONTEND_ORIGIN`. */\n readonly origin?: string\n /** Bearer token. Defaults to `process.env.CACHE_INVALIDATION_TOKEN`. */\n readonly token?: string\n /** Endpoint path on the frontend. Defaults to `/api/invalidate`. */\n readonly path?: string\n /**\n * Entities whose write invalidates site chrome. Defaults to\n * {@link DEFAULT_CHROME_ENTITIES} — `['site_chrome']`.\n */\n readonly chromeEntities?: readonly string[]\n /** Entity name → the field holding its menu handle. Defaults to `{ menu: 'handle' }`. */\n readonly menuEntities?: Readonly<Record<string, string>>\n /**\n * Entity name → the field holding the entity its rows draw. Defaults to\n * `{ detail_template: 'target' }`. See {@link DEFAULT_TEMPLATE_ENTITIES}.\n */\n readonly templateEntities?: Readonly<Record<string, string>>\n /**\n * Entities a page can EMBED in a block tree. Defaults to `['media']`.\n * See {@link DEFAULT_EMBEDDED_ENTITIES}.\n */\n readonly embeddedEntities?: readonly string[]\n readonly flushIntervalMs?: number\n readonly minBatchMs?: number\n readonly maxTagsPerRequest?: number\n readonly maxRequestsPerFlush?: number\n readonly requestTimeoutMs?: number\n readonly maxPendingTags?: number\n}\n\nexport interface ResolvedFrontendCacheConfig {\n readonly locales: readonly string[]\n readonly path: string\n readonly chromeEntities: readonly string[]\n readonly menuEntities: Readonly<Record<string, string>>\n readonly templateEntities: Readonly<Record<string, string>>\n readonly embeddedEntities: readonly string[]\n readonly flushIntervalMs: number\n readonly minBatchMs: number\n readonly maxTagsPerRequest: number\n readonly maxRequestsPerFlush: number\n readonly requestTimeoutMs: number\n readonly maxPendingTags: number\n /** Explicit overrides; `undefined` means \"read the environment at send time\". */\n readonly originOverride: string | undefined\n readonly tokenOverride: string | undefined\n /**\n * Non-empty when the plugin is misconfigured. Logged once at boot; every\n * entry describes something that was CORRECTED or IGNORED, never something\n * still in force — see the module header.\n */\n readonly problems: readonly string[]\n}\n\n/**\n * A positive integer, or the default when the caller supplied nonsense.\n *\n * The `>= 1` test rather than `> 0` is load-bearing: `Math.floor(0.5)` is `0`,\n * so a fractional knob would silently become a cap of zero — nothing would\n * ever be sent — instead of falling back to the documented default.\n */\nfunction positive(value: number | undefined, fallback: number): number {\n if (value === undefined) return fallback\n return Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback\n}\n\n/**\n * Read an environment variable, treating empty and whitespace-only as UNSET.\n *\n * The scaffold ships `CACHE_INVALIDATION_TOKEN=` with no value, so `''` is the\n * shape of \"I have not configured this yet\". Mirrors `optionalEnv` in\n * `apps/front-astro/src/lib/env.ts`, which makes the same call on the same\n * variable at the other end of the wire.\n */\nexport function optionalEnv(name: string): string | undefined {\n return optionalValue(process.env[name])\n}\n\n/**\n * Empty and whitespace-only mean UNSET — for an explicit option exactly as for\n * an environment variable.\n *\n * The asymmetry this removes was live: `frontendCache({ token: '' })`, or the\n * far likelier `{ token: process.env.SOMETHING_UNSET ?? '' }`, produced a\n * `tokenOverride` of `''`. That is not `undefined`, so `??` never fell back to\n * `CACHE_INVALIDATION_TOKEN`, `resolvePurgeTarget` answered `ok: true` with an\n * empty bearer, and every purge 401'd at runtime instead of the plugin saying\n * \"not configured\" once at boot.\n */\nfunction optionalValue(raw: string | undefined): string | undefined {\n if (raw === undefined) return undefined\n const trimmed = raw.trim()\n return trimmed === '' ? undefined : trimmed\n}\n\n/**\n * Validate an operator-supplied origin.\n *\n * Not user input, so this is not an SSRF control — but a config typo that\n * turns into `file:///etc/passwd` or an unparseable string should be a logged\n * refusal rather than a runtime exception on the publish path, and the scheme\n * check makes the refusal specific.\n */\nexport function parseOrigin(\n raw: string,\n): { ok: true; origin: string } | { ok: false; why: string } {\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return { ok: false, why: `not a valid absolute URL: ${JSON.stringify(raw)}` }\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') {\n return { ok: false, why: `scheme must be http or https, got ${url.protocol}` }\n }\n // Drop any path/query the operator pasted in — `path` owns that half.\n return { ok: true, origin: url.origin }\n}\n\nexport function resolveFrontendCacheConfig(\n config: FrontendCacheConfig,\n): ResolvedFrontendCacheConfig {\n const problems: string[] = []\n\n // `typeof === 'string'` rather than a bare `.map(l => l.trim())`, and the\n // reason is the shape sitting three lines above it in every real config:\n // `content({ locales: [{ code: 'en' }] })`. Passing that array here would\n // throw `l.trim is not a function` while jiti is loading `lumi.config.ts`,\n // taking down every `lumi` command — which is exactly the failure this\n // module's whole \"nothing throws\" promise exists to prevent, on the single\n // most likely way to get it wrong. `?? []` covers an absent list from\n // untyped JS.\n // `readonly unknown[]`, not the inferred type: `Array.isArray` narrows to\n // `any[]`, which would make every element `any` and cost the package its\n // 100% type-coverage floor. `unknown` is what this actually is — the value\n // reaches here from an untyped `lumi.config.ts` loaded through jiti.\n const rawLocales: readonly unknown[] = Array.isArray(config.locales) ? config.locales : []\n const stringLocales = rawLocales.filter((l): l is string => typeof l === 'string')\n const locales = stringLocales.map((l) => l.trim()).filter((l) => l !== '')\n // Counted against the STRING count, not the raw count. Comparing against the\n // raw count made `locales: ['en', '']` report the wrong-shape message and\n // send the operator hunting for an object they never wrote.\n if (stringLocales.length !== rawLocales.length) {\n problems.push(\n '`locales` must be an array of locale-code STRINGS — e.g. ' +\n \"`['en', 'et']`, not `content()`'s `[{ code: 'en' }]` shape. \" +\n 'Entries that were not strings have been ignored.',\n )\n }\n if (locales.length === 0) {\n problems.push(\n 'no locales configured — chrome and menu purges are disabled, so a header or ' +\n 'navigation edit will not appear until the page TTL expires. Pass the same ' +\n '`locales` list you pass to `content({ locales })`.',\n )\n }\n\n // Dropped, not merely reported. `resolvePurgeTarget` reads\n // `originOverride ?? env`, so keeping an unparseable override would let a\n // typo in `lumi.config.ts` SHADOW a perfectly good `FRONTEND_ORIGIN` from\n // compose and stop every purge — the opposite of the policy stated above,\n // and applied to the one knob where a fallback exists.\n let originOverride = config.origin\n if (config.origin !== undefined) {\n const parsed = parseOrigin(config.origin)\n if (!parsed.ok) {\n problems.push(\n `\\`origin\\` is unusable — ${parsed.why}; ignoring it and falling back to ${FRONTEND_ORIGIN_ENV}`,\n )\n originOverride = undefined\n }\n }\n\n // Corrected, not merely reported. A recorded-but-applied bad path produced\n // `http://front:4321api/invalidate` — where `4321api` is not a port, so\n // every flush threw inside `fetch` and logged, once per interval, forever.\n // Falling back keeps the sender working on the overwhelmingly likely\n // intent, and the problem line still says what was ignored.\n const requestedPath = config.path ?? DEFAULT_INVALIDATE_PATH\n const pathUsable = typeof requestedPath === 'string' && requestedPath.startsWith('/')\n if (!pathUsable) {\n problems.push(\n `\\`path\\` must start with \"/\", got ${JSON.stringify(requestedPath)} — ` +\n `falling back to ${DEFAULT_INVALIDATE_PATH}`,\n )\n }\n const path = pathUsable ? requestedPath : DEFAULT_INVALIDATE_PATH\n\n // Guarded exactly like `locales`, and for the same reason — which the first\n // version of this function forgot, hardening one of three sibling options\n // against untyped jiti input and leaving the other two open. A non-array\n // `chromeEntities` reaches `.includes(...)` and a null `menuEntities`\n // reaches `[event.entityName]`, both inside `tagsForWrite` on the WRITE\n // path: a `TypeError` thrown at exactly the moment this module promises\n // never to throw.\n // `readonly unknown[]` for the same reason as `rawLocales` above:\n // `Array.isArray` narrows to `any[]`, which makes every element `any` and\n // costs the package its 100% type-coverage floor.\n const rawChrome: readonly unknown[] | undefined = Array.isArray(config.chromeEntities)\n ? config.chromeEntities\n : undefined\n // Same treatment as `embeddedEntities` and `locales`, and for the same\n // reason — this list is matched with `includes` too.\n const chromeEntities = rawChrome\n ?.filter((e): e is string => typeof e === 'string' && e.trim() !== '')\n .map((e) => e.trim())\n if (config.chromeEntities !== undefined && chromeEntities === undefined) {\n problems.push('`chromeEntities` must be an array of entity-name strings — ignoring it')\n }\n\n // Same guard, same reason as `chromeEntities` directly above: this reaches\n // `.includes(...)` inside `tagsForWrite`, on the write path, in a module that\n // promises never to throw.\n const rawEmbedded: readonly unknown[] | undefined = Array.isArray(config.embeddedEntities)\n ? config.embeddedEntities\n : undefined\n // TRIMMED, not merely non-blank — `locales` above already does this, and the\n // asymmetry was silent in the worst way: `[' media ']` passed the filter,\n // matched nothing in `tagsForWrite`'s `includes`, and produced no `problems`\n // entry, so an operator got no purges and no explanation.\n const embeddedEntities = rawEmbedded\n ?.filter((e): e is string => typeof e === 'string' && e.trim() !== '')\n .map((e) => e.trim())\n if (config.embeddedEntities !== undefined && embeddedEntities === undefined) {\n problems.push('`embeddedEntities` must be an array of entity-name strings — ignoring it')\n }\n\n const menuEntitiesRaw = config.menuEntities\n const menuEntitiesUsable =\n typeof menuEntitiesRaw === 'object' &&\n menuEntitiesRaw !== null &&\n !Array.isArray(menuEntitiesRaw)\n if (menuEntitiesRaw !== undefined && !menuEntitiesUsable) {\n problems.push('`menuEntities` must be an object of `entityName: handleField` — ignoring it')\n }\n const menuEntities = menuEntitiesUsable\n ? Object.fromEntries(\n Object.entries(menuEntitiesRaw).filter(([, field]) => typeof field === 'string'),\n )\n : undefined\n\n const templateEntitiesRaw = config.templateEntities\n const templateEntitiesUsable =\n typeof templateEntitiesRaw === 'object' &&\n templateEntitiesRaw !== null &&\n !Array.isArray(templateEntitiesRaw)\n if (templateEntitiesRaw !== undefined && !templateEntitiesUsable) {\n problems.push('`templateEntities` must be an object of `entityName: targetField` — ignoring it')\n }\n // A blank field name is refused as well as a non-string one: `row['']` is\n // never set, so such an entry would REPLACE the default mapping with one that\n // emits no tag — template publishes would leave every detail page stale, and\n // nothing would say so. An explicit `{}` still means \"none\"; a map whose\n // entries were ALL unusable is a mistake, and falls back to the default.\n let templateEntities: Record<string, string> | undefined\n if (templateEntitiesUsable) {\n const supplied = Object.entries(templateEntitiesRaw)\n const usable = supplied.filter(\n (entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim() !== '',\n )\n if (usable.length < supplied.length) {\n problems.push(\n '`templateEntities` has an entry whose target field is not a non-empty string — ignoring that entry',\n )\n }\n templateEntities =\n supplied.length > 0 && usable.length === 0 ? undefined : Object.fromEntries(usable)\n }\n\n return {\n locales,\n path,\n chromeEntities: chromeEntities ?? [...DEFAULT_CHROME_ENTITIES],\n embeddedEntities: embeddedEntities ?? [...DEFAULT_EMBEDDED_ENTITIES],\n menuEntities: menuEntities ?? { ...DEFAULT_MENU_ENTITIES },\n templateEntities: templateEntities ?? { ...DEFAULT_TEMPLATE_ENTITIES },\n flushIntervalMs: positive(config.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS),\n minBatchMs: positive(config.minBatchMs, DEFAULT_MIN_BATCH_MS),\n maxTagsPerRequest: positive(config.maxTagsPerRequest, DEFAULT_MAX_TAGS_PER_REQUEST),\n maxRequestsPerFlush: positive(config.maxRequestsPerFlush, DEFAULT_MAX_REQUESTS_PER_FLUSH),\n requestTimeoutMs: positive(config.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS),\n maxPendingTags: positive(config.maxPendingTags, DEFAULT_MAX_PENDING_TAGS),\n originOverride: optionalValue(originOverride),\n tokenOverride: optionalValue(config.token),\n problems,\n }\n}\n\n/** What the sender needs to actually reach the frontend, or why it cannot. */\nexport type PurgeTarget =\n | { readonly ok: true; readonly url: string; readonly token: string }\n | { readonly ok: false; readonly why: string }\n\n/**\n * Resolve the destination AT SEND TIME rather than at construction.\n *\n * The plugin is constructed while `lumi.config.ts` is being parsed, which on\n * some hosts happens before the process's `.env` has been loaded. Reading the\n * environment on each flush costs nothing and removes an ordering dependency\n * that would present as \"purging silently stopped working after a deploy\".\n */\nexport function resolvePurgeTarget(config: ResolvedFrontendCacheConfig): PurgeTarget {\n const rawOrigin = config.originOverride ?? optionalEnv(FRONTEND_ORIGIN_ENV)\n if (rawOrigin === undefined) {\n return { ok: false, why: `${FRONTEND_ORIGIN_ENV} is not set` }\n }\n const parsed = parseOrigin(rawOrigin)\n if (!parsed.ok) return { ok: false, why: parsed.why }\n\n const token = config.tokenOverride ?? optionalEnv(CACHE_INVALIDATION_TOKEN_ENV)\n if (token === undefined) {\n return { ok: false, why: `${CACHE_INVALIDATION_TOKEN_ENV} is not set` }\n }\n\n return { ok: true, url: `${parsed.origin}${config.path}`, token }\n}\n","/**\n * The `globalThis`-keyed slot `@murumets-ee/core`'s `AdminClient` factories\n * read to find the active purge observer.\n *\n * Same shape as `@murumets-ee/queue`'s `enqueue-on-commit-slot.ts` and\n * `@murumets-ee/content`'s `paths/writers.ts`: a symbol on `globalThis`, so a\n * leaf package (`@murumets-ee/entity`) can be reached by a plugin without\n * either importing the other. `Symbol.for` rather than a module-local symbol\n * because a dev server's HMR and a bundler's module duplication both produce\n * two copies of a module and only one global symbol registry.\n *\n * The key itself is `ENTITY_WRITE_OBSERVER_SLOT`, declared once in the entity\n * package beside the type. Both ends import it; neither spells it out. A\n * duplicated `Symbol.for(...)` literal would make a typo silent — no error,\n * just a purge that never runs.\n */\n\nimport {\n ENTITY_WRITE_OBSERVER_SLOT as OBSERVER_KEY,\n type EntityWriteObserver,\n} from '@murumets-ee/entity/admin'\n\n/**\n * Install (or, with `undefined`, remove) the process-wide write observer.\n *\n * Last writer wins, and that is correct: one process runs one app, and an app\n * has one frontend to purge. Two `frontendCache()` plugins in one config is a\n * misconfiguration, not a fan-out — declare more origins on one plugin if that\n * is ever needed.\n */\nexport function setActiveEntityWriteObserver(observer: EntityWriteObserver | undefined): void {\n if (observer === undefined) {\n delete (globalThis as Record<symbol, unknown>)[OBSERVER_KEY]\n return\n }\n ;(globalThis as Record<symbol, unknown>)[OBSERVER_KEY] = observer\n}\n\n/** Read the installed observer. Exists for tests and for introspection. */\nexport function getActiveEntityWriteObserver(): EntityWriteObserver | undefined {\n const slot = (globalThis as Record<symbol, unknown>)[OBSERVER_KEY]\n return typeof slot === 'function' ? (slot as EntityWriteObserver) : undefined\n}\n","/**\n * Turning an entity write into the cache tags the FRONTEND uses.\n *\n * 🔴 **The vocabulary here is the frontend's, not content-api's, and that is a\n * decision rather than an oversight.** `@murumets-ee/content-api` stamps every\n * storable response with `Cache-Tag: lumi:<entity>[:<id>]`, and the Astro\n * template throws that header away — Astro's cache handler deletes it before\n * the response leaves the process. What the template actually keys its cache\n * on is its own four-shape vocabulary, minted in `apps/front-astro/src/lib/\n * cache-tags.ts`:\n *\n * entity:<entity> every document of one type\n * doc:<entity>:<id> one specific document\n * chrome:<locale> header / footer / the two content bands\n * menu:<handle>:<locale> one menu, per locale\n *\n * Astro's memory provider matches tags by EXACT STRING EQUALITY — no prefix\n * match, no globbing. So a purge naming `lumi:page:abc` evicts precisely\n * nothing, silently, and the page stays stale until its TTL runs out. Sending\n * the front's vocabulary is the only thing that works.\n *\n * The one thing to keep in sync is the NORMALISATION, duplicated below. The\n * receiving endpoint normalises inbound tags with the same rules before\n * matching, so a mismatch here degrades to \"purged nothing\" rather than an\n * error — which is exactly the failure mode that hides. `tags.test.ts` pins\n * the same literal strings that `apps/front-astro/src/lib/cache-tags.test.ts`\n * pins, so a drift on either side turns a suite red.\n */\n\nimport type { ResolvedFrontendCacheConfig } from './config.js'\n\n/**\n * The frontend's charset, ceiling and count cap, mirrored.\n *\n * Counterpart: `apps/front-astro/src/lib/cache-tags.ts` — `CACHE_TAG_MAX_LENGTH`\n * (Akamai's per-tag ceiling, the tightest of the four vendors surveyed there)\n * and `CACHE_TAG_MAX_COUNT`.\n */\nexport const CACHE_TAG_MAX_LENGTH = 128\n\n/** One write's worth of tags is bounded here; the SENDER bounds the batch. */\nexport const MAX_TAGS_PER_EVENT = 64\n\n/**\n * Lowercase, collapse anything outside `[a-z0-9:_-]` to a single `-`, truncate\n * to the ceiling, and trim separators off both ends.\n *\n * Returns `undefined` — never a widened tag — when nothing usable survives. A\n * menu handle written in Cyrillic normalises to nothing, and emitting the bare\n * prefix `menu` for it would purge every menu on the site.\n */\nexport function normalizeTag(raw: string): string | undefined {\n const cleaned = raw\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9:_-]+/g, '-')\n .replace(/-{2,}/g, '-')\n const trimmed = cleaned.slice(0, CACHE_TAG_MAX_LENGTH).replace(/^[-:]+|[-:]+$/g, '')\n return trimmed || undefined\n}\n\n/**\n * Build `<prefix>:<part>:<part>` with every variable part normalised\n * independently, so a part that normalises away kills the whole tag instead of\n * producing a shorter, broader one.\n */\n/**\n * A variable part may not contain the `:` separator — that belongs to the\n * builder. `normalizeTag` keeps `:` in its charset, which is right for the\n * joined tag and wrong for one part of it: a menu handle of `main:en` would\n * otherwise produce `menu:main:en:en`, indistinguishable from a handle of\n * `main` in a locale called `en:x`. Bounded (it purges the wrong menu, it\n * leaks nothing), but the separator should be the builder's alone.\n */\nfunction normalizePart(raw: string): string | undefined {\n return normalizeTag(raw.replaceAll(':', '-'))\n}\n\nfunction joinTag(prefix: string, ...parts: readonly string[]): string | undefined {\n const normalized = parts.map(normalizePart)\n if (normalized.some((part) => part === undefined)) return undefined\n return normalizeTag(`${prefix}:${normalized.join(':')}`)\n}\n\n/** Every document of one entity type. */\nexport function entityTag(entity: string): string | undefined {\n return joinTag('entity', entity)\n}\n\n/** One specific document. */\nexport function documentTag(entity: string, id: string): string | undefined {\n return joinTag('doc', entity, id)\n}\n\n/** The site chrome for one locale. */\nexport function chromeTag(locale: string): string | undefined {\n return joinTag('chrome', locale)\n}\n\n/** One menu, per locale — a menu's rendered items are locale-specific. */\nexport function menuTag(handle: string, locale: string): string | undefined {\n return joinTag('menu', handle, locale)\n}\n\n/** The subset of an entity-write event this module needs. */\nexport interface WriteTagInput {\n readonly entityName: string\n readonly action: 'upsert' | 'delete'\n readonly id: string\n /**\n * Whether a public frontend can address a row of this entity at a URL\n * (`entity.frontend.path`). Gates the `entity:` / `doc:` pair — see\n * {@link tagsForWrite}.\n */\n readonly routable: boolean\n readonly row?: Record<string, unknown> | undefined\n readonly locale?: string | undefined\n}\n\n/**\n * The tags one entity write should purge.\n *\n * **Both `entity:E` and `doc:E:<id>`, on EVERY write** — including plain\n * updates. That rule is content-api's, published on its own `_schema`\n * contract, and the reasoning is worth restating because the intuitive rule is\n * wrong: publishing a draft is an UPDATE, and that row was by definition\n * absent from every cached `?filter[status]=published` listing. A\n * create/delete-only collection purge would leave those listings stale\n * forever.\n *\n * The consequence, accepted deliberately: because the template mints\n * `entity:<e>` onto every document page, `entity:E` is a superset of every\n * `doc:E:*`, so editing one page evicts all pages of that type. Over-purging\n * is the safe direction, it is what the frontend's own docstring says\n * `entity:E` means, and at the sizes these sites run it costs nothing.\n */\nexport function tagsForWrite(\n event: WriteTagInput,\n config: ResolvedFrontendCacheConfig,\n): readonly string[] {\n // 🔴 Deny-by-default on the id-bearing pair. The destination is an\n // internet-facing anonymous process, and `doc:order:<uuid>` on every write\n // would hand it a live oracle for which entity types exist and how fast each\n // is written — while evicting nothing, because the frontend only ever mints\n // `entity:E` / `doc:E:<id>` for a document it rendered at a URL. Non-routable\n // entities still reach here; they are mapped by chrome/menu tags or not at\n // all.\n const candidates: (string | undefined)[] = event.routable\n ? [entityTag(event.entityName), documentTag(event.entityName, event.id)]\n : []\n\n // 🔴 The EMBEDDED case, and it is not covered by the line above. A page can\n // render a row nothing addresses at a URL — an image, placed inside a block —\n // and the frontend mints `doc:media:<id>` for it from the resolved tree's\n // `refs`. Media is not `routable`, so before this arm a media write produced\n // NO tags whatsoever: re-publishing an image evicted nothing and every page\n // embedding it served the old one until its route TTL expired.\n //\n // The DOCUMENT tag only, never `entity:media`. The precise tag is what the\n // frontend actually minted; the collection tag would evict every page on the\n // site for one image, which is a different (and much worse) trade than the\n // one `entity:E` makes for a routable type — there, the frontend mints\n // `entity:E` onto every document page of that type deliberately, and here it\n // mints nothing of the kind, so the tag would match nothing anyway.\n //\n // Deny-by-default still holds: the list is config, and its default names the\n // one entity whose ids already cross on the public wire inside every resolved\n // block tree.\n if (!event.routable && config.embeddedEntities.includes(event.entityName)) {\n candidates.push(documentTag(event.entityName, event.id))\n }\n\n if (config.chromeEntities.includes(event.entityName)) {\n // Every locale, not just `event.locale`. A base-row write carries no\n // locale at all, and guessing the default would quietly miss the others.\n for (const locale of config.locales) candidates.push(chromeTag(locale))\n }\n\n const handleField = config.menuEntities[event.entityName]\n if (handleField !== undefined) {\n const handle = event.row?.[handleField]\n if (typeof handle === 'string' && handle !== '') {\n for (const locale of config.locales) candidates.push(menuTag(handle, locale))\n }\n // No row (every delete, and any path that does not read the row back) means\n // no handle, so no menu tag. The frontend keys menus by handle and nothing\n // else, so there is no broader tag to fall back to — a deleted menu stays\n // rendered until the page's TTL expires. Named here rather than left to be\n // rediscovered.\n }\n\n // A stored detail template (block-library D009) draws EVERY row of the\n // entity its `target` names, so a write to it evicts that entity's pages —\n // `entity:<target>`, which the frontend mints onto each of them. The target\n // is read off the written row, like a menu's handle; `entityTag` refuses a\n // value that is not a plain entity name, so a stored string cannot coin an\n // arbitrary tag.\n const targetField = config.templateEntities[event.entityName]\n if (targetField !== undefined) {\n const target = event.row?.[targetField]\n if (typeof target === 'string' && target !== '') candidates.push(entityTag(target))\n }\n\n const seen = new Set<string>()\n for (const tag of candidates) {\n if (tag === undefined) continue\n if (seen.size >= MAX_TAGS_PER_EVENT) break\n seen.add(tag)\n }\n return [...seen]\n}\n","/**\n * The purge sender: entity writes in, at most one bounded HTTP request per\n * interval out.\n *\n * Three properties this file exists to guarantee, each of which was a stated\n * requirement before a line was written:\n *\n * 1. **It never blocks or fails a publish.** `notify()` is synchronous and\n * returns after a `Set.add`. Every failure path — unreachable host, wrong\n * token, 429, malformed config — ends in a log line, never a rejection an\n * editor could see. The entity package additionally try/catches the call,\n * so even a bug in here cannot turn Publish into a 500.\n *\n * 2. **It is bounded, at four levels.** At most one flush is ever scheduled,\n * queued or running (`flushPending`), and a flush starts no sooner than\n * `flushIntervalMs` after the previous one STARTED. Each request carries at\n * most `maxTagsPerRequest` tags (the endpoint 400s above its own cap), a\n * flush sends at most `maxRequestsPerFlush` of them, and the pending set is\n * capped. A 400-row publish costs ONE request, not 400.\n *\n * 3. **Whatever it drops, it says so.** Silent truncation reads as \"covered\n * everything\". Every cap logs what it shed and whether the shedding was\n * lossless.\n *\n * ## Two things the first cut got wrong, kept here because they are subtle\n *\n * **The interval has to be re-checked when the flush RUNS, not only when it is\n * scheduled.** The delay is computed from `lastFlushStartedAt` at schedule\n * time; if the previous request is slow, a flush queued behind it sits in the\n * chain while that value goes stale, and every `notify()` in the meantime\n * scheduled another one. Measured with a single 2.5 s request under a bulk\n * import: **11 requests in 203 ms**. `flushPending` is what makes it\n * impossible — it covers scheduled, queued AND running, so nothing stacks.\n *\n * **The steady-state rate is per PROCESS, and a deployment runs more than\n * one.** The CMS and the queue worker each construct their own sender from the\n * same config, and `docker compose up -d --scale worker=N` multiplies it\n * further, all against ONE process-wide token bucket at the receiving end. So\n * the interval alone cannot be the whole answer, and `Retry-After` is honoured\n * on a 429: a sender that is told to slow down does, which is what lets N\n * independent senders share one bucket without any of them coordinating.\n *\n * **It does not RETRY.** Backing off is not retrying — the batch that got a\n * 429 is still dropped. Retrying an unreachable frontend converts one lost\n * purge into a growing queue of them, all redundant the moment the site comes\n * back and re-renders, and the page TTL is already the reconciliation\n * mechanism for every dependency the tags cannot name. That is why that TTL\n * must not be zero.\n */\n\nimport type { ResolvedFrontendCacheConfig } from './config.js'\nimport { resolvePurgeTarget } from './config.js'\nimport { tagsForWrite, type WriteTagInput } from './tags.js'\n\n/** The slice of a structured logger this module uses. */\nexport interface PurgeLogger {\n warn(obj: Record<string, unknown>, msg: string): void\n debug(obj: Record<string, unknown>, msg: string): void\n}\n\n/** The response fields this module reads — everything `Response` already has. */\nexport interface PurgeResponse {\n readonly ok: boolean\n readonly status: number\n readonly headers: { get(name: string): string | null }\n text(): Promise<string>\n}\n\n/** `globalThis.fetch`, narrowed to what is used, so tests can substitute. */\nexport type FetchLike = (\n input: string,\n init: {\n method: string\n headers: Record<string, string>\n body: string\n signal: AbortSignal\n redirect: 'manual'\n },\n) => Promise<PurgeResponse>\n\nconst defaultFetch: FetchLike = (input, init) => fetch(input, init)\n\n/** A scheduled callback that can be cancelled. */\nexport interface PurgeTimer {\n clear(): void\n}\n\n/**\n * Injectable `setTimeout`, so the pacing can be tested by driving it rather\n * than by waiting. Fake timers plus a real promise chain is how you write a\n * scheduler test that passes for the wrong reason — this file had one.\n */\nexport type PurgeScheduler = (fn: () => void, delayMs: number) => PurgeTimer\n\nconst defaultScheduler: PurgeScheduler = (fn, delayMs) => {\n const handle = setTimeout(fn, delayMs)\n // Never hold a Next.js server or a queue worker open for a pending purge.\n handle.unref?.()\n return {\n clear: () => {\n clearTimeout(handle)\n },\n }\n}\n\nexport interface PurgeSenderDeps {\n readonly config: ResolvedFrontendCacheConfig\n readonly logger: PurgeLogger\n /** Defaults to `globalThis.fetch`. */\n readonly fetchImpl?: FetchLike\n /** Defaults to `Date.now`. Injected so the interval logic is testable without waiting. */\n readonly now?: () => number\n /** Defaults to an unref'd `setTimeout`. Injected so the pacing is testable. */\n readonly scheduler?: PurgeScheduler\n}\n\nexport interface PurgeSender {\n /** Record a write. Synchronous, allocation-cheap, never throws. */\n notify(event: WriteTagInput): void\n /**\n * Send whatever is pending right now, ignoring the pacing.\n *\n * For tests and shutdown ONLY — the interval exists to protect the receiving\n * rate limiter, and this is the one door through it.\n */\n flush(): Promise<void>\n /** Cancel any scheduled flush. Pending tags are dropped — the TTL reconciles. */\n stop(): void\n /** Tags awaiting a flush. Test/observability only. */\n readonly pendingCount: number\n}\n\n/**\n * Order tags so that dropping the tail is as lossless as it can be.\n *\n * `entity:`, `chrome:` and `menu:` go first; `doc:` last. A `doc:E:<id>` is\n * always accompanied by its `entity:E` (both are emitted on every write of a\n * routable entity), and the frontend's own definition of `entity:E` is \"every\n * document of one entity type\" — so shedding document tags loses no eviction,\n * while shedding a broad tag would. `chrome:` and `menu:` have no broader tag\n * at all, which is why they must never be the first casualty.\n */\nexport function prioritiseTags(tags: readonly string[]): readonly string[] {\n const broad: string[] = []\n const narrow: string[] = []\n for (const tag of tags) {\n if (tag.startsWith('doc:')) narrow.push(tag)\n else broad.push(tag)\n }\n return [...broad, ...narrow]\n}\n\nfunction chunk(items: readonly string[], size: number): readonly (readonly string[])[] {\n const out: string[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nfunction describe(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n\n/**\n * A redirected purge, in either of the two shapes a runtime may report.\n *\n * `0` is the Fetch standard's opaque-redirect filtered response; `3xx` is what\n * Node's undici returns instead, deliberately, so a server-side caller can see\n * the status and `Location`. Node is where this runs, so `3xx` is the live one.\n */\nexport function isRedirect(status: number): boolean {\n return status === 0 || (status >= 300 && status < 400)\n}\n\n/** `Retry-After` in seconds, clamped. Absent/garbage/huge → a sane default. */\nexport function parseRetryAfterSeconds(raw: string | null, fallback: number, max: number): number {\n if (raw === null) return fallback\n const seconds = Number.parseInt(raw.trim(), 10)\n if (!Number.isFinite(seconds) || seconds < 0) return fallback\n return Math.min(seconds, max)\n}\n\n/** A misconfigured or hostile endpoint must not park the sender for a week. */\nconst MAX_BACKOFF_SECONDS = 300\nconst DEFAULT_BACKOFF_SECONDS = 5\n\nexport function createPurgeSender(deps: PurgeSenderDeps): PurgeSender {\n const { config, logger } = deps\n const now = deps.now ?? (() => Date.now())\n const doFetch = deps.fetchImpl ?? defaultFetch\n const schedulerImpl = deps.scheduler ?? defaultScheduler\n\n const pending = new Set<string>()\n let timer: PurgeTimer | undefined\n /** True from the moment a flush is scheduled until it has finished running. */\n let flushPending = false\n let lastFlushStartedAt = Number.NEGATIVE_INFINITY\n /** Set by a 429. The next flush waits at least this long. */\n let backoffUntil = Number.NEGATIVE_INFINITY\n /** One warning per full-buffer episode, not one per dropped write. */\n let bufferFullReported = false\n /**\n * Set by `stop()`, and separate from `flushPending` on purpose.\n *\n * Clearing `flushPending` in `stop()` broke the \"at most one flush is\n * scheduled, queued or running\" invariant while a flush was mid-await: a\n * `notify()` after `stop()` passed `schedule()`'s guard and armed a timer\n * under the still-running flush. Nothing calls `stop()` in production today,\n * which is exactly why the shutdown door should be right before something\n * does.\n */\n let stopped = false\n /** Serialises flushes so a slow one cannot overlap the next. Never rejects. */\n let inFlight: Promise<void> = Promise.resolve()\n\n /** The earliest a flush may START. */\n function nextAllowedAt(): number {\n return Math.max(lastFlushStartedAt + config.flushIntervalMs, backoffUntil)\n }\n\n function schedule(): void {\n // Covers scheduled, queued behind a slow request, AND running. Without the\n // last two a slow request lets every `notify()` stack another timer.\n if (stopped || timer !== undefined || flushPending) return\n const delay = Math.max(config.minBatchMs, nextAllowedAt() - now())\n timer = schedulerImpl(() => {\n timer = undefined\n void runPaced()\n }, delay)\n }\n\n /**\n * Make room for a broad tag by evicting a document tag, or report that we\n * cannot.\n *\n * 🔴 **Ordering, not just priority.** An earlier fix sorted each event's own\n * tags so document tags were shed first — which only helps if the broad tag\n * arrives while there is still room. It is the opposite case that matters: a\n * bulk import fills the buffer with `entity:page` plus 1999 document tags,\n * an editor saves the header mid-import, and its `chrome:*` tags are refused\n * wholesale. Those have NO broader fallback, so that header edit stays\n * invisible for the whole TTL — the exact failure the priority sort claimed\n * to prevent, reached by arriving second instead of first.\n *\n * The document tag being evicted is by construction redundant: `entity:E`\n * was admitted alongside it and already evicts every page it names.\n */\n function makeRoomForBroadTag(): boolean {\n for (const existing of pending) {\n if (existing.startsWith('doc:')) {\n pending.delete(existing)\n return true\n }\n }\n return false\n }\n\n function reportBufferFull(entityName: string, dropped: string): void {\n if (bufferFullReported) return\n bufferFullReported = true\n // Once per episode. A 10 000-row `deleteMany` loop never yields to the\n // event loop, so the buffer cannot drain inside it and a per-write warning\n // would emit ~19 000 lines from one call.\n logger.warn(\n { entity: entityName, pending: pending.size, cap: config.maxPendingTags, dropped },\n 'frontend-cache: pending purge-tag buffer is full; dropping tags until the next flush',\n )\n }\n\n function notify(event: WriteTagInput): void {\n // A stopped sender accumulating tags nothing will ever flush is a slow\n // leak with no upside — the buffer would fill, warn, and hold until the\n // process ends.\n if (stopped) return\n\n // Broad first, so that within ONE event the document tags are the ones\n // that hit a full buffer. Across events, `makeRoomForBroadTag` handles it.\n const tags = prioritiseTags(tagsForWrite(event, config))\n if (tags.length === 0) return\n\n for (const tag of tags) {\n if (pending.has(tag)) continue\n if (pending.size >= config.maxPendingTags) {\n const isBroad = !tag.startsWith('doc:')\n if (!isBroad || !makeRoomForBroadTag()) {\n reportBufferFull(event.entityName, tag)\n // A document tag that cannot get in is lossless (its `entity:` tag\n // is already pending). A broad one that cannot is not — and the only\n // way to reach that is a buffer holding nothing BUT broad tags, i.e.\n // more distinct entities, locales and menus than `maxPendingTags`.\n break\n }\n }\n pending.add(tag)\n }\n schedule()\n }\n\n async function send(\n target: { readonly url: string; readonly token: string },\n tags: readonly string[],\n ): Promise<void> {\n let response: PurgeResponse\n try {\n response = await doFetch(target.url, {\n method: 'POST',\n headers: {\n // Required, not optional: Astro's `security.checkOrigin` refuses a\n // cross-origin POST whose content type is absent or form-like with a\n // 403 BEFORE the handler runs, which reads exactly like a bad token.\n 'content-type': 'application/json',\n authorization: `Bearer ${target.token}`,\n },\n body: JSON.stringify({ tags }),\n signal: AbortSignal.timeout(config.requestTimeoutMs),\n // The purge endpoint has no legitimate reason to redirect, and a\n // bearer token should not chase one.\n redirect: 'manual',\n })\n } catch (error) {\n // The headline case: the site is down, mid-deploy, or the origin is\n // wrong. The publish already succeeded; this line is its only trace.\n logger.warn(\n { url: target.url, tags: tags.length, err: describe(error) },\n 'frontend-cache: purge request failed; the frontend keeps serving cached pages until their TTL expires',\n )\n return\n }\n\n if (response.ok) {\n // Drained even though nothing reads it: an unconsumed body keeps the\n // socket out of undici's keep-alive pool until GC, once per interval,\n // forever.\n await response.text().catch(() => '')\n logger.debug({ url: target.url, tags: tags.length }, 'frontend-cache: purged')\n return\n }\n\n if (response.status === 429) {\n // Honouring this is what lets the CMS and the worker — two independent\n // senders, one shared bucket — converge without coordinating.\n const seconds = parseRetryAfterSeconds(\n response.headers.get('retry-after'),\n DEFAULT_BACKOFF_SECONDS,\n MAX_BACKOFF_SECONDS,\n )\n backoffUntil = Math.max(backoffUntil, now() + seconds * 1000)\n }\n\n // Read the body so the connection is released, and so the operator gets\n // the endpoint's own reason rather than a bare status.\n let detail: string\n try {\n detail = (await response.text()).slice(0, 200)\n } catch (error) {\n detail = `<unreadable body: ${describe(error)}>`\n }\n\n // Three refusals that mean genuinely different things, kept apart so an\n // operator is not left guessing which one they have.\n const hint =\n response.status === 401\n ? 'the frontend rejected the token — check that CACHE_INVALIDATION_TOKEN matches on both services'\n : response.status === 429\n ? 'the frontend rate-limited this purge; this batch is dropped and the next is delayed'\n : response.status === 503\n ? 'the frontend has no token configured, or route caching is off on that deployment'\n : // A redirect, named rather than left as \"unexpected\" — it is the\n // most likely new misconfiguration this PR introduces\n // (FRONTEND_ORIGIN on `http://` behind a proxy that redirects to\n // https), and the one the sender would otherwise say least about.\n //\n // BOTH shapes, because they are not the same everywhere. The\n // Fetch standard says `redirect: 'manual'` yields an\n // opaque-redirect filtered response (status 0, no headers, no\n // body); Node's undici deliberately deviates and hands back the\n // real 3xx so a server-side caller can inspect it. This runs on\n // Node, so 3xx is the case that actually fires; status 0 covers\n // a spec-conformant implementation.\n isRedirect(response.status)\n ? 'the frontend redirected the purge, which is never legitimate — FRONTEND_ORIGIN probably names a scheme or host the site redirects away from'\n : 'unexpected response'\n logger.warn(\n { url: target.url, status: response.status, tags: tags.length, detail },\n `frontend-cache: purge refused — ${hint}`,\n )\n }\n\n async function runFlush(): Promise<void> {\n if (pending.size === 0) return\n lastFlushStartedAt = now()\n\n const tags = prioritiseTags([...pending])\n pending.clear()\n bufferFullReported = false\n\n const target = resolvePurgeTarget(config)\n if (!target.ok) {\n logger.warn(\n { reason: target.why, tags: tags.length },\n 'frontend-cache: not configured, so nothing was purged',\n )\n return\n }\n\n const capacity = config.maxTagsPerRequest * config.maxRequestsPerFlush\n const selected = tags.slice(0, capacity)\n if (tags.length > selected.length) {\n const broadCount = tags.length - tags.filter((t) => t.startsWith('doc:')).length\n const lossless = selected.length >= broadCount\n logger.warn(\n { dropped: tags.length - selected.length, sent: selected.length, lossless },\n lossless\n ? 'frontend-cache: purge batch capped; only document tags were shed, each already covered by its entity tag'\n : 'frontend-cache: purge batch capped and BROAD tags were shed — some pages will stay cached until their TTL expires',\n )\n }\n\n const batches = chunk(selected, config.maxTagsPerRequest)\n for (const [i, batch] of batches.entries()) {\n // 🔴 Re-checked INSIDE the loop, not only between flushes. `backoffUntil`\n // is set by a 429 from the batch we just sent; without this the next\n // batch goes out immediately and spends another token from the bucket\n // that just said stop — up to `maxRequestsPerFlush` of them. Latent at\n // the shipped default of 1, and the config docs explicitly tell an\n // operator when to raise it, which is exactly when the backoff would\n // have been silently defeated.\n if (now() < backoffUntil) {\n logger.warn(\n { dropped: batches.length - i, remainingTags: selected.length - i * config.maxTagsPerRequest },\n 'frontend-cache: rate limited mid-flush; abandoning the rest of this batch rather than spending more of the bucket',\n )\n break\n }\n await send(target, batch)\n }\n }\n\n /** Chain `run` onto the serialising queue, guaranteeing it never rejects. */\n function enqueue(run: () => Promise<void>): Promise<void> {\n inFlight = inFlight.then(run, run).catch((error: unknown) => {\n try {\n logger.warn({ err: describe(error) }, 'frontend-cache: purge flush failed unexpectedly')\n } catch {\n // A logger that throws is the one thing left that could surface as an\n // unhandled rejection out of the fire-and-forget timer path, and a\n // cache purge must not be able to take the process down. Nothing to\n // report it WITH, which is exactly why this is empty.\n }\n })\n return inFlight\n }\n\n /** The timer path: re-checks the pacing at RUN time, not only at schedule time. */\n function runPaced(): Promise<void> {\n flushPending = true\n return enqueue(async () => {\n try {\n // The stale-`lastFlushStartedAt` window. By the time this runs, a slow\n // predecessor may have moved the goalposts.\n if (now() < nextAllowedAt()) return\n await runFlush()\n } finally {\n flushPending = false\n if (pending.size > 0) schedule()\n }\n })\n }\n\n return {\n notify,\n flush: () => enqueue(runFlush),\n stop() {\n stopped = true\n if (timer !== undefined) {\n timer.clear()\n timer = undefined\n }\n // `flushPending` is deliberately NOT cleared: a flush may be mid-await,\n // and its own `finally` owns that flag. `stopped` is what keeps the\n // sender from arming another timer once it lands.\n pending.clear()\n },\n get pendingCount() {\n return pending.size\n },\n }\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@murumets-ee/frontend-cache",
3
- "version": "0.89.0",
3
+ "version": "0.91.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,8 +17,8 @@
17
17
  "dist"
18
18
  ],
19
19
  "dependencies": {
20
- "@murumets-ee/core": "0.89.0",
21
- "@murumets-ee/entity": "0.89.0"
20
+ "@murumets-ee/core": "0.91.0",
21
+ "@murumets-ee/entity": "0.91.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^20.19.42",
@@ -1 +0,0 @@
1
- {"version":3,"file":"config-OULQXyhn.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;AA0BA;;;;AAAgC;AAShC;;;;AAAyC;AAGzC;;;;AAAoC;AAqBpC;;;;AAA+D;AAoC/D;;;;cArEa,mBAAA;AA4Eb;;;;AAAsC;AAOtC;;AAPA,cAnEa,4BAAA;;cAGA,uBAAA;AA0Eb;;;;AAAyC;AAiBzC;;;;AAA2C;AAG3C;;;;AAAuC;AAQvC;;;;AA5BA,cArDa,uBAAA;;cAoCA,qBAAA;EAAA,SAAmD,IAAA;AAAA;;AAuG7C;AAoDnB;;;cApJa,yBAAA;;;;;;cAOA,oBAAA;AA+IqC;AAAA,cA5IrC,4BAAA;;;;;;;;AA4JiB;AA2I9B;;;;;;;cAtRa,8BAAA;;cAGA,0BAAA;AAAA,UAQI,mBAAA;EAuRkB;;;AAAiD;;;;;;;EAAjD,SA5QxB,OAAA;;WAEA,MAAA;;WAEA,KAAA;;WAEA,IAAA;;;;;WAKA,cAAA;;WAEA,YAAA,GAAe,QAAQ,CAAC,MAAA;;;;;WAKxB,gBAAA;EAAA,SACA,eAAA;EAAA,SACA,UAAA;EAAA,SACA,iBAAA;EAAA,SACA,mBAAA;EAAA,SACA,gBAAA;EAAA,SACA,cAAA;AAAA;AAAA,UAGM,2BAAA;EAAA,SACN,OAAA;EAAA,SACA,IAAA;EAAA,SACA,cAAA;EAAA,SACA,YAAA,EAAc,QAAQ,CAAC,MAAA;EAAA,SACvB,gBAAA;EAAA,SACA,eAAA;EAAA,SACA,UAAA;EAAA,SACA,iBAAA;EAAA,SACA,mBAAA;EAAA,SACA,gBAAA;EAAA,SACA,cAAA;;WAEA,cAAA;EAAA,SACA,aAAA;;;;;;WAMA,QAAA;AAAA;;;;;;;;;iBAoDK,WAAA,CACd,GAAA;EACG,EAAA;EAAU,MAAA;AAAA;EAAqB,EAAA;EAAW,GAAA;AAAA;AAAA,iBAc/B,0BAAA,CACd,MAAA,EAAQ,mBAAA,GACP,2BAA2B;;KA2IlB,WAAA;EAAA,SACG,EAAA;EAAA,SAAmB,GAAA;EAAA,SAAsB,KAAA;AAAA;EAAA,SACzC,EAAA;EAAA,SAAoB,GAAA;AAAA;;;;;;;;;iBAUnB,kBAAA,CAAmB,MAAA,EAAQ,2BAAA,GAA8B,WAAW"}
@@ -1,2 +0,0 @@
1
- import{ENTITY_WRITE_OBSERVER_SLOT as e}from"@murumets-ee/entity/admin";const t=`FRONTEND_ORIGIN`,n=`CACHE_INVALIDATION_TOKEN`,r=`/api/invalidate`,i=[`site_chrome`],a=[`media`],o={menu:`handle`},s=1e3,c=150,l=50,u=1,d=3e3;function f(e,t){return e===void 0?t:Number.isFinite(e)&&e>=1?Math.floor(e):t}function p(e){return m(process.env[e])}function m(e){if(e===void 0)return;let t=e.trim();return t===``?void 0:t}function h(e){let t;try{t=new URL(e)}catch{return{ok:!1,why:`not a valid absolute URL: ${JSON.stringify(e)}`}}return t.protocol!==`http:`&&t.protocol!==`https:`?{ok:!1,why:`scheme must be http or https, got ${t.protocol}`}:{ok:!0,origin:t.origin}}function g(e){let n=[],c=Array.isArray(e.locales)?e.locales:[],l=c.filter(e=>typeof e==`string`),u=l.map(e=>e.trim()).filter(e=>e!==``);l.length!==c.length&&n.push("`locales` must be an array of locale-code STRINGS — e.g. `['en', 'et']`, not `content()`'s `[{ code: 'en' }]` shape. Entries that were not strings have been ignored."),u.length===0&&n.push("no locales configured — chrome and menu purges are disabled, so a header or navigation edit will not appear until the page TTL expires. Pass the same `locales` list you pass to `content({ locales })`.");let p=e.origin;if(e.origin!==void 0){let r=h(e.origin);r.ok||(n.push(`\`origin\` is unusable — ${r.why}; ignoring it and falling back to ${t}`),p=void 0)}let g=e.path??`/api/invalidate`,_=typeof g==`string`&&g.startsWith(`/`);_||n.push(`\`path\` must start with "/", got ${JSON.stringify(g)} — falling back to ${r}`);let v=_?g:r,y=(Array.isArray(e.chromeEntities)?e.chromeEntities:void 0)?.filter(e=>typeof e==`string`&&e.trim()!==``).map(e=>e.trim());e.chromeEntities!==void 0&&y===void 0&&n.push("`chromeEntities` must be an array of entity-name strings — ignoring it");let b=(Array.isArray(e.embeddedEntities)?e.embeddedEntities:void 0)?.filter(e=>typeof e==`string`&&e.trim()!==``).map(e=>e.trim());e.embeddedEntities!==void 0&&b===void 0&&n.push("`embeddedEntities` must be an array of entity-name strings — ignoring it");let x=e.menuEntities,S=typeof x==`object`&&!!x&&!Array.isArray(x);x!==void 0&&!S&&n.push("`menuEntities` must be an object of `entityName: handleField` — ignoring it");let C=S?Object.fromEntries(Object.entries(x).filter(([,e])=>typeof e==`string`)):void 0;return{locales:u,path:v,chromeEntities:y??[...i],embeddedEntities:b??[...a],menuEntities:C??{...o},flushIntervalMs:f(e.flushIntervalMs,s),minBatchMs:f(e.minBatchMs,150),maxTagsPerRequest:f(e.maxTagsPerRequest,50),maxRequestsPerFlush:f(e.maxRequestsPerFlush,1),requestTimeoutMs:f(e.requestTimeoutMs,d),maxPendingTags:f(e.maxPendingTags,2e3),originOverride:m(p),tokenOverride:m(e.token),problems:n}}function _(e){let r=e.originOverride??p(`FRONTEND_ORIGIN`);if(r===void 0)return{ok:!1,why:`${t} is not set`};let i=h(r);if(!i.ok)return{ok:!1,why:i.why};let a=e.tokenOverride??p(`CACHE_INVALIDATION_TOKEN`);return a===void 0?{ok:!1,why:`${n} is not set`}:{ok:!0,url:`${i.origin}${e.path}`,token:a}}function v(t){if(t===void 0){delete globalThis[e];return}globalThis[e]=t}function y(){let t=globalThis[e];return typeof t==`function`?t:void 0}const b=128,x=64;function S(e){return e.trim().toLowerCase().replace(/[^a-z0-9:_-]+/g,`-`).replace(/-{2,}/g,`-`).slice(0,128).replace(/^[-:]+|[-:]+$/g,``)||void 0}function C(e){return S(e.replaceAll(`:`,`-`))}function w(e,...t){let n=t.map(C);if(!n.some(e=>e===void 0))return S(`${e}:${n.join(`:`)}`)}function T(e){return w(`entity`,e)}function E(e,t){return w(`doc`,e,t)}function D(e){return w(`chrome`,e)}function O(e,t){return w(`menu`,e,t)}function k(e,t){let n=e.routable?[T(e.entityName),E(e.entityName,e.id)]:[];if(!e.routable&&t.embeddedEntities.includes(e.entityName)&&n.push(E(e.entityName,e.id)),t.chromeEntities.includes(e.entityName))for(let e of t.locales)n.push(D(e));let r=t.menuEntities[e.entityName];if(r!==void 0){let i=e.row?.[r];if(typeof i==`string`&&i!==``)for(let e of t.locales)n.push(O(i,e))}let i=new Set;for(let e of n)if(e!==void 0){if(i.size>=64)break;i.add(e)}return[...i]}const A=(e,t)=>fetch(e,t),j=(e,t)=>{let n=setTimeout(e,t);return n.unref?.(),{clear:()=>{clearTimeout(n)}}};function M(e){let t=[],n=[];for(let r of e)r.startsWith(`doc:`)?n.push(r):t.push(r);return[...t,...n]}function N(e,t){let n=[];for(let r=0;r<e.length;r+=t)n.push(e.slice(r,r+t));return n}function P(e){return e instanceof Error?e.message:String(e)}function F(e){return e===0||e>=300&&e<400}function I(e,t,n){if(e===null)return t;let r=Number.parseInt(e.trim(),10);return!Number.isFinite(r)||r<0?t:Math.min(r,n)}function L(e){let{config:t,logger:n}=e,r=e.now??(()=>Date.now()),i=e.fetchImpl??A,a=e.scheduler??j,o=new Set,s,c=!1,l=-1/0,u=-1/0,d=!1,f=!1,p=Promise.resolve();function m(){return Math.max(l+t.flushIntervalMs,u)}function h(){f||s!==void 0||c||(s=a(()=>{s=void 0,C()},Math.max(t.minBatchMs,m()-r())))}function g(){for(let e of o)if(e.startsWith(`doc:`))return o.delete(e),!0;return!1}function v(e,r){d||(d=!0,n.warn({entity:e,pending:o.size,cap:t.maxPendingTags,dropped:r},`frontend-cache: pending purge-tag buffer is full; dropping tags until the next flush`))}function y(e){if(f)return;let n=M(k(e,t));if(n.length!==0){for(let r of n)if(!o.has(r)){if(o.size>=t.maxPendingTags&&(r.startsWith(`doc:`)||!g())){v(e.entityName,r);break}o.add(r)}h()}}async function b(e,a){let o;try{o=await i(e.url,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${e.token}`},body:JSON.stringify({tags:a}),signal:AbortSignal.timeout(t.requestTimeoutMs),redirect:`manual`})}catch(t){n.warn({url:e.url,tags:a.length,err:P(t)},`frontend-cache: purge request failed; the frontend keeps serving cached pages until their TTL expires`);return}if(o.ok){await o.text().catch(()=>``),n.debug({url:e.url,tags:a.length},`frontend-cache: purged`);return}if(o.status===429){let e=I(o.headers.get(`retry-after`),5,300);u=Math.max(u,r()+e*1e3)}let s;try{s=(await o.text()).slice(0,200)}catch(e){s=`<unreadable body: ${P(e)}>`}let c=o.status===401?`the frontend rejected the token — check that CACHE_INVALIDATION_TOKEN matches on both services`:o.status===429?`the frontend rate-limited this purge; this batch is dropped and the next is delayed`:o.status===503?`the frontend has no token configured, or route caching is off on that deployment`:F(o.status)?`the frontend redirected the purge, which is never legitimate — FRONTEND_ORIGIN probably names a scheme or host the site redirects away from`:`unexpected response`;n.warn({url:e.url,status:o.status,tags:a.length,detail:s},`frontend-cache: purge refused — ${c}`)}async function x(){if(o.size===0)return;l=r();let e=M([...o]);o.clear(),d=!1;let i=_(t);if(!i.ok){n.warn({reason:i.why,tags:e.length},`frontend-cache: not configured, so nothing was purged`);return}let a=t.maxTagsPerRequest*t.maxRequestsPerFlush,s=e.slice(0,a);if(e.length>s.length){let t=e.length-e.filter(e=>e.startsWith(`doc:`)).length,r=s.length>=t;n.warn({dropped:e.length-s.length,sent:s.length,lossless:r},r?`frontend-cache: purge batch capped; only document tags were shed, each already covered by its entity tag`:`frontend-cache: purge batch capped and BROAD tags were shed — some pages will stay cached until their TTL expires`)}let c=N(s,t.maxTagsPerRequest);for(let[e,a]of c.entries()){if(r()<u){n.warn({dropped:c.length-e,remainingTags:s.length-e*t.maxTagsPerRequest},`frontend-cache: rate limited mid-flush; abandoning the rest of this batch rather than spending more of the bucket`);break}await b(i,a)}}function S(e){return p=p.then(e,e).catch(e=>{try{n.warn({err:P(e)},`frontend-cache: purge flush failed unexpectedly`)}catch{}}),p}function C(){return c=!0,S(async()=>{try{if(r()<m())return;await x()}finally{c=!1,o.size>0&&h()}})}return{notify:y,flush:()=>S(x),stop(){f=!0,s!==void 0&&(s.clear(),s=void 0),o.clear()},get pendingCount(){return o.size}}}export{t as C,_ as E,d as S,g as T,r as _,x as a,o as b,T as c,k as d,y as f,s as g,i as h,b as i,O as l,n as m,I as n,D as o,v as p,M as r,E as s,L as t,S as u,u as v,h as w,c as x,l as y};
2
- //# sourceMappingURL=sender-xWC7aWIl.mjs.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"sender-xWC7aWIl.mjs","names":["OBSERVER_KEY"],"sources":["../src/config.ts","../src/observer-slot.ts","../src/tags.ts","../src/sender.ts"],"sourcesContent":["/**\n * Configuration for the purge sender.\n *\n * 🔴 **Nothing here throws.** `frontendCache()` is called from\n * `lumi.config.ts`, which the CLI loads through jiti at BUILD time — a throw\n * there takes down `lumi migrate`, `next build` and every other command that\n * touches the config, for a misconfiguration that only matters at runtime.\n * (See `gotcha_lumi_config_throw_at_build_time`.) So resolution collects\n * `problems` instead and the plugin logs them once at `onReady`.\n *\n * **A problem DEGRADES the sender; it does not silence it.** An unusable\n * `path` falls back to the default, non-string locale codes are dropped, a\n * nonsensical numeric knob takes its documented default, and an unusable\n * `origin` is the one case that genuinely stops sending — because there is\n * nowhere to send to. Recording a problem and then using the broken value\n * anyway is worse than either: it produced a URL that failed inside `fetch`\n * once per second, forever, with a warning nobody had asked for.\n *\n * Degrade rather than fail-closed is the right direction on this side of the\n * wire. The receiving endpoint fails closed because accepting an unauthorised\n * purge is a denial-of-service lever. Here the equivalent of \"fail closed\"\n * would be refusing an editor's publish because the frontend's URL has a typo,\n * which is absurd — the publish must land and the page catches up on TTL.\n */\n\n/** Environment variable holding the frontend's base origin, e.g. `http://front:4321`. */\nexport const FRONTEND_ORIGIN_ENV = 'FRONTEND_ORIGIN'\n\n/**\n * Environment variable holding the shared purge bearer token.\n *\n * Deliberately the same NAME the frontend reads, because it is the same secret\n * — compose sets it once and both services receive it. A second variable\n * nobody remembers to keep in sync is a purge that silently 401s.\n */\nexport const CACHE_INVALIDATION_TOKEN_ENV = 'CACHE_INVALIDATION_TOKEN'\n\n/** Default path of the frontend's invalidation endpoint. */\nexport const DEFAULT_INVALIDATE_PATH = '/api/invalidate'\n\n/**\n * The entity `@murumets-ee/content` uses for site chrome.\n *\n * A write to one of these emits `chromeTag(locale)` per locale — the tag that\n * tells the delivery cache every page's chrome changed.\n *\n * 🔴 This named the legacy single-row layout entity until PR30, and it had\n * been wrong since the chrome DOCUMENTS were introduced (F086). No app\n * overrides `chromeEntities`, so publishing a header emitted no purge at all\n * and the live site kept serving the old chrome — while a write to that layout\n * row, which supplied nothing, purged everything. The tag fired for the entity\n * that changed nothing and stayed silent for the two that changed everything,\n * and no gate could see it because the tests assert the tag's SHAPE, not its\n * entity set.\n *\n * What makes that unrepeatable is not vigilance: after PR30 there is ONE\n * chrome entity name, exported once from `@murumets-ee/content`, instead of a\n * list maintained in six places.\n */\nexport const DEFAULT_CHROME_ENTITIES = ['site_chrome'] as const\n\n/**\n * Entities whose rows a PAGE can embed inside a block tree, and which therefore\n * get a `doc:` tag on write even though nothing addresses them at a URL.\n *\n * `media` by default, because that is the whole of the case today: a page\n * renders an image from inside a block, the frontend mints\n * `doc:media:<id>` from the resolved tree's `refs`, and without this list the\n * matching purge is never sent — a media write is not `routable`, so\n * `tagsForWrite`'s deny-by-default gate produced NO tags at all and\n * re-publishing an image evicted nothing.\n *\n * Defaulting it ON is safe in the way `doc:order:<uuid>` would not be: a\n * resolved block tree already carries every media id it embeds on the public\n * wire, so the id is not new information to the frontend receiving the purge.\n * An operator adding an entity here is asserting the same about that entity.\n *\n * ⚠️ **The gate is the ENTITY NAME, not \"this row is embedded\", and the\n * difference is worth stating rather than leaving to be discovered.** Every\n * media write emits its document tag — including assets that are unused,\n * unpublished or admin-only and have never crossed the public wire. Knowing\n * otherwise would mean a reference query on the write path, which is the wrong\n * place to put one.\n *\n * So the receiver DOES learn the specific uuid and the write cadence of assets\n * it may never have been served — the same oracle `tagsForWrite`'s\n * deny-by-default comment rejects for `doc:order:<uuid>`, and it is not\n * softened by the collection tag, because this arm deliberately emits none.\n * What bounds it is that the destination is **Bearer-authenticated and\n * trusted**, not that there is nothing to learn. An operator adding an entity\n * here is accepting that trade for that entity.\n */\nexport const DEFAULT_EMBEDDED_ENTITIES = ['media'] as const\n\n/** `@murumets-ee/menus`' entity, and the field carrying its fetch key. */\nexport const DEFAULT_MENU_ENTITIES = { menu: 'handle' } as const\n\n/**\n * At most one request per second in the steady state — exactly the receiving\n * bucket's refill rate (`PURGE_RATE_LIMIT.refillPerSecond`), so a sustained\n * write stream can never spend its 60-token burst.\n */\nexport const DEFAULT_FLUSH_INTERVAL_MS = 1000\n\n/**\n * How long the first event of a batch waits for company. Short enough that an\n * editor who publishes and immediately reloads has already lost more time to\n * the browser round trip.\n */\nexport const DEFAULT_MIN_BATCH_MS = 150\n\n/** The endpoint's own per-request cap (`MAX_PURGE_TAGS`). Sending more is a 400. */\nexport const DEFAULT_MAX_TAGS_PER_REQUEST = 50\n\n/**\n * Requests per flush. **One**, and the arithmetic is the argument.\n *\n * A flush happens at most once per `flushIntervalMs` (1s), and the receiving\n * bucket refills at exactly 1 token per second. One request per flush is\n * therefore the largest number that a SUSTAINED write stream can spend\n * indefinitely without draining the 60-token burst — at 4, a long import would\n * exhaust it in twenty seconds and every purge after that would 429.\n *\n * The cost of the cap is paid in document tags, which is why they are shed\n * first: `entity:E` is emitted alongside every `doc:E:<id>` and already evicts\n * all of them. Raise this knob only if a `lossless: false` warning says you\n * are shedding BROAD tags, which needs more than 50 distinct entities, locales\n * and menus touched inside one second.\n */\nexport const DEFAULT_MAX_REQUESTS_PER_FLUSH = 1\n\n/** A purge is a background nicety; it must never sit on a socket. */\nexport const DEFAULT_REQUEST_TIMEOUT_MS = 3000\n\n/**\n * Upper bound on tags buffered between flushes, so a runaway import cannot\n * grow the pending set without limit. Well above what a real batch produces.\n */\nexport const DEFAULT_MAX_PENDING_TAGS = 2000\n\nexport interface FrontendCacheConfig {\n /**\n * The locales this site serves — **the same list passed to\n * `content({ locales })`**.\n *\n * Required, and deliberately not inferred. `@murumets-ee/content` does not\n * publish its config through `PluginConfigRegistry`, so the alternatives were\n * a guess or a reach into another plugin's internals. A silently wrong\n * default here means chrome purges that miss a locale — a stale header on\n * one language only, which is precisely the kind of bug nobody reports.\n */\n readonly locales: readonly string[]\n /** Frontend base origin. Defaults to `process.env.FRONTEND_ORIGIN`. */\n readonly origin?: string\n /** Bearer token. Defaults to `process.env.CACHE_INVALIDATION_TOKEN`. */\n readonly token?: string\n /** Endpoint path on the frontend. Defaults to `/api/invalidate`. */\n readonly path?: string\n /**\n * Entities whose write invalidates site chrome. Defaults to\n * {@link DEFAULT_CHROME_ENTITIES} — `['site_chrome']`.\n */\n readonly chromeEntities?: readonly string[]\n /** Entity name → the field holding its menu handle. Defaults to `{ menu: 'handle' }`. */\n readonly menuEntities?: Readonly<Record<string, string>>\n /**\n * Entities a page can EMBED in a block tree. Defaults to `['media']`.\n * See {@link DEFAULT_EMBEDDED_ENTITIES}.\n */\n readonly embeddedEntities?: readonly string[]\n readonly flushIntervalMs?: number\n readonly minBatchMs?: number\n readonly maxTagsPerRequest?: number\n readonly maxRequestsPerFlush?: number\n readonly requestTimeoutMs?: number\n readonly maxPendingTags?: number\n}\n\nexport interface ResolvedFrontendCacheConfig {\n readonly locales: readonly string[]\n readonly path: string\n readonly chromeEntities: readonly string[]\n readonly menuEntities: Readonly<Record<string, string>>\n readonly embeddedEntities: readonly string[]\n readonly flushIntervalMs: number\n readonly minBatchMs: number\n readonly maxTagsPerRequest: number\n readonly maxRequestsPerFlush: number\n readonly requestTimeoutMs: number\n readonly maxPendingTags: number\n /** Explicit overrides; `undefined` means \"read the environment at send time\". */\n readonly originOverride: string | undefined\n readonly tokenOverride: string | undefined\n /**\n * Non-empty when the plugin is misconfigured. Logged once at boot; every\n * entry describes something that was CORRECTED or IGNORED, never something\n * still in force — see the module header.\n */\n readonly problems: readonly string[]\n}\n\n/**\n * A positive integer, or the default when the caller supplied nonsense.\n *\n * The `>= 1` test rather than `> 0` is load-bearing: `Math.floor(0.5)` is `0`,\n * so a fractional knob would silently become a cap of zero — nothing would\n * ever be sent — instead of falling back to the documented default.\n */\nfunction positive(value: number | undefined, fallback: number): number {\n if (value === undefined) return fallback\n return Number.isFinite(value) && value >= 1 ? Math.floor(value) : fallback\n}\n\n/**\n * Read an environment variable, treating empty and whitespace-only as UNSET.\n *\n * The scaffold ships `CACHE_INVALIDATION_TOKEN=` with no value, so `''` is the\n * shape of \"I have not configured this yet\". Mirrors `optionalEnv` in\n * `apps/front-astro/src/lib/env.ts`, which makes the same call on the same\n * variable at the other end of the wire.\n */\nexport function optionalEnv(name: string): string | undefined {\n return optionalValue(process.env[name])\n}\n\n/**\n * Empty and whitespace-only mean UNSET — for an explicit option exactly as for\n * an environment variable.\n *\n * The asymmetry this removes was live: `frontendCache({ token: '' })`, or the\n * far likelier `{ token: process.env.SOMETHING_UNSET ?? '' }`, produced a\n * `tokenOverride` of `''`. That is not `undefined`, so `??` never fell back to\n * `CACHE_INVALIDATION_TOKEN`, `resolvePurgeTarget` answered `ok: true` with an\n * empty bearer, and every purge 401'd at runtime instead of the plugin saying\n * \"not configured\" once at boot.\n */\nfunction optionalValue(raw: string | undefined): string | undefined {\n if (raw === undefined) return undefined\n const trimmed = raw.trim()\n return trimmed === '' ? undefined : trimmed\n}\n\n/**\n * Validate an operator-supplied origin.\n *\n * Not user input, so this is not an SSRF control — but a config typo that\n * turns into `file:///etc/passwd` or an unparseable string should be a logged\n * refusal rather than a runtime exception on the publish path, and the scheme\n * check makes the refusal specific.\n */\nexport function parseOrigin(\n raw: string,\n): { ok: true; origin: string } | { ok: false; why: string } {\n let url: URL\n try {\n url = new URL(raw)\n } catch {\n return { ok: false, why: `not a valid absolute URL: ${JSON.stringify(raw)}` }\n }\n if (url.protocol !== 'http:' && url.protocol !== 'https:') {\n return { ok: false, why: `scheme must be http or https, got ${url.protocol}` }\n }\n // Drop any path/query the operator pasted in — `path` owns that half.\n return { ok: true, origin: url.origin }\n}\n\nexport function resolveFrontendCacheConfig(\n config: FrontendCacheConfig,\n): ResolvedFrontendCacheConfig {\n const problems: string[] = []\n\n // `typeof === 'string'` rather than a bare `.map(l => l.trim())`, and the\n // reason is the shape sitting three lines above it in every real config:\n // `content({ locales: [{ code: 'en' }] })`. Passing that array here would\n // throw `l.trim is not a function` while jiti is loading `lumi.config.ts`,\n // taking down every `lumi` command — which is exactly the failure this\n // module's whole \"nothing throws\" promise exists to prevent, on the single\n // most likely way to get it wrong. `?? []` covers an absent list from\n // untyped JS.\n // `readonly unknown[]`, not the inferred type: `Array.isArray` narrows to\n // `any[]`, which would make every element `any` and cost the package its\n // 100% type-coverage floor. `unknown` is what this actually is — the value\n // reaches here from an untyped `lumi.config.ts` loaded through jiti.\n const rawLocales: readonly unknown[] = Array.isArray(config.locales) ? config.locales : []\n const stringLocales = rawLocales.filter((l): l is string => typeof l === 'string')\n const locales = stringLocales.map((l) => l.trim()).filter((l) => l !== '')\n // Counted against the STRING count, not the raw count. Comparing against the\n // raw count made `locales: ['en', '']` report the wrong-shape message and\n // send the operator hunting for an object they never wrote.\n if (stringLocales.length !== rawLocales.length) {\n problems.push(\n '`locales` must be an array of locale-code STRINGS — e.g. ' +\n \"`['en', 'et']`, not `content()`'s `[{ code: 'en' }]` shape. \" +\n 'Entries that were not strings have been ignored.',\n )\n }\n if (locales.length === 0) {\n problems.push(\n 'no locales configured — chrome and menu purges are disabled, so a header or ' +\n 'navigation edit will not appear until the page TTL expires. Pass the same ' +\n '`locales` list you pass to `content({ locales })`.',\n )\n }\n\n // Dropped, not merely reported. `resolvePurgeTarget` reads\n // `originOverride ?? env`, so keeping an unparseable override would let a\n // typo in `lumi.config.ts` SHADOW a perfectly good `FRONTEND_ORIGIN` from\n // compose and stop every purge — the opposite of the policy stated above,\n // and applied to the one knob where a fallback exists.\n let originOverride = config.origin\n if (config.origin !== undefined) {\n const parsed = parseOrigin(config.origin)\n if (!parsed.ok) {\n problems.push(\n `\\`origin\\` is unusable — ${parsed.why}; ignoring it and falling back to ${FRONTEND_ORIGIN_ENV}`,\n )\n originOverride = undefined\n }\n }\n\n // Corrected, not merely reported. A recorded-but-applied bad path produced\n // `http://front:4321api/invalidate` — where `4321api` is not a port, so\n // every flush threw inside `fetch` and logged, once per interval, forever.\n // Falling back keeps the sender working on the overwhelmingly likely\n // intent, and the problem line still says what was ignored.\n const requestedPath = config.path ?? DEFAULT_INVALIDATE_PATH\n const pathUsable = typeof requestedPath === 'string' && requestedPath.startsWith('/')\n if (!pathUsable) {\n problems.push(\n `\\`path\\` must start with \"/\", got ${JSON.stringify(requestedPath)} — ` +\n `falling back to ${DEFAULT_INVALIDATE_PATH}`,\n )\n }\n const path = pathUsable ? requestedPath : DEFAULT_INVALIDATE_PATH\n\n // Guarded exactly like `locales`, and for the same reason — which the first\n // version of this function forgot, hardening one of three sibling options\n // against untyped jiti input and leaving the other two open. A non-array\n // `chromeEntities` reaches `.includes(...)` and a null `menuEntities`\n // reaches `[event.entityName]`, both inside `tagsForWrite` on the WRITE\n // path: a `TypeError` thrown at exactly the moment this module promises\n // never to throw.\n // `readonly unknown[]` for the same reason as `rawLocales` above:\n // `Array.isArray` narrows to `any[]`, which makes every element `any` and\n // costs the package its 100% type-coverage floor.\n const rawChrome: readonly unknown[] | undefined = Array.isArray(config.chromeEntities)\n ? config.chromeEntities\n : undefined\n // Same treatment as `embeddedEntities` and `locales`, and for the same\n // reason — this list is matched with `includes` too.\n const chromeEntities = rawChrome\n ?.filter((e): e is string => typeof e === 'string' && e.trim() !== '')\n .map((e) => e.trim())\n if (config.chromeEntities !== undefined && chromeEntities === undefined) {\n problems.push('`chromeEntities` must be an array of entity-name strings — ignoring it')\n }\n\n // Same guard, same reason as `chromeEntities` directly above: this reaches\n // `.includes(...)` inside `tagsForWrite`, on the write path, in a module that\n // promises never to throw.\n const rawEmbedded: readonly unknown[] | undefined = Array.isArray(config.embeddedEntities)\n ? config.embeddedEntities\n : undefined\n // TRIMMED, not merely non-blank — `locales` above already does this, and the\n // asymmetry was silent in the worst way: `[' media ']` passed the filter,\n // matched nothing in `tagsForWrite`'s `includes`, and produced no `problems`\n // entry, so an operator got no purges and no explanation.\n const embeddedEntities = rawEmbedded\n ?.filter((e): e is string => typeof e === 'string' && e.trim() !== '')\n .map((e) => e.trim())\n if (config.embeddedEntities !== undefined && embeddedEntities === undefined) {\n problems.push('`embeddedEntities` must be an array of entity-name strings — ignoring it')\n }\n\n const menuEntitiesRaw = config.menuEntities\n const menuEntitiesUsable =\n typeof menuEntitiesRaw === 'object' &&\n menuEntitiesRaw !== null &&\n !Array.isArray(menuEntitiesRaw)\n if (menuEntitiesRaw !== undefined && !menuEntitiesUsable) {\n problems.push('`menuEntities` must be an object of `entityName: handleField` — ignoring it')\n }\n const menuEntities = menuEntitiesUsable\n ? Object.fromEntries(\n Object.entries(menuEntitiesRaw).filter(([, field]) => typeof field === 'string'),\n )\n : undefined\n\n return {\n locales,\n path,\n chromeEntities: chromeEntities ?? [...DEFAULT_CHROME_ENTITIES],\n embeddedEntities: embeddedEntities ?? [...DEFAULT_EMBEDDED_ENTITIES],\n menuEntities: menuEntities ?? { ...DEFAULT_MENU_ENTITIES },\n flushIntervalMs: positive(config.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS),\n minBatchMs: positive(config.minBatchMs, DEFAULT_MIN_BATCH_MS),\n maxTagsPerRequest: positive(config.maxTagsPerRequest, DEFAULT_MAX_TAGS_PER_REQUEST),\n maxRequestsPerFlush: positive(config.maxRequestsPerFlush, DEFAULT_MAX_REQUESTS_PER_FLUSH),\n requestTimeoutMs: positive(config.requestTimeoutMs, DEFAULT_REQUEST_TIMEOUT_MS),\n maxPendingTags: positive(config.maxPendingTags, DEFAULT_MAX_PENDING_TAGS),\n originOverride: optionalValue(originOverride),\n tokenOverride: optionalValue(config.token),\n problems,\n }\n}\n\n/** What the sender needs to actually reach the frontend, or why it cannot. */\nexport type PurgeTarget =\n | { readonly ok: true; readonly url: string; readonly token: string }\n | { readonly ok: false; readonly why: string }\n\n/**\n * Resolve the destination AT SEND TIME rather than at construction.\n *\n * The plugin is constructed while `lumi.config.ts` is being parsed, which on\n * some hosts happens before the process's `.env` has been loaded. Reading the\n * environment on each flush costs nothing and removes an ordering dependency\n * that would present as \"purging silently stopped working after a deploy\".\n */\nexport function resolvePurgeTarget(config: ResolvedFrontendCacheConfig): PurgeTarget {\n const rawOrigin = config.originOverride ?? optionalEnv(FRONTEND_ORIGIN_ENV)\n if (rawOrigin === undefined) {\n return { ok: false, why: `${FRONTEND_ORIGIN_ENV} is not set` }\n }\n const parsed = parseOrigin(rawOrigin)\n if (!parsed.ok) return { ok: false, why: parsed.why }\n\n const token = config.tokenOverride ?? optionalEnv(CACHE_INVALIDATION_TOKEN_ENV)\n if (token === undefined) {\n return { ok: false, why: `${CACHE_INVALIDATION_TOKEN_ENV} is not set` }\n }\n\n return { ok: true, url: `${parsed.origin}${config.path}`, token }\n}\n","/**\n * The `globalThis`-keyed slot `@murumets-ee/core`'s `AdminClient` factories\n * read to find the active purge observer.\n *\n * Same shape as `@murumets-ee/queue`'s `enqueue-on-commit-slot.ts` and\n * `@murumets-ee/content`'s `paths/writers.ts`: a symbol on `globalThis`, so a\n * leaf package (`@murumets-ee/entity`) can be reached by a plugin without\n * either importing the other. `Symbol.for` rather than a module-local symbol\n * because a dev server's HMR and a bundler's module duplication both produce\n * two copies of a module and only one global symbol registry.\n *\n * The key itself is `ENTITY_WRITE_OBSERVER_SLOT`, declared once in the entity\n * package beside the type. Both ends import it; neither spells it out. A\n * duplicated `Symbol.for(...)` literal would make a typo silent — no error,\n * just a purge that never runs.\n */\n\nimport {\n ENTITY_WRITE_OBSERVER_SLOT as OBSERVER_KEY,\n type EntityWriteObserver,\n} from '@murumets-ee/entity/admin'\n\n/**\n * Install (or, with `undefined`, remove) the process-wide write observer.\n *\n * Last writer wins, and that is correct: one process runs one app, and an app\n * has one frontend to purge. Two `frontendCache()` plugins in one config is a\n * misconfiguration, not a fan-out — declare more origins on one plugin if that\n * is ever needed.\n */\nexport function setActiveEntityWriteObserver(observer: EntityWriteObserver | undefined): void {\n if (observer === undefined) {\n delete (globalThis as Record<symbol, unknown>)[OBSERVER_KEY]\n return\n }\n ;(globalThis as Record<symbol, unknown>)[OBSERVER_KEY] = observer\n}\n\n/** Read the installed observer. Exists for tests and for introspection. */\nexport function getActiveEntityWriteObserver(): EntityWriteObserver | undefined {\n const slot = (globalThis as Record<symbol, unknown>)[OBSERVER_KEY]\n return typeof slot === 'function' ? (slot as EntityWriteObserver) : undefined\n}\n","/**\n * Turning an entity write into the cache tags the FRONTEND uses.\n *\n * 🔴 **The vocabulary here is the frontend's, not content-api's, and that is a\n * decision rather than an oversight.** `@murumets-ee/content-api` stamps every\n * storable response with `Cache-Tag: lumi:<entity>[:<id>]`, and the Astro\n * template throws that header away — Astro's cache handler deletes it before\n * the response leaves the process. What the template actually keys its cache\n * on is its own four-shape vocabulary, minted in `apps/front-astro/src/lib/\n * cache-tags.ts`:\n *\n * entity:<entity> every document of one type\n * doc:<entity>:<id> one specific document\n * chrome:<locale> header / footer / the two content bands\n * menu:<handle>:<locale> one menu, per locale\n *\n * Astro's memory provider matches tags by EXACT STRING EQUALITY — no prefix\n * match, no globbing. So a purge naming `lumi:page:abc` evicts precisely\n * nothing, silently, and the page stays stale until its TTL runs out. Sending\n * the front's vocabulary is the only thing that works.\n *\n * The one thing to keep in sync is the NORMALISATION, duplicated below. The\n * receiving endpoint normalises inbound tags with the same rules before\n * matching, so a mismatch here degrades to \"purged nothing\" rather than an\n * error — which is exactly the failure mode that hides. `tags.test.ts` pins\n * the same literal strings that `apps/front-astro/src/lib/cache-tags.test.ts`\n * pins, so a drift on either side turns a suite red.\n */\n\nimport type { ResolvedFrontendCacheConfig } from './config.js'\n\n/**\n * The frontend's charset, ceiling and count cap, mirrored.\n *\n * Counterpart: `apps/front-astro/src/lib/cache-tags.ts` — `CACHE_TAG_MAX_LENGTH`\n * (Akamai's per-tag ceiling, the tightest of the four vendors surveyed there)\n * and `CACHE_TAG_MAX_COUNT`.\n */\nexport const CACHE_TAG_MAX_LENGTH = 128\n\n/** One write's worth of tags is bounded here; the SENDER bounds the batch. */\nexport const MAX_TAGS_PER_EVENT = 64\n\n/**\n * Lowercase, collapse anything outside `[a-z0-9:_-]` to a single `-`, truncate\n * to the ceiling, and trim separators off both ends.\n *\n * Returns `undefined` — never a widened tag — when nothing usable survives. A\n * menu handle written in Cyrillic normalises to nothing, and emitting the bare\n * prefix `menu` for it would purge every menu on the site.\n */\nexport function normalizeTag(raw: string): string | undefined {\n const cleaned = raw\n .trim()\n .toLowerCase()\n .replace(/[^a-z0-9:_-]+/g, '-')\n .replace(/-{2,}/g, '-')\n const trimmed = cleaned.slice(0, CACHE_TAG_MAX_LENGTH).replace(/^[-:]+|[-:]+$/g, '')\n return trimmed || undefined\n}\n\n/**\n * Build `<prefix>:<part>:<part>` with every variable part normalised\n * independently, so a part that normalises away kills the whole tag instead of\n * producing a shorter, broader one.\n */\n/**\n * A variable part may not contain the `:` separator — that belongs to the\n * builder. `normalizeTag` keeps `:` in its charset, which is right for the\n * joined tag and wrong for one part of it: a menu handle of `main:en` would\n * otherwise produce `menu:main:en:en`, indistinguishable from a handle of\n * `main` in a locale called `en:x`. Bounded (it purges the wrong menu, it\n * leaks nothing), but the separator should be the builder's alone.\n */\nfunction normalizePart(raw: string): string | undefined {\n return normalizeTag(raw.replaceAll(':', '-'))\n}\n\nfunction joinTag(prefix: string, ...parts: readonly string[]): string | undefined {\n const normalized = parts.map(normalizePart)\n if (normalized.some((part) => part === undefined)) return undefined\n return normalizeTag(`${prefix}:${normalized.join(':')}`)\n}\n\n/** Every document of one entity type. */\nexport function entityTag(entity: string): string | undefined {\n return joinTag('entity', entity)\n}\n\n/** One specific document. */\nexport function documentTag(entity: string, id: string): string | undefined {\n return joinTag('doc', entity, id)\n}\n\n/** The site chrome for one locale. */\nexport function chromeTag(locale: string): string | undefined {\n return joinTag('chrome', locale)\n}\n\n/** One menu, per locale — a menu's rendered items are locale-specific. */\nexport function menuTag(handle: string, locale: string): string | undefined {\n return joinTag('menu', handle, locale)\n}\n\n/** The subset of an entity-write event this module needs. */\nexport interface WriteTagInput {\n readonly entityName: string\n readonly action: 'upsert' | 'delete'\n readonly id: string\n /**\n * Whether a public frontend can address a row of this entity at a URL\n * (`entity.frontend.path`). Gates the `entity:` / `doc:` pair — see\n * {@link tagsForWrite}.\n */\n readonly routable: boolean\n readonly row?: Record<string, unknown> | undefined\n readonly locale?: string | undefined\n}\n\n/**\n * The tags one entity write should purge.\n *\n * **Both `entity:E` and `doc:E:<id>`, on EVERY write** — including plain\n * updates. That rule is content-api's, published on its own `_schema`\n * contract, and the reasoning is worth restating because the intuitive rule is\n * wrong: publishing a draft is an UPDATE, and that row was by definition\n * absent from every cached `?filter[status]=published` listing. A\n * create/delete-only collection purge would leave those listings stale\n * forever.\n *\n * The consequence, accepted deliberately: because the template mints\n * `entity:<e>` onto every document page, `entity:E` is a superset of every\n * `doc:E:*`, so editing one page evicts all pages of that type. Over-purging\n * is the safe direction, it is what the frontend's own docstring says\n * `entity:E` means, and at the sizes these sites run it costs nothing.\n */\nexport function tagsForWrite(\n event: WriteTagInput,\n config: ResolvedFrontendCacheConfig,\n): readonly string[] {\n // 🔴 Deny-by-default on the id-bearing pair. The destination is an\n // internet-facing anonymous process, and `doc:order:<uuid>` on every write\n // would hand it a live oracle for which entity types exist and how fast each\n // is written — while evicting nothing, because the frontend only ever mints\n // `entity:E` / `doc:E:<id>` for a document it rendered at a URL. Non-routable\n // entities still reach here; they are mapped by chrome/menu tags or not at\n // all.\n const candidates: (string | undefined)[] = event.routable\n ? [entityTag(event.entityName), documentTag(event.entityName, event.id)]\n : []\n\n // 🔴 The EMBEDDED case, and it is not covered by the line above. A page can\n // render a row nothing addresses at a URL — an image, placed inside a block —\n // and the frontend mints `doc:media:<id>` for it from the resolved tree's\n // `refs`. Media is not `routable`, so before this arm a media write produced\n // NO tags whatsoever: re-publishing an image evicted nothing and every page\n // embedding it served the old one until its route TTL expired.\n //\n // The DOCUMENT tag only, never `entity:media`. The precise tag is what the\n // frontend actually minted; the collection tag would evict every page on the\n // site for one image, which is a different (and much worse) trade than the\n // one `entity:E` makes for a routable type — there, the frontend mints\n // `entity:E` onto every document page of that type deliberately, and here it\n // mints nothing of the kind, so the tag would match nothing anyway.\n //\n // Deny-by-default still holds: the list is config, and its default names the\n // one entity whose ids already cross on the public wire inside every resolved\n // block tree.\n if (!event.routable && config.embeddedEntities.includes(event.entityName)) {\n candidates.push(documentTag(event.entityName, event.id))\n }\n\n if (config.chromeEntities.includes(event.entityName)) {\n // Every locale, not just `event.locale`. A base-row write carries no\n // locale at all, and guessing the default would quietly miss the others.\n for (const locale of config.locales) candidates.push(chromeTag(locale))\n }\n\n const handleField = config.menuEntities[event.entityName]\n if (handleField !== undefined) {\n const handle = event.row?.[handleField]\n if (typeof handle === 'string' && handle !== '') {\n for (const locale of config.locales) candidates.push(menuTag(handle, locale))\n }\n // No row (every delete, and any path that does not read the row back) means\n // no handle, so no menu tag. The frontend keys menus by handle and nothing\n // else, so there is no broader tag to fall back to — a deleted menu stays\n // rendered until the page's TTL expires. Named here rather than left to be\n // rediscovered.\n }\n\n const seen = new Set<string>()\n for (const tag of candidates) {\n if (tag === undefined) continue\n if (seen.size >= MAX_TAGS_PER_EVENT) break\n seen.add(tag)\n }\n return [...seen]\n}\n","/**\n * The purge sender: entity writes in, at most one bounded HTTP request per\n * interval out.\n *\n * Three properties this file exists to guarantee, each of which was a stated\n * requirement before a line was written:\n *\n * 1. **It never blocks or fails a publish.** `notify()` is synchronous and\n * returns after a `Set.add`. Every failure path — unreachable host, wrong\n * token, 429, malformed config — ends in a log line, never a rejection an\n * editor could see. The entity package additionally try/catches the call,\n * so even a bug in here cannot turn Publish into a 500.\n *\n * 2. **It is bounded, at four levels.** At most one flush is ever scheduled,\n * queued or running (`flushPending`), and a flush starts no sooner than\n * `flushIntervalMs` after the previous one STARTED. Each request carries at\n * most `maxTagsPerRequest` tags (the endpoint 400s above its own cap), a\n * flush sends at most `maxRequestsPerFlush` of them, and the pending set is\n * capped. A 400-row publish costs ONE request, not 400.\n *\n * 3. **Whatever it drops, it says so.** Silent truncation reads as \"covered\n * everything\". Every cap logs what it shed and whether the shedding was\n * lossless.\n *\n * ## Two things the first cut got wrong, kept here because they are subtle\n *\n * **The interval has to be re-checked when the flush RUNS, not only when it is\n * scheduled.** The delay is computed from `lastFlushStartedAt` at schedule\n * time; if the previous request is slow, a flush queued behind it sits in the\n * chain while that value goes stale, and every `notify()` in the meantime\n * scheduled another one. Measured with a single 2.5 s request under a bulk\n * import: **11 requests in 203 ms**. `flushPending` is what makes it\n * impossible — it covers scheduled, queued AND running, so nothing stacks.\n *\n * **The steady-state rate is per PROCESS, and a deployment runs more than\n * one.** The CMS and the queue worker each construct their own sender from the\n * same config, and `docker compose up -d --scale worker=N` multiplies it\n * further, all against ONE process-wide token bucket at the receiving end. So\n * the interval alone cannot be the whole answer, and `Retry-After` is honoured\n * on a 429: a sender that is told to slow down does, which is what lets N\n * independent senders share one bucket without any of them coordinating.\n *\n * **It does not RETRY.** Backing off is not retrying — the batch that got a\n * 429 is still dropped. Retrying an unreachable frontend converts one lost\n * purge into a growing queue of them, all redundant the moment the site comes\n * back and re-renders, and the page TTL is already the reconciliation\n * mechanism for every dependency the tags cannot name. That is why that TTL\n * must not be zero.\n */\n\nimport type { ResolvedFrontendCacheConfig } from './config.js'\nimport { resolvePurgeTarget } from './config.js'\nimport { tagsForWrite, type WriteTagInput } from './tags.js'\n\n/** The slice of a structured logger this module uses. */\nexport interface PurgeLogger {\n warn(obj: Record<string, unknown>, msg: string): void\n debug(obj: Record<string, unknown>, msg: string): void\n}\n\n/** The response fields this module reads — everything `Response` already has. */\nexport interface PurgeResponse {\n readonly ok: boolean\n readonly status: number\n readonly headers: { get(name: string): string | null }\n text(): Promise<string>\n}\n\n/** `globalThis.fetch`, narrowed to what is used, so tests can substitute. */\nexport type FetchLike = (\n input: string,\n init: {\n method: string\n headers: Record<string, string>\n body: string\n signal: AbortSignal\n redirect: 'manual'\n },\n) => Promise<PurgeResponse>\n\nconst defaultFetch: FetchLike = (input, init) => fetch(input, init)\n\n/** A scheduled callback that can be cancelled. */\nexport interface PurgeTimer {\n clear(): void\n}\n\n/**\n * Injectable `setTimeout`, so the pacing can be tested by driving it rather\n * than by waiting. Fake timers plus a real promise chain is how you write a\n * scheduler test that passes for the wrong reason — this file had one.\n */\nexport type PurgeScheduler = (fn: () => void, delayMs: number) => PurgeTimer\n\nconst defaultScheduler: PurgeScheduler = (fn, delayMs) => {\n const handle = setTimeout(fn, delayMs)\n // Never hold a Next.js server or a queue worker open for a pending purge.\n handle.unref?.()\n return {\n clear: () => {\n clearTimeout(handle)\n },\n }\n}\n\nexport interface PurgeSenderDeps {\n readonly config: ResolvedFrontendCacheConfig\n readonly logger: PurgeLogger\n /** Defaults to `globalThis.fetch`. */\n readonly fetchImpl?: FetchLike\n /** Defaults to `Date.now`. Injected so the interval logic is testable without waiting. */\n readonly now?: () => number\n /** Defaults to an unref'd `setTimeout`. Injected so the pacing is testable. */\n readonly scheduler?: PurgeScheduler\n}\n\nexport interface PurgeSender {\n /** Record a write. Synchronous, allocation-cheap, never throws. */\n notify(event: WriteTagInput): void\n /**\n * Send whatever is pending right now, ignoring the pacing.\n *\n * For tests and shutdown ONLY — the interval exists to protect the receiving\n * rate limiter, and this is the one door through it.\n */\n flush(): Promise<void>\n /** Cancel any scheduled flush. Pending tags are dropped — the TTL reconciles. */\n stop(): void\n /** Tags awaiting a flush. Test/observability only. */\n readonly pendingCount: number\n}\n\n/**\n * Order tags so that dropping the tail is as lossless as it can be.\n *\n * `entity:`, `chrome:` and `menu:` go first; `doc:` last. A `doc:E:<id>` is\n * always accompanied by its `entity:E` (both are emitted on every write of a\n * routable entity), and the frontend's own definition of `entity:E` is \"every\n * document of one entity type\" — so shedding document tags loses no eviction,\n * while shedding a broad tag would. `chrome:` and `menu:` have no broader tag\n * at all, which is why they must never be the first casualty.\n */\nexport function prioritiseTags(tags: readonly string[]): readonly string[] {\n const broad: string[] = []\n const narrow: string[] = []\n for (const tag of tags) {\n if (tag.startsWith('doc:')) narrow.push(tag)\n else broad.push(tag)\n }\n return [...broad, ...narrow]\n}\n\nfunction chunk(items: readonly string[], size: number): readonly (readonly string[])[] {\n const out: string[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nfunction describe(error: unknown): string {\n return error instanceof Error ? error.message : String(error)\n}\n\n/**\n * A redirected purge, in either of the two shapes a runtime may report.\n *\n * `0` is the Fetch standard's opaque-redirect filtered response; `3xx` is what\n * Node's undici returns instead, deliberately, so a server-side caller can see\n * the status and `Location`. Node is where this runs, so `3xx` is the live one.\n */\nexport function isRedirect(status: number): boolean {\n return status === 0 || (status >= 300 && status < 400)\n}\n\n/** `Retry-After` in seconds, clamped. Absent/garbage/huge → a sane default. */\nexport function parseRetryAfterSeconds(raw: string | null, fallback: number, max: number): number {\n if (raw === null) return fallback\n const seconds = Number.parseInt(raw.trim(), 10)\n if (!Number.isFinite(seconds) || seconds < 0) return fallback\n return Math.min(seconds, max)\n}\n\n/** A misconfigured or hostile endpoint must not park the sender for a week. */\nconst MAX_BACKOFF_SECONDS = 300\nconst DEFAULT_BACKOFF_SECONDS = 5\n\nexport function createPurgeSender(deps: PurgeSenderDeps): PurgeSender {\n const { config, logger } = deps\n const now = deps.now ?? (() => Date.now())\n const doFetch = deps.fetchImpl ?? defaultFetch\n const schedulerImpl = deps.scheduler ?? defaultScheduler\n\n const pending = new Set<string>()\n let timer: PurgeTimer | undefined\n /** True from the moment a flush is scheduled until it has finished running. */\n let flushPending = false\n let lastFlushStartedAt = Number.NEGATIVE_INFINITY\n /** Set by a 429. The next flush waits at least this long. */\n let backoffUntil = Number.NEGATIVE_INFINITY\n /** One warning per full-buffer episode, not one per dropped write. */\n let bufferFullReported = false\n /**\n * Set by `stop()`, and separate from `flushPending` on purpose.\n *\n * Clearing `flushPending` in `stop()` broke the \"at most one flush is\n * scheduled, queued or running\" invariant while a flush was mid-await: a\n * `notify()` after `stop()` passed `schedule()`'s guard and armed a timer\n * under the still-running flush. Nothing calls `stop()` in production today,\n * which is exactly why the shutdown door should be right before something\n * does.\n */\n let stopped = false\n /** Serialises flushes so a slow one cannot overlap the next. Never rejects. */\n let inFlight: Promise<void> = Promise.resolve()\n\n /** The earliest a flush may START. */\n function nextAllowedAt(): number {\n return Math.max(lastFlushStartedAt + config.flushIntervalMs, backoffUntil)\n }\n\n function schedule(): void {\n // Covers scheduled, queued behind a slow request, AND running. Without the\n // last two a slow request lets every `notify()` stack another timer.\n if (stopped || timer !== undefined || flushPending) return\n const delay = Math.max(config.minBatchMs, nextAllowedAt() - now())\n timer = schedulerImpl(() => {\n timer = undefined\n void runPaced()\n }, delay)\n }\n\n /**\n * Make room for a broad tag by evicting a document tag, or report that we\n * cannot.\n *\n * 🔴 **Ordering, not just priority.** An earlier fix sorted each event's own\n * tags so document tags were shed first — which only helps if the broad tag\n * arrives while there is still room. It is the opposite case that matters: a\n * bulk import fills the buffer with `entity:page` plus 1999 document tags,\n * an editor saves the header mid-import, and its `chrome:*` tags are refused\n * wholesale. Those have NO broader fallback, so that header edit stays\n * invisible for the whole TTL — the exact failure the priority sort claimed\n * to prevent, reached by arriving second instead of first.\n *\n * The document tag being evicted is by construction redundant: `entity:E`\n * was admitted alongside it and already evicts every page it names.\n */\n function makeRoomForBroadTag(): boolean {\n for (const existing of pending) {\n if (existing.startsWith('doc:')) {\n pending.delete(existing)\n return true\n }\n }\n return false\n }\n\n function reportBufferFull(entityName: string, dropped: string): void {\n if (bufferFullReported) return\n bufferFullReported = true\n // Once per episode. A 10 000-row `deleteMany` loop never yields to the\n // event loop, so the buffer cannot drain inside it and a per-write warning\n // would emit ~19 000 lines from one call.\n logger.warn(\n { entity: entityName, pending: pending.size, cap: config.maxPendingTags, dropped },\n 'frontend-cache: pending purge-tag buffer is full; dropping tags until the next flush',\n )\n }\n\n function notify(event: WriteTagInput): void {\n // A stopped sender accumulating tags nothing will ever flush is a slow\n // leak with no upside — the buffer would fill, warn, and hold until the\n // process ends.\n if (stopped) return\n\n // Broad first, so that within ONE event the document tags are the ones\n // that hit a full buffer. Across events, `makeRoomForBroadTag` handles it.\n const tags = prioritiseTags(tagsForWrite(event, config))\n if (tags.length === 0) return\n\n for (const tag of tags) {\n if (pending.has(tag)) continue\n if (pending.size >= config.maxPendingTags) {\n const isBroad = !tag.startsWith('doc:')\n if (!isBroad || !makeRoomForBroadTag()) {\n reportBufferFull(event.entityName, tag)\n // A document tag that cannot get in is lossless (its `entity:` tag\n // is already pending). A broad one that cannot is not — and the only\n // way to reach that is a buffer holding nothing BUT broad tags, i.e.\n // more distinct entities, locales and menus than `maxPendingTags`.\n break\n }\n }\n pending.add(tag)\n }\n schedule()\n }\n\n async function send(\n target: { readonly url: string; readonly token: string },\n tags: readonly string[],\n ): Promise<void> {\n let response: PurgeResponse\n try {\n response = await doFetch(target.url, {\n method: 'POST',\n headers: {\n // Required, not optional: Astro's `security.checkOrigin` refuses a\n // cross-origin POST whose content type is absent or form-like with a\n // 403 BEFORE the handler runs, which reads exactly like a bad token.\n 'content-type': 'application/json',\n authorization: `Bearer ${target.token}`,\n },\n body: JSON.stringify({ tags }),\n signal: AbortSignal.timeout(config.requestTimeoutMs),\n // The purge endpoint has no legitimate reason to redirect, and a\n // bearer token should not chase one.\n redirect: 'manual',\n })\n } catch (error) {\n // The headline case: the site is down, mid-deploy, or the origin is\n // wrong. The publish already succeeded; this line is its only trace.\n logger.warn(\n { url: target.url, tags: tags.length, err: describe(error) },\n 'frontend-cache: purge request failed; the frontend keeps serving cached pages until their TTL expires',\n )\n return\n }\n\n if (response.ok) {\n // Drained even though nothing reads it: an unconsumed body keeps the\n // socket out of undici's keep-alive pool until GC, once per interval,\n // forever.\n await response.text().catch(() => '')\n logger.debug({ url: target.url, tags: tags.length }, 'frontend-cache: purged')\n return\n }\n\n if (response.status === 429) {\n // Honouring this is what lets the CMS and the worker — two independent\n // senders, one shared bucket — converge without coordinating.\n const seconds = parseRetryAfterSeconds(\n response.headers.get('retry-after'),\n DEFAULT_BACKOFF_SECONDS,\n MAX_BACKOFF_SECONDS,\n )\n backoffUntil = Math.max(backoffUntil, now() + seconds * 1000)\n }\n\n // Read the body so the connection is released, and so the operator gets\n // the endpoint's own reason rather than a bare status.\n let detail: string\n try {\n detail = (await response.text()).slice(0, 200)\n } catch (error) {\n detail = `<unreadable body: ${describe(error)}>`\n }\n\n // Three refusals that mean genuinely different things, kept apart so an\n // operator is not left guessing which one they have.\n const hint =\n response.status === 401\n ? 'the frontend rejected the token — check that CACHE_INVALIDATION_TOKEN matches on both services'\n : response.status === 429\n ? 'the frontend rate-limited this purge; this batch is dropped and the next is delayed'\n : response.status === 503\n ? 'the frontend has no token configured, or route caching is off on that deployment'\n : // A redirect, named rather than left as \"unexpected\" — it is the\n // most likely new misconfiguration this PR introduces\n // (FRONTEND_ORIGIN on `http://` behind a proxy that redirects to\n // https), and the one the sender would otherwise say least about.\n //\n // BOTH shapes, because they are not the same everywhere. The\n // Fetch standard says `redirect: 'manual'` yields an\n // opaque-redirect filtered response (status 0, no headers, no\n // body); Node's undici deliberately deviates and hands back the\n // real 3xx so a server-side caller can inspect it. This runs on\n // Node, so 3xx is the case that actually fires; status 0 covers\n // a spec-conformant implementation.\n isRedirect(response.status)\n ? 'the frontend redirected the purge, which is never legitimate — FRONTEND_ORIGIN probably names a scheme or host the site redirects away from'\n : 'unexpected response'\n logger.warn(\n { url: target.url, status: response.status, tags: tags.length, detail },\n `frontend-cache: purge refused — ${hint}`,\n )\n }\n\n async function runFlush(): Promise<void> {\n if (pending.size === 0) return\n lastFlushStartedAt = now()\n\n const tags = prioritiseTags([...pending])\n pending.clear()\n bufferFullReported = false\n\n const target = resolvePurgeTarget(config)\n if (!target.ok) {\n logger.warn(\n { reason: target.why, tags: tags.length },\n 'frontend-cache: not configured, so nothing was purged',\n )\n return\n }\n\n const capacity = config.maxTagsPerRequest * config.maxRequestsPerFlush\n const selected = tags.slice(0, capacity)\n if (tags.length > selected.length) {\n const broadCount = tags.length - tags.filter((t) => t.startsWith('doc:')).length\n const lossless = selected.length >= broadCount\n logger.warn(\n { dropped: tags.length - selected.length, sent: selected.length, lossless },\n lossless\n ? 'frontend-cache: purge batch capped; only document tags were shed, each already covered by its entity tag'\n : 'frontend-cache: purge batch capped and BROAD tags were shed — some pages will stay cached until their TTL expires',\n )\n }\n\n const batches = chunk(selected, config.maxTagsPerRequest)\n for (const [i, batch] of batches.entries()) {\n // 🔴 Re-checked INSIDE the loop, not only between flushes. `backoffUntil`\n // is set by a 429 from the batch we just sent; without this the next\n // batch goes out immediately and spends another token from the bucket\n // that just said stop — up to `maxRequestsPerFlush` of them. Latent at\n // the shipped default of 1, and the config docs explicitly tell an\n // operator when to raise it, which is exactly when the backoff would\n // have been silently defeated.\n if (now() < backoffUntil) {\n logger.warn(\n { dropped: batches.length - i, remainingTags: selected.length - i * config.maxTagsPerRequest },\n 'frontend-cache: rate limited mid-flush; abandoning the rest of this batch rather than spending more of the bucket',\n )\n break\n }\n await send(target, batch)\n }\n }\n\n /** Chain `run` onto the serialising queue, guaranteeing it never rejects. */\n function enqueue(run: () => Promise<void>): Promise<void> {\n inFlight = inFlight.then(run, run).catch((error: unknown) => {\n try {\n logger.warn({ err: describe(error) }, 'frontend-cache: purge flush failed unexpectedly')\n } catch {\n // A logger that throws is the one thing left that could surface as an\n // unhandled rejection out of the fire-and-forget timer path, and a\n // cache purge must not be able to take the process down. Nothing to\n // report it WITH, which is exactly why this is empty.\n }\n })\n return inFlight\n }\n\n /** The timer path: re-checks the pacing at RUN time, not only at schedule time. */\n function runPaced(): Promise<void> {\n flushPending = true\n return enqueue(async () => {\n try {\n // The stale-`lastFlushStartedAt` window. By the time this runs, a slow\n // predecessor may have moved the goalposts.\n if (now() < nextAllowedAt()) return\n await runFlush()\n } finally {\n flushPending = false\n if (pending.size > 0) schedule()\n }\n })\n }\n\n return {\n notify,\n flush: () => enqueue(runFlush),\n stop() {\n stopped = true\n if (timer !== undefined) {\n timer.clear()\n timer = undefined\n }\n // `flushPending` is deliberately NOT cleared: a flush may be mid-await,\n // and its own `finally` owns that flag. `stopped` is what keeps the\n // sender from arming another timer once it lands.\n pending.clear()\n },\n get pendingCount() {\n return pending.size\n },\n }\n}\n"],"mappings":"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"}