@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 +9 -0
- package/index.js +1 -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 +100 -19
- package/src/component-elision.js +67 -18
- package/src/dev.js +48 -4
- 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 +161 -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.d.ts +10 -0
- package/src/testing.js +40 -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/index.js
CHANGED
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
|
@@ -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
|
|
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
|
|
272
|
-
//
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
463
|
-
//
|
|
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: `
|
|
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
|
-
*
|
|
1034
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
* fetched), not module load
|
|
1040
|
-
*
|
|
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
|