@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 +4 -1
- package/package.json +4 -2
- package/src/actions.js +21 -3
- package/src/auth.js +8 -1
- package/src/base-path.js +149 -0
- package/src/build-info.js +59 -0
- package/src/cache-fn.js +40 -0
- package/src/cache-tags.js +147 -0
- package/src/conditional-get.js +183 -0
- package/src/context.js +74 -1
- package/src/dev.js +449 -49
- package/src/html-cache.js +305 -0
- package/src/importmap.js +54 -3
- package/src/redirects.js +389 -0
- package/src/route-types.js +176 -0
- package/src/session.js +4 -0
- package/src/ssr.js +210 -9
- package/src/vendor.js +9 -6
- package/webjs-config.schema.json +147 -0
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.
|
|
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(
|
|
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;
|
package/src/base-path.js
ADDED
|
@@ -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
|
+
}
|