@webjsdev/server 0.8.10 → 0.8.12

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/index.js CHANGED
@@ -2,6 +2,7 @@ export { startServer, createRequestHandler } from './src/dev.js';
2
2
  export { assertNodeVersion, checkNodeVersion, requiredNodeMajor, parseMajor, parseRequiredMajor } from './src/node-version.js';
3
3
  export { validateEnv, formatEnvErrors, loadEnvSchema, applyEnvValidation } from './src/env-schema.js';
4
4
  export { buildRouteTable, matchPage, matchApi } from './src/router.js';
5
+ export { generateRouteTypes } from './src/route-types.js';
5
6
  export { ssrPage, ssrNotFound } from './src/ssr.js';
6
7
  export { handleApi } from './src/api.js';
7
8
  export {
@@ -34,12 +35,14 @@ export {
34
35
  } from './src/vendor.js';
35
36
  export { buildModuleGraph, transitiveDeps } from './src/module-graph.js';
36
37
  export { scanComponents, primeComponentRegistry, extractComponents, findOrphanComponents } from './src/component-scanner.js';
37
- export { headers, cookies, getRequest, withRequest, cspNonce } from './src/context.js';
38
+ export { headers, cookies, getRequest, withRequest, cspNonce, requestId } from './src/context.js';
38
39
  export { defaultLogger } from './src/logger.js';
39
40
  export { rateLimit, parseWindow, clientIp, stampRemoteIp } from './src/rate-limit.js';
40
41
  export { cors, resolveOrigin, applyCorsHeaders } from './src/cors.js';
41
42
  export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
42
43
  export { cache } from './src/cache-fn.js';
44
+ export { revalidateTag, revalidateTags } from './src/cache-tags.js';
45
+ export { revalidatePath, revalidateAll } from './src/html-cache.js';
43
46
  export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
44
47
  export { broadcast } from './src/broadcast.js';
45
48
  export { json, readBody } from './src/json.js';
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
7
7
  "exports": {
8
8
  ".": "./index.js",
9
- "./check": "./src/check.js"
9
+ "./check": "./src/check.js",
10
+ "./webjs-config.schema.json": "./webjs-config.schema.json"
10
11
  },
11
12
  "files": [
12
13
  "index.js",
13
14
  "src",
15
+ "webjs-config.schema.json",
14
16
  "README.md"
15
17
  ],
16
18
  "dependencies": {
package/src/actions.js CHANGED
@@ -9,6 +9,8 @@ import { getSerializer } from './serializer.js';
9
9
  import { resolveOrigin } from './cors.js';
10
10
  import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
11
11
  import { getBodyLimits } from './context.js';
12
+ import { basePath } from './importmap.js';
13
+ import { withBasePath } from './base-path.js';
12
14
 
13
15
  /**
14
16
  * The JSON / RPC body cap in effect for the current request: the per-request
@@ -257,6 +259,12 @@ export async function serveActionStub(idx, absFile) {
257
259
  if (typeof mod.default === 'function' && !fnNames.includes('default')) {
258
260
  fnNames.push('default');
259
261
  }
262
+ // The RPC endpoint is a framework-emitted same-origin URL, so it must
263
+ // carry the basePath prefix under a sub-path deploy (#256), exactly like
264
+ // the importmap targets and the boot module specifiers. Without this the
265
+ // stub would POST to a bare /__webjs/action/... that the ingress strip
266
+ // 404s, breaking every server action when webjs.basePath is set.
267
+ const actionUrl = withBasePath(`/__webjs/action/${hash}/`, basePath());
260
268
  const body = `// webjs: generated server-action stub for ${relative(idx.appDir, absFile)}\n` +
261
269
  `import { stringify as __wjStringify, parse as __wjParse } from '@webjsdev/core';\n` +
262
270
  `function __csrf() {\n` +
@@ -265,7 +273,7 @@ export async function serveActionStub(idx, absFile) {
265
273
  `}\n` +
266
274
  `async function __rpc(fn, args) {\n` +
267
275
  ` const body = await __wjStringify(args);\n` +
268
- ` const res = await fetch(${JSON.stringify(`/__webjs/action/${hash}/`)} + fn, {\n` +
276
+ ` const res = await fetch(${JSON.stringify(actionUrl)} + fn, {\n` +
269
277
  ` method: 'POST',\n` +
270
278
  ` headers: {\n` +
271
279
  ` 'content-type': ${JSON.stringify(RPC_CONTENT_TYPE)},\n` +
@@ -301,8 +309,12 @@ export async function serveActionStub(idx, absFile) {
301
309
  * @param {string} hash
302
310
  * @param {string} fnName
303
311
  * @param {Request} req
312
+ * @param {(error: unknown) => void} [onError] best-effort sink (issue #239)
313
+ * invoked when the action throws unexpectedly, BEFORE the sanitized 500 is
314
+ * returned, so an APM integration sees the original error. The caller wraps
315
+ * it so a throwing sink can never affect the response.
304
316
  */
305
- export async function invokeAction(idx, hash, fnName, req) {
317
+ export async function invokeAction(idx, hash, fnName, req, onError) {
306
318
  if (!verifyCsrf(req)) {
307
319
  return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
308
320
  }
@@ -327,6 +339,7 @@ export async function invokeAction(idx, hash, fnName, req) {
327
339
  const result = await fn(...args);
328
340
  return rpcResponse(result ?? null);
329
341
  } catch (e) {
342
+ if (typeof onError === 'function') onError(e);
330
343
  return actionErrorResponse(e, idx.dev);
331
344
  }
332
345
  }
@@ -432,8 +445,12 @@ function matchOrigin(configured, origin) {
432
445
  * @param {ExposedRoute} route
433
446
  * @param {Record<string,string>} params
434
447
  * @param {Request} req
448
+ * @param {(error: unknown) => void} [onError] best-effort sink (issue #239)
449
+ * invoked when the exposed REST handler throws unexpectedly, BEFORE the
450
+ * sanitized 500 is returned, so an APM integration sees the original error.
451
+ * The caller wraps it so a throwing sink can never affect the response.
435
452
  */
436
- export async function invokeExposedAction(idx, route, params, req) {
453
+ export async function invokeExposedAction(idx, route, params, req, onError) {
437
454
  const url = new URL(req.url);
438
455
  const query = Object.fromEntries(url.searchParams.entries());
439
456
  let body = {};
@@ -473,6 +490,7 @@ export async function invokeExposedAction(idx, route, params, req) {
473
490
  if (result instanceof Response) return result;
474
491
  return Response.json(result ?? null);
475
492
  } catch (e) {
493
+ if (typeof onError === 'function') onError(e);
476
494
  return actionErrorResponse(e, idx.dev);
477
495
  }
478
496
  }
package/src/auth.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { getStore } from './cache.js';
11
- import { getRequest, getBodyLimits } from './context.js';
11
+ import { getRequest, getBodyLimits, markDynamicAccess } from './context.js';
12
12
  import { readTextBounded, readFormDataBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
13
13
 
14
14
  const enc = new TextEncoder();
@@ -220,6 +220,13 @@ export function createAuth(config) {
220
220
  // -- Session read/write ---------------------------------------------------
221
221
 
222
222
  async function readSession(req) {
223
+ // Reading the auth session is per-user (the body branches on the logged-in
224
+ // user), so mark the request dynamic so the server HTML cache excludes the
225
+ // page even when it wrongly set `revalidate`, mirroring getSession() /
226
+ // cookies() / headers(). This closes the auth-path leak (#241): `auth()`
227
+ // reaches here, reads the auth cookie raw, and would otherwise leave the
228
+ // page cacheable so a logged-in body could be served to the next visitor.
229
+ markDynamicAccess();
223
230
  const cookies = parseCookies(req.headers.get('cookie') || '');
224
231
  const raw = cookies[AUTH_COOKIE];
225
232
  if (!raw) return null;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Sub-path deployment support: `webjs.basePath` (issue #256).
3
+ *
4
+ * An app deployed under a sub-path (example.com/app/) behind a proxy that
5
+ * does NOT strip the prefix is broken without this: every
6
+ * framework-emitted absolute URL (the importmap targets, the modulepreload
7
+ * hints, the boot script's `/__webjs/core/*` specifiers and per-route
8
+ * module URLs, the dev reload `src`) assumes the app sits at the origin
9
+ * root, so they point at `/__webjs/core/*` instead of
10
+ * `/app/__webjs/core/*`, module resolution 404s, and the page never
11
+ * hydrates. `createRequestHandler` explicitly targets embedding, where a
12
+ * sub-path mount is the norm.
13
+ *
14
+ * The model is strip-at-ingress + prefix-on-emit, two seams only:
15
+ *
16
+ * 1. STRIP AT INGRESS. At the very start of request handling, when the
17
+ * request pathname starts with the basePath, strip it so all
18
+ * downstream logic (route matching, the `/__webjs/*` checks, the
19
+ * source-file gate, redirects, trailing-slash) sees a ROOT-relative
20
+ * path and works UNCHANGED. This single strip is why the rest of the
21
+ * framework needs no per-site changes. `stripBasePath` does the path
22
+ * computation; `dev.js` rewrites the Request URL with it.
23
+ *
24
+ * 2. PREFIX ON EMIT. Every framework-emitted same-origin absolute URL
25
+ * (begins with a single `/`) gets the basePath prepended via the one
26
+ * `withBasePath` helper. Applied to the importmap targets
27
+ * (`importmap.js`), the modulepreload hrefs + boot module specifiers +
28
+ * dev reload `src` (`ssr.js`). A cross-origin URL (a `https://` CDN
29
+ * vendor target) is absolute and is left untouched.
30
+ *
31
+ * Empty basePath (the default) makes both seams pure no-ops, so an
32
+ * unconfigured app is byte-identical to before this feature. That
33
+ * invariant is the #1 risk and is guarded differentially in the tests.
34
+ *
35
+ * OUT OF SCOPE (a documented follow-up, the same boundary Next draws):
36
+ * rewriting AUTHOR-written `<a href="/about">` links and client-router
37
+ * navigation prefixing. This module covers framework-emitted URLs and the
38
+ * ingress match only.
39
+ */
40
+
41
+ /**
42
+ * Normalize a raw `webjs.basePath` value to the canonical internal form:
43
+ * either `''` (the no-op default) or a string with exactly one leading
44
+ * `/` and no trailing `/`. So `'app'`, `'/app'`, and `'/app/'` all map to
45
+ * `'/app'`, and a nested `'/foo/bar'` is preserved.
46
+ *
47
+ * Empty / undefined / non-string / `'/'` all map to `''` (no base path).
48
+ * A value that cannot be a safe, single-origin path prefix is rejected to
49
+ * `''`: anything containing `..` (path traversal), a protocol (`://`), a
50
+ * backslash, whitespace, or a network-path `//host` reference. So a typo
51
+ * or a hostile value fails safe to "no base path" rather than poisoning
52
+ * every emitted URL.
53
+ *
54
+ * @param {unknown} raw the configured value
55
+ * @returns {string} `''` or `/segment[/segment...]`
56
+ */
57
+ export function normalizeBasePath(raw) {
58
+ if (typeof raw !== 'string') return '';
59
+ let v = raw.trim();
60
+ if (v === '' || v === '/') return '';
61
+ // Reject anything that is not a plain same-origin path prefix.
62
+ if (v.includes('..')) return '';
63
+ if (v.includes('://')) return '';
64
+ if (v.includes('\\')) return '';
65
+ if (/\s/.test(v)) return '';
66
+ // Reject a network-path reference (`//host`) BEFORE collapsing leading
67
+ // slashes: such a value would emit a protocol-relative, cross-origin URL
68
+ // once prefixed (an open redirect / origin escape), so it fails safe to
69
+ // "no base path" rather than being collapsed to `/host`.
70
+ if (v.startsWith('//')) return '';
71
+ // Ensure exactly one leading slash.
72
+ v = '/' + v.replace(/^\/+/, '');
73
+ // Strip any trailing slash(es).
74
+ v = v.replace(/\/+$/, '');
75
+ // A leading-slash-only value collapses to '' here (already handled as
76
+ // '/' above, but guard a value like '//' that slipped a different path).
77
+ if (v === '' || v === '/') return '';
78
+ return v;
79
+ }
80
+
81
+ /**
82
+ * Read and normalize the `webjs.basePath` config from a parsed
83
+ * package.json (or any object). Pure; `dev.js` wraps it with the
84
+ * package.json read like the other `webjs.*` readers.
85
+ *
86
+ * @param {unknown} pkg parsed package.json (or any object)
87
+ * @returns {string} the normalized base path (`''` when unset)
88
+ */
89
+ export function readBasePath(pkg) {
90
+ const raw =
91
+ pkg &&
92
+ typeof pkg === 'object' &&
93
+ /** @type {any} */ (pkg).webjs &&
94
+ /** @type {any} */ (pkg).webjs.basePath;
95
+ return normalizeBasePath(raw);
96
+ }
97
+
98
+ /**
99
+ * Prefix a framework-emitted same-origin absolute URL with the base path.
100
+ * Returns the URL unchanged when basePath is empty (the no-op default) or
101
+ * when the URL is not a same-origin absolute path (a cross-origin
102
+ * `https://` CDN target, a protocol-relative `//host` reference, or a
103
+ * relative URL), so only the framework's own `/`-rooted paths are moved.
104
+ *
105
+ * @param {string} url the URL to (maybe) prefix
106
+ * @param {string} basePath the normalized base path (`''` = no-op)
107
+ * @returns {string}
108
+ */
109
+ export function withBasePath(url, basePath) {
110
+ if (!basePath) return url;
111
+ if (typeof url !== 'string') return url;
112
+ // Only a single-leading-slash same-origin path is prefixed. A
113
+ // protocol-relative `//host` or an absolute `scheme://` URL is left
114
+ // alone (a vendor CDN target), as is a relative URL.
115
+ if (url[0] !== '/' || url[1] === '/') return url;
116
+ return basePath + url;
117
+ }
118
+
119
+ /**
120
+ * Compute the root-relative pathname for an incoming request pathname
121
+ * under the base path (the ingress strip). Returns:
122
+ * - the stripped, root-relative pathname (always begins with `/`) when
123
+ * the request is for this app, OR
124
+ * - `null` when the request path is NOT under the base path, so the
125
+ * caller can 404 it (the request is not for this mounted app).
126
+ *
127
+ * Mapping: `<basePath>` and `<basePath>/` both map to the root `/`.
128
+ * `<basePath>/foo` maps to `/foo`. A path that merely shares a prefix but
129
+ * is not a real segment boundary (e.g. `/application` under basePath
130
+ * `/app`) is NOT under the base path and returns null. When basePath is
131
+ * empty this is a pure pass-through (returns the input unchanged).
132
+ *
133
+ * @param {string} pathname the incoming request pathname (begins with `/`)
134
+ * @param {string} basePath the normalized base path (`''` = no-op)
135
+ * @returns {string | null}
136
+ */
137
+ export function stripBasePath(pathname, basePath) {
138
+ if (!basePath) return pathname;
139
+ if (pathname === basePath) return '/';
140
+ const withSlash = basePath + '/';
141
+ if (pathname.startsWith(withSlash)) {
142
+ // Slice off the base path; keep the leading slash of the remainder.
143
+ const rest = pathname.slice(basePath.length);
144
+ return rest || '/';
145
+ }
146
+ // Not under the base path (`/application` vs basePath `/app`, or an
147
+ // unrelated path): not this app.
148
+ return null;
149
+ }
@@ -0,0 +1,59 @@
1
+ import { createRequire } from 'node:module';
2
+ import { publishedBuildId } from './importmap.js';
3
+
4
+ /**
5
+ * Build-info / version probe (issue #239).
6
+ *
7
+ * `GET /__webjs/version` returns a small JSON object a deploy can curl to
8
+ * verify which build is live, alongside the existing `/__webjs/health` and
9
+ * `/__webjs/ready` probes. It carries NO secrets: only the framework version,
10
+ * the published importmap build id (the same value the client router reads
11
+ * from `data-webjs-build` to detect a deploy), the running node version, and
12
+ * process uptime. Served before `ensureReady()` like the other probes, so it
13
+ * answers on a cold instance without blocking on the whole-app analysis.
14
+ *
15
+ * The framework version is read once from this package's own `package.json`,
16
+ * the same single-source pattern `requiredNodeMajor()` uses, so it never drifts
17
+ * from the published version.
18
+ */
19
+
20
+ /** @type {string} */
21
+ let _frameworkVersion = '';
22
+ function frameworkVersion() {
23
+ if (_frameworkVersion) return _frameworkVersion;
24
+ try {
25
+ const require = createRequire(import.meta.url);
26
+ const pkg = require('../package.json');
27
+ _frameworkVersion = String(pkg?.version || '');
28
+ } catch {
29
+ _frameworkVersion = '';
30
+ }
31
+ return _frameworkVersion;
32
+ }
33
+
34
+ /**
35
+ * Compose the build-info payload. Pure (takes the moment as an argument) so a
36
+ * test can assert the shape without mocking the clock; the handler calls it
37
+ * with `process.uptime()`.
38
+ *
39
+ * @param {{ uptime?: number }} [opts]
40
+ * @returns {{ version: string, build: string, node: string, uptime: number }}
41
+ */
42
+ export function buildInfo(opts = {}) {
43
+ return {
44
+ version: frameworkVersion(),
45
+ build: publishedBuildId(),
46
+ node: process.version,
47
+ uptime: typeof opts.uptime === 'number' ? opts.uptime : process.uptime(),
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Build the `GET /__webjs/version` response. `no-store` so a proxy / browser
53
+ * never caches a stale build fingerprint.
54
+ *
55
+ * @returns {Response}
56
+ */
57
+ export function buildInfoResponse() {
58
+ return Response.json(buildInfo(), { headers: { 'cache-control': 'no-store' } });
59
+ }
package/src/cache-fn.js CHANGED
@@ -24,6 +24,7 @@
24
24
  */
25
25
 
26
26
  import { getStore } from './cache.js';
27
+ import { addKeyToTags } from './cache-tags.js';
27
28
 
28
29
  /**
29
30
  * Wrap an async function with server-side caching.
@@ -33,9 +34,18 @@ import { getStore } from './cache.js';
33
34
  * @param {{
34
35
  * key: string,
35
36
  * ttl?: number,
37
+ * tags?: string[] | ((...args: Parameters<T>) => string[]),
36
38
  * }} opts
37
39
  * - `key`: cache key prefix. Combined with serialized args to form the full key.
38
40
  * - `ttl`: time-to-live in seconds. Default: 60.
41
+ * - `tags`: optional tags this cached result belongs to, for cross-module
42
+ * invalidation via `revalidateTag(tag)`. Either a static `string[]`
43
+ * (every cached entry of this function shares them) or a function
44
+ * `(...args) => string[]` so a per-arg read tags with the entity id
45
+ * (e.g. `tags: (id) => ['post:' + id]`). The result is also recorded
46
+ * under each tag's thin key index so `revalidateTag` can find and
47
+ * evict it later, including arg-specific entries that the no-args
48
+ * `invalidate()` cannot reach.
39
49
  * @returns {T & { invalidate: () => Promise<void> }}
40
50
  * The cached function with the same signature, plus an `invalidate()`
41
51
  * method to manually clear the cache.
@@ -43,6 +53,19 @@ import { getStore } from './cache.js';
43
53
  export function cache(fn, opts) {
44
54
  const prefix = opts.key;
45
55
  const ttlMs = (opts.ttl ?? 60) * 1000;
56
+ const tagsOpt = opts.tags;
57
+
58
+ /**
59
+ * Resolve the tag list for one call. A function form receives the call
60
+ * args (so a per-entity read can tag with the id); a static array is
61
+ * returned as-is. Anything else yields no tags.
62
+ * @param {any[]} args
63
+ * @returns {string[]}
64
+ */
65
+ function tagsFor(args) {
66
+ const raw = typeof tagsOpt === 'function' ? tagsOpt(...args) : tagsOpt;
67
+ return Array.isArray(raw) ? raw.filter((t) => typeof t === 'string' && t) : [];
68
+ }
46
69
 
47
70
  const wrapped = /** @type {T & { invalidate: () => Promise<void> }} */ (
48
71
  async function (...args) {
@@ -58,6 +81,23 @@ export function cache(fn, opts) {
58
81
 
59
82
  const result = await fn(...args);
60
83
  await store.set(cacheKey, JSON.stringify(result), ttlMs);
84
+ // Record tag -> cacheKey in the thin tag index so a later
85
+ // revalidateTag can find and evict this entry (including
86
+ // arg-specific keys the no-args invalidate() cannot reach).
87
+ // Best-effort: the value is already stored, so taggability must
88
+ // never break the cached call. A user tags() function that throws
89
+ // (e.g. reading post.id off a null arg), or an index write that
90
+ // fails, leaves the value cached (just untagged) and returns
91
+ // normally. tagsFor() is INSIDE the try because it runs the
92
+ // user-supplied function.
93
+ try {
94
+ await addKeyToTags(tagsFor(args), cacheKey, ttlMs);
95
+ } catch (err) {
96
+ console.warn(
97
+ `[webjs] cache(${prefix}): tag indexing failed, value is cached ` +
98
+ `but untagged (revalidateTag will not reach it): ${err && err.message ? err.message : err}`
99
+ );
100
+ }
61
101
  return result;
62
102
  }
63
103
  );
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Tag-based invalidation for server `cache()` (the Next.js revalidateTag
3
+ * model), built as a THIN key-index over the existing pluggable store
4
+ * (`get` / `set` / `delete` in `cache.js`), NOT a new subsystem.
5
+ *
6
+ * The problem it solves: `cache(fn, { key, ttl })` can only be invalidated
7
+ * by calling `wrapped.invalidate()` from the module that owns the wrapper,
8
+ * and even then only the no-args base key (arg-specific keys leak until
9
+ * their TTL). There was no way for an unrelated mutation (createComment)
10
+ * to invalidate a related read (postById) without importing every wrapper.
11
+ *
12
+ * The index: when a cached result is stored, `cache-fn.js` also records the
13
+ * mapping `tag -> cacheKey` here. Each tag holds a JSON array of the cache
14
+ * keys tagged with it, under the namespaced store key `cache:tag:<tag>`.
15
+ * `revalidateTag(tag)` reads that array, deletes every cache key in it, then
16
+ * clears the index entry. A mutation in ANY module can therefore evict every
17
+ * read tagged `'posts'` across modules with one explicit call.
18
+ *
19
+ * It stays a thin index over `store.get/set/delete`: no new store method, a
20
+ * plain JSON array (a Set is trivial in the memory store; the same get/set of
21
+ * a JSON array works for Redis). The cohesive companion for HTML paths is
22
+ * `revalidatePath` in `html-cache.js`; together they are the server cache
23
+ * invalidation surface (this one for `cache()` DATA, that one for cached HTML).
24
+ *
25
+ * MULTI-INSTANCE CAVEAT (mirrors #241): the index is a plain read-modify-write
26
+ * of a JSON array, NOT atomic across processes. With a shared Redis store,
27
+ * `revalidateTag` deletes the keys it can see and reaches every instance for
28
+ * those keys, but two instances appending to the same tag concurrently can
29
+ * lose an append (last write wins), so a freshly-stored key on a peer might
30
+ * miss eviction and live until its TTL. The tag index entry itself also
31
+ * carries the cache TTL, so the index self-prunes and never grows unbounded.
32
+ * For strict cross-instance invalidation, prefer a short `ttl` as the floor.
33
+ *
34
+ * @module cache-tags
35
+ */
36
+
37
+ import { getStore } from './cache.js';
38
+
39
+ /** Namespace prefix for every tag-index key, parallel to `cache:` entries. */
40
+ const TAG_PREFIX = 'cache:tag:';
41
+
42
+ /** @param {string} tag @returns {string} */
43
+ function tagKey(tag) {
44
+ return `${TAG_PREFIX}${tag}`;
45
+ }
46
+
47
+ /**
48
+ * Read the cache-key set stored under one tag. Returns a plain array (empty
49
+ * on a miss / parse error, failing open). The store holds a JSON array.
50
+ *
51
+ * @param {string} tag
52
+ * @returns {Promise<string[]>}
53
+ */
54
+ async function readTagKeys(tag) {
55
+ try {
56
+ const raw = await getStore().get(tagKey(tag));
57
+ if (!raw) return [];
58
+ const arr = JSON.parse(raw);
59
+ return Array.isArray(arr) ? arr : [];
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Append a cache key to every given tag's index (deduped). Called by
67
+ * `cache-fn.js` right after it stores a cached result. The index entry
68
+ * carries the same TTL as the cached value so it self-prunes and the tag
69
+ * index never outgrows the data it points at.
70
+ *
71
+ * Best-effort: a store failure here never affects the cached result that was
72
+ * already written (the value is still served; only its taggability is lost).
73
+ *
74
+ * @param {string[]} tags
75
+ * @param {string} cacheKey
76
+ * @param {number} [ttlMs]
77
+ * @returns {Promise<void>}
78
+ */
79
+ export async function addKeyToTags(tags, cacheKey, ttlMs) {
80
+ if (!Array.isArray(tags) || tags.length === 0) return;
81
+ const store = getStore();
82
+ for (const tag of tags) {
83
+ if (typeof tag !== 'string' || !tag) continue;
84
+ try {
85
+ const keys = await readTagKeys(tag);
86
+ if (keys.includes(cacheKey)) continue;
87
+ keys.push(cacheKey);
88
+ await store.set(tagKey(tag), JSON.stringify(keys), ttlMs);
89
+ } catch {
90
+ /* a tag-index write failure must never affect the cached value */
91
+ }
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Evict every cached entry tagged with `tag`, then clear the tag index.
97
+ * A mutating server action calls this after a write so the next read of any
98
+ * tagged query recomputes. Works across modules: the read tagged `'posts'`
99
+ * in one module is invalidated by a `revalidateTag('posts')` issued from
100
+ * any other.
101
+ *
102
+ * ```js
103
+ * // modules/comments/actions/create-comment.server.ts
104
+ * 'use server';
105
+ * import { revalidateTag } from '@webjsdev/server';
106
+ * export async function createComment(input) {
107
+ * await prisma.comment.create({ data: input });
108
+ * await revalidateTag('post:' + input.postId); // postById(postId) recomputes
109
+ * return { success: true };
110
+ * }
111
+ * ```
112
+ *
113
+ * @param {string} tag
114
+ * @returns {Promise<void>}
115
+ */
116
+ export async function revalidateTag(tag) {
117
+ if (typeof tag !== 'string' || !tag) return;
118
+ const store = getStore();
119
+ const keys = await readTagKeys(tag);
120
+ for (const k of keys) {
121
+ try {
122
+ await store.delete(k);
123
+ } catch {
124
+ /* a delete failure is non-fatal: the entry still expires via its TTL */
125
+ }
126
+ }
127
+ try {
128
+ await store.delete(tagKey(tag));
129
+ } catch {
130
+ /* non-fatal */
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Convenience: `revalidateTag` for several tags in one call. A mutation that
136
+ * touches multiple cached surfaces (e.g. a post AND the post list) evicts
137
+ * them together.
138
+ *
139
+ * @param {string[]} tags
140
+ * @returns {Promise<void>}
141
+ */
142
+ export async function revalidateTags(tags) {
143
+ if (!Array.isArray(tags)) return;
144
+ for (const tag of tags) {
145
+ await revalidateTag(tag);
146
+ }
147
+ }