@webjsdev/server 0.8.38 → 0.8.40

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.d.ts CHANGED
@@ -59,6 +59,15 @@ export interface CacheStore {
59
59
  delete(key: string): Promise<void>;
60
60
  /** Atomically increment a counter; returns the new value (TTL set on creation only). */
61
61
  increment(key: string, ttlMs?: number): Promise<number>;
62
+ /**
63
+ * OPTIONAL atomic set insert for the tag index (#752): add `member` to the set
64
+ * at `key`, refreshing the key TTL. Eliminates the lost-update race of the
65
+ * read-modify-write JSON path. A store omitting it falls back to that path.
66
+ * Paired with `setMembers` (implement both or neither).
67
+ */
68
+ setAdd?(key: string, member: string, ttlMs?: number): Promise<void>;
69
+ /** OPTIONAL set read, the counterpart of `setAdd` (empty array on a miss). */
70
+ setMembers?(key: string): Promise<string[]>;
62
71
  }
63
72
 
64
73
  /** The pluggable logger interface. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.38",
3
+ "version": "0.8.40",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Server-action error sanitization (#749).
3
+ *
4
+ * A thrown server action must NOT leak its raw error message to the client in
5
+ * production. Most thrown errors are NOT author-controlled (a Postgres driver
6
+ * error carries the constraint / column name, an `ECONNREFUSED` carries an
7
+ * internal host / IP, an fs error carries an absolute server path), and the RPC
8
+ * client surfaces the message. So in prod we return a GENERIC message plus a
9
+ * short `digest`, and log the full error server-side keyed by that digest, the
10
+ * same model as Next.js's server-action errors. An author-facing user-safe
11
+ * message belongs on the `ActionResult` `{ success: false, error }` envelope
12
+ * (the expected-failure channel), not on a throw.
13
+ *
14
+ * Used by both the buffered RPC error path (`actions.js`) and the streaming
15
+ * error frame (`action-stream.js`); it is a leaf module so neither importer
16
+ * creates a cycle.
17
+ *
18
+ * @module action-error
19
+ */
20
+
21
+ import { isRedirect, isNotFound } from '@webjsdev/core';
22
+ import { digestHex } from './crypto-utils.js';
23
+
24
+ /** The generic client-facing message for a sanitized prod action error. */
25
+ export const GENERIC_ERROR_MESSAGE = 'Internal server error';
26
+
27
+ /**
28
+ * `redirect()` / `notFound()` throw `Error` sentinels tagged with a `__webjs`
29
+ * SYMBOL VALUE. They are control flow, not errors to sanitize, and their
30
+ * message is not sensitive, so the error path passes them through unchanged.
31
+ *
32
+ * Reuses core's authoritative `isRedirect` / `isNotFound`, which match the exact
33
+ * sentinel symbol value (`__webjs === REDIRECT` / `=== NOT_FOUND`), NOT mere
34
+ * property presence: a genuine error that happens to carry a `__webjs` property
35
+ * must still be sanitized, never passed through.
36
+ *
37
+ * @param {unknown} err
38
+ * @returns {boolean}
39
+ */
40
+ export function isControlFlowThrow(err) {
41
+ return isRedirect(err) || isNotFound(err);
42
+ }
43
+
44
+ /**
45
+ * A short, stable correlation digest for a thrown error (a hash of its message
46
+ * and stack). The SAME value is logged server-side and returned to the client,
47
+ * so a generic client error maps back to the full server log line, and two
48
+ * occurrences of the same error share a digest (groupable in an APM).
49
+ *
50
+ * @param {unknown} err
51
+ * @returns {Promise<string>}
52
+ */
53
+ export async function errorDigest(err) {
54
+ const basis = err instanceof Error ? `${err.message}\n${err.stack || ''}` : String(err);
55
+ return (await digestHex('SHA-256', basis)).slice(0, 10);
56
+ }
@@ -26,73 +26,39 @@
26
26
  * same module Bun would have loaded.
27
27
  */
28
28
 
29
- /** Bun's global install cache + node_modules: a dep, never an app file to rewrite. */
30
- const DEP_PATH_RE = /[\\/](?:node_modules|install[\\/]cache|\.bun)[\\/]/;
31
-
32
29
  /**
33
- * Install the webjs `Bun.plugin` `onLoad`. It carries two independent transforms
34
- * on ONE handler (Bun `onLoad` is first-match-wins, so a second overlapping
35
- * plugin would starve one of them):
36
- *
37
- * - **Pin rewrite (#685)**, when `pinTransform` is supplied: rewrite bare
38
- * specifiers of declared deps to inline-versioned ones so Bun zero-install
39
- * fetches the pinned version, not latest. Applies to APP files only (a dep
40
- * in `node_modules` / the global cache is returned raw). This broadens the
41
- * filter to all JS/TS; without it the filter stays the cheap `*.server.*`
42
- * pre-screen.
43
- * - **Seed facade (#472)**, when `seedEnabled`: facet a `'use server'`
44
- * candidate into the SSR action-result seed facade, built from the
45
- * (already pin-rewritten) source.
46
- *
47
- * Idempotency is the caller's responsibility (`registerSeedHooks` guards on
48
- * `_registered`).
30
+ * Install the `Bun.plugin` seed `onLoad`. Idempotency is the caller's
31
+ * responsibility (`registerSeedHooks` guards on `_registered`).
49
32
  *
50
33
  * @param {{
51
34
  * isSeedCandidate: (specifier: string) => boolean,
52
35
  * buildSeedFacade: (origSpec: string, absPath: string, src: string) => (string | null),
53
36
  * serverFileRe: RegExp,
54
- * seedEnabled?: boolean,
55
- * pinTransform?: ((src: string, loader: 'ts' | 'js') => string) | null,
56
37
  * }} helpers
57
38
  */
58
- export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe, seedEnabled = true, pinTransform = null }) {
59
- // Pinning needs to see every app module's imports, so broaden the filter when
60
- // it is active; otherwise keep the narrow `*.server.*` seed pre-screen.
61
- const filter = pinTransform ? /\.m?[jt]s(\?|$)/ : serverFileRe;
39
+ export function installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe }) {
62
40
  Bun.plugin({
63
- name: 'webjs-onload',
41
+ name: 'webjs-action-seed',
64
42
  setup(build) {
65
- build.onLoad({ filter }, async (args) => {
43
+ // The filter is a cheap path pre-screen (`*.server.*`, optional query).
44
+ build.onLoad({ filter: serverFileRe }, async (args) => {
66
45
  const absPath = args.path.split('?')[0];
67
46
  // `.ts` / `.mts` strip via the `ts` loader; `.js` / `.mjs` via `js`.
68
47
  const loader = /\.m?ts$/.test(absPath) ? 'ts' : 'js';
69
- // A dependency (node_modules or Bun's global cache): never rewrite it
70
- // (the app's package.json does not pin transitive deps; they follow from
71
- // the pinned direct deps' own manifests). Return the raw source unchanged.
72
- if (pinTransform && DEP_PATH_RE.test(absPath)) {
73
- return { contents: await Bun.file(absPath).text(), loader };
74
- }
75
48
  // Read the real source. A genuine read failure (missing file) propagates,
76
49
  // which Bun reports as a load error exactly as it would without the plugin.
77
- let src = await Bun.file(absPath).text();
78
- // Pin rewrite. For a NON-`.server` module this is where its specifiers get
79
- // pinned. A `.server` candidate's REAL module is pinned on its
80
- // `?webjs-seed-orig` passthrough load (which re-enters this onLoad); the
81
- // facade itself only reads export names, unaffected by the rewrite.
82
- if (pinTransform) {
83
- try { src = pinTransform(src, loader); } catch { /* fail-open: raw source */ }
84
- }
50
+ const src = await Bun.file(absPath).text();
85
51
  try {
86
52
  // Facet only a `'use server'` candidate (not the `?webjs-seed-orig`
87
53
  // passthrough); a non-candidate or a passthrough falls through to the
88
- // (possibly pin-rewritten) source below.
89
- if (seedEnabled && isSeedCandidate(args.path)) {
54
+ // raw source below.
55
+ if (isSeedCandidate(args.path)) {
90
56
  const source = buildSeedFacade(args.path, absPath, src);
91
57
  if (source != null) return { contents: source, loader: 'js' };
92
58
  }
93
59
  } catch {
94
- // Fail-open: any faceting error serves the source (no seeding for this
95
- // module), the Bun analog of the Node hook's `nextLoad` fallback.
60
+ // Fail-open: any faceting error serves the raw source (no seeding for
61
+ // this module), the Bun analog of the Node hook's `nextLoad` fallback.
96
62
  }
97
63
  return { contents: src, loader };
98
64
  });
@@ -50,14 +50,12 @@
50
50
 
51
51
  import * as nodeModule from 'node:module';
52
52
  import { AsyncLocalStorage } from 'node:async_hooks';
53
- import { readFileSync, existsSync } from 'node:fs';
54
- import { join } from 'node:path';
53
+ import { readFileSync } from 'node:fs';
55
54
  import { fileURLToPath } from 'node:url';
56
55
  import { stringify } from '@webjsdev/core';
57
56
  import { hashFile } from './actions.js';
58
57
  import { isStreamable } from './action-stream.js';
59
58
  import { serverRuntime } from './listener-core.js';
60
- import { resolveDepVersions, rewriteDepSpecifiers } from './bun-pin-rewrite.js';
61
59
 
62
60
  /** Ambient per-render seed collector. `Map<key, value>` or undefined. */
63
61
  const als = new AsyncLocalStorage();
@@ -298,73 +296,27 @@ function seedLoadHook(url, context, nextLoad) {
298
296
  }
299
297
 
300
298
  /**
301
- * Build the per-file pin transform for Bun (#685): read the app's declared dep
302
- * versions (package.json, with `bun.lock` exact pins preferred) and return a
303
- * function that rewrites a module's bare dep specifiers to inline-versioned ones
304
- * so Bun zero-install fetches the pinned version, not latest. Returns null when
305
- * there is nothing to pin (no package.json, or no declared deps). Bun-only: it
306
- * references `Bun.Transpiler`, so it is invoked only on the Bun branch.
307
- * @param {string} appDir
308
- * @returns {((src: string, loader: 'ts' | 'js') => string) | null}
309
- */
310
- export function buildBunPinTransform(appDir) {
311
- // Pinning is for TRUE zero-install only. When `node_modules` exists (an
312
- // installed app, or a workspace member like this repo's own examples), Bun
313
- // resolves bare specifiers from it, so injecting an inline version would
314
- // bypass that. Worse, for a workspace-linked dep it would swap the local
315
- // package for the published one (the #698 blog-on-Bun regression). So skip
316
- // pinning entirely when node_modules is present; Bun uses the installed copy.
317
- if (existsSync(join(appDir, 'node_modules'))) return null;
318
- let pkgText;
319
- try { pkgText = readFileSync(join(appDir, 'package.json'), 'utf8'); } catch { return null; }
320
- let lockText = null;
321
- try { lockText = readFileSync(join(appDir, 'bun.lock'), 'utf8'); } catch { /* optional */ }
322
- const versions = resolveDepVersions(pkgText, lockText);
323
- if (Object.keys(versions).length === 0) return null;
324
- return (src, loader) => {
325
- const imports = new Bun.Transpiler({ loader }).scanImports(src);
326
- return rewriteDepSpecifiers(src, imports, versions);
327
- };
328
- }
329
-
330
- /**
331
- * Install the boot-time load hook(s) (idempotent). Called once from `dev.js`,
332
- * BEFORE any app module is imported (a module loaded before the hook would
333
- * already be cached untransformed). Two concerns ride here:
334
- *
335
- * - **Seeding (#472)** when `seedEnabled`: facet `'use server'` modules into
336
- * the SSR action-result seed facade. Node uses `module.registerHooks`; Bun a
337
- * `Bun.plugin` `onLoad`.
338
- * - **Pinning (#685)** when `pinEnabled` (Bun only): rewrite bare dep
339
- * specifiers to the package.json/bun.lock version so zero-install fetches the
340
- * pinned version, not latest. Independent of seeding, so the Bun plugin is
341
- * installed when EITHER is on (they share the one first-match-wins onLoad).
342
- *
343
- * No-op on a second call. Async because the Bun path dynamically imports
344
- * `action-seed-bun.js` (so the `Bun.*` global is never referenced on Node).
345
- *
346
- * @param {{ appDir?: string | null, seedEnabled?: boolean, pinEnabled?: boolean }} [opts]
299
+ * Install the seed load hook (idempotent). Called once at boot from `dev.js`
300
+ * when seeding is enabled, BEFORE any action module is imported (a module loaded
301
+ * before the hook would already be cached unwrapped). The install mechanism is
302
+ * chosen by `serverRuntime()` (#529): Node's synchronous `module.registerHooks`,
303
+ * or a `Bun.plugin` `onLoad` on Bun. A no-op on a second call. Async because the
304
+ * Bun path dynamically imports `action-seed-bun.js` (so the `Bun.*` global is
305
+ * never referenced on Node); the Node path resolves synchronously.
347
306
  * @returns {Promise<void>}
348
307
  */
349
- export async function registerSeedHooks({ appDir = null, seedEnabled = true, pinEnabled = false } = {}) {
308
+ export async function registerSeedHooks() {
350
309
  if (_registered) return;
351
310
  _registered = true;
352
311
 
353
312
  if (serverRuntime() === 'bun') {
354
- // Bun has no module.registerHooks; install the facade + pin rewrite via one
355
- // Bun.plugin onLoad (first-match-wins, so the two transforms must share it).
313
+ // Bun has no module.registerHooks; install the same facade via Bun.plugin.
356
314
  const { installBunSeedPlugin } = await import('./action-seed-bun.js');
357
- const pinTransform = (pinEnabled && appDir) ? buildBunPinTransform(appDir) : null;
358
- installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE, seedEnabled, pinTransform });
359
- if (seedEnabled) _enabled = true;
315
+ installBunSeedPlugin({ isSeedCandidate, buildSeedFacade, serverFileRe: SERVER_FILE_RE });
316
+ _enabled = true;
360
317
  return;
361
318
  }
362
319
 
363
- // Node: seeding only (Bun-specific inline-version pinning does not apply; the
364
- // Node zero-install resolve-hook is #669's separate concern). Nothing to
365
- // install when seeding is off.
366
- if (!seedEnabled) return;
367
-
368
320
  // A runtime that is neither Node-with-registerHooks nor Bun: seeding stays
369
321
  // OFF (fail-open). `seedingEnabled()` is false, ssr.js emits no seed block,
370
322
  // and the client RPC stub falls back to a normal fetch. Never wrong data.
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { encodeFrame, FRAME_CHUNK, FRAME_END, FRAME_ERROR, STREAM_CONTENT_TYPE } from '@webjsdev/core';
23
23
  import { getSerializer } from './serializer.js';
24
+ import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
24
25
 
25
26
  /**
26
27
  * Whether an action's return value should stream over the RPC wire rather than
@@ -73,11 +74,11 @@ function toAsyncIterator(source) {
73
74
  * iterator, stopping a server generator.
74
75
  *
75
76
  * @param {any} source the streamable action return value
76
- * @param {{ signal?: AbortSignal, onError?: (e: unknown) => void, headers?: Record<string,string> }} [opts]
77
+ * @param {{ signal?: AbortSignal, onError?: (e: unknown) => void, headers?: Record<string,string>, dev?: boolean }} [opts]
77
78
  * @returns {Response}
78
79
  */
79
80
  export function streamActionResponse(source, opts = {}) {
80
- const { signal, onError, headers } = opts;
81
+ const { signal, onError, headers, dev } = opts;
81
82
  const s = getSerializer();
82
83
  const enc = new TextEncoder();
83
84
  const iter = toAsyncIterator(source);
@@ -125,8 +126,23 @@ export function streamActionResponse(source, opts = {}) {
125
126
  // like the buffered error path, then ship the author-controlled message
126
127
  // on the frame channel (the status is already 200, mid-stream).
127
128
  if (typeof onError === 'function') { try { onError(e); } catch {} }
128
- console.error('[webjs] streaming action threw:', e);
129
- const msg = e instanceof Error && e.message ? e.message : 'Internal server error';
129
+ // Sanitize like the buffered path (#749): dev ships the real message,
130
+ // prod ships a generic message + correlation digest (logged with the
131
+ // full error), never the raw message. A redirect()/notFound() sentinel
132
+ // passes through. The status is already 200 here, so the error rides the
133
+ // frame channel; the client stub throws Error(frame text).
134
+ let msg;
135
+ if (dev) {
136
+ msg = e instanceof Error && e.message ? e.message : GENERIC_ERROR_MESSAGE;
137
+ console.error('[webjs] streaming action threw:', e);
138
+ } else if (isControlFlowThrow(e)) {
139
+ msg = e instanceof Error ? e.message : String(e);
140
+ console.error('[webjs] streaming action threw:', e);
141
+ } else {
142
+ const digest = await errorDigest(e);
143
+ msg = `${GENERIC_ERROR_MESSAGE} [digest=${digest}]`;
144
+ console.error(`[webjs] streaming action threw [digest=${digest}]:`, e);
145
+ }
130
146
  try { controller.enqueue(encodeFrame(FRAME_ERROR, enc.encode(msg))); } catch {}
131
147
  await releaseSource(e);
132
148
  detach();
package/src/actions.js CHANGED
@@ -15,6 +15,7 @@ import { revalidateTags } from './cache-tags.js';
15
15
  import { runWithActionSignal } from './action-signal.js';
16
16
  import { runActionChain } from './action-middleware.js';
17
17
  import { isStreamable, streamActionResponse } from './action-stream.js';
18
+ import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
18
19
  import { ifNoneMatchSatisfied } from './conditional-get.js';
19
20
  import { getBodyLimits } from './context.js';
20
21
  import { basePath } from './importmap.js';
@@ -349,7 +350,7 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
349
350
  lines.push(` if (ct.includes(__STREAM_CT)) return __readStream(res, fn);`);
350
351
  lines.push(` const text = await res.text();`);
351
352
  lines.push(` const parsed = ct.includes(__CT) ? __p(text) : (ct.includes('application/json') ? JSON.parse(text) : text);`);
352
- lines.push(` if (!res.ok) throw new Error((parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status));`);
353
+ lines.push(` if (!res.ok) { const __e = new Error((parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status)); if (parsed && parsed.digest) __e.digest = parsed.digest; throw __e; }`);
353
354
  lines.push(` return parsed;`);
354
355
  lines.push(`}`);
355
356
  // Decode a framed streamed body into an async generator of rich-deserialized
@@ -533,7 +534,7 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
533
534
  const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
534
535
  if (inv.length) { await revalidateTags(inv); headers['x-webjs-invalidate'] = inv.join(','); }
535
536
  }
536
- return streamActionResponse(result, { signal: req.signal, onError, headers });
537
+ return streamActionResponse(result, { signal: req.signal, onError, headers, dev: idx.dev });
537
538
  }
538
539
  if (method === 'GET') {
539
540
  // A short-circuit (the action did not run, e.g. an auth denial) is NEVER
@@ -588,27 +589,38 @@ async function getActionResponse(result, mod, args, req) {
588
589
  }
589
590
 
590
591
  /**
591
- * Return a JSON error response with dev-vs-prod sanitization.
592
- * In prod we return only the error message (not the stack), and we log the
593
- * full error server-side. Internal errors with no message become a generic
594
- * 500.
592
+ * Return a JSON error response with dev-vs-prod sanitization (#749).
593
+ *
594
+ * Dev returns the real message + stack. PROD returns a GENERIC message plus a
595
+ * short `digest`, and logs the full error server-side keyed by that digest, so
596
+ * a raw error (a DB driver message with a constraint name, an `ECONNREFUSED`
597
+ * with an internal IP, an fs path) never reaches the client while staying
598
+ * diagnosable from the logs. `redirect()` / `notFound()` control-flow sentinels
599
+ * pass through unchanged (not errors, message not sensitive). An author-facing
600
+ * user-safe message belongs on the `ActionResult` `{ success: false, error }`
601
+ * envelope, not on a throw.
595
602
  *
596
603
  * @param {unknown} err
597
604
  * @param {boolean} dev
605
+ * @returns {Promise<Response>}
598
606
  */
599
- function actionErrorResponse(err, dev) {
600
- console.error('[webjs] action threw:', err);
607
+ async function actionErrorResponse(err, dev) {
601
608
  if (dev) {
609
+ console.error('[webjs] action threw:', err);
602
610
  const msg = err instanceof Error ? err.message : String(err);
603
611
  const stack = err instanceof Error ? err.stack : undefined;
604
612
  return rpcResponse({ error: msg, stack }, { status: 500 });
605
613
  }
606
- // Prod: only expose the thrown message (author-controlled), never the stack.
607
- const msg =
608
- err instanceof Error && typeof err.message === 'string' && err.message
609
- ? err.message
610
- : 'Internal server error';
611
- return rpcResponse({ error: msg }, { status: 500 });
614
+ // Control-flow sentinels (redirect/notFound) are not errors to sanitize.
615
+ if (isControlFlowThrow(err)) {
616
+ console.error('[webjs] action threw:', err);
617
+ const msg = err instanceof Error ? err.message : String(err);
618
+ return rpcResponse({ error: msg }, { status: 500 });
619
+ }
620
+ // Prod genuine error: generic message + correlation digest, full error logged.
621
+ const digest = await errorDigest(err);
622
+ console.error(`[webjs] action threw [digest=${digest}]:`, err);
623
+ return rpcResponse({ error: GENERIC_ERROR_MESSAGE, digest }, { status: 500 });
612
624
  }
613
625
 
614
626
  /**
package/src/cache-fn.js CHANGED
@@ -23,9 +23,21 @@
23
23
  * @module cache-fn
24
24
  */
25
25
 
26
+ import { stringify, parse } from '@webjsdev/core';
26
27
  import { getStore } from './cache.js';
27
28
  import { addKeyToTags } from './cache-tags.js';
28
29
 
30
+ // Cache value/key encoding version. Entries written by an older encoding used
31
+ // a DIFFERENT key namespace, so a reader never deserializes them with the new
32
+ // format. Before this segment existed, values were JSON and keys were the
33
+ // unversioned `cache:<prefix>`; a JSON-format entry is still valid JSON that
34
+ // `parse` would silently accept as a lossy value (a Date as a string) without
35
+ // recomputing, so the version segment forces those pre-upgrade entries to miss
36
+ // and expire by their own TTL instead of being served stale. Bump it whenever
37
+ // the value/key encoding changes. Distinct from the `cache:tag:` tag-index
38
+ // namespace in cache-tags.js.
39
+ const CACHE_FORMAT = 'r1';
40
+
29
41
  /**
30
42
  * Wrap an async function with server-side caching.
31
43
  *
@@ -70,17 +82,28 @@ export function cache(fn, opts) {
70
82
  const wrapped = /** @type {T & { invalidate: () => Promise<void> }} */ (
71
83
  async function (...args) {
72
84
  const store = getStore();
85
+ // Both the key fingerprint and the stored value go through the same
86
+ // rich serializer the RPC wire uses (@webjsdev/core stringify/parse),
87
+ // NOT JSON. JSON collapses Date to a string and Map/Set to {} / [], so
88
+ // (a) a cached value would change shape between a cold miss (the real
89
+ // value) and a warm hit (the lossy JSON value), and (b) two distinct
90
+ // Map/Set args would collide to the same key. Going through core's
91
+ // serializer makes a hit byte-faithful to a miss and keys collision-safe.
92
+ // We use core stringify/parse DIRECTLY rather than getSerializer() so the
93
+ // cache preserves full fidelity even when an app swaps the wire format to
94
+ // a lossy one (e.g. plain JSON); cache fidelity is a storage concern, not
95
+ // a wire concern.
73
96
  const cacheKey = args.length
74
- ? `cache:${prefix}:${JSON.stringify(args)}`
75
- : `cache:${prefix}`;
97
+ ? `cache:${CACHE_FORMAT}:${prefix}:${await stringify(args)}`
98
+ : `cache:${CACHE_FORMAT}:${prefix}`;
76
99
 
77
100
  const hit = await store.get(cacheKey);
78
101
  if (hit !== null) {
79
- try { return JSON.parse(hit); } catch { /* corrupted: recompute */ }
102
+ try { return parse(hit); } catch { /* corrupted: recompute */ }
80
103
  }
81
104
 
82
105
  const result = await fn(...args);
83
- await store.set(cacheKey, JSON.stringify(result), ttlMs);
106
+ await store.set(cacheKey, await stringify(result), ttlMs);
84
107
  // Record tag -> cacheKey in the thin tag index so a later
85
108
  // revalidateTag can find and evict this entry (including
86
109
  // arg-specific keys the no-args invalidate() cannot reach).
@@ -115,7 +138,7 @@ export function cache(fn, opts) {
115
138
  wrapped.invalidate = async function () {
116
139
  const store = getStore();
117
140
  // Delete the base key (no-args call)
118
- await store.delete(`cache:${prefix}`);
141
+ await store.delete(`cache:${CACHE_FORMAT}:${prefix}`);
119
142
  // Note: arg-specific keys are not tracked. If the cached function
120
143
  // is called with different arguments, those entries expire via TTL.
121
144
  // For full invalidation of arg-specific keys, use a short TTL.
package/src/cache-tags.js CHANGED
@@ -16,20 +16,22 @@
16
16
  * clears the index entry. A mutation in ANY module can therefore evict every
17
17
  * read tagged `'posts'` across modules with one explicit call.
18
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).
19
+ * The cohesive companion for HTML paths is `revalidatePath` in
20
+ * `html-cache.js`; together they are the server cache invalidation surface
21
+ * (this one for `cache()` DATA, that one for cached HTML).
24
22
  *
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.
23
+ * ATOMICITY (#752): when the store exposes the optional `setAdd` / `setMembers`
24
+ * primitives, the tag index is a real SET, so adding a cache key is an atomic
25
+ * insert (`SADD` on Redis, a native `Set` in the single-process memory store).
26
+ * This eliminates the lost-update race the previous JSON-array read-modify-write
27
+ * had: two concurrent mutations appending to the same tag (whether across Redis
28
+ * instances or interleaved in one process at the read/write await gap) no longer
29
+ * drop an entry, so `revalidateTag` reliably evicts every tagged key. The index
30
+ * key carries the cache TTL (refreshed on each add), so it self-prunes. A custom
31
+ * store WITHOUT the primitives falls back to the old JSON-array path, which keeps
32
+ * the documented non-atomic caveat (prefer a short `ttl` as the cross-instance
33
+ * floor there). The HTML-side analog `revalidatePath` (#241) still carries the
34
+ * older caveat and is a separate follow-up.
33
35
  *
34
36
  * @module cache-tags
35
37
  */
@@ -44,16 +46,30 @@ function tagKey(tag) {
44
46
  return `${TAG_PREFIX}${tag}`;
45
47
  }
46
48
 
49
+ /** @param {import('./cache.js').CacheStore} store */
50
+ function supportsAtomicSets(store) {
51
+ return typeof store.setAdd === 'function' && typeof store.setMembers === 'function';
52
+ }
53
+
47
54
  /**
48
55
  * 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.
56
+ * on a miss / parse error, failing open). Reads via the store's SET primitive
57
+ * when available (the atomic path), else parses the legacy JSON array.
50
58
  *
51
59
  * @param {string} tag
52
60
  * @returns {Promise<string[]>}
53
61
  */
54
62
  async function readTagKeys(tag) {
63
+ const store = getStore();
64
+ if (supportsAtomicSets(store)) {
65
+ try {
66
+ return await store.setMembers(tagKey(tag));
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
55
71
  try {
56
- const raw = await getStore().get(tagKey(tag));
72
+ const raw = await store.get(tagKey(tag));
57
73
  if (!raw) return [];
58
74
  const arr = JSON.parse(raw);
59
75
  return Array.isArray(arr) ? arr : [];
@@ -79,13 +95,21 @@ async function readTagKeys(tag) {
79
95
  export async function addKeyToTags(tags, cacheKey, ttlMs) {
80
96
  if (!Array.isArray(tags) || tags.length === 0) return;
81
97
  const store = getStore();
98
+ const atomic = supportsAtomicSets(store);
82
99
  for (const tag of tags) {
83
100
  if (typeof tag !== 'string' || !tag) continue;
84
101
  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);
102
+ if (atomic) {
103
+ // Atomic set insert: no read-modify-write, so a concurrent add to the
104
+ // same tag (across instances or interleaved in-process) cannot lose
105
+ // this key. SADD dedups, so no membership pre-check is needed.
106
+ await store.setAdd(tagKey(tag), cacheKey, ttlMs);
107
+ } else {
108
+ const keys = await readTagKeys(tag);
109
+ if (keys.includes(cacheKey)) continue;
110
+ keys.push(cacheKey);
111
+ await store.set(tagKey(tag), JSON.stringify(keys), ttlMs);
112
+ }
89
113
  } catch {
90
114
  /* a tag-index write failure must never affect the cached value */
91
115
  }
package/src/cache.js CHANGED
@@ -23,6 +23,15 @@
23
23
  * @property {(key: string, ttlMs?: number) => Promise<number>} increment
24
24
  * Atomically increment a counter. Returns the new value. Creates the
25
25
  * key with value 1 if it doesn't exist. TTL is set on creation only.
26
+ * @property {((key: string, member: string, ttlMs?: number) => Promise<void>)} [setAdd]
27
+ * OPTIONAL. Atomically add `member` to the SET stored at `key`, refreshing the
28
+ * key's TTL. Eliminates the lost-update race of a read-modify-write JSON array
29
+ * (the tag index in `cache-tags.js`, #752). A store WITHOUT this method makes
30
+ * the tag index fall back to the non-atomic JSON path (the documented caveat).
31
+ * Paired with `setMembers`; implement both or neither.
32
+ * @property {((key: string) => Promise<string[]>)} [setMembers]
33
+ * OPTIONAL. Return the members of the SET stored at `key` (empty array on a
34
+ * miss). The read counterpart of `setAdd`.
26
35
  */
27
36
 
28
37
  /**
@@ -98,6 +107,28 @@ export function memoryStore(opts = {}) {
98
107
  map.set(key, entry);
99
108
  return next;
100
109
  },
110
+ // Atomic SET ops (#752). The entry value for a set key is a native `Set`
111
+ // (not a JSON string), so the add is a single synchronous mutation with no
112
+ // read-modify-write await gap: concurrent `setAdd` calls in one process
113
+ // cannot lose an entry. Only the tag index uses these keys, so the mixed
114
+ // value type (string for normal keys, Set for set keys) never collides with
115
+ // `get`/`increment`. The key still rides the same LRU + TTL machinery.
116
+ async setAdd(key, member, ttlMs) {
117
+ const entry = map.get(key);
118
+ const set = entry && !isExpired(entry) && entry.value instanceof Set
119
+ ? entry.value
120
+ : new Set();
121
+ set.add(member);
122
+ map.delete(key); // re-insert at the end (LRU) + refresh TTL like Redis EXPIRE
123
+ map.set(key, { value: set, expiresAt: expiresAtFrom(ttlMs) });
124
+ evict();
125
+ },
126
+ async setMembers(key) {
127
+ const entry = map.get(key);
128
+ if (!entry) return [];
129
+ if (isExpired(entry)) { map.delete(key); return []; }
130
+ return entry.value instanceof Set ? [...entry.value] : [];
131
+ },
101
132
  };
102
133
  }
103
134
 
@@ -172,6 +203,28 @@ export function redisStore(opts = {}) {
172
203
  }
173
204
  return val;
174
205
  },
206
+ // Atomic SET ops (#752): SADD is an atomic set insert across instances, so
207
+ // the tag index no longer loses a concurrent append the way a JSON-array
208
+ // read-modify-write did. A per-member TTL is not native to a Redis set, so
209
+ // refresh the whole key's expiry on each add (PEXPIRE), keeping the
210
+ // short-TTL floor as defense-in-depth. ioredis uses lowercase command names
211
+ // (`sadd`); node-redis v4 uses camelCase (`sAdd`), so probe both.
212
+ async setAdd(key, member, ttlMs) {
213
+ const c = await getClient();
214
+ if (typeof c.sadd === 'function') await c.sadd(key, member);
215
+ else await c.sAdd(key, member);
216
+ if (ttlMs) {
217
+ if (typeof c.pexpire === 'function') await c.pexpire(key, ttlMs);
218
+ else await c.pExpire(key, ttlMs);
219
+ }
220
+ },
221
+ async setMembers(key) {
222
+ const c = await getClient();
223
+ const members = typeof c.smembers === 'function'
224
+ ? await c.smembers(key)
225
+ : await c.sMembers(key);
226
+ return Array.isArray(members) ? members : [];
227
+ },
175
228
  };
176
229
  }
177
230