@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 CHANGED
@@ -59,6 +59,15 @@ export interface CacheStore {
59
59
  delete(key: string): Promise<void>;
60
60
  /** Atomically increment a counter; returns the new value (TTL set on creation only). */
61
61
  increment(key: string, ttlMs?: number): Promise<number>;
62
+ /**
63
+ * OPTIONAL atomic set insert for the tag index (#752): add `member` to the set
64
+ * at `key`, refreshing the key TTL. Eliminates the lost-update race of the
65
+ * read-modify-write JSON path. A store omitting it falls back to that path.
66
+ * Paired with `setMembers` (implement both or neither).
67
+ */
68
+ setAdd?(key: string, member: string, ttlMs?: number): Promise<void>;
69
+ /** OPTIONAL set read, the counterpart of `setAdd` (empty array on a miss). */
70
+ setMembers?(key: string): Promise<string[]>;
62
71
  }
63
72
 
64
73
  /** The pluggable logger interface. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.39",
3
+ "version": "0.8.40",
4
4
  "type": "module",
5
5
  "description": "webjs dev/prod server: SSR, router, API, server actions, live reload",
6
6
  "main": "index.js",
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Server-action error sanitization (#749).
3
+ *
4
+ * A thrown server action must NOT leak its raw error message to the client in
5
+ * production. Most thrown errors are NOT author-controlled (a Postgres driver
6
+ * error carries the constraint / column name, an `ECONNREFUSED` carries an
7
+ * internal host / IP, an fs error carries an absolute server path), and the RPC
8
+ * client surfaces the message. So in prod we return a GENERIC message plus a
9
+ * short `digest`, and log the full error server-side keyed by that digest, the
10
+ * same model as Next.js's server-action errors. An author-facing user-safe
11
+ * message belongs on the `ActionResult` `{ success: false, error }` envelope
12
+ * (the expected-failure channel), not on a throw.
13
+ *
14
+ * Used by both the buffered RPC error path (`actions.js`) and the streaming
15
+ * error frame (`action-stream.js`); it is a leaf module so neither importer
16
+ * creates a cycle.
17
+ *
18
+ * @module action-error
19
+ */
20
+
21
+ import { isRedirect, isNotFound } from '@webjsdev/core';
22
+ import { digestHex } from './crypto-utils.js';
23
+
24
+ /** The generic client-facing message for a sanitized prod action error. */
25
+ export const GENERIC_ERROR_MESSAGE = 'Internal server error';
26
+
27
+ /**
28
+ * `redirect()` / `notFound()` throw `Error` sentinels tagged with a `__webjs`
29
+ * SYMBOL VALUE. They are control flow, not errors to sanitize, and their
30
+ * message is not sensitive, so the error path passes them through unchanged.
31
+ *
32
+ * Reuses core's authoritative `isRedirect` / `isNotFound`, which match the exact
33
+ * sentinel symbol value (`__webjs === REDIRECT` / `=== NOT_FOUND`), NOT mere
34
+ * property presence: a genuine error that happens to carry a `__webjs` property
35
+ * must still be sanitized, never passed through.
36
+ *
37
+ * @param {unknown} err
38
+ * @returns {boolean}
39
+ */
40
+ export function isControlFlowThrow(err) {
41
+ return isRedirect(err) || isNotFound(err);
42
+ }
43
+
44
+ /**
45
+ * A short, stable correlation digest for a thrown error (a hash of its message
46
+ * and stack). The SAME value is logged server-side and returned to the client,
47
+ * so a generic client error maps back to the full server log line, and two
48
+ * occurrences of the same error share a digest (groupable in an APM).
49
+ *
50
+ * @param {unknown} err
51
+ * @returns {Promise<string>}
52
+ */
53
+ export async function errorDigest(err) {
54
+ const basis = err instanceof Error ? `${err.message}\n${err.stack || ''}` : String(err);
55
+ return (await digestHex('SHA-256', basis)).slice(0, 10);
56
+ }
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { encodeFrame, FRAME_CHUNK, FRAME_END, FRAME_ERROR, STREAM_CONTENT_TYPE } from '@webjsdev/core';
23
23
  import { getSerializer } from './serializer.js';
24
+ import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
24
25
 
25
26
  /**
26
27
  * Whether an action's return value should stream over the RPC wire rather than
@@ -73,11 +74,11 @@ function toAsyncIterator(source) {
73
74
  * iterator, stopping a server generator.
74
75
  *
75
76
  * @param {any} source the streamable action return value
76
- * @param {{ signal?: AbortSignal, onError?: (e: unknown) => void, headers?: Record<string,string> }} [opts]
77
+ * @param {{ signal?: AbortSignal, onError?: (e: unknown) => void, headers?: Record<string,string>, dev?: boolean }} [opts]
77
78
  * @returns {Response}
78
79
  */
79
80
  export function streamActionResponse(source, opts = {}) {
80
- const { signal, onError, headers } = opts;
81
+ const { signal, onError, headers, dev } = opts;
81
82
  const s = getSerializer();
82
83
  const enc = new TextEncoder();
83
84
  const iter = toAsyncIterator(source);
@@ -125,8 +126,23 @@ export function streamActionResponse(source, opts = {}) {
125
126
  // like the buffered error path, then ship the author-controlled message
126
127
  // on the frame channel (the status is already 200, mid-stream).
127
128
  if (typeof onError === 'function') { try { onError(e); } catch {} }
128
- console.error('[webjs] streaming action threw:', e);
129
- const msg = e instanceof Error && e.message ? e.message : 'Internal server error';
129
+ // Sanitize like the buffered path (#749): dev ships the real message,
130
+ // prod ships a generic message + correlation digest (logged with the
131
+ // full error), never the raw message. A redirect()/notFound() sentinel
132
+ // passes through. The status is already 200 here, so the error rides the
133
+ // frame channel; the client stub throws Error(frame text).
134
+ let msg;
135
+ if (dev) {
136
+ msg = e instanceof Error && e.message ? e.message : GENERIC_ERROR_MESSAGE;
137
+ console.error('[webjs] streaming action threw:', e);
138
+ } else if (isControlFlowThrow(e)) {
139
+ msg = e instanceof Error ? e.message : String(e);
140
+ console.error('[webjs] streaming action threw:', e);
141
+ } else {
142
+ const digest = await errorDigest(e);
143
+ msg = `${GENERIC_ERROR_MESSAGE} [digest=${digest}]`;
144
+ console.error(`[webjs] streaming action threw [digest=${digest}]:`, e);
145
+ }
130
146
  try { controller.enqueue(encodeFrame(FRAME_ERROR, enc.encode(msg))); } catch {}
131
147
  await releaseSource(e);
132
148
  detach();
package/src/actions.js CHANGED
@@ -15,6 +15,7 @@ import { revalidateTags } from './cache-tags.js';
15
15
  import { runWithActionSignal } from './action-signal.js';
16
16
  import { runActionChain } from './action-middleware.js';
17
17
  import { isStreamable, streamActionResponse } from './action-stream.js';
18
+ import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
18
19
  import { ifNoneMatchSatisfied } from './conditional-get.js';
19
20
  import { getBodyLimits } from './context.js';
20
21
  import { basePath } from './importmap.js';
@@ -349,7 +350,7 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
349
350
  lines.push(` if (ct.includes(__STREAM_CT)) return __readStream(res, fn);`);
350
351
  lines.push(` const text = await res.text();`);
351
352
  lines.push(` const parsed = ct.includes(__CT) ? __p(text) : (ct.includes('application/json') ? JSON.parse(text) : text);`);
352
- lines.push(` if (!res.ok) throw new Error((parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status));`);
353
+ lines.push(` if (!res.ok) { const __e = new Error((parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status)); if (parsed && parsed.digest) __e.digest = parsed.digest; throw __e; }`);
353
354
  lines.push(` return parsed;`);
354
355
  lines.push(`}`);
355
356
  // Decode a framed streamed body into an async generator of rich-deserialized
@@ -533,7 +534,7 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
533
534
  const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
534
535
  if (inv.length) { await revalidateTags(inv); headers['x-webjs-invalidate'] = inv.join(','); }
535
536
  }
536
- return streamActionResponse(result, { signal: req.signal, onError, headers });
537
+ return streamActionResponse(result, { signal: req.signal, onError, headers, dev: idx.dev });
537
538
  }
538
539
  if (method === 'GET') {
539
540
  // A short-circuit (the action did not run, e.g. an auth denial) is NEVER
@@ -588,27 +589,38 @@ async function getActionResponse(result, mod, args, req) {
588
589
  }
589
590
 
590
591
  /**
591
- * Return a JSON error response with dev-vs-prod sanitization.
592
- * In prod we return only the error message (not the stack), and we log the
593
- * full error server-side. Internal errors with no message become a generic
594
- * 500.
592
+ * Return a JSON error response with dev-vs-prod sanitization (#749).
593
+ *
594
+ * Dev returns the real message + stack. PROD returns a GENERIC message plus a
595
+ * short `digest`, and logs the full error server-side keyed by that digest, so
596
+ * a raw error (a DB driver message with a constraint name, an `ECONNREFUSED`
597
+ * with an internal IP, an fs path) never reaches the client while staying
598
+ * diagnosable from the logs. `redirect()` / `notFound()` control-flow sentinels
599
+ * pass through unchanged (not errors, message not sensitive). An author-facing
600
+ * user-safe message belongs on the `ActionResult` `{ success: false, error }`
601
+ * envelope, not on a throw.
595
602
  *
596
603
  * @param {unknown} err
597
604
  * @param {boolean} dev
605
+ * @returns {Promise<Response>}
598
606
  */
599
- function actionErrorResponse(err, dev) {
600
- console.error('[webjs] action threw:', err);
607
+ async function actionErrorResponse(err, dev) {
601
608
  if (dev) {
609
+ console.error('[webjs] action threw:', err);
602
610
  const msg = err instanceof Error ? err.message : String(err);
603
611
  const stack = err instanceof Error ? err.stack : undefined;
604
612
  return rpcResponse({ error: msg, stack }, { status: 500 });
605
613
  }
606
- // Prod: only expose the thrown message (author-controlled), never the stack.
607
- const msg =
608
- err instanceof Error && typeof err.message === 'string' && err.message
609
- ? err.message
610
- : 'Internal server error';
611
- return rpcResponse({ error: msg }, { status: 500 });
614
+ // Control-flow sentinels (redirect/notFound) are not errors to sanitize.
615
+ if (isControlFlowThrow(err)) {
616
+ console.error('[webjs] action threw:', err);
617
+ const msg = err instanceof Error ? err.message : String(err);
618
+ return rpcResponse({ error: msg }, { status: 500 });
619
+ }
620
+ // Prod genuine error: generic message + correlation digest, full error logged.
621
+ const digest = await errorDigest(err);
622
+ console.error(`[webjs] action threw [digest=${digest}]:`, err);
623
+ return rpcResponse({ error: GENERIC_ERROR_MESSAGE, digest }, { status: 500 });
612
624
  }
613
625
 
614
626
  /**
package/src/cache-fn.js CHANGED
@@ -23,9 +23,21 @@
23
23
  * @module cache-fn
24
24
  */
25
25
 
26
+ import { stringify, parse } from '@webjsdev/core';
26
27
  import { getStore } from './cache.js';
27
28
  import { addKeyToTags } from './cache-tags.js';
28
29
 
30
+ // Cache value/key encoding version. Entries written by an older encoding used
31
+ // a DIFFERENT key namespace, so a reader never deserializes them with the new
32
+ // format. Before this segment existed, values were JSON and keys were the
33
+ // unversioned `cache:<prefix>`; a JSON-format entry is still valid JSON that
34
+ // `parse` would silently accept as a lossy value (a Date as a string) without
35
+ // recomputing, so the version segment forces those pre-upgrade entries to miss
36
+ // and expire by their own TTL instead of being served stale. Bump it whenever
37
+ // the value/key encoding changes. Distinct from the `cache:tag:` tag-index
38
+ // namespace in cache-tags.js.
39
+ const CACHE_FORMAT = 'r1';
40
+
29
41
  /**
30
42
  * Wrap an async function with server-side caching.
31
43
  *
@@ -70,17 +82,28 @@ export function cache(fn, opts) {
70
82
  const wrapped = /** @type {T & { invalidate: () => Promise<void> }} */ (
71
83
  async function (...args) {
72
84
  const store = getStore();
85
+ // Both the key fingerprint and the stored value go through the same
86
+ // rich serializer the RPC wire uses (@webjsdev/core stringify/parse),
87
+ // NOT JSON. JSON collapses Date to a string and Map/Set to {} / [], so
88
+ // (a) a cached value would change shape between a cold miss (the real
89
+ // value) and a warm hit (the lossy JSON value), and (b) two distinct
90
+ // Map/Set args would collide to the same key. Going through core's
91
+ // serializer makes a hit byte-faithful to a miss and keys collision-safe.
92
+ // We use core stringify/parse DIRECTLY rather than getSerializer() so the
93
+ // cache preserves full fidelity even when an app swaps the wire format to
94
+ // a lossy one (e.g. plain JSON); cache fidelity is a storage concern, not
95
+ // a wire concern.
73
96
  const cacheKey = args.length
74
- ? `cache:${prefix}:${JSON.stringify(args)}`
75
- : `cache:${prefix}`;
97
+ ? `cache:${CACHE_FORMAT}:${prefix}:${await stringify(args)}`
98
+ : `cache:${CACHE_FORMAT}:${prefix}`;
76
99
 
77
100
  const hit = await store.get(cacheKey);
78
101
  if (hit !== null) {
79
- try { return JSON.parse(hit); } catch { /* corrupted: recompute */ }
102
+ try { return parse(hit); } catch { /* corrupted: recompute */ }
80
103
  }
81
104
 
82
105
  const result = await fn(...args);
83
- await store.set(cacheKey, JSON.stringify(result), ttlMs);
106
+ await store.set(cacheKey, await stringify(result), ttlMs);
84
107
  // Record tag -> cacheKey in the thin tag index so a later
85
108
  // revalidateTag can find and evict this entry (including
86
109
  // arg-specific keys the no-args invalidate() cannot reach).
@@ -115,7 +138,7 @@ export function cache(fn, opts) {
115
138
  wrapped.invalidate = async function () {
116
139
  const store = getStore();
117
140
  // Delete the base key (no-args call)
118
- await store.delete(`cache:${prefix}`);
141
+ await store.delete(`cache:${CACHE_FORMAT}:${prefix}`);
119
142
  // Note: arg-specific keys are not tracked. If the cached function
120
143
  // is called with different arguments, those entries expire via TTL.
121
144
  // For full invalidation of arg-specific keys, use a short TTL.
package/src/cache-tags.js CHANGED
@@ -16,20 +16,22 @@
16
16
  * clears the index entry. A mutation in ANY module can therefore evict every
17
17
  * read tagged `'posts'` across modules with one explicit call.
18
18
  *
19
- * It stays a thin index over `store.get/set/delete`: no new store method, a
20
- * plain JSON array (a Set is trivial in the memory store; the same get/set of
21
- * a JSON array works for Redis). The cohesive companion for HTML paths is
22
- * `revalidatePath` in `html-cache.js`; together they are the server cache
23
- * invalidation surface (this one for `cache()` DATA, that one for cached HTML).
19
+ * The cohesive companion for HTML paths is `revalidatePath` in
20
+ * `html-cache.js`; together they are the server cache invalidation surface
21
+ * (this one for `cache()` DATA, that one for cached HTML).
24
22
  *
25
- * MULTI-INSTANCE CAVEAT (mirrors #241): the index is a plain read-modify-write
26
- * of a JSON array, NOT atomic across processes. With a shared Redis store,
27
- * `revalidateTag` deletes the keys it can see and reaches every instance for
28
- * those keys, but two instances appending to the same tag concurrently can
29
- * lose an append (last write wins), so a freshly-stored key on a peer might
30
- * miss eviction and live until its TTL. The tag index entry itself also
31
- * carries the cache TTL, so the index self-prunes and never grows unbounded.
32
- * For strict cross-instance invalidation, prefer a short `ttl` as the floor.
23
+ * ATOMICITY (#752): when the store exposes the optional `setAdd` / `setMembers`
24
+ * primitives, the tag index is a real SET, so adding a cache key is an atomic
25
+ * insert (`SADD` on Redis, a native `Set` in the single-process memory store).
26
+ * This eliminates the lost-update race the previous JSON-array read-modify-write
27
+ * had: two concurrent mutations appending to the same tag (whether across Redis
28
+ * instances or interleaved in one process at the read/write await gap) no longer
29
+ * drop an entry, so `revalidateTag` reliably evicts every tagged key. The index
30
+ * key carries the cache TTL (refreshed on each add), so it self-prunes. A custom
31
+ * store WITHOUT the primitives falls back to the old JSON-array path, which keeps
32
+ * the documented non-atomic caveat (prefer a short `ttl` as the cross-instance
33
+ * floor there). The HTML-side analog `revalidatePath` (#241) still carries the
34
+ * older caveat and is a separate follow-up.
33
35
  *
34
36
  * @module cache-tags
35
37
  */
@@ -44,16 +46,30 @@ function tagKey(tag) {
44
46
  return `${TAG_PREFIX}${tag}`;
45
47
  }
46
48
 
49
+ /** @param {import('./cache.js').CacheStore} store */
50
+ function supportsAtomicSets(store) {
51
+ return typeof store.setAdd === 'function' && typeof store.setMembers === 'function';
52
+ }
53
+
47
54
  /**
48
55
  * Read the cache-key set stored under one tag. Returns a plain array (empty
49
- * on a miss / parse error, failing open). The store holds a JSON array.
56
+ * on a miss / parse error, failing open). Reads via the store's SET primitive
57
+ * when available (the atomic path), else parses the legacy JSON array.
50
58
  *
51
59
  * @param {string} tag
52
60
  * @returns {Promise<string[]>}
53
61
  */
54
62
  async function readTagKeys(tag) {
63
+ const store = getStore();
64
+ if (supportsAtomicSets(store)) {
65
+ try {
66
+ return await store.setMembers(tagKey(tag));
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
55
71
  try {
56
- const raw = await getStore().get(tagKey(tag));
72
+ const raw = await store.get(tagKey(tag));
57
73
  if (!raw) return [];
58
74
  const arr = JSON.parse(raw);
59
75
  return Array.isArray(arr) ? arr : [];
@@ -79,13 +95,21 @@ async function readTagKeys(tag) {
79
95
  export async function addKeyToTags(tags, cacheKey, ttlMs) {
80
96
  if (!Array.isArray(tags) || tags.length === 0) return;
81
97
  const store = getStore();
98
+ const atomic = supportsAtomicSets(store);
82
99
  for (const tag of tags) {
83
100
  if (typeof tag !== 'string' || !tag) continue;
84
101
  try {
85
- const keys = await readTagKeys(tag);
86
- if (keys.includes(cacheKey)) continue;
87
- keys.push(cacheKey);
88
- await store.set(tagKey(tag), JSON.stringify(keys), ttlMs);
102
+ if (atomic) {
103
+ // Atomic set insert: no read-modify-write, so a concurrent add to the
104
+ // same tag (across instances or interleaved in-process) cannot lose
105
+ // this key. SADD dedups, so no membership pre-check is needed.
106
+ await store.setAdd(tagKey(tag), cacheKey, ttlMs);
107
+ } else {
108
+ const keys = await readTagKeys(tag);
109
+ if (keys.includes(cacheKey)) continue;
110
+ keys.push(cacheKey);
111
+ await store.set(tagKey(tag), JSON.stringify(keys), ttlMs);
112
+ }
89
113
  } catch {
90
114
  /* a tag-index write failure must never affect the cached value */
91
115
  }
package/src/cache.js CHANGED
@@ -23,6 +23,15 @@
23
23
  * @property {(key: string, ttlMs?: number) => Promise<number>} increment
24
24
  * Atomically increment a counter. Returns the new value. Creates the
25
25
  * key with value 1 if it doesn't exist. TTL is set on creation only.
26
+ * @property {((key: string, member: string, ttlMs?: number) => Promise<void>)} [setAdd]
27
+ * OPTIONAL. Atomically add `member` to the SET stored at `key`, refreshing the
28
+ * key's TTL. Eliminates the lost-update race of a read-modify-write JSON array
29
+ * (the tag index in `cache-tags.js`, #752). A store WITHOUT this method makes
30
+ * the tag index fall back to the non-atomic JSON path (the documented caveat).
31
+ * Paired with `setMembers`; implement both or neither.
32
+ * @property {((key: string) => Promise<string[]>)} [setMembers]
33
+ * OPTIONAL. Return the members of the SET stored at `key` (empty array on a
34
+ * miss). The read counterpart of `setAdd`.
26
35
  */
27
36
 
28
37
  /**
@@ -98,6 +107,28 @@ export function memoryStore(opts = {}) {
98
107
  map.set(key, entry);
99
108
  return next;
100
109
  },
110
+ // Atomic SET ops (#752). The entry value for a set key is a native `Set`
111
+ // (not a JSON string), so the add is a single synchronous mutation with no
112
+ // read-modify-write await gap: concurrent `setAdd` calls in one process
113
+ // cannot lose an entry. Only the tag index uses these keys, so the mixed
114
+ // value type (string for normal keys, Set for set keys) never collides with
115
+ // `get`/`increment`. The key still rides the same LRU + TTL machinery.
116
+ async setAdd(key, member, ttlMs) {
117
+ const entry = map.get(key);
118
+ const set = entry && !isExpired(entry) && entry.value instanceof Set
119
+ ? entry.value
120
+ : new Set();
121
+ set.add(member);
122
+ map.delete(key); // re-insert at the end (LRU) + refresh TTL like Redis EXPIRE
123
+ map.set(key, { value: set, expiresAt: expiresAtFrom(ttlMs) });
124
+ evict();
125
+ },
126
+ async setMembers(key) {
127
+ const entry = map.get(key);
128
+ if (!entry) return [];
129
+ if (isExpired(entry)) { map.delete(key); return []; }
130
+ return entry.value instanceof Set ? [...entry.value] : [];
131
+ },
101
132
  };
102
133
  }
103
134
 
@@ -172,6 +203,28 @@ export function redisStore(opts = {}) {
172
203
  }
173
204
  return val;
174
205
  },
206
+ // Atomic SET ops (#752): SADD is an atomic set insert across instances, so
207
+ // the tag index no longer loses a concurrent append the way a JSON-array
208
+ // read-modify-write did. A per-member TTL is not native to a Redis set, so
209
+ // refresh the whole key's expiry on each add (PEXPIRE), keeping the
210
+ // short-TTL floor as defense-in-depth. ioredis uses lowercase command names
211
+ // (`sadd`); node-redis v4 uses camelCase (`sAdd`), so probe both.
212
+ async setAdd(key, member, ttlMs) {
213
+ const c = await getClient();
214
+ if (typeof c.sadd === 'function') await c.sadd(key, member);
215
+ else await c.sAdd(key, member);
216
+ if (ttlMs) {
217
+ if (typeof c.pexpire === 'function') await c.pexpire(key, ttlMs);
218
+ else await c.pExpire(key, ttlMs);
219
+ }
220
+ },
221
+ async setMembers(key) {
222
+ const c = await getClient();
223
+ const members = typeof c.smembers === 'function'
224
+ ? await c.smembers(key)
225
+ : await c.sMembers(key);
226
+ return Array.isArray(members) ? members : [];
227
+ },
175
228
  };
176
229
  }
177
230
 
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
- * Known limitation: a DYNAMIC `import('./x.server.ts')` is invisible to this
1034
- * rule. The framework's import scanner (`IMPORT_RE` in module-graph.js) matches
1035
- * only static `import`/`export … from`, not the `import(` call form, so a
1036
- * dynamic server import is not a graph edge here. This is deliberate and
1037
- * consistent with the framework-wide graph: a dynamic import is also not elided
1038
- * framework-wide, and its crash is deferred to call time (when the chunk is
1039
- * fetched), not module load. Catching it would mean teaching the shared scanner
1040
- * about `import(`, which is out of scope for this rule.
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
- req = new Request(
1237
+ const next = new Request(
1226
1238
  reqUrl.toString(),
1227
1239
  /** @type {any} */ ({
1228
1240
  method: req.method,
1229
- headers: req.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