@murumets-ee/frontend-cache 0.90.0 → 0.92.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.
- package/dist/{config-OULQXyhn.d.mts → config-5I8MVd6s.d.mts} +121 -3
- package/dist/config-5I8MVd6s.d.mts.map +1 -0
- package/dist/index.d.mts +75 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/plugin.d.mts +21 -1
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/plugin.mjs.map +1 -1
- package/dist/sender-C_R_H6eC.mjs +2 -0
- package/dist/sender-C_R_H6eC.mjs.map +1 -0
- package/package.json +3 -3
- package/dist/config-OULQXyhn.d.mts.map +0 -1
- package/dist/sender-xWC7aWIl.mjs +0 -2
- package/dist/sender-xWC7aWIl.mjs.map +0 -1
|
@@ -33,6 +33,25 @@ declare const FRONTEND_ORIGIN_ENV = "FRONTEND_ORIGIN";
|
|
|
33
33
|
* nobody remembers to keep in sync is a purge that silently 401s.
|
|
34
34
|
*/
|
|
35
35
|
declare const CACHE_INVALIDATION_TOKEN_ENV = "CACHE_INVALIDATION_TOKEN";
|
|
36
|
+
/**
|
|
37
|
+
* Environment variable naming the sites that each have their OWN frontend to
|
|
38
|
+
* purge — comma-separated site handles, e.g. `kulli,davatoidud`.
|
|
39
|
+
*
|
|
40
|
+
* Read only when `frontendCache({ sites })` is omitted, so that adding a site
|
|
41
|
+
* to a running deployment is an environment change and nothing else. Each
|
|
42
|
+
* handle then reads its own origin and token — see {@link siteEnvName}.
|
|
43
|
+
*/
|
|
44
|
+
declare const FRONTEND_CACHE_SITES_ENV = "FRONTEND_CACHE_SITES";
|
|
45
|
+
/**
|
|
46
|
+
* The most purge targets one plugin will send to. Targets configured beyond
|
|
47
|
+
* it are dropped at resolve time and reported in `problems`.
|
|
48
|
+
*
|
|
49
|
+
* A write to a SHARED entity (a media row) goes to every target, so this is
|
|
50
|
+
* the fan-out bound for one write — and the number of sites is the kind of N
|
|
51
|
+
* that grows with the customer. Sixteen is well above any deployment this
|
|
52
|
+
* toolkit runs; past it the answer is a purge hub, not a longer loop.
|
|
53
|
+
*/
|
|
54
|
+
declare const MAX_PURGE_TARGETS = 16;
|
|
36
55
|
/** Default path of the frontend's invalidation endpoint. */
|
|
37
56
|
declare const DEFAULT_INVALIDATE_PATH = "/api/invalidate";
|
|
38
57
|
/**
|
|
@@ -59,6 +78,23 @@ declare const DEFAULT_CHROME_ENTITIES: readonly ["site_chrome"];
|
|
|
59
78
|
declare const DEFAULT_MENU_ENTITIES: {
|
|
60
79
|
readonly menu: "handle";
|
|
61
80
|
};
|
|
81
|
+
/**
|
|
82
|
+
* Entities holding a stored DETAIL TEMPLATE (block-library D009), and the field
|
|
83
|
+
* naming the entity each row draws. `@murumets-ee/content`'s `detail_template`
|
|
84
|
+
* by default.
|
|
85
|
+
*
|
|
86
|
+
* A template is not routable — nothing addresses it at a URL — so without this
|
|
87
|
+
* a publish of "Room detail" would send no tag at all, and every room page
|
|
88
|
+
* would keep the old layout until its route TTL expired. With it, a template
|
|
89
|
+
* write sends `entity:<target>`, the tag the frontend already mints onto every
|
|
90
|
+
* document page of that type.
|
|
91
|
+
*
|
|
92
|
+
* The collection tag, deliberately, and not a per-row one: one template draws
|
|
93
|
+
* EVERY row of its target, so every one of those pages changed.
|
|
94
|
+
*/
|
|
95
|
+
declare const DEFAULT_TEMPLATE_ENTITIES: {
|
|
96
|
+
readonly detail_template: "target";
|
|
97
|
+
};
|
|
62
98
|
/**
|
|
63
99
|
* At most one request per second in the steady state — exactly the receiving
|
|
64
100
|
* bucket's refill rate (`PURGE_RATE_LIMIT.refillPerSecond`), so a sustained
|
|
@@ -91,6 +127,31 @@ declare const DEFAULT_MAX_TAGS_PER_REQUEST = 50;
|
|
|
91
127
|
declare const DEFAULT_MAX_REQUESTS_PER_FLUSH = 1;
|
|
92
128
|
/** A purge is a background nicety; it must never sit on a socket. */
|
|
93
129
|
declare const DEFAULT_REQUEST_TIMEOUT_MS = 3000;
|
|
130
|
+
/**
|
|
131
|
+
* One site's frontend, as a purge destination (plan `multisite`).
|
|
132
|
+
*
|
|
133
|
+
* Each site of a multisite app is served by its own frontend process with its
|
|
134
|
+
* own cache, so each is purged separately: a write to a site-bound row goes
|
|
135
|
+
* to that site's target and no other, and a write to a shared row goes to all
|
|
136
|
+
* of them.
|
|
137
|
+
*/
|
|
138
|
+
interface FrontendCacheSiteTarget {
|
|
139
|
+
/** The site handle, as stored in the site registry (e.g. `'kulli'`). */
|
|
140
|
+
readonly site: string;
|
|
141
|
+
/**
|
|
142
|
+
* This site's frontend origin. Defaults to `process.env.FRONTEND_ORIGIN_<HANDLE>`
|
|
143
|
+
* — the handle upper-cased with `-` written as `_` (see {@link siteEnvName}).
|
|
144
|
+
*/
|
|
145
|
+
readonly origin?: string;
|
|
146
|
+
/** This site's bearer token. Defaults to `process.env.CACHE_INVALIDATION_TOKEN_<HANDLE>`. */
|
|
147
|
+
readonly token?: string;
|
|
148
|
+
/**
|
|
149
|
+
* The locales THIS site serves, when they differ from the plugin-wide
|
|
150
|
+
* `locales`. Chrome and menu tags are emitted per locale; a site that serves
|
|
151
|
+
* fewer should not be sent tags for the rest.
|
|
152
|
+
*/
|
|
153
|
+
readonly locales?: readonly string[];
|
|
154
|
+
}
|
|
94
155
|
interface FrontendCacheConfig {
|
|
95
156
|
/**
|
|
96
157
|
* The locales this site serves — **the same list passed to
|
|
@@ -109,6 +170,17 @@ interface FrontendCacheConfig {
|
|
|
109
170
|
readonly token?: string;
|
|
110
171
|
/** Endpoint path on the frontend. Defaults to `/api/invalidate`. */
|
|
111
172
|
readonly path?: string;
|
|
173
|
+
/**
|
|
174
|
+
* One purge target per site, for an app that serves several sites from
|
|
175
|
+
* several frontends. Defaults to the handles listed in
|
|
176
|
+
* `process.env.FRONTEND_CACHE_SITES`; with neither, the plugin has the ONE
|
|
177
|
+
* target `origin` / `token` describe and sends it every write.
|
|
178
|
+
*
|
|
179
|
+
* At most {@link MAX_PURGE_TARGETS}. `origin` / `token` above are not used
|
|
180
|
+
* once any site target is in force — except by a target whose handle is
|
|
181
|
+
* `default`, which falls back to the unsuffixed environment variables.
|
|
182
|
+
*/
|
|
183
|
+
readonly sites?: readonly FrontendCacheSiteTarget[];
|
|
112
184
|
/**
|
|
113
185
|
* Entities whose write invalidates site chrome. Defaults to
|
|
114
186
|
* {@link DEFAULT_CHROME_ENTITIES} — `['site_chrome']`.
|
|
@@ -116,6 +188,11 @@ interface FrontendCacheConfig {
|
|
|
116
188
|
readonly chromeEntities?: readonly string[];
|
|
117
189
|
/** Entity name → the field holding its menu handle. Defaults to `{ menu: 'handle' }`. */
|
|
118
190
|
readonly menuEntities?: Readonly<Record<string, string>>;
|
|
191
|
+
/**
|
|
192
|
+
* Entity name → the field holding the entity its rows draw. Defaults to
|
|
193
|
+
* `{ detail_template: 'target' }`. See {@link DEFAULT_TEMPLATE_ENTITIES}.
|
|
194
|
+
*/
|
|
195
|
+
readonly templateEntities?: Readonly<Record<string, string>>;
|
|
119
196
|
/**
|
|
120
197
|
* Entities a page can EMBED in a block tree. Defaults to `['media']`.
|
|
121
198
|
* See {@link DEFAULT_EMBEDDED_ENTITIES}.
|
|
@@ -128,11 +205,27 @@ interface FrontendCacheConfig {
|
|
|
128
205
|
readonly requestTimeoutMs?: number;
|
|
129
206
|
readonly maxPendingTags?: number;
|
|
130
207
|
}
|
|
208
|
+
/** A {@link FrontendCacheSiteTarget} after validation. */
|
|
209
|
+
interface ResolvedFrontendCacheSiteTarget {
|
|
210
|
+
readonly site: string;
|
|
211
|
+
/** Explicit overrides; `undefined` means "read the environment at send time". */
|
|
212
|
+
readonly originOverride: string | undefined;
|
|
213
|
+
readonly tokenOverride: string | undefined;
|
|
214
|
+
/** `undefined` means "the plugin-wide `locales`". */
|
|
215
|
+
readonly locales: readonly string[] | undefined;
|
|
216
|
+
}
|
|
131
217
|
interface ResolvedFrontendCacheConfig {
|
|
132
218
|
readonly locales: readonly string[];
|
|
219
|
+
/**
|
|
220
|
+
* The per-site targets. EMPTY means single-target mode: one destination from
|
|
221
|
+
* `originOverride` / `tokenOverride` / the unsuffixed environment, receiving
|
|
222
|
+
* every write whatever site it belongs to.
|
|
223
|
+
*/
|
|
224
|
+
readonly sites: readonly ResolvedFrontendCacheSiteTarget[];
|
|
133
225
|
readonly path: string;
|
|
134
226
|
readonly chromeEntities: readonly string[];
|
|
135
227
|
readonly menuEntities: Readonly<Record<string, string>>;
|
|
228
|
+
readonly templateEntities: Readonly<Record<string, string>>;
|
|
136
229
|
readonly embeddedEntities: readonly string[];
|
|
137
230
|
readonly flushIntervalMs: number;
|
|
138
231
|
readonly minBatchMs: number;
|
|
@@ -165,6 +258,16 @@ declare function parseOrigin(raw: string): {
|
|
|
165
258
|
ok: false;
|
|
166
259
|
why: string;
|
|
167
260
|
};
|
|
261
|
+
/**
|
|
262
|
+
* The per-site form of an environment variable name: `FRONTEND_ORIGIN` for
|
|
263
|
+
* handle `front-two` is `FRONTEND_ORIGIN_FRONT_TWO`.
|
|
264
|
+
*
|
|
265
|
+
* A handle is `[a-z][a-z0-9-]*`, so upper-casing and writing `-` as `_` always
|
|
266
|
+
* yields a legal variable name, and two distinct handles can only collide if
|
|
267
|
+
* they differ solely in where a `-` sits against nothing — which the handle
|
|
268
|
+
* pattern does not allow.
|
|
269
|
+
*/
|
|
270
|
+
declare function siteEnvName(base: string, site: string): string;
|
|
168
271
|
declare function resolveFrontendCacheConfig(config: FrontendCacheConfig): ResolvedFrontendCacheConfig;
|
|
169
272
|
/** What the sender needs to actually reach the frontend, or why it cannot. */
|
|
170
273
|
type PurgeTarget = {
|
|
@@ -183,7 +286,22 @@ type PurgeTarget = {
|
|
|
183
286
|
* environment on each flush costs nothing and removes an ordering dependency
|
|
184
287
|
* that would present as "purging silently stopped working after a deploy".
|
|
185
288
|
*/
|
|
186
|
-
declare function resolvePurgeTarget(config: ResolvedFrontendCacheConfig
|
|
289
|
+
declare function resolvePurgeTarget(config: ResolvedFrontendCacheConfig,
|
|
290
|
+
/**
|
|
291
|
+
* The site target to resolve. Omitted for single-target mode, which reads
|
|
292
|
+
* the plugin-wide overrides and the unsuffixed environment variables.
|
|
293
|
+
*/
|
|
294
|
+
site?: ResolvedFrontendCacheSiteTarget): PurgeTarget;
|
|
295
|
+
/**
|
|
296
|
+
* The environment variables one target reads — the unsuffixed pair for
|
|
297
|
+
* single-target mode, the per-site pair otherwise. Exists so the messages that
|
|
298
|
+
* tell an operator which variable to fix name the one this target actually
|
|
299
|
+
* reads.
|
|
300
|
+
*/
|
|
301
|
+
declare function purgeTargetEnvNames(site: string | undefined): {
|
|
302
|
+
readonly origin: string;
|
|
303
|
+
readonly token: string;
|
|
304
|
+
};
|
|
187
305
|
//#endregion
|
|
188
|
-
export { DEFAULT_MAX_REQUESTS_PER_FLUSH as a, DEFAULT_MIN_BATCH_MS as c,
|
|
189
|
-
//# sourceMappingURL=config-
|
|
306
|
+
export { siteEnvName as C, resolvePurgeTarget as S, ResolvedFrontendCacheConfig as _, DEFAULT_MAX_REQUESTS_PER_FLUSH as a, purgeTargetEnvNames as b, DEFAULT_MIN_BATCH_MS as c, FRONTEND_CACHE_SITES_ENV as d, FRONTEND_ORIGIN_ENV as f, PurgeTarget as g, MAX_PURGE_TARGETS as h, DEFAULT_INVALIDATE_PATH as i, DEFAULT_REQUEST_TIMEOUT_MS as l, FrontendCacheSiteTarget as m, DEFAULT_CHROME_ENTITIES as n, DEFAULT_MAX_TAGS_PER_REQUEST as o, FrontendCacheConfig as p, DEFAULT_FLUSH_INTERVAL_MS as r, DEFAULT_MENU_ENTITIES as s, CACHE_INVALIDATION_TOKEN_ENV as t, DEFAULT_TEMPLATE_ENTITIES as u, ResolvedFrontendCacheSiteTarget as v, resolveFrontendCacheConfig as x, parseOrigin as y };
|
|
307
|
+
//# sourceMappingURL=config-5I8MVd6s.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config-5I8MVd6s.d.mts","names":[],"sources":["../src/config.ts"],"mappings":";;AA4BA;;;;AAAgC;AAShC;;;;AAAyC;AAUzC;;;;AAAqC;AAWrC;;;;AAA8B;AAG9B;;;;cAjCa,mBAAA;AAsDb;;;;AAA+D;AAoC/D;;AApCA,cA7Ca,4BAAA;;;AAiGb;;;;;AAOA;cA9Fa,wBAAA;;;AA8FyB;AAOtC;;;;AAAiC;AAGjC;cA7Fa,iBAAA;;cAGA,uBAAA;AA0F4B;AAiBzC;;;;AAA2C;AAG3C;;;;AAAuC;AAgBvC;;;;;;;;AApCyC,cArE5B,uBAAA;AA8KY;AAAA,cA1IZ,qBAAA;EAAA,SAAmD,IAAA;AAAA;;;;;;;AAoJ9C;AAGlB;;;;;;;cAvIa,yBAAA;EAAA,SAAkE,eAAA;AAAA;;;;;;cAOlE,yBAAA;;;;;;cAOA,oBAAA;;cAGA,4BAAA;;;;;;;;;AAiJM;AAoDnB;;;;;;cApLa,8BAAA;;cAGA,0BAAA;;AA0MyC;AAqItD;;;;;;UA/TiB,uBAAA;EAiUa;EAAA,SA/TnB,IAAA;EA6eC;;;;EAAA,SAxeD,MAAA;EAyeuB;EAAA,SAvevB,KAAA;EAweI;;;AAAuB;AAUtC;EAVe,SAleJ,OAAA;AAAA;AAAA,UAGM,mBAAA;EA+eR;;;;;;;;;;EAAA,SApeE,OAAA;EAohBK;EAAA,SAlhBL,MAAA;;WAEA,KAAA;EAghByB;EAAA,SA9gBzB,IAAA;EAghBA;;AAAK;;;;;;;;EAAL,SArgBA,KAAA,YAAiB,uBAAA;;;;;WAKjB,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;;UAIM,+BAAA;EAAA,SACN,IAAA;;WAEA,cAAA;EAAA,SACA,aAAA;;WAEA,OAAA;AAAA;AAAA,UAGM,2BAAA;EAAA,SACN,OAAA;;;;;;WAMA,KAAA,WAAgB,+BAAA;EAAA,SAChB,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;;;;;;;;;;iBAuB/B,WAAA,CAAY,IAAA,UAAc,IAAY;AAAA,iBAqItC,0BAAA,CACd,MAAA,EAAQ,mBAAA,GACP,2BAA2B;;KA8KlB,WAAA;EAAA,SACG,EAAA;EAAA,SAAmB,GAAA;EAAA,SAAsB,KAAA;AAAA;EAAA,SACzC,EAAA;EAAA,SAAoB,GAAA;AAAA;;;;;;;;;iBAUnB,kBAAA,CACd,MAAA,EAAQ,2BAAA;;;;;AAKR,IAAA,GAAO,+BAAA,GACN,WAAA;;;;;;;iBA+Ca,mBAAA,CAAoB,IAAA;EAAA,SACzB,MAAA;EAAA,SACA,KAAA;AAAA"}
|
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
|
|
1
|
+
import { C as siteEnvName, S as resolvePurgeTarget, _ as ResolvedFrontendCacheConfig, a as DEFAULT_MAX_REQUESTS_PER_FLUSH, b as purgeTargetEnvNames, c as DEFAULT_MIN_BATCH_MS, d as FRONTEND_CACHE_SITES_ENV, f as FRONTEND_ORIGIN_ENV, g as PurgeTarget, h as MAX_PURGE_TARGETS, i as DEFAULT_INVALIDATE_PATH, l as DEFAULT_REQUEST_TIMEOUT_MS, m as FrontendCacheSiteTarget, n as DEFAULT_CHROME_ENTITIES, o as DEFAULT_MAX_TAGS_PER_REQUEST, p as FrontendCacheConfig, r as DEFAULT_FLUSH_INTERVAL_MS, s as DEFAULT_MENU_ENTITIES, t as CACHE_INVALIDATION_TOKEN_ENV, u as DEFAULT_TEMPLATE_ENTITIES, v as ResolvedFrontendCacheSiteTarget, x as resolveFrontendCacheConfig, y as parseOrigin } from "./config-5I8MVd6s.mjs";
|
|
2
2
|
import { EntityWriteObserver } from "@murumets-ee/entity/admin";
|
|
3
3
|
|
|
4
4
|
//#region src/observer-slot.d.ts
|
|
@@ -42,6 +42,25 @@ declare function documentTag(entity: string, id: string): string | undefined;
|
|
|
42
42
|
declare function chromeTag(locale: string): string | undefined;
|
|
43
43
|
/** One menu, per locale — a menu's rendered items are locale-specific. */
|
|
44
44
|
declare function menuTag(handle: string, locale: string): string | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* The one tag EVERY stored response of a frontend carries — pages and sitemaps
|
|
47
|
+
* alike — so naming it evicts the whole of that frontend's route cache in one
|
|
48
|
+
* request (multisite F021).
|
|
49
|
+
*
|
|
50
|
+
* It exists for the changes no content tag can name: a site's languages,
|
|
51
|
+
* default language and URL strategy are baked into every rendered link,
|
|
52
|
+
* `hreflang` set and sitemap entry, and none of those is a row a page was
|
|
53
|
+
* tagged with. Never emitted by {@link tagsForWrite} — a content write has
|
|
54
|
+
* narrower tags, and this one costs a full re-render of the site.
|
|
55
|
+
*
|
|
56
|
+
* Carries no site handle, for the reason `WriteTagInput.site` gives: each
|
|
57
|
+
* frontend serves one site, so WHICH site is decided by which frontend is
|
|
58
|
+
* told, never by the tag.
|
|
59
|
+
*
|
|
60
|
+
* Counterpart: `SITE_TAG` in `apps/front-astro/src/lib/cache-tags.ts`. Both
|
|
61
|
+
* test files pin the literal.
|
|
62
|
+
*/
|
|
63
|
+
declare const SITE_TAG = "site";
|
|
45
64
|
/** The subset of an entity-write event this module needs. */
|
|
46
65
|
interface WriteTagInput {
|
|
47
66
|
readonly entityName: string;
|
|
@@ -55,6 +74,13 @@ interface WriteTagInput {
|
|
|
55
74
|
readonly routable: boolean;
|
|
56
75
|
readonly row?: Record<string, unknown> | undefined;
|
|
57
76
|
readonly locale?: string | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* The site the written row belongs to — set for a site-bound entity, absent
|
|
79
|
+
* for a shared one. It ROUTES the purge (which frontend is told) and is
|
|
80
|
+
* never part of a tag: each frontend serves one site, so the vocabulary
|
|
81
|
+
* above needs no site in it.
|
|
82
|
+
*/
|
|
83
|
+
readonly site?: string | undefined;
|
|
58
84
|
}
|
|
59
85
|
/**
|
|
60
86
|
* The tags one entity write should purge.
|
|
@@ -118,9 +144,39 @@ interface PurgeSenderDeps {
|
|
|
118
144
|
/** Defaults to an unref'd `setTimeout`. Injected so the pacing is testable. */
|
|
119
145
|
readonly scheduler?: PurgeScheduler;
|
|
120
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* What became of a site-wide purge request. `queued` means the tag is in the
|
|
149
|
+
* lane and will go out with its next paced flush — NOT that the frontend
|
|
150
|
+
* accepted it; like every purge here, the send itself only ever ends in a log
|
|
151
|
+
* line.
|
|
152
|
+
*
|
|
153
|
+
* - `not-configured` — the target has no usable origin or token.
|
|
154
|
+
* - `site-required` — several targets are configured and no site was named.
|
|
155
|
+
* Never widened to "all of them": a change to one site is not a reason to
|
|
156
|
+
* re-render every other site.
|
|
157
|
+
* - `no-target` — the named site has no purge target.
|
|
158
|
+
* - `stopped` — the sender was shut down.
|
|
159
|
+
*/
|
|
160
|
+
type SitePurgeResult = {
|
|
161
|
+
readonly queued: true;
|
|
162
|
+
} | {
|
|
163
|
+
readonly queued: false;
|
|
164
|
+
readonly reason: 'not-configured' | 'site-required' | 'no-target' | 'stopped';
|
|
165
|
+
};
|
|
121
166
|
interface PurgeSender {
|
|
122
167
|
/** Record a write. Synchronous, allocation-cheap, never throws. */
|
|
123
168
|
notify(event: WriteTagInput): void;
|
|
169
|
+
/**
|
|
170
|
+
* Evict EVERYTHING one site's frontend has cached, by queueing the one tag
|
|
171
|
+
* every stored response carries ({@link SITE_TAG}). For a change no content
|
|
172
|
+
* tag can name — the site's languages, default language or URL strategy.
|
|
173
|
+
*
|
|
174
|
+
* Synchronous like `notify()`, and it goes out through the same lane: same
|
|
175
|
+
* destination, token, pacing, backoff and log lines. `site` selects the
|
|
176
|
+
* target when several are configured; with a single target it is ignored,
|
|
177
|
+
* because that one frontend receives everything. `trigger` is only logged.
|
|
178
|
+
*/
|
|
179
|
+
purgeSite(site: string | undefined, trigger: string): SitePurgeResult;
|
|
124
180
|
/**
|
|
125
181
|
* Send whatever is pending right now, ignoring the pacing.
|
|
126
182
|
*
|
|
@@ -146,7 +202,24 @@ interface PurgeSender {
|
|
|
146
202
|
declare function prioritiseTags(tags: readonly string[]): readonly string[];
|
|
147
203
|
/** `Retry-After` in seconds, clamped. Absent/garbage/huge → a sane default. */
|
|
148
204
|
declare function parseRetryAfterSeconds(raw: string | null, fallback: number, max: number): number;
|
|
205
|
+
/**
|
|
206
|
+
* Flushes in flight across ALL lanes at once.
|
|
207
|
+
*
|
|
208
|
+
* Not 1, deliberately. Serialising every site through one chain is the
|
|
209
|
+
* simplest bound, and it makes a dead frontend everybody's problem: each of
|
|
210
|
+
* its flushes holds the chain for the full `requestTimeoutMs`, once per
|
|
211
|
+
* interval, so every other site's purge would sit behind a 3 s timeout for as
|
|
212
|
+
* long as that one site stayed down — the cross-site delay per-lane state
|
|
213
|
+
* exists to prevent.
|
|
214
|
+
*
|
|
215
|
+
* Not unbounded either. Each lane already has at most one flush in flight, so
|
|
216
|
+
* the ceiling without this would be the lane count; four keeps the worst case
|
|
217
|
+
* (a shared-entity write waking every lane together) to a handful of sockets,
|
|
218
|
+
* while it takes four simultaneously hung frontends to delay a healthy one —
|
|
219
|
+
* and then only by one request timeout.
|
|
220
|
+
*/
|
|
221
|
+
declare const MAX_CONCURRENT_PURGES = 4;
|
|
149
222
|
declare function createPurgeSender(deps: PurgeSenderDeps): PurgeSender;
|
|
150
223
|
//#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 };
|
|
224
|
+
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_CACHE_SITES_ENV, FRONTEND_ORIGIN_ENV, type FetchLike, type FrontendCacheConfig, type FrontendCacheSiteTarget, MAX_CONCURRENT_PURGES, MAX_PURGE_TARGETS, MAX_TAGS_PER_EVENT, type PurgeLogger, type PurgeResponse, type PurgeScheduler, type PurgeSender, type PurgeSenderDeps, type PurgeTarget, type PurgeTimer, type ResolvedFrontendCacheConfig, type ResolvedFrontendCacheSiteTarget, SITE_TAG, type SitePurgeResult, type WriteTagInput, chromeTag, createPurgeSender, documentTag, entityTag, getActiveEntityWriteObserver, menuTag, normalizeTag, parseOrigin, parseRetryAfterSeconds, prioritiseTags, purgeTargetEnvNames, resolveFrontendCacheConfig, resolvePurgeTarget, setActiveEntityWriteObserver, siteEnvName, tagsForWrite };
|
|
152
225
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/observer-slot.ts","../src/tags.ts","../src/sender.ts"],"mappings":";;;;
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/observer-slot.ts","../src/tags.ts","../src/sender.ts"],"mappings":";;;;ACuCA;;;;AAAiC;AAGjC;;;AAHA,iBDTgB,4BAAA,CAA6B,QAAyC,EAA/B,mBAAmB;ACY3C;AAAA,iBDHf,4BAAA,CAAA,GAAgC,mBAAmB;;;;ACa3B;AAkCxC;;;;AAAwC;cA/C3B,oBAAA;;cAGA,kBAAA;;AAiDyC;AAKtD;;;;AAAwC;AAKxC;iBAjDgB,YAAA,CAAa,GAAW;;iBAkCxB,SAAA,CAAU,MAAc;AAec;AAAA,iBAVtC,WAAA,CAAY,MAAA,UAAgB,EAAU;;iBAKtC,SAAA,CAAU,MAAc;;iBAKxB,OAAA,CAAQ,MAAA,UAAgB,MAAc;AAyBtD;;;;;;;;;;;;;AAkBe;AAoBf;;;;AAtCA,cAHa,QAAA;;UAGI,aAAA;EAAA,SACN,UAAA;EAAA,SACA,MAAA;EAAA,SACA,EAAA;;;;AC5DX;;WDkEW,QAAA;EAAA,SACA,GAAA,GAAM,MAAM;EAAA,SACZ,MAAA;ECnEC;;;;;;EAAA,SD0ED,IAAA;AAAA;ACzEsC;AAIjD;;;;;;;;;;;;AAIiB;AAIjB;;;AAZiD,iBD6FjC,YAAA,CACd,KAAA,EAAO,aAAA,EACP,MAAA,EAAQ,2BAA2B;;;;UCjGpB,WAAA;EACf,IAAA,CAAK,GAAA,EAAK,MAAA,mBAAyB,GAAA;EACnC,KAAA,CAAM,GAAA,EAAK,MAAM,mBAAmB,GAAA;AAAA;ADyEvB;AAAA,UCrEE,aAAA;EAAA,SACN,EAAA;EAAA,SACA,MAAA;EAAA,SACA,OAAA;IAAW,GAAA,CAAI,IAAA;EAAA;EACxB,IAAA,IAAQ,OAAO;AAAA;;KAIL,SAAA,IACV,KAAA,UACA,IAAA;EACE,MAAA;EACA,OAAA,EAAS,MAAA;EACT,IAAA;EACA,MAAA,EAAQ,WAAA;EACR,QAAA;AAAA,MAEC,OAAA,CAAQ,aAAA;;UAKI,UAAA;EACf,KAAK;AAAA;;;;;;KAQK,cAAA,IAAkB,EAAA,cAAgB,OAAA,aAAoB,UAAU;AAAA,UAa3D,eAAA;EAAA,SACN,MAAA,EAAQ,2BAAA;EAAA,SACR,MAAA,EAAQ,WAAA;;WAER,SAAA,GAAY,SAAA;EA/CZ;EAAA,SAiDA,GAAA;EA/CA;EAAA,SAiDA,SAAA,GAAY,cAAA;AAAA;;;;AAhDN;AAIjB;;;;;;;;;KA4DY,eAAA;EAAA,SACG,MAAA;AAAA;EAAA,SAEA,MAAA;EAAA,SACA,MAAA;AAAA;AAAA,UAGE,WAAA;EA5Db;EA8DF,MAAA,CAAO,KAAA,EAAO,aAAA;EA5DX;;;AAAqB;AAK1B;;;;AACO;AAQP;EAyDE,SAAA,CAAU,IAAA,sBAA0B,OAAA,WAAkB,eAAA;;;;;;;EAOtD,KAAA,IAAS,OAAA;EAnDM;EAqDf,IAAA;;WAES,YAAA;AAAA;;;;;;;;;;;iBAaK,cAAA,CAAe,IAAuB;;iBAgCtC,sBAAA,CAAuB,GAAA,iBAAoB,QAAA,UAAkB,GAAA;;;AAxExD;AAGrB;;;;;;;;;;;;;cAgGa,qBAAA;AAAA,iBAwCG,iBAAA,CAAkB,IAAA,EAAM,eAAA,GAAkB,WAAW"}
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{A as e,C as t,D as n,E as r,M as i,N as a,O as o,S as s,T as c,_ as l,a as u,b as d,c as f,d as p,f as m,g as h,h as g,i as _,j as v,k as y,l as b,m as x,n as S,o as C,p as w,r as T,s as E,t as D,u as O,v as k,w as A,x as j,y as M}from"./sender-C_R_H6eC.mjs";export{h as CACHE_INVALIDATION_TOKEN_ENV,u as CACHE_TAG_MAX_LENGTH,l as DEFAULT_CHROME_ENTITIES,k as DEFAULT_FLUSH_INTERVAL_MS,M as DEFAULT_INVALIDATE_PATH,d as DEFAULT_MAX_REQUESTS_PER_FLUSH,j as DEFAULT_MAX_TAGS_PER_REQUEST,s as DEFAULT_MENU_ENTITIES,t as DEFAULT_MIN_BATCH_MS,A as DEFAULT_REQUEST_TIMEOUT_MS,c as DEFAULT_TEMPLATE_ENTITIES,r as FRONTEND_CACHE_SITES_ENV,n as FRONTEND_ORIGIN_ENV,D as MAX_CONCURRENT_PURGES,o as MAX_PURGE_TARGETS,C as MAX_TAGS_PER_EVENT,E as SITE_TAG,f as chromeTag,S as createPurgeSender,b as documentTag,O as entityTag,x as getActiveEntityWriteObserver,p as menuTag,m as normalizeTag,y as parseOrigin,T as parseRetryAfterSeconds,_ as prioritiseTags,e as purgeTargetEnvNames,v as resolveFrontendCacheConfig,i as resolvePurgeTarget,g as setActiveEntityWriteObserver,a as siteEnvName,w as tagsForWrite};
|
package/dist/plugin.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { _ as ResolvedFrontendCacheConfig, p as FrontendCacheConfig } from "./config-5I8MVd6s.mjs";
|
|
2
2
|
|
|
3
3
|
//#region src/plugin.d.ts
|
|
4
4
|
declare const FRONTEND_CACHE_PLUGIN_NAME = "@murumets-ee/frontend-cache";
|
|
@@ -59,6 +59,26 @@ declare module '@murumets-ee/core' {
|
|
|
59
59
|
*
|
|
60
60
|
* With either absent the plugin is INERT: writes are observed, nothing is
|
|
61
61
|
* sent, and one warning per flush says so. It never fails a publish.
|
|
62
|
+
*
|
|
63
|
+
* ## Several sites
|
|
64
|
+
*
|
|
65
|
+
* An app serving several sites has one frontend per site, and each is purged
|
|
66
|
+
* on its own. List the handles — `FRONTEND_CACHE_SITES=kulli,davatoidud`, or
|
|
67
|
+
* `frontendCache({ locales, sites: [{ site: 'kulli' }, …] })` — and give each
|
|
68
|
+
* its own pair, the handle upper-cased with `-` as `_`:
|
|
69
|
+
* `FRONTEND_ORIGIN_KULLI`, `CACHE_INVALIDATION_TOKEN_KULLI`. A write to a
|
|
70
|
+
* site's own content purges that site's frontend only; a write to something
|
|
71
|
+
* shared (a media file) purges all of them. A site with no target is not
|
|
72
|
+
* purged and is named once in the log.
|
|
73
|
+
*
|
|
74
|
+
* ## A site's own settings
|
|
75
|
+
*
|
|
76
|
+
* Changing a site's languages, default language or URL strategy changes every
|
|
77
|
+
* page and sitemap that site's frontend has rendered, and no content tag names
|
|
78
|
+
* those. The admin asks for the whole of that ONE site's cache to be dropped
|
|
79
|
+
* (`purgeSiteFrontend()` in `@murumets-ee/core`, served by this plugin's
|
|
80
|
+
* `'frontend.sitePurger'` contribution): one request carrying the `site` tag,
|
|
81
|
+
* to that site's target only.
|
|
62
82
|
*/
|
|
63
83
|
declare function frontendCache(config: FrontendCacheConfig): Pick<import("@murumets-ee/core").Plugin, "server" | "adminUi" | "postInstall" | "requires" | "contributes"> & {
|
|
64
84
|
readonly name: "@murumets-ee/frontend-cache";
|
package/dist/plugin.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;cAqBa,0BAAA;AAAA;EAAA,UAGD,oBAAA;IACR,6BAAA,EAA+B,2BAA2B;EAAA;AAAA;AAAA;
|
|
1
|
+
{"version":3,"file":"plugin.d.mts","names":[],"sources":["../src/plugin.ts"],"mappings":";;;cAqBa,0BAAA;AAAA;EAAA,UAGD,oBAAA;IACR,6BAAA,EAA+B,2BAA2B;EAAA;AAAA;AAAA;AA6E9D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA7E8D,iBA6E9C,aAAA,CAAc,MAAA,EAAQ,mBAAA,GAAmB,IAAA,6BAAA,MAAA;EAAA"}
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{M as e,h as t,j as n,n as r}from"./sender-C_R_H6eC.mjs";import{definePlugin as i}from"@murumets-ee/core";const a=`@murumets-ee/frontend-cache`;function o(o){let s=n(o),c;return i({name:a,contributes:{"frontend.sitePurger":({site:e,trigger:t})=>c===void 0?{queued:!1,reason:`not-ready`}:c.purgeSite(e,t)},server:{config:s,onReady:async i=>{let s=i.logger.child({plugin:a}),l=n(o);for(let e of l.problems)s.error({problem:e},`frontend-cache: configuration problem`);let u=l.sites.length===0?[void 0]:l.sites;for(let t of u){let n=e(l,t),r=t===void 0?{}:{site:t.site};n.ok?s.info({...r,url:n.url},`frontend-cache: purge sender ready`):s.warn({...r,reason:n.why},`frontend-cache: not configured — publishes will NOT purge the frontend, and edits will only appear once the page cache TTL expires`)}let d=r({config:l,logger:s});c=d,t(e=>{d.notify(e)})}}})}export{a as FRONTEND_CACHE_PLUGIN_NAME,o as frontendCache};
|
|
2
2
|
//# sourceMappingURL=plugin.mjs.map
|
package/dist/plugin.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * `@murumets-ee/frontend-cache/plugin` — the jiti-safe plugin entry.\n *\n * Apps wire it by importing `frontendCache()` into `lumi.config.ts`, which the\n * `lumi` CLI loads through jiti. A static `import 'server-only'` anywhere in\n * this module's top-level graph throws under plain Node and breaks every\n * `lumi` command (see `gotcha_server_only_in_cli_load_path`), so the static\n * imports here are limited to `definePlugin` and this package's own pure\n * modules — none of which touch the database, auth or app context.\n */\n\nimport { definePlugin, type ToolkitApp } from '@murumets-ee/core'\nimport {\n type FrontendCacheConfig,\n type ResolvedFrontendCacheConfig,\n resolveFrontendCacheConfig,\n resolvePurgeTarget,\n} from './config.js'\nimport { setActiveEntityWriteObserver } from './observer-slot.js'\nimport { createPurgeSender } from './sender.js'\n\nexport const FRONTEND_CACHE_PLUGIN_NAME = '@murumets-ee/frontend-cache'\n\ndeclare module '@murumets-ee/core' {\n interface PluginConfigRegistry {\n '@murumets-ee/frontend-cache': ResolvedFrontendCacheConfig\n }\n}\n\n/**\n * Tell the frontend to drop its cached pages when content changes, so a\n * publish is live on the next page view with no rebuild and no deploy.\n *\n * The plugin observes every `AdminClient` write in this process — including\n * writes from queue jobs and the CLI, which never pass through the admin API —\n * maps each to the frontend's own cache tags, coalesces them, and POSTs at\n * most one bounded request per second to the frontend's `/api/invalidate`.\n *\n * ## Security\n *\n * The purge endpoint is a public-internet-reachable mutation on the site, so\n * it is bearer-authenticated, constant-time compared and rate limited AT THE\n * RECEIVING END. This side holds the other half of that contract: the token\n * comes from the environment and is never logged; the destination origin is\n * operator config validated to be `http`/`https`; and the request body carries\n * nothing but tags.\n *\n * **Which ids cross is deny-by-default, and it has to be.** `EntityWriteEvent`\n * hands this plugin the FULL row, and the destination is the one process in\n * the deployment designed to hold nothing. So an `id` is transmitted only for\n * an entity that declares `frontend.path` — one a public URL can already\n * address. A write to `order`, `user` or `ticket_message` sends no tag at all\n * rather than a `doc:<entity>:<uuid>` that would evict nothing and disclose\n * both the entity's existence and its write rate. Of the row itself, exactly\n * one configured field is read (a menu's handle); nothing else is touched.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // lumi.config.ts\n * import { frontendCache } from '@murumets-ee/frontend-cache/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * content({ locales: [{ code: 'en' }, { code: 'et' }] }),\n * // The same locale codes: chrome and menu tags are per-locale, and a\n * // list that drifts means a stale header in one language only.\n * frontendCache({ locales: ['en', 'et'] }),\n * ],\n * })\n * ```\n *\n * Two environment variables, both on the CMS service:\n *\n * - `FRONTEND_ORIGIN` — e.g. `http://front:4321` (the Docker-internal name;\n * the purge never needs to leave the internal network).\n * - `CACHE_INVALIDATION_TOKEN` — the same secret the frontend reads.\n *\n * With either absent the plugin is INERT: writes are observed, nothing is\n * sent, and one warning per flush says so. It never fails a publish.\n */\nexport function frontendCache(config: FrontendCacheConfig) {\n const resolved = resolveFrontendCacheConfig(config)\n\n return definePlugin({\n name: FRONTEND_CACHE_PLUGIN_NAME,\n\n server: {\n config: resolved,\n\n /**\n * `onReady`, not `init`, and not a declarative slot.\n *\n * Not a declarative slot because there is nothing to contribute — the\n * plugin registers no entity, route or setting. What it installs is a\n * live object with a timer, which is exactly the \"genuinely-imperative\n * work\" `onReady` exists for.\n *\n * `onReady` rather than the deprecated `init` is safe here precisely\n * because `buildEntityWriteObserver` resolves this slot PER EVENT rather\n * than at client construction — so a client another plugin built during\n * its own `init` still fires. That is the whole reason core's trampoline\n * differs from its three sibling slots.\n */\n onReady: async (app: ToolkitApp) => {\n const logger = app.logger.child({ plugin: FRONTEND_CACHE_PLUGIN_NAME })\n\n for (const problem of resolved.problems) {\n logger.error({ problem }, 'frontend-cache: configuration problem')\n }\n\n const target = resolvePurgeTarget(resolved)\n
|
|
1
|
+
{"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["/**\n * `@murumets-ee/frontend-cache/plugin` — the jiti-safe plugin entry.\n *\n * Apps wire it by importing `frontendCache()` into `lumi.config.ts`, which the\n * `lumi` CLI loads through jiti. A static `import 'server-only'` anywhere in\n * this module's top-level graph throws under plain Node and breaks every\n * `lumi` command (see `gotcha_server_only_in_cli_load_path`), so the static\n * imports here are limited to `definePlugin` and this package's own pure\n * modules — none of which touch the database, auth or app context.\n */\n\nimport { definePlugin, type ToolkitApp } from '@murumets-ee/core'\nimport {\n type FrontendCacheConfig,\n type ResolvedFrontendCacheConfig,\n resolveFrontendCacheConfig,\n resolvePurgeTarget,\n} from './config.js'\nimport { setActiveEntityWriteObserver } from './observer-slot.js'\nimport { createPurgeSender, type PurgeSender } from './sender.js'\n\nexport const FRONTEND_CACHE_PLUGIN_NAME = '@murumets-ee/frontend-cache'\n\ndeclare module '@murumets-ee/core' {\n interface PluginConfigRegistry {\n '@murumets-ee/frontend-cache': ResolvedFrontendCacheConfig\n }\n}\n\n/**\n * Tell the frontend to drop its cached pages when content changes, so a\n * publish is live on the next page view with no rebuild and no deploy.\n *\n * The plugin observes every `AdminClient` write in this process — including\n * writes from queue jobs and the CLI, which never pass through the admin API —\n * maps each to the frontend's own cache tags, coalesces them, and POSTs at\n * most one bounded request per second to the frontend's `/api/invalidate`.\n *\n * ## Security\n *\n * The purge endpoint is a public-internet-reachable mutation on the site, so\n * it is bearer-authenticated, constant-time compared and rate limited AT THE\n * RECEIVING END. This side holds the other half of that contract: the token\n * comes from the environment and is never logged; the destination origin is\n * operator config validated to be `http`/`https`; and the request body carries\n * nothing but tags.\n *\n * **Which ids cross is deny-by-default, and it has to be.** `EntityWriteEvent`\n * hands this plugin the FULL row, and the destination is the one process in\n * the deployment designed to hold nothing. So an `id` is transmitted only for\n * an entity that declares `frontend.path` — one a public URL can already\n * address. A write to `order`, `user` or `ticket_message` sends no tag at all\n * rather than a `doc:<entity>:<uuid>` that would evict nothing and disclose\n * both the entity's existence and its write rate. Of the row itself, exactly\n * one configured field is read (a menu's handle); nothing else is touched.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // lumi.config.ts\n * import { frontendCache } from '@murumets-ee/frontend-cache/plugin'\n *\n * export default defineLumiConfig({\n * plugins: [\n * content({ locales: [{ code: 'en' }, { code: 'et' }] }),\n * // The same locale codes: chrome and menu tags are per-locale, and a\n * // list that drifts means a stale header in one language only.\n * frontendCache({ locales: ['en', 'et'] }),\n * ],\n * })\n * ```\n *\n * Two environment variables, both on the CMS service:\n *\n * - `FRONTEND_ORIGIN` — e.g. `http://front:4321` (the Docker-internal name;\n * the purge never needs to leave the internal network).\n * - `CACHE_INVALIDATION_TOKEN` — the same secret the frontend reads.\n *\n * With either absent the plugin is INERT: writes are observed, nothing is\n * sent, and one warning per flush says so. It never fails a publish.\n *\n * ## Several sites\n *\n * An app serving several sites has one frontend per site, and each is purged\n * on its own. List the handles — `FRONTEND_CACHE_SITES=kulli,davatoidud`, or\n * `frontendCache({ locales, sites: [{ site: 'kulli' }, …] })` — and give each\n * its own pair, the handle upper-cased with `-` as `_`:\n * `FRONTEND_ORIGIN_KULLI`, `CACHE_INVALIDATION_TOKEN_KULLI`. A write to a\n * site's own content purges that site's frontend only; a write to something\n * shared (a media file) purges all of them. A site with no target is not\n * purged and is named once in the log.\n *\n * ## A site's own settings\n *\n * Changing a site's languages, default language or URL strategy changes every\n * page and sitemap that site's frontend has rendered, and no content tag names\n * those. The admin asks for the whole of that ONE site's cache to be dropped\n * (`purgeSiteFrontend()` in `@murumets-ee/core`, served by this plugin's\n * `'frontend.sitePurger'` contribution): one request carrying the `site` tag,\n * to that site's target only.\n */\nexport function frontendCache(config: FrontendCacheConfig) {\n const resolved = resolveFrontendCacheConfig(config)\n\n /**\n * The sender `onReady` builds. Held here so the `'frontend.sitePurger'`\n * contribution below — which has to exist when the plugin object is\n * constructed, long before there is an app or a logger — can reach it.\n */\n let activeSender: PurgeSender | undefined\n\n return definePlugin({\n name: FRONTEND_CACHE_PLUGIN_NAME,\n\n /**\n * The site-wide purge, DECLARED: the Sites page and the Languages editor\n * call core's `purgeSiteFrontend()`, which reads this slot. It is the same\n * sender content purges use — one destination per site, one token, one\n * pacing — so a save of a site's languages reaches that site's frontend\n * and no other. Before `onReady` (the CLI, a build) there is no sender and\n * nothing is sent.\n */\n contributes: {\n 'frontend.sitePurger': ({ site, trigger }) =>\n activeSender === undefined\n ? { queued: false, reason: 'not-ready' }\n : activeSender.purgeSite(site, trigger),\n },\n\n server: {\n config: resolved,\n\n /**\n * `onReady`, not `init`, and not a declarative slot.\n *\n * Not a declarative slot because there is nothing to contribute — the\n * plugin registers no entity, route or setting. What it installs is a\n * live object with a timer, which is exactly the \"genuinely-imperative\n * work\" `onReady` exists for.\n *\n * `onReady` rather than the deprecated `init` is safe here precisely\n * because `buildEntityWriteObserver` resolves this slot PER EVENT rather\n * than at client construction — so a client another plugin built during\n * its own `init` still fires. That is the whole reason core's trampoline\n * differs from its three sibling slots.\n */\n onReady: async (app: ToolkitApp) => {\n const logger = app.logger.child({ plugin: FRONTEND_CACHE_PLUGIN_NAME })\n\n // Resolved AGAIN, here. The copy above was built while `lumi.config.ts`\n // loaded, and on a host that reads `.env` after that the site list in\n // `FRONTEND_CACHE_SITES` was not visible yet: the plugin would sit in\n // single-target mode, purging one frontend with every site's writes,\n // with nothing in the log to say so. The resolver is pure and reads\n // the environment, so asking it at boot costs nothing. Origins and\n // tokens were never affected — those are read per send.\n const resolved = resolveFrontendCacheConfig(config)\n\n for (const problem of resolved.problems) {\n logger.error({ problem }, 'frontend-cache: configuration problem')\n }\n\n // One line per destination: the single target, or each site's own.\n // Bounded — `resolved.sites` is capped at `MAX_PURGE_TARGETS`.\n const destinations = resolved.sites.length === 0 ? [undefined] : resolved.sites\n for (const site of destinations) {\n const target = resolvePurgeTarget(resolved, site)\n const siteField = site !== undefined ? { site: site.site } : {}\n if (!target.ok) {\n // Said once, loudly, at boot — not only on the first publish. An\n // operator who deployed without the variables should learn it from\n // the startup log, not from a page that will not update.\n logger.warn(\n { ...siteField, reason: target.why },\n 'frontend-cache: not configured — publishes will NOT purge the frontend, ' +\n 'and edits will only appear once the page cache TTL expires',\n )\n } else {\n // The URL, never the token.\n logger.info({ ...siteField, url: target.url }, 'frontend-cache: purge sender ready')\n }\n }\n\n const sender = createPurgeSender({ config: resolved, logger })\n activeSender = sender\n setActiveEntityWriteObserver((event) => {\n sender.notify(event)\n })\n },\n },\n })\n}\n"],"mappings":"gHAqBA,MAAa,EAA6B,8BAiF1C,SAAgB,EAAc,EAA6B,CACzD,IAAM,EAAW,EAA2B,CAAM,EAO9C,EAEJ,OAAO,EAAa,CAClB,KAAM,EAUN,YAAa,CACX,uBAAwB,CAAE,OAAM,aAC9B,IAAiB,IAAA,GACb,CAAE,OAAQ,GAAO,OAAQ,WAAY,EACrC,EAAa,UAAU,EAAM,CAAO,CAC5C,EAEA,OAAQ,CACN,OAAQ,EAgBR,QAAS,KAAO,IAAoB,CAClC,IAAM,EAAS,EAAI,OAAO,MAAM,CAAE,OAAQ,CAA2B,CAAC,EAShE,EAAW,EAA2B,CAAM,EAElD,IAAK,IAAM,KAAW,EAAS,SAC7B,EAAO,MAAM,CAAE,SAAQ,EAAG,uCAAuC,EAKnE,IAAM,EAAe,EAAS,MAAM,SAAW,EAAI,CAAC,IAAA,EAAS,EAAI,EAAS,MAC1E,IAAK,IAAM,KAAQ,EAAc,CAC/B,IAAM,EAAS,EAAmB,EAAU,CAAI,EAC1C,EAAY,IAAS,IAAA,GAAkC,CAAC,EAAvB,CAAE,KAAM,EAAK,IAAK,EACpD,EAAO,GAWV,EAAO,KAAK,CAAE,GAAG,EAAW,IAAK,EAAO,GAAI,EAAG,oCAAoC,EAPnF,EAAO,KACL,CAAE,GAAG,EAAW,OAAQ,EAAO,GAAI,EACnC,oIAEF,CAKJ,CAEA,IAAM,EAAS,EAAkB,CAAE,OAAQ,EAAU,QAAO,CAAC,EAC7D,EAAe,EACf,EAA8B,GAAU,CACtC,EAAO,OAAO,CAAK,CACrB,CAAC,CACH,CACF,CACF,CAAC,CACH"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{DEFAULT_SITE_HANDLE as e,isValidSiteHandle as t}from"@murumets-ee/entity";import{ENTITY_WRITE_OBSERVER_SLOT as n}from"@murumets-ee/entity/admin";const r=`FRONTEND_ORIGIN`,i=`CACHE_INVALIDATION_TOKEN`,a=`FRONTEND_CACHE_SITES`,o=16,s=`/api/invalidate`,c=[`site_chrome`],l=[`media`],u={menu:`handle`},d={detail_template:`target`},f=1e3,p=150,m=50,h=1,g=3e3;function _(e,t){return e===void 0?t:Number.isFinite(e)&&e>=1?Math.floor(e):t}function v(e){return y(process.env[e])}function y(e){if(e===void 0)return;let t=e.trim();return t===``?void 0:t}function b(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 x(e,t){return`${e}_${t.toUpperCase().replaceAll(`-`,`_`)}`}function S(e){let t=e.filter(e=>typeof e==`string`);return{locales:t.map(e=>e.trim()).filter(e=>e!==``),hadNonStrings:t.length!==e.length}}function C(e,n){let o;if(e===void 0){let e=v(a);if(e===void 0)return[];o=e.split(`,`).map(e=>e.trim()).filter(e=>e!==``).map(e=>({site:e}))}else if(Array.isArray(e))o=e;else return n.push("`sites` must be an array of `{ site, origin?, token?, locales? }` — ignoring it"),[];let s=[],c=new Set,l=[];for(let e of o){let a=typeof e==`object`&&e?{...e}:{},o=a.site;if(!t(o)){n.push(`a purge target names an unusable site handle ${JSON.stringify(o)} — handles are lowercase letters, digits and single hyphens; ignoring that target`);continue}if(c.has(o)){n.push(`site '${o}' is listed as a purge target more than once — keeping the first`);continue}if(c.add(o),s.length>=16){l.push(o);continue}for(let e of[`origin`,`token`])a[e]!==void 0&&typeof a[e]!=`string`&&n.push(`site '${o}': \`${e}\` must be a string — ignoring it and falling back to `+x(e===`origin`?r:i,o));let u=typeof a.origin==`string`?a.origin:void 0;if(u!==void 0&&y(u)!==void 0){let e=b(u);e.ok||(n.push(`site '${o}': \`origin\` is unusable — ${e.why}; ignoring it and falling back to `+x(r,o)),u=void 0)}let d;if(a.locales!==void 0){let e=S(Array.isArray(a.locales)?a.locales:[]);(!Array.isArray(a.locales)||e.hadNonStrings)&&n.push(`site '${o}': \`locales\` must be an array of locale-code strings — entries that were not strings have been ignored`),e.locales.length>0&&(d=e.locales)}s.push({site:o,originOverride:y(u),tokenOverride:y(typeof a.token==`string`?a.token:void 0),locales:d})}return l.length>0&&n.push(`${s.length+l.length} purge targets configured but at most 16 are supported — these sites will NOT be purged and their pages only refresh when the cache TTL expires: ${l.join(`, `)}`),s}function w(e){let t=[],n=Array.isArray(e.locales)?e.locales:[],i=n.filter(e=>typeof e==`string`),a=i.map(e=>e.trim()).filter(e=>e!==``);i.length!==n.length&&t.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."),a.length===0&&t.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 o=e.origin;if(e.origin!==void 0){let n=b(e.origin);n.ok||(t.push(`\`origin\` is unusable — ${n.why}; ignoring it and falling back to ${r}`),o=void 0)}let p=e.path??`/api/invalidate`,m=typeof p==`string`&&p.startsWith(`/`);m||t.push(`\`path\` must start with "/", got ${JSON.stringify(p)} — falling back to ${s}`);let h=m?p:s,v=(Array.isArray(e.chromeEntities)?e.chromeEntities:void 0)?.filter(e=>typeof e==`string`&&e.trim()!==``).map(e=>e.trim());e.chromeEntities!==void 0&&v===void 0&&t.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&&t.push("`embeddedEntities` must be an array of entity-name strings — ignoring it");let S=e.menuEntities,w=typeof S==`object`&&!!S&&!Array.isArray(S);S!==void 0&&!w&&t.push("`menuEntities` must be an object of `entityName: handleField` — ignoring it");let T=w?Object.fromEntries(Object.entries(S).filter(([,e])=>typeof e==`string`)):void 0,E=e.sites,D=C(E,t),O=e.templateEntities,k=typeof O==`object`&&!!O&&!Array.isArray(O);O!==void 0&&!k&&t.push("`templateEntities` must be an object of `entityName: targetField` — ignoring it");let A;if(k){let e=Object.entries(O),n=e.filter(e=>typeof e[1]==`string`&&e[1].trim()!==``);n.length<e.length&&t.push("`templateEntities` has an entry whose target field is not a non-empty string — ignoring that entry"),A=e.length>0&&n.length===0?void 0:Object.fromEntries(n)}return{locales:a,sites:D,path:h,chromeEntities:v??[...c],embeddedEntities:x??[...l],menuEntities:T??{...u},templateEntities:A??{...d},flushIntervalMs:_(e.flushIntervalMs,f),minBatchMs:_(e.minBatchMs,150),maxTagsPerRequest:_(e.maxTagsPerRequest,50),maxRequestsPerFlush:_(e.maxRequestsPerFlush,1),requestTimeoutMs:_(e.requestTimeoutMs,g),maxPendingTags:_(e.maxPendingTags,2e3),originOverride:y(o),tokenOverride:y(e.token),problems:t}}function T(t,n){let i=E(n?.site),a=n?.site===e,o=(n?n.originOverride:t.originOverride)??v(i.origin)??(a?v(`FRONTEND_ORIGIN`):void 0);if(o===void 0)return{ok:!1,why:`${i.origin} is not set`};let s=b(o);if(!s.ok)return n===void 0?{ok:!1,why:s.why}:{ok:!1,why:`${n.originOverride===void 0?v(i.origin)===void 0?r:i.origin:`sites[].origin for '${n.site}'`}: ${s.why}`};let c=(n?n.tokenOverride:t.tokenOverride)??v(i.token)??(a?v(`CACHE_INVALIDATION_TOKEN`):void 0);return c===void 0?{ok:!1,why:`${i.token} is not set`}:{ok:!0,url:`${s.origin}${t.path}`,token:c}}function E(e){return e===void 0?{origin:r,token:i}:{origin:x(r,e),token:x(i,e)}}function D(e){if(e===void 0){delete globalThis[n];return}globalThis[n]=e}function O(){let e=globalThis[n];return typeof e==`function`?e:void 0}const k=128,A=64;function j(e){return e.trim().toLowerCase().replace(/[^a-z0-9:_-]+/g,`-`).replace(/-{2,}/g,`-`).slice(0,128).replace(/^[-:]+|[-:]+$/g,``)||void 0}function M(e){return j(e.replaceAll(`:`,`-`))}function N(e,...t){let n=t.map(M);if(!n.some(e=>e===void 0))return j(`${e}:${n.join(`:`)}`)}function P(e){return N(`entity`,e)}function F(e,t){return N(`doc`,e,t)}function I(e){return N(`chrome`,e)}function L(e,t){return N(`menu`,e,t)}const R=`site`;function z(e,t){let n=e.routable?[P(e.entityName),F(e.entityName,e.id)]:[];if(!e.routable&&t.embeddedEntities.includes(e.entityName)&&n.push(F(e.entityName,e.id)),t.chromeEntities.includes(e.entityName))for(let e of t.locales)n.push(I(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(L(i,e))}let i=t.templateEntities[e.entityName];if(i!==void 0){let t=e.row?.[i];typeof t==`string`&&t!==``&&n.push(P(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 B=(e,t)=>fetch(e,t),V=(e,t)=>{let n=setTimeout(e,t);return n.unref?.(),{clear:()=>{clearTimeout(n)}}};function H(e){let t=[],n=[];for(let r of e)r.startsWith(`doc:`)?n.push(r):t.push(r);return[...t,...n]}function U(e,t){let n=[];for(let r=0;r<e.length;r+=t)n.push(e.slice(r,r+t));return n}function W(e){return e instanceof Error?e.message:String(e)}function G(e){return e===0||e>=300&&e<400}function K(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)}const q=4;function J(e){let t=0,n=[];return async r=>{t>=e?await new Promise(e=>{n.push(e)}):t+=1;try{await r()}finally{let e=n.shift();e?e():--t}}}function Y(e){let{config:t,logger:n}=e,r=J(4);if(t.sites.length===0)return X(e,void 0,r);let i=new Map;for(let n of t.sites)i.set(n.site,X(e,n,r));let a=[...i.values()],o=new Set;return{notify(e){if(e.site===void 0){for(let t of a)t.notify(e);return}let t=i.get(e.site);if(t!==void 0){t.notify(e);return}o.has(e.site)||o.size>=64||(o.add(e.site),n.warn({site:e.site,entity:e.entityName,configured:[...i.keys()]},`frontend-cache: no purge target is configured for this site, so its writes purge nothing — its pages only refresh when the cache TTL expires`))},purgeSite(e,t){if(e===void 0)return n.warn({trigger:t,configured:[...i.keys()]},`frontend-cache: a site-wide purge was asked for without naming the site, so nothing was purged`),{queued:!1,reason:`site-required`};let r=i.get(e);return r===void 0?(n.warn({site:e,trigger:t,configured:[...i.keys()]},`frontend-cache: no purge target is configured for this site, so its site-wide purge was not sent — its pages only refresh when the cache TTL expires`),{queued:!1,reason:`no-target`}):r.purgeSite(e,t)},async flush(){await Promise.all(a.map(e=>e.flush()))},stop(){for(let e of a)e.stop()},get pendingCount(){let e=0;for(let t of a)e+=t.pendingCount;return e}}}function X(e,t,n){let{logger:r}=e,i=t?.locales===void 0?e.config:{...e.config,locales:t.locales},a=E(t?.site),o=t===void 0?{}:{site:t.site},s=e.now??(()=>Date.now()),c=e.fetchImpl??B,l=e.scheduler??V,u=new Set,d,f=!1,p=-1/0,m=-1/0,h=!1,g=!1,_=Promise.resolve();function v(){return Math.max(p+i.flushIntervalMs,m)}function y(){g||d!==void 0||f||(d=l(()=>{d=void 0,k()},Math.max(i.minBatchMs,v()-s())))}function b(){for(let e of u)if(e.startsWith(`doc:`))return u.delete(e),!0;return!1}function x(e,t){h||(h=!0,r.warn({...o,entity:e,pending:u.size,cap:i.maxPendingTags,dropped:t},`frontend-cache: pending purge-tag buffer is full; dropping tags until the next flush`))}function S(e){if(g||u.has(`site`))return;let t=H(z(e,i));if(t.length!==0){for(let n of t)if(!u.has(n)){if(u.size>=i.maxPendingTags&&(n.startsWith(`doc:`)||!b())){x(e.entityName,n);break}u.add(n)}y()}}function C(e,n){if(g)return{queued:!1,reason:`stopped`};let a=T(i,t);return a.ok?(u.clear(),u.add(R),r.debug({...o,trigger:n},`frontend-cache: site-wide purge queued`),y(),{queued:!0}):(r.warn({...o,reason:a.why,trigger:n},`frontend-cache: not configured, so the site-wide purge was not sent`),{queued:!1,reason:`not-configured`})}async function w(e,t){let n;try{n=await c(e.url,{method:`POST`,headers:{"content-type":`application/json`,authorization:`Bearer ${e.token}`},body:JSON.stringify({tags:t}),signal:AbortSignal.timeout(i.requestTimeoutMs),redirect:`manual`})}catch(n){r.warn({...o,url:e.url,tags:t.length,err:W(n)},`frontend-cache: purge request failed; the frontend keeps serving cached pages until their TTL expires`);return}if(n.ok){await n.text().catch(()=>``),r.debug({...o,url:e.url,tags:t.length},`frontend-cache: purged`);return}if(n.status===429){let e=K(n.headers.get(`retry-after`),5,300);m=Math.max(m,s()+e*1e3)}let l;try{l=(await n.text()).slice(0,200)}catch(e){l=`<unreadable body: ${W(e)}>`}let u=n.status===401?`the frontend rejected the token — check that ${a.token} matches on both services`:n.status===429?`the frontend rate-limited this purge; this batch is dropped and the next is delayed`:n.status===503?`the frontend has no token configured, or route caching is off on that deployment`:G(n.status)?`the frontend redirected the purge, which is never legitimate — ${a.origin} probably names a scheme or host the site redirects away from`:`unexpected response`;r.warn({...o,url:e.url,status:n.status,tags:t.length,detail:l},`frontend-cache: purge refused — ${u}`)}async function D(){if(u.size===0)return;p=s();let e=H([...u]);u.clear(),h=!1;let n=T(i,t);if(!n.ok){r.warn({...o,reason:n.why,tags:e.length},`frontend-cache: not configured, so nothing was purged`);return}let a=i.maxTagsPerRequest*i.maxRequestsPerFlush,c=e.slice(0,a);if(e.length>c.length){let t=e.length-e.filter(e=>e.startsWith(`doc:`)).length,n=c.length>=t;r.warn({...o,dropped:e.length-c.length,sent:c.length,lossless:n},n?`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 l=U(c,i.maxTagsPerRequest);for(let[e,t]of l.entries()){if(s()<m){r.warn({...o,dropped:l.length-e,remainingTags:c.length-e*i.maxTagsPerRequest},`frontend-cache: rate limited mid-flush; abandoning the rest of this batch rather than spending more of the bucket`);break}await w(n,t)}}function O(e){let t=()=>n(e);return _=_.then(t,t).catch(e=>{try{r.warn({...o,err:W(e)},`frontend-cache: purge flush failed unexpectedly`)}catch{}}),_}function k(){return f=!0,O(async()=>{try{if(s()<v())return;await D()}finally{f=!1,u.size>0&&y()}})}return{notify:S,purgeSite:C,flush:()=>O(D),stop(){g=!0,d!==void 0&&(d.clear(),d=void 0),u.clear()},get pendingCount(){return u.size}}}export{E as A,p as C,r as D,a as E,T as M,x as N,o as O,u as S,d as T,c as _,k as a,h as b,I as c,L as d,j as f,i as g,D as h,H as i,w as j,b as k,F as l,O as m,Y as n,A as o,z as p,K as r,R as s,q as t,P as u,f as v,g as w,m as x,s as y};
|
|
2
|
+
//# sourceMappingURL=sender-C_R_H6eC.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sender-C_R_H6eC.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\nimport { DEFAULT_SITE_HANDLE, isValidSiteHandle } from '@murumets-ee/entity'\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/**\n * Environment variable naming the sites that each have their OWN frontend to\n * purge — comma-separated site handles, e.g. `kulli,davatoidud`.\n *\n * Read only when `frontendCache({ sites })` is omitted, so that adding a site\n * to a running deployment is an environment change and nothing else. Each\n * handle then reads its own origin and token — see {@link siteEnvName}.\n */\nexport const FRONTEND_CACHE_SITES_ENV = 'FRONTEND_CACHE_SITES'\n\n/**\n * The most purge targets one plugin will send to. Targets configured beyond\n * it are dropped at resolve time and reported in `problems`.\n *\n * A write to a SHARED entity (a media row) goes to every target, so this is\n * the fan-out bound for one write — and the number of sites is the kind of N\n * that grows with the customer. Sixteen is well above any deployment this\n * toolkit runs; past it the answer is a purge hub, not a longer loop.\n */\nexport const MAX_PURGE_TARGETS = 16\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\n/**\n * One site's frontend, as a purge destination (plan `multisite`).\n *\n * Each site of a multisite app is served by its own frontend process with its\n * own cache, so each is purged separately: a write to a site-bound row goes\n * to that site's target and no other, and a write to a shared row goes to all\n * of them.\n */\nexport interface FrontendCacheSiteTarget {\n /** The site handle, as stored in the site registry (e.g. `'kulli'`). */\n readonly site: string\n /**\n * This site's frontend origin. Defaults to `process.env.FRONTEND_ORIGIN_<HANDLE>`\n * — the handle upper-cased with `-` written as `_` (see {@link siteEnvName}).\n */\n readonly origin?: string\n /** This site's bearer token. Defaults to `process.env.CACHE_INVALIDATION_TOKEN_<HANDLE>`. */\n readonly token?: string\n /**\n * The locales THIS site serves, when they differ from the plugin-wide\n * `locales`. Chrome and menu tags are emitted per locale; a site that serves\n * fewer should not be sent tags for the rest.\n */\n readonly locales?: readonly string[]\n}\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 * One purge target per site, for an app that serves several sites from\n * several frontends. Defaults to the handles listed in\n * `process.env.FRONTEND_CACHE_SITES`; with neither, the plugin has the ONE\n * target `origin` / `token` describe and sends it every write.\n *\n * At most {@link MAX_PURGE_TARGETS}. `origin` / `token` above are not used\n * once any site target is in force — except by a target whose handle is\n * `default`, which falls back to the unsuffixed environment variables.\n */\n readonly sites?: readonly FrontendCacheSiteTarget[]\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\n/** A {@link FrontendCacheSiteTarget} after validation. */\nexport interface ResolvedFrontendCacheSiteTarget {\n readonly site: string\n /** Explicit overrides; `undefined` means \"read the environment at send time\". */\n readonly originOverride: string | undefined\n readonly tokenOverride: string | undefined\n /** `undefined` means \"the plugin-wide `locales`\". */\n readonly locales: readonly string[] | undefined\n}\n\nexport interface ResolvedFrontendCacheConfig {\n readonly locales: readonly string[]\n /**\n * The per-site targets. EMPTY means single-target mode: one destination from\n * `originOverride` / `tokenOverride` / the unsuffixed environment, receiving\n * every write whatever site it belongs to.\n */\n readonly sites: readonly ResolvedFrontendCacheSiteTarget[]\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\n/**\n * The per-site form of an environment variable name: `FRONTEND_ORIGIN` for\n * handle `front-two` is `FRONTEND_ORIGIN_FRONT_TWO`.\n *\n * A handle is `[a-z][a-z0-9-]*`, so upper-casing and writing `-` as `_` always\n * yields a legal variable name, and two distinct handles can only collide if\n * they differ solely in where a `-` sits against nothing — which the handle\n * pattern does not allow.\n */\nexport function siteEnvName(base: string, site: string): string {\n return `${base}_${site.toUpperCase().replaceAll('-', '_')}`\n}\n\n/** Trimmed, non-blank locale codes out of an untyped list — see `locales` below. */\nfunction cleanLocales(raw: readonly unknown[]): {\n readonly locales: string[]\n readonly hadNonStrings: boolean\n} {\n const strings = raw.filter((l): l is string => typeof l === 'string')\n return {\n locales: strings.map((l) => l.trim()).filter((l) => l !== ''),\n hadNonStrings: strings.length !== raw.length,\n }\n}\n\n/**\n * Validate the per-site targets, from config or — when config names none —\n * from {@link FRONTEND_CACHE_SITES_ENV}.\n *\n * Same policy as everything else here: a bad entry is dropped and reported,\n * never thrown and never left in force. An entry that cannot be used must not\n * take the others down with it — one site's typo is not every site's outage.\n */\nfunction resolveSiteTargets(\n configured: unknown,\n problems: string[],\n): readonly ResolvedFrontendCacheSiteTarget[] {\n let entries: readonly unknown[]\n if (configured === undefined) {\n const fromEnv = optionalEnv(FRONTEND_CACHE_SITES_ENV)\n if (fromEnv === undefined) return []\n entries = fromEnv\n .split(',')\n .map((handle) => handle.trim())\n .filter((handle) => handle !== '')\n .map((handle) => ({ site: handle }))\n } else if (Array.isArray(configured)) {\n // `readonly unknown[]` for the same reason as `rawLocales`: the value\n // arrives through jiti from an untyped file, and `any[]` would cost the\n // package its type-coverage floor.\n entries = configured\n } else {\n problems.push('`sites` must be an array of `{ site, origin?, token?, locales? }` — ignoring it')\n return []\n }\n\n const targets: ResolvedFrontendCacheSiteTarget[] = []\n const seen = new Set<string>()\n const overCap: string[] = []\n for (const entry of entries) {\n const record: Record<string, unknown> =\n typeof entry === 'object' && entry !== null ? { ...entry } : {}\n const site = record.site\n if (!isValidSiteHandle(site)) {\n problems.push(\n `a purge target names an unusable site handle ${JSON.stringify(site)} — ` +\n 'handles are lowercase letters, digits and single hyphens; ignoring that target',\n )\n continue\n }\n if (seen.has(site)) {\n problems.push(`site '${site}' is listed as a purge target more than once — keeping the first`)\n continue\n }\n seen.add(site)\n // Counted AFTER validation and de-duplication, so the cap is on usable\n // targets and a list padded with junk does not crowd out real ones.\n if (targets.length >= MAX_PURGE_TARGETS) {\n overCap.push(site)\n continue\n }\n\n // A value of the wrong type is ignored like any other unusable one — and\n // SAID, or the target quietly falls back to the environment and the\n // operator's explicit setting reads as \"applied\".\n for (const key of ['origin', 'token'] as const) {\n if (record[key] !== undefined && typeof record[key] !== 'string') {\n problems.push(\n `site '${site}': \\`${key}\\` must be a string — ignoring it and falling back to ` +\n siteEnvName(\n key === 'origin' ? FRONTEND_ORIGIN_ENV : CACHE_INVALIDATION_TOKEN_ENV,\n site,\n ),\n )\n }\n }\n let originOverride = typeof record.origin === 'string' ? record.origin : undefined\n if (originOverride !== undefined && optionalValue(originOverride) !== undefined) {\n const parsed = parseOrigin(originOverride)\n if (!parsed.ok) {\n problems.push(\n `site '${site}': \\`origin\\` is unusable — ${parsed.why}; ignoring it and falling back to ` +\n siteEnvName(FRONTEND_ORIGIN_ENV, site),\n )\n originOverride = undefined\n }\n }\n\n let locales: readonly string[] | undefined\n if (record.locales !== undefined) {\n const rawSiteLocales: readonly unknown[] = Array.isArray(record.locales) ? record.locales : []\n const cleaned = cleanLocales(rawSiteLocales)\n if (!Array.isArray(record.locales) || cleaned.hadNonStrings) {\n problems.push(\n `site '${site}': \\`locales\\` must be an array of locale-code strings — ` +\n 'entries that were not strings have been ignored',\n )\n }\n // An explicit list that came out empty falls back to the plugin-wide\n // one. Honouring it would silently switch off chrome and menu purges for\n // this site alone, which is the stale-header-in-one-place bug again.\n if (cleaned.locales.length > 0) locales = cleaned.locales\n }\n\n targets.push({\n site,\n originOverride: optionalValue(originOverride),\n tokenOverride: optionalValue(typeof record.token === 'string' ? record.token : undefined),\n locales,\n })\n }\n\n if (overCap.length > 0) {\n problems.push(\n `${targets.length + overCap.length} purge targets configured but at most ` +\n `${MAX_PURGE_TARGETS} are supported — these sites will NOT be purged and their ` +\n `pages only refresh when the cache TTL expires: ${overCap.join(', ')}`,\n )\n }\n return targets\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 // `unknown`, not the declared type: like every other option here it reaches\n // this function from an untyped `lumi.config.ts`.\n const rawSites: unknown = config.sites\n const sites = resolveSiteTargets(rawSites, problems)\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 sites,\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(\n config: ResolvedFrontendCacheConfig,\n /**\n * The site target to resolve. Omitted for single-target mode, which reads\n * the plugin-wide overrides and the unsuffixed environment variables.\n */\n site?: ResolvedFrontendCacheSiteTarget,\n): PurgeTarget {\n const names = purgeTargetEnvNames(site?.site)\n // The `default` site is the one a single-site deployment already has, so a\n // deployment that grows a second site keeps working on the variables it was\n // installed with. No other handle falls back: a missing variable for\n // `kulli` must not quietly purge somebody else's frontend instead.\n const unsuffixed = site?.site === DEFAULT_SITE_HANDLE\n\n const rawOrigin =\n (site ? site.originOverride : config.originOverride) ??\n optionalEnv(names.origin) ??\n (unsuffixed ? optionalEnv(FRONTEND_ORIGIN_ENV) : undefined)\n if (rawOrigin === undefined) {\n return { ok: false, why: `${names.origin} is not set` }\n }\n const parsed = parseOrigin(rawOrigin)\n if (!parsed.ok) {\n // A site target can take its origin from three places; name the one that\n // supplied the bad value, or the operator fixes the wrong variable. The\n // single-target message is left exactly as it was.\n if (site === undefined) return { ok: false, why: parsed.why }\n const source =\n site.originOverride !== undefined\n ? `sites[].origin for '${site.site}'`\n : optionalEnv(names.origin) !== undefined\n ? names.origin\n : FRONTEND_ORIGIN_ENV\n return { ok: false, why: `${source}: ${parsed.why}` }\n }\n\n const token =\n (site ? site.tokenOverride : config.tokenOverride) ??\n optionalEnv(names.token) ??\n (unsuffixed ? optionalEnv(CACHE_INVALIDATION_TOKEN_ENV) : undefined)\n if (token === undefined) {\n return { ok: false, why: `${names.token} is not set` }\n }\n\n return { ok: true, url: `${parsed.origin}${config.path}`, token }\n}\n\n/**\n * The environment variables one target reads — the unsuffixed pair for\n * single-target mode, the per-site pair otherwise. Exists so the messages that\n * tell an operator which variable to fix name the one this target actually\n * reads.\n */\nexport function purgeTargetEnvNames(site: string | undefined): {\n readonly origin: string\n readonly token: string\n} {\n if (site === undefined) {\n return { origin: FRONTEND_ORIGIN_ENV, token: CACHE_INVALIDATION_TOKEN_ENV }\n }\n return {\n origin: siteEnvName(FRONTEND_ORIGIN_ENV, site),\n token: siteEnvName(CACHE_INVALIDATION_TOKEN_ENV, site),\n }\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 five-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 * site every response the frontend stores\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/**\n * The one tag EVERY stored response of a frontend carries — pages and sitemaps\n * alike — so naming it evicts the whole of that frontend's route cache in one\n * request (multisite F021).\n *\n * It exists for the changes no content tag can name: a site's languages,\n * default language and URL strategy are baked into every rendered link,\n * `hreflang` set and sitemap entry, and none of those is a row a page was\n * tagged with. Never emitted by {@link tagsForWrite} — a content write has\n * narrower tags, and this one costs a full re-render of the site.\n *\n * Carries no site handle, for the reason `WriteTagInput.site` gives: each\n * frontend serves one site, so WHICH site is decided by which frontend is\n * told, never by the tag.\n *\n * Counterpart: `SITE_TAG` in `apps/front-astro/src/lib/cache-tags.ts`. Both\n * test files pin the literal.\n */\nexport const SITE_TAG = 'site'\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 * The site the written row belongs to — set for a site-bound entity, absent\n * for a shared one. It ROUTES the purge (which frontend is told) and is\n * never part of a tag: each frontend serves one site, so the vocabulary\n * above needs no site in it.\n */\n readonly site?: 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 * ## Several sites, several frontends\n *\n * Everything above describes ONE destination, and with per-site targets\n * configured there is one such sender — a LANE — per site, each with its own\n * pending set, pacing, backoff and buffer cap. That separation is the point:\n * a site whose frontend is answering 429, or not answering at all, must not\n * cost another site a single purge. `notify()` routes: a write to a site-bound\n * row goes to that site's lane only; a write to a shared row (media) goes to\n * every lane.\n *\n * The fan-out is bounded twice. The number of lanes is capped at resolve time\n * (`MAX_PURGE_TARGETS`), and requests across lanes share\n * {@link MAX_CONCURRENT_PURGES} slots — see it for why that is not 1.\n */\n\nimport type { ResolvedFrontendCacheConfig, ResolvedFrontendCacheSiteTarget } from './config.js'\nimport { purgeTargetEnvNames, resolvePurgeTarget } from './config.js'\nimport { SITE_TAG, 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\n/**\n * What became of a site-wide purge request. `queued` means the tag is in the\n * lane and will go out with its next paced flush — NOT that the frontend\n * accepted it; like every purge here, the send itself only ever ends in a log\n * line.\n *\n * - `not-configured` — the target has no usable origin or token.\n * - `site-required` — several targets are configured and no site was named.\n * Never widened to \"all of them\": a change to one site is not a reason to\n * re-render every other site.\n * - `no-target` — the named site has no purge target.\n * - `stopped` — the sender was shut down.\n */\nexport type SitePurgeResult =\n | { readonly queued: true }\n | {\n readonly queued: false\n readonly reason: 'not-configured' | 'site-required' | 'no-target' | 'stopped'\n }\n\nexport interface PurgeSender {\n /** Record a write. Synchronous, allocation-cheap, never throws. */\n notify(event: WriteTagInput): void\n /**\n * Evict EVERYTHING one site's frontend has cached, by queueing the one tag\n * every stored response carries ({@link SITE_TAG}). For a change no content\n * tag can name — the site's languages, default language or URL strategy.\n *\n * Synchronous like `notify()`, and it goes out through the same lane: same\n * destination, token, pacing, backoff and log lines. `site` selects the\n * target when several are configured; with a single target it is ignored,\n * because that one frontend receives everything. `trigger` is only logged.\n */\n purgeSite(site: string | undefined, trigger: string): SitePurgeResult\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\n/**\n * Flushes in flight across ALL lanes at once.\n *\n * Not 1, deliberately. Serialising every site through one chain is the\n * simplest bound, and it makes a dead frontend everybody's problem: each of\n * its flushes holds the chain for the full `requestTimeoutMs`, once per\n * interval, so every other site's purge would sit behind a 3 s timeout for as\n * long as that one site stayed down — the cross-site delay per-lane state\n * exists to prevent.\n *\n * Not unbounded either. Each lane already has at most one flush in flight, so\n * the ceiling without this would be the lane count; four keeps the worst case\n * (a shared-entity write waking every lane together) to a handful of sockets,\n * while it takes four simultaneously hung frontends to delay a healthy one —\n * and then only by one request timeout.\n */\nexport const MAX_CONCURRENT_PURGES = 4\n\n/** Runs `run` once a slot is free. FIFO; the returned promise settles with `run`. */\ntype SlotRunner = (run: () => Promise<void>) => Promise<void>\n\n/**\n * A counting semaphore over flushes. The wait queue cannot grow past the lane\n * count: a lane serialises its own flushes, so it waits here at most once.\n */\nfunction createSlots(max: number): SlotRunner {\n let active = 0\n const waiters: (() => void)[] = []\n return async (run) => {\n if (active >= max) {\n // The slot is handed over by the finishing flush, so `active` is not\n // touched on this path — it never dips, and a newcomer cannot jump the\n // queue in the gap.\n await new Promise<void>((resolve) => {\n waiters.push(resolve)\n })\n } else {\n active += 1\n }\n try {\n await run()\n } finally {\n const next = waiters.shift()\n if (next) next()\n else active -= 1\n }\n }\n}\n\n/**\n * How many distinct unconfigured site handles get their own warning. The\n * handles come from written rows, so the set is bounded by the site registry\n * in practice — and capped here so that it is bounded in fact.\n */\nconst MAX_REPORTED_UNKNOWN_SITES = 64\n\nexport function createPurgeSender(deps: PurgeSenderDeps): PurgeSender {\n const { config, logger } = deps\n const withSlot = createSlots(MAX_CONCURRENT_PURGES)\n\n // Single-target mode: one lane, no site, every write. This is the whole of\n // the sender for an app with one frontend, exactly as before sites existed.\n if (config.sites.length === 0) return createLane(deps, undefined, withSlot)\n\n const lanes = new Map<string, PurgeSender>()\n for (const site of config.sites) lanes.set(site.site, createLane(deps, site, withSlot))\n const all = [...lanes.values()]\n const reportedUnknown = new Set<string>()\n\n return {\n notify(event) {\n if (event.site === undefined) {\n // A shared entity — a media row is on every site's pages. Bounded by\n // `MAX_PURGE_TARGETS`, and each call is a synchronous `Set.add`.\n for (const lane of all) lane.notify(event)\n return\n }\n const lane = lanes.get(event.site)\n if (lane !== undefined) {\n lane.notify(event)\n return\n }\n // Once per handle, not per write: an import into an unconfigured site\n // would otherwise log a line per row.\n if (reportedUnknown.has(event.site)) return\n if (reportedUnknown.size >= MAX_REPORTED_UNKNOWN_SITES) return\n reportedUnknown.add(event.site)\n logger.warn(\n { site: event.site, entity: event.entityName, configured: [...lanes.keys()] },\n 'frontend-cache: no purge target is configured for this site, so its writes purge nothing — ' +\n 'its pages only refresh when the cache TTL expires',\n )\n },\n purgeSite(siteHandle, trigger) {\n // Deliberately not `for (const lane of all)`. One site's settings changed;\n // a caller that cannot say which one gets nothing rather than a\n // re-render of every site this app serves.\n if (siteHandle === undefined) {\n logger.warn(\n { trigger, configured: [...lanes.keys()] },\n 'frontend-cache: a site-wide purge was asked for without naming the site, so nothing was purged',\n )\n return { queued: false, reason: 'site-required' }\n }\n const lane = lanes.get(siteHandle)\n if (lane === undefined) {\n logger.warn(\n { site: siteHandle, trigger, configured: [...lanes.keys()] },\n 'frontend-cache: no purge target is configured for this site, so its site-wide purge was not sent — ' +\n 'its pages only refresh when the cache TTL expires',\n )\n return { queued: false, reason: 'no-target' }\n }\n return lane.purgeSite(siteHandle, trigger)\n },\n async flush() {\n // At most `MAX_PURGE_TARGETS` promises, and the slots above bound how\n // many of them are on the wire together.\n await Promise.all(all.map((lane) => lane.flush()))\n },\n stop() {\n for (const lane of all) lane.stop()\n },\n get pendingCount() {\n let total = 0\n for (const lane of all) total += lane.pendingCount\n return total\n },\n }\n}\n\n/**\n * One destination's sender: everything the module header promises, for a\n * single frontend. `site` is `undefined` in single-target mode.\n */\nfunction createLane(\n deps: PurgeSenderDeps,\n site: ResolvedFrontendCacheSiteTarget | undefined,\n withSlot: SlotRunner,\n): PurgeSender {\n const { logger } = deps\n // A site may serve fewer locales than the app as a whole; the chrome and\n // menu fan-out follows the target's own list when it has one.\n const config: ResolvedFrontendCacheConfig =\n site?.locales !== undefined ? { ...deps.config, locales: site.locales } : deps.config\n /** Which variables this lane reads — so a refusal names the right one. */\n const envNames = purgeTargetEnvNames(site?.site)\n /** Added to every log line of a site lane; empty in single-target mode. */\n const siteField: { site?: string } = site !== undefined ? { site: site.site } : {}\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 {\n ...siteField,\n entity: entityName,\n pending: pending.size,\n cap: config.maxPendingTags,\n dropped,\n },\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 // A site-wide purge is already going out with the next flush, and it\n // evicts everything any tag of this write could name.\n if (pending.has(SITE_TAG)) 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 function purgeSite(_site: string | undefined, trigger: string): SitePurgeResult {\n if (stopped) return { queued: false, reason: 'stopped' }\n // Asked NOW as well as at flush time, so the caller is told rather than\n // left to read it out of a log line a second later.\n const target = resolvePurgeTarget(config, site)\n if (!target.ok) {\n logger.warn(\n { ...siteField, reason: target.why, trigger },\n 'frontend-cache: not configured, so the site-wide purge was not sent',\n )\n return { queued: false, reason: 'not-configured' }\n }\n // Everything already pending is a subset of what this evicts, so the batch\n // becomes this one tag — which also means no cap can ever shed it.\n pending.clear()\n pending.add(SITE_TAG)\n logger.debug({ ...siteField, trigger }, 'frontend-cache: site-wide purge queued')\n schedule()\n return { queued: true }\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 { ...siteField, 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({ ...siteField, 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 ${envNames.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 — ${envNames.origin} probably names a scheme or host the site redirects away from`\n : 'unexpected response'\n logger.warn(\n { ...siteField, 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, site)\n if (!target.ok) {\n logger.warn(\n { ...siteField, 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 { ...siteField, 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 {\n ...siteField,\n dropped: batches.length - i,\n remainingTags: selected.length - i * config.maxTagsPerRequest,\n },\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 /**\n * Chain `run` onto this lane's serialising queue, guaranteeing it never\n * rejects. The slot is taken when the flush reaches the head of the lane's\n * own queue, so a lane waits for at most one slot at a time.\n */\n function enqueue(run: () => Promise<void>): Promise<void> {\n const slotted = (): Promise<void> => withSlot(run)\n inFlight = inFlight.then(slotted, slotted).catch((error: unknown) => {\n try {\n logger.warn(\n { ...siteField, err: describe(error) },\n 'frontend-cache: purge flush failed unexpectedly',\n )\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 purgeSite,\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.
|
|
3
|
+
"version": "0.92.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.
|
|
21
|
-
"@murumets-ee/entity": "0.
|
|
20
|
+
"@murumets-ee/core": "0.92.0",
|
|
21
|
+
"@murumets-ee/entity": "0.92.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"}
|
package/dist/sender-xWC7aWIl.mjs
DELETED
|
@@ -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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"}
|