@webjsdev/server 0.8.39 → 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-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 +30 -2
- 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/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-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
|
|
package/src/check.js
CHANGED
|
@@ -1030,14 +1030,18 @@ export async function checkConventions(appDir) {
|
|
|
1030
1030
|
* `middleware.ts` / `route.ts` (server-only, never page/layout/component
|
|
1031
1031
|
* entries, so they are not in the candidate set to begin with).
|
|
1032
1032
|
*
|
|
1033
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
* fetched), not module load
|
|
1040
|
-
*
|
|
1033
|
+
* Scope note for dynamic imports: a string-literal `import('./widget.ts')` IS
|
|
1034
|
+
* tracked by the module graph now (#751), but only as a GATE edge (so the
|
|
1035
|
+
* lazily-imported module is servable); this rule's server-import detection
|
|
1036
|
+
* still runs over STATIC edges only (`transitiveDeps`), so a dynamic
|
|
1037
|
+
* `import('./x.server.ts')` of a no-`'use server'` utility is not flagged here.
|
|
1038
|
+
* That is deliberate: the throw-at-load crash is deferred to call time (when
|
|
1039
|
+
* the module is actually fetched), not module load, and a dynamic import is
|
|
1040
|
+
* also not elided framework-wide. A computed `import(expr)` cannot be resolved
|
|
1041
|
+
* statically at all; rather than a false-positive-prone check rule (a computed
|
|
1042
|
+
* import of an npm specifier or an otherwise-reachable app module is perfectly
|
|
1043
|
+
* valid, so it fails the check-is-correctness-only dividing line), the dev
|
|
1044
|
+
* server surfaces it with a 404 hint when the target 404s (see dev.js #751).
|
|
1041
1045
|
*
|
|
1042
1046
|
* @param {string} appDir
|
|
1043
1047
|
* @param {Violation[]} violations appended to in place
|
package/src/dev.js
CHANGED
|
@@ -87,6 +87,7 @@ function shouldAccessLog(pathname) {
|
|
|
87
87
|
}
|
|
88
88
|
import { setVendorEntries, setCoreInstall, publishBuildId, setBasePath, basePath, setImportAliasEntries, importAliasBrowserEntries } from './importmap.js';
|
|
89
89
|
import { readBasePath, stripBasePath, withBasePath } from './base-path.js';
|
|
90
|
+
import { propagateTrustedRemoteIp } from './rate-limit.js';
|
|
90
91
|
import { readAllowedOrigins } from './csrf.js';
|
|
91
92
|
import { setAssetRoots, clearAssetHashCache, setElisionFingerprint, withAssetHash, assetHashFor, versionModuleImports } from './asset-hash.js';
|
|
92
93
|
import { urlFromRequest } from './forwarded.js';
|
|
@@ -1221,18 +1222,31 @@ export async function createRequestHandler(opts) {
|
|
|
1221
1222
|
// Rewrite the Request with the stripped URL, preserving method,
|
|
1222
1223
|
// headers, and body. `duplex: 'half'` is required by the spec when
|
|
1223
1224
|
// a body stream is present on a non-GET/HEAD request.
|
|
1225
|
+
//
|
|
1226
|
+
// SECURITY (#756): this is a fresh Request object, so it is NOT in
|
|
1227
|
+
// the listener's out-of-band trusted-IP WeakMap (the Bun shell
|
|
1228
|
+
// stamps the IP there instead of cloning the Request to set the
|
|
1229
|
+
// header). Without carrying it forward, `clientIp` would fall back
|
|
1230
|
+
// to the inbound `x-webjs-remote-ip` header that the copied
|
|
1231
|
+
// `req.headers` still carries, which a client can spoof. So strip
|
|
1232
|
+
// that header on the rebuild and propagate the framework-trusted IP
|
|
1233
|
+
// across the new object, the same pattern page-action.js uses.
|
|
1234
|
+
const headers = new Headers(req.headers);
|
|
1235
|
+
headers.delete('x-webjs-remote-ip');
|
|
1224
1236
|
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
|
|
1225
|
-
|
|
1237
|
+
const next = new Request(
|
|
1226
1238
|
reqUrl.toString(),
|
|
1227
1239
|
/** @type {any} */ ({
|
|
1228
1240
|
method: req.method,
|
|
1229
|
-
headers
|
|
1241
|
+
headers,
|
|
1230
1242
|
body: hasBody ? req.body : undefined,
|
|
1231
1243
|
duplex: hasBody ? 'half' : undefined,
|
|
1232
1244
|
redirect: req.redirect,
|
|
1233
1245
|
signal: req.signal,
|
|
1234
1246
|
}),
|
|
1235
1247
|
);
|
|
1248
|
+
propagateTrustedRemoteIp(req, next);
|
|
1249
|
+
req = next;
|
|
1236
1250
|
}
|
|
1237
1251
|
}
|
|
1238
1252
|
}
|
|
@@ -1903,6 +1917,20 @@ async function handleCore(req, ctx) {
|
|
|
1903
1917
|
}
|
|
1904
1918
|
return fileResponse(abs, { dev, immutable: versioned });
|
|
1905
1919
|
}
|
|
1920
|
+
// Dev hint (#751): the request is for a real app source module that EXISTS
|
|
1921
|
+
// on disk but is NOT in the browser-bound graph, so the gate 404s it. The
|
|
1922
|
+
// most common cause is a dynamic `import()` the static scanner cannot
|
|
1923
|
+
// track: a string-literal `import('./x.ts')` IS tracked and servable, but a
|
|
1924
|
+
// computed `import(expr)` / `import('./' + name)` cannot be resolved
|
|
1925
|
+
// statically and falls through here. Surface the likely cause instead of a
|
|
1926
|
+
// bare 404 so the author is not left guessing. Dev-only and diagnostic; it
|
|
1927
|
+
// does not change the 404 status, only the body + a server log line.
|
|
1928
|
+
if (dev && abs.startsWith(appDir) && /\.m?[jt]s$/.test(abs) && (await exists(abs))) {
|
|
1929
|
+
const rel = relative(appDir, abs);
|
|
1930
|
+
const hint = `[webjs] 404: ${rel} exists but is not reachable from any browser-bound entry, so it is not servable. If you load it via a dynamic import(), use a STRING-LITERAL specifier (e.g. import('./x.ts')) so the scanner can track it; a computed import(expr) cannot be resolved statically and will 404. Otherwise this module is simply unreferenced by client code.`;
|
|
1931
|
+
console.warn(hint);
|
|
1932
|
+
return new Response(hint, { status: 404, headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
1933
|
+
}
|
|
1906
1934
|
}
|
|
1907
1935
|
|
|
1908
1936
|
// Metadata routes: /sitemap.xml, /robots.txt, /icon, /opengraph-image, etc.
|
package/src/importmap.js
CHANGED
|
@@ -459,6 +459,42 @@ export function buildImportMap(opts = {}) {
|
|
|
459
459
|
return out;
|
|
460
460
|
}
|
|
461
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Resolve a set of bare vendor specifiers to `modulepreload` targets (#754), so
|
|
464
|
+
* SSR can flatten the vendor CDN waterfall: instead of discovering each vendor
|
|
465
|
+
* module level by level (fetch app module -> parse -> fetch vendor -> parse ...),
|
|
466
|
+
* the reached vendor URLs are hinted up front and fetched in parallel.
|
|
467
|
+
*
|
|
468
|
+
* The href is taken DIRECTLY from `buildImportMap().imports[spec]`, so it is
|
|
469
|
+
* BYTE-IDENTICAL to the importmap target (same base-path + `?v` rewrite): a
|
|
470
|
+
* differing href would make the browser treat the preload and the import as two
|
|
471
|
+
* resources and double-fetch. The matching `integrity` comes from the same map.
|
|
472
|
+
* A specifier NOT in the importmap (an unpinned / unreached / elided vendor)
|
|
473
|
+
* yields nothing, so this never over-fetches. Duplicate hrefs are collapsed.
|
|
474
|
+
*
|
|
475
|
+
* Framework runtime specifiers (`@webjsdev/core`...) are excluded: core is
|
|
476
|
+
* served same-origin and already on the boot path, not a CDN-waterfall vendor.
|
|
477
|
+
*
|
|
478
|
+
* @param {Iterable<string>} specifiers bare specifiers reached by the page
|
|
479
|
+
* @returns {Array<{ href: string, integrity?: string }>}
|
|
480
|
+
*/
|
|
481
|
+
export function vendorPreloadTargets(specifiers) {
|
|
482
|
+
const specs = [...(specifiers || [])];
|
|
483
|
+
if (!specs.length) return [];
|
|
484
|
+
const map = buildImportMap();
|
|
485
|
+
/** @type {Array<{ href: string, integrity?: string }>} */
|
|
486
|
+
const out = [];
|
|
487
|
+
const seen = new Set();
|
|
488
|
+
for (const spec of specs) {
|
|
489
|
+
if (spec === '@webjsdev/core' || spec.startsWith('@webjsdev/core/')) continue;
|
|
490
|
+
const href = map.imports[spec];
|
|
491
|
+
if (!href || seen.has(href)) continue;
|
|
492
|
+
seen.add(href);
|
|
493
|
+
out.push({ href, integrity: map.integrity ? map.integrity[href] : undefined });
|
|
494
|
+
}
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
|
|
462
498
|
/**
|
|
463
499
|
* Derive the cross-origin vendor CDN origins from the resolved vendor
|
|
464
500
|
* importmap, most-common first (issue #243, auto vendor preconnect). For an
|