@webjsdev/server 0.8.39 → 0.8.41

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/index.js CHANGED
@@ -77,4 +77,5 @@ export {
77
77
  actionEndpoint,
78
78
  invokeActionForTest,
79
79
  rawActionRequest,
80
+ createBrowserTestHandler,
80
81
  } from './src/testing.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/server",
3
- "version": "0.8.39",
3
+ "version": "0.8.41",
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
@@ -82,7 +82,7 @@ export const RULES = [
82
82
  {
83
83
  name: 'reactive-props-no-class-field',
84
84
  description:
85
- 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field initializer (`count = 0` or `count: number = 0`) in the class body. Under modern class-field semantics that initializer runs Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default by assigning in the constructor after super().',
85
+ 'A reactive property declared via the `extends WebComponent({ … })` factory must NOT also have a plain class-field declaration (`count = 0`, `count: number = 0`, `count!: number`, or `count?: number`) in the class body. Under modern class-field semantics (including `erasableSyntaxOnly: true`) every class-field declaration compiles to Object.defineProperty *after* super(), clobbering the framework\'s reactive accessor and silently breaking re-renders. Set the default by assigning in the constructor after super().',
86
86
  },
87
87
  {
88
88
  name: 'array-prop-uses-array-type',
@@ -134,6 +134,11 @@ export const RULES = [
134
134
  description:
135
135
  'The one sentinel-based check, and the deliberate exception to the "objectively broken code" framing of the other rules: it flags scaffold example content that has not been replaced, so a delivered app contains only what the user intended and never leftover scaffold code. `webjs create` marks its example/demo files (the example homepage in app/page.ts, the example app chrome in app/layout.ts) with a one-line marker comment carrying the literal token `webjs-scaffold-placeholder`. The rule scans raw source (the marker lives in a comment, which the redacted scan view would hide) and fails for every file that still carries the token. A freshly scaffolded app fails this rule BY DESIGN until its placeholders are addressed: replace the example content, or deliberately keep it, and in either case delete the marker line. No finished app legitimately ships a literal remove-me marker, so this still qualifies as a check rather than a convention. The marker is acknowledge-and-remove, so keeping a piece is a one-line deletion rather than a forced rewrite.',
136
136
  },
137
+ {
138
+ name: 'no-redirect-in-api-route',
139
+ description:
140
+ 'API route handlers (`route.{js,ts}`) must NOT call `redirect()` from `@webjsdev/core`. That function throws a control-flow signal designed for the SSR page renderer; in a route handler it goes uncaught and produces a 500. Use `Response.redirect(url, 303)` for external redirects or return a 3xx Response directly. Page functions, layouts, and server actions may still use `redirect()` (caught by the SSR pipeline).',
141
+ },
137
142
  ];
138
143
 
139
144
  /** Set of all known rule names for fast lookup. */
@@ -268,12 +273,20 @@ function findFieldInitializers(classBody, props) {
268
273
  let j = lineStart;
269
274
  while (j < classBody.length && classBody[j] !== '\n') j++;
270
275
  const line = classBody.slice(lineStart, j);
271
- // Match: optional whitespace, optional `public/private/protected/readonly`,
272
- // an identifier, optional `: <type>`, then `=`.
273
- const fieldRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
274
- const m = fieldRe.exec(line);
275
- if (m) {
276
- const name = m[2];
276
+ // Match class-field declarations. Two shapes:
277
+ // 1. With initializer: `count = 0`, `count: number = 0`, `public count = 0`
278
+ // 2. Type-only (no initializer): `count!: number`, `count?: number`, `count: number`
279
+ // Both compile to Object.defineProperty after super() under modern class-field
280
+ // semantics, clobbering the reactive accessor.
281
+ // The initializer regex: optional modifier, identifier, optional type, then `=`.
282
+ const initRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*(?::\s*[^=;]+)?\s*=\s*[^=>]/;
283
+ // The type-only regex: optional modifier, identifier, then `!:` or `?:` or `:` with a type.
284
+ const typeOnlyRe = /^\s*(?:(public|private|protected|readonly)\s+)?([A-Za-z_$][\w$]*)\s*[!?]?\s*:\s*\S/;
285
+ const initM = initRe.exec(line);
286
+ const typeM = typeOnlyRe.exec(line);
287
+ // Prefer the initializer match; if neither, skip.
288
+ const name = initM ? initM[2] : (typeM ? typeM[2] : null);
289
+ if (name) {
277
290
  // `declare`, `static`, and `this.` patterns shouldn't reach here
278
291
  // (declare/static start with their keyword, this.x has the dot in
279
292
  // the regex group), but guard against matching keywords as names:
@@ -459,8 +472,11 @@ export async function checkConventions(appDir) {
459
472
 
460
473
  // --- Rule: reactive-props-no-class-field ---
461
474
  // A reactive property declared via the factory must not also carry a plain
462
- // class-field initializer: it runs Object.defineProperty after super() and
463
- // clobbers the framework's reactive accessor (silent broken re-renders).
475
+ // class-field declaration (initializer OR type-only): under modern class-field
476
+ // semantics (including `erasableSyntaxOnly: true`) every class-field declaration
477
+ // compiles to Object.defineProperty after super() and clobbers the framework's
478
+ // reactive accessor (silent broken re-renders). Catches `count = 0`,
479
+ // `count: number = 0`, `count!: number`, and `count?: number`.
464
480
  {
465
481
  for (const { rel, scan } of files) {
466
482
  if (!/class\s+\w+\s+extends\s+WebComponent/.test(scan)) continue;
@@ -470,8 +486,8 @@ export async function checkConventions(appDir) {
470
486
  violations.push({
471
487
  rule: 'reactive-props-no-class-field',
472
488
  file: rel,
473
- message: `Reactive prop \`${bad}\` uses a class-field initializer; this clobbers the framework's reactive accessor under modern class-field semantics.`,
474
- fix: `Remove the class-field initializer and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
489
+ message: `Reactive prop \`${bad}\` uses a class-field declaration (initializer or type-only); this clobbers the framework's reactive accessor under modern class-field semantics.`,
490
+ fix: `Delete the class-field declaration and set the default by assigning \`this.${bad} = <value>\` inside \`constructor()\` after \`super()\`.`,
475
491
  });
476
492
  }
477
493
  }
@@ -557,6 +573,67 @@ export async function checkConventions(appDir) {
557
573
  }
558
574
  }
559
575
 
576
+ // --- Rule: no-redirect-in-api-route ---
577
+ // `redirect()` from `@webjsdev/core` throws a control-flow signal designed
578
+ // for the SSR page renderer. In a `route.ts` API handler it goes uncaught
579
+ // and produces a 500. API handlers must use `Response.redirect(url, 303)`
580
+ // instead. Page functions, layouts, and server actions may still use
581
+ // `redirect()` (caught by the SSR / action pipeline).
582
+ {
583
+ const ROUTE_FILE = /(?:^|\/)route\.m?[jt]s$/;
584
+ for (const { rel, scan } of files) {
585
+ if (!ROUTE_FILE.test(rel)) continue;
586
+ // `redirect` reaches the route file in one of two statically-visible ways:
587
+ // 1. A NAMED import from `@webjsdev/core` (possibly aliased):
588
+ // `import { redirect }`, `import { redirect as r }`, `import { …, redirect, … }`.
589
+ // 2. A NAMESPACE import then a member call:
590
+ // `import * as core from '@webjsdev/core'` then `core.redirect(...)`.
591
+ // The named case flags a bare `redirect(` call; the namespace case flags
592
+ // `<ns>.redirect(`. `Response.redirect(` and any other `obj.redirect(` are
593
+ // the standard API and stay fine. A `redirect()` thrown inside a
594
+ // '`use server`' action the route calls DIRECTLY (an uncaught 500) needs
595
+ // cross-file analysis and is left to the AST rework (#753).
596
+ const namedM = /\bimport\s+\{[^}]*\bredirect\b(?:\s+as\s+(\w+))?\s*[^}]*\}\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
597
+ const nsM = /\bimport\s+\*\s+as\s+(\w+)\s+from\s+['"]@webjsdev\/core['"]/.exec(scan);
598
+ // A file can carry BOTH a named `redirect` import AND a namespace import,
599
+ // so check every matcher independently (not mutually exclusive): a named
600
+ // import means a bare `<localName>(`, a namespace import means
601
+ // `<ns>.redirect(`. The `member` flag distinguishes the two so a bare
602
+ // named call can screen out `Response.redirect(` / `obj.redirect(`.
603
+ /** @type {Array<{ re: RegExp, member: boolean }>} */
604
+ const matchers = [];
605
+ if (namedM) {
606
+ const localName = namedM[1] || 'redirect';
607
+ matchers.push({ re: new RegExp(`(?<!\\.)\\b${localName}\\s*\\(`, 'g'), member: false });
608
+ }
609
+ if (nsM) {
610
+ matchers.push({ re: new RegExp(`\\b${nsM[1]}\\.redirect\\s*\\(`, 'g'), member: true });
611
+ }
612
+ let flagged = false;
613
+ for (const { re, member } of matchers) {
614
+ if (flagged) break;
615
+ let m;
616
+ while ((m = re.exec(scan)) !== null) {
617
+ if (!member) {
618
+ // Screen out `Response.redirect(` / `someObj.redirect(` sharing the
619
+ // local name: a preceding member-access dot means it is not the import.
620
+ const before = scan.slice(Math.max(0, m.index - 20), m.index);
621
+ if (/\w\.$/.test(before)) continue;
622
+ }
623
+ violations.push({
624
+ rule: 'no-redirect-in-api-route',
625
+ file: rel,
626
+ message:
627
+ `\`redirect()\` from \`@webjsdev/core\` throws a control-flow signal for the SSR page renderer; in a \`route.ts\` handler it goes uncaught and returns a 500.`,
628
+ fix: `Use \`Response.redirect(url, 303)\` for external redirects, or return a 3xx Response directly. The \`redirect()\` sentinel is only valid in page functions, layouts, and server actions (where the SSR pipeline catches it).`,
629
+ });
630
+ flagged = true; // one violation per file is enough
631
+ break;
632
+ }
633
+ }
634
+ }
635
+ }
636
+
560
637
  // --- Rule: no-server-env-in-components ---
561
638
  // Catches `process.env.X` reads in component files where X is not a
562
639
  // WEBJS_PUBLIC_* var and not NODE_ENV. The SSR shim only exposes those
@@ -1030,14 +1107,18 @@ export async function checkConventions(appDir) {
1030
1107
  * `middleware.ts` / `route.ts` (server-only, never page/layout/component
1031
1108
  * entries, so they are not in the candidate set to begin with).
1032
1109
  *
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.
1110
+ * Scope note for dynamic imports: a string-literal `import('./widget.ts')` IS
1111
+ * tracked by the module graph now (#751), but only as a GATE edge (so the
1112
+ * lazily-imported module is servable); this rule's server-import detection
1113
+ * still runs over STATIC edges only (`transitiveDeps`), so a dynamic
1114
+ * `import('./x.server.ts')` of a no-`'use server'` utility is not flagged here.
1115
+ * That is deliberate: the throw-at-load crash is deferred to call time (when
1116
+ * the module is actually fetched), not module load, and a dynamic import is
1117
+ * also not elided framework-wide. A computed `import(expr)` cannot be resolved
1118
+ * statically at all; rather than a false-positive-prone check rule (a computed
1119
+ * import of an npm specifier or an otherwise-reachable app module is perfectly
1120
+ * valid, so it fails the check-is-correctness-only dividing line), the dev
1121
+ * server surfaces it with a 404 hint when the target 404s (see dev.js #751).
1041
1122
  *
1042
1123
  * @param {string} appDir
1043
1124
  * @param {Violation[]} violations appended to in place