@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 +9 -0
- package/package.json +1 -1
- package/src/action-error.js +56 -0
- package/src/action-seed-bun.js +11 -45
- package/src/action-seed.js +12 -60
- package/src/action-stream.js +20 -4
- package/src/actions.js +26 -14
- package/src/cache-fn.js +28 -5
- package/src/cache-tags.js +43 -19
- package/src/cache.js +53 -0
- package/src/check.js +12 -8
- package/src/dev.js +31 -33
- package/src/importmap.js +36 -0
- package/src/listener-bun.js +78 -33
- package/src/listener-core.js +175 -1
- package/src/module-graph.js +143 -17
- package/src/page-action.js +9 -0
- package/src/rate-limit.js +60 -5
- package/src/router.js +63 -6
- package/src/ssr.js +100 -2
- package/src/testing.js +5 -1
- package/src/vendor.js +3 -35
- package/webjs-config.schema.json +0 -5
- package/src/bun-pin-rewrite.js +0 -192
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
|
@@ -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
|
+
}
|
package/src/action-seed-bun.js
CHANGED
|
@@ -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
|
|
34
|
-
*
|
|
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
|
|
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-
|
|
41
|
+
name: 'webjs-action-seed',
|
|
64
42
|
setup(build) {
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
89
|
-
if (
|
|
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
|
|
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
|
});
|
package/src/action-seed.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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(
|
|
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
|
|
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
|
-
|
|
358
|
-
|
|
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.
|
package/src/action-stream.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
129
|
-
|
|
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)
|
|
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
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
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
|
-
//
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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}:${
|
|
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
|
|
102
|
+
try { return parse(hit); } catch { /* corrupted: recompute */ }
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
const result = await fn(...args);
|
|
83
|
-
await store.set(cacheKey,
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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).
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|