@webjsdev/server 0.8.23 → 0.8.25
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 +8 -0
- package/index.js +2 -0
- package/package.json +1 -1
- package/src/action-config.js +162 -0
- package/src/action-middleware.d.ts +21 -0
- package/src/action-middleware.js +68 -0
- package/src/action-seed.js +308 -0
- package/src/action-signal.js +39 -0
- package/src/actions.js +221 -56
- package/src/check.js +37 -0
- package/src/dev.js +57 -1
- package/src/ssr.js +28 -2
- package/webjs-config.schema.json +6 -1
package/index.d.ts
CHANGED
|
@@ -844,3 +844,11 @@ export declare function GitHub(opts?: Record<string, unknown>): ProviderConfig;
|
|
|
844
844
|
// ---------------------------------------------------------------------------
|
|
845
845
|
|
|
846
846
|
export type { LayoutProps, PageProps, RouteHandlerContext };
|
|
847
|
+
|
|
848
|
+
/** The current action request AbortSignal (#492), or a never-aborting signal outside an action. */
|
|
849
|
+
export declare function actionSignal(): AbortSignal;
|
|
850
|
+
/** Run a function with a request AbortSignal available to actionSignal() (#492). */
|
|
851
|
+
export declare function runWithActionSignal<T>(signal: AbortSignal | undefined, fn: () => T): T;
|
|
852
|
+
|
|
853
|
+
/** The accumulated per-action middleware context (#490), or {} outside an action. */
|
|
854
|
+
export declare function actionContext(): Record<string, unknown>;
|
package/index.js
CHANGED
|
@@ -48,6 +48,8 @@ export { cors, resolveOrigin, applyCorsHeaders } from './src/cors.js';
|
|
|
48
48
|
export { memoryStore, redisStore, getStore, setStore } from './src/cache.js';
|
|
49
49
|
export { cache } from './src/cache-fn.js';
|
|
50
50
|
export { revalidateTag, revalidateTags } from './src/cache-tags.js';
|
|
51
|
+
export { actionSignal, runWithActionSignal } from './src/action-signal.js';
|
|
52
|
+
export { actionContext } from './src/action-middleware.js';
|
|
51
53
|
export { revalidatePath, revalidateAll } from './src/html-cache.js';
|
|
52
54
|
export { Session, session, cookieSessionStorage, storeSessionStorage, cookieSession, storeSession, getSession } from './src/session.js';
|
|
53
55
|
export { broadcast } from './src/broadcast.js';
|
package/package.json
CHANGED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP-verb server actions (#488): read the per-action configuration declared as
|
|
3
|
+
* RESERVED sibling exports in a `'use server'` action file, and the helpers that
|
|
4
|
+
* turn that config into transport + caching behavior.
|
|
5
|
+
*
|
|
6
|
+
* The function stays a plain `export async function`; the framework reads these
|
|
7
|
+
* statically-named exports the same way a page declares `export const
|
|
8
|
+
* revalidate` / `metadata`:
|
|
9
|
+
*
|
|
10
|
+
* 'use server';
|
|
11
|
+
* export const method = 'GET'; // 'GET'|'POST'|'PUT'|'PATCH'|'DELETE'; absent => POST
|
|
12
|
+
* export const cache = 60; // seconds, or { maxAge, swr, public }
|
|
13
|
+
* export const tags = (id) => [`user:${id}`]; // GET: tags the cached read
|
|
14
|
+
* export const invalidates = (id) => [...]; // mutation: tags to evict
|
|
15
|
+
* export const validate = (input) => ...; // boundary validator
|
|
16
|
+
* export async function getUser(id) { ... } // THE action (one per file)
|
|
17
|
+
*
|
|
18
|
+
* No folder automation: an action with no `method` export stays a POST, exactly
|
|
19
|
+
* as before this feature (additive, non-breaking).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** The HTTP verbs an action may declare. */
|
|
23
|
+
export const RPC_VERBS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
24
|
+
|
|
25
|
+
/** Reserved config export names (special only in a `'use server'` file). */
|
|
26
|
+
export const RESERVED_CONFIG = new Set(['method', 'cache', 'tags', 'invalidates', 'validate', 'middleware']);
|
|
27
|
+
|
|
28
|
+
/** Verbs whose args ride the URL (so the read is cacheable / the URL is the key). */
|
|
29
|
+
export const URL_ARG_VERBS = new Set(['GET', 'DELETE']);
|
|
30
|
+
|
|
31
|
+
/** Verbs that are CSRF-exempt (safe, no state change). */
|
|
32
|
+
export const SAFE_VERBS = new Set(['GET']);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Max length of the URL-encoded args for a URL-arg verb before the client stub
|
|
36
|
+
* falls back to a POST with the args in the body (uncacheable). Conservative so
|
|
37
|
+
* the URL stays within common browser / CDN limits. TanStack Start uses 1MB for
|
|
38
|
+
* its server-side GET cap; the browser/CDN URL ceiling is the tighter bound.
|
|
39
|
+
*/
|
|
40
|
+
export const MAX_URL_ARGS = 4096;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The exported function names that are ACTIONS (callable), i.e. every function
|
|
44
|
+
* export whose name is not a reserved config key. With one-function-per-file
|
|
45
|
+
* there is exactly one; the framework still returns the list so a misconfigured
|
|
46
|
+
* multi-function file is detectable (a `webjs check` rule).
|
|
47
|
+
* @param {Record<string, unknown>} mod
|
|
48
|
+
* @returns {string[]}
|
|
49
|
+
*/
|
|
50
|
+
export function actionFunctionNames(mod) {
|
|
51
|
+
const names = Object.keys(mod).filter(
|
|
52
|
+
(k) => typeof mod[k] === 'function' && !RESERVED_CONFIG.has(k),
|
|
53
|
+
);
|
|
54
|
+
if (typeof mod.default === 'function' && !names.includes('default')) names.push('default');
|
|
55
|
+
return names;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The action's HTTP method: the declared `method` export uppercased and
|
|
60
|
+
* validated against {@link RPC_VERBS}, defaulting to POST (an unknown value
|
|
61
|
+
* falls back to POST rather than throwing, the #232 fail-safe posture).
|
|
62
|
+
* @param {Record<string, unknown>} mod
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function actionMethod(mod) {
|
|
66
|
+
const raw = mod && mod.method;
|
|
67
|
+
if (typeof raw !== 'string') return 'POST';
|
|
68
|
+
const m = raw.trim().toUpperCase();
|
|
69
|
+
return RPC_VERBS.has(m) ? m : 'POST';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normalize the `cache` export to `{ maxAge, swr, public }` or null (no
|
|
74
|
+
* caching). A number is `maxAge` seconds; an object carries `maxAge` / `swr`
|
|
75
|
+
* (stale-while-revalidate) / `public`. Default `private` (a read is per-user
|
|
76
|
+
* safe unless the author opts into shared caching with `public: true`).
|
|
77
|
+
* @param {Record<string, unknown>} mod
|
|
78
|
+
* @returns {{ maxAge: number, swr: number, public: boolean } | null}
|
|
79
|
+
*/
|
|
80
|
+
export function actionCache(mod) {
|
|
81
|
+
const raw = mod && mod.cache;
|
|
82
|
+
if (raw == null) return null;
|
|
83
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
|
|
84
|
+
return { maxAge: Math.floor(raw), swr: 0, public: false };
|
|
85
|
+
}
|
|
86
|
+
if (typeof raw === 'object') {
|
|
87
|
+
const maxAge = Number.isFinite(raw.maxAge) && raw.maxAge >= 0 ? Math.floor(raw.maxAge) : 0;
|
|
88
|
+
const swr = Number.isFinite(raw.swr) && raw.swr >= 0 ? Math.floor(raw.swr) : 0;
|
|
89
|
+
return { maxAge, swr, public: raw.public === true };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The action's per-action middleware chain (#490): the `middleware` export, an
|
|
96
|
+
* array of `(ctx, next) => result` functions, or [] when absent / malformed.
|
|
97
|
+
* @param {Record<string, unknown>} mod
|
|
98
|
+
* @returns {Function[]}
|
|
99
|
+
*/
|
|
100
|
+
export function actionMiddleware(mod) {
|
|
101
|
+
const raw = mod && mod.middleware;
|
|
102
|
+
if (!Array.isArray(raw)) return [];
|
|
103
|
+
return raw.filter((m) => typeof m === 'function');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the `tags` / `invalidates` / `validate` config functions off the
|
|
108
|
+
* module (each is a function or absent).
|
|
109
|
+
* @param {Record<string, unknown>} mod
|
|
110
|
+
* @param {'tags'|'invalidates'|'validate'} name
|
|
111
|
+
* @returns {Function | null}
|
|
112
|
+
*/
|
|
113
|
+
export function actionConfigFn(mod, name) {
|
|
114
|
+
return mod && typeof mod[name] === 'function' ? mod[name] : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Compute the tag list for a `tags` / `invalidates` config given the call args.
|
|
119
|
+
* A function is invoked with the args (so `(id) => [`user:${id}`]` works); an
|
|
120
|
+
* array is used verbatim. Never throws into the caller; a bad tag fn yields [].
|
|
121
|
+
* @param {Function | unknown[] | null} tagsConfig
|
|
122
|
+
* @param {unknown[]} args
|
|
123
|
+
* @returns {string[]}
|
|
124
|
+
*/
|
|
125
|
+
export function resolveTags(tagsConfig, args) {
|
|
126
|
+
try {
|
|
127
|
+
const out = typeof tagsConfig === 'function' ? tagsConfig(...args) : tagsConfig;
|
|
128
|
+
if (!Array.isArray(out)) return [];
|
|
129
|
+
return out.filter((t) => typeof t === 'string' && t.length > 0);
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The `Cache-Control` value for a GET action response, or null (caller emits
|
|
137
|
+
* `no-store`). A cached GET is `public|private, max-age=N[, stale-while-
|
|
138
|
+
* revalidate=M]`; a GET with no `cache` config is uncached (null -> no-store).
|
|
139
|
+
* @param {string} method
|
|
140
|
+
* @param {{ maxAge: number, swr: number, public: boolean } | null} cache
|
|
141
|
+
* @returns {string | null}
|
|
142
|
+
*/
|
|
143
|
+
export function cacheControlFor(method, cache) {
|
|
144
|
+
if (method !== 'GET' || !cache) return null;
|
|
145
|
+
const scope = cache.public ? 'public' : 'private';
|
|
146
|
+
let h = `${scope}, max-age=${cache.maxAge}`;
|
|
147
|
+
if (cache.swr > 0) h += `, stale-while-revalidate=${cache.swr}`;
|
|
148
|
+
return h;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The set of request methods the action endpoint accepts for an action whose
|
|
153
|
+
* declared method is `method`. A URL-arg verb (GET/DELETE) ALSO accepts POST as
|
|
154
|
+
* the too-large-args fallback; a body verb accepts only itself.
|
|
155
|
+
* @param {string} method
|
|
156
|
+
* @returns {Set<string>}
|
|
157
|
+
*/
|
|
158
|
+
export function allowedRequestMethods(method) {
|
|
159
|
+
const s = new Set([method]);
|
|
160
|
+
if (URL_ARG_VERBS.has(method)) s.add('POST');
|
|
161
|
+
return s;
|
|
162
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Per-action middleware (#490). */
|
|
2
|
+
|
|
3
|
+
/** The context object middleware accumulate and the action reads via actionContext(). */
|
|
4
|
+
export interface ActionContext { [key: string]: unknown }
|
|
5
|
+
|
|
6
|
+
/** A middleware's ctx: the request, the action args, the abort signal, and the shared context. */
|
|
7
|
+
export interface ActionMiddlewareCtx {
|
|
8
|
+
request?: Request;
|
|
9
|
+
args?: unknown[];
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
context: ActionContext;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A per-action middleware: run `next()` to proceed, or return a value to short-circuit. */
|
|
15
|
+
export type ActionMiddleware = (ctx: ActionMiddlewareCtx, next: () => Promise<unknown>) => unknown;
|
|
16
|
+
|
|
17
|
+
/** The accumulated middleware context for the current action, or {} outside one. */
|
|
18
|
+
export declare function actionContext(): ActionContext;
|
|
19
|
+
|
|
20
|
+
/** Run an action through its middleware chain (framework-internal). */
|
|
21
|
+
export declare function runActionChain(middleware: ActionMiddleware[], baseCtx: Partial<ActionMiddlewareCtx>, finalFn: () => unknown): Promise<unknown>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-action middleware (#490). An action file declares `export const middleware
|
|
3
|
+
* = [mw1, mw2]` (a reserved config export). Each middleware is
|
|
4
|
+
* `async (ctx, next) => result`, run around the action on BOTH the RPC endpoint
|
|
5
|
+
* and the `route.ts`/`expose()` boundary, like `validate`.
|
|
6
|
+
*
|
|
7
|
+
* - `ctx` carries `{ request, args, signal, context }`. `context` is a shared
|
|
8
|
+
* mutable object the chain accumulates; the action reads it via
|
|
9
|
+
* `actionContext()` (no signature change, the same plumbing as
|
|
10
|
+
* `actionSignal()`).
|
|
11
|
+
* - `next()` runs the next middleware, ending at the action; its return value
|
|
12
|
+
* flows back up. A middleware SHORT-CIRCUITS by returning a value (an
|
|
13
|
+
* `ActionResult` envelope, e.g. `{ success: false, status: 401 }`) WITHOUT
|
|
14
|
+
* calling `next()`, so the action never runs. A short-circuit is NOT a
|
|
15
|
+
* completion: a GET short-circuit is served `no-store` (never cached) and a
|
|
16
|
+
* mutation short-circuit does not run `invalidates`. On the RPC boundary the
|
|
17
|
+
* short-circuit must be an `ActionResult` (serialized as the result, the
|
|
18
|
+
* `status` riding inside per the #245 contract); on the `route.ts` / REST
|
|
19
|
+
* boundary it may also be a `Response`, and an `ActionResult`'s `status`
|
|
20
|
+
* maps to the HTTP status.
|
|
21
|
+
*
|
|
22
|
+
* The framework ships no middleware; it only runs the chain. Server-only.
|
|
23
|
+
*/
|
|
24
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
25
|
+
|
|
26
|
+
/** Per-request accumulated context, readable by the action via actionContext(). */
|
|
27
|
+
const als = new AsyncLocalStorage();
|
|
28
|
+
|
|
29
|
+
/** Shared empty context returned outside an action (a direct server-to-server call). */
|
|
30
|
+
const EMPTY = Object.freeze({});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The accumulated middleware context for the current action, or a frozen empty
|
|
34
|
+
* object outside an action. The action reads `actionContext().user` etc.
|
|
35
|
+
* @returns {Record<string, unknown>}
|
|
36
|
+
*/
|
|
37
|
+
export function actionContext() {
|
|
38
|
+
return als.getStore() || EMPTY;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run `finalFn` (the action) through the middleware chain, with a fresh shared
|
|
43
|
+
* `context` available to `actionContext()` for the whole chain. A middleware
|
|
44
|
+
* that does not call `next()` short-circuits (its return is the result). Guards
|
|
45
|
+
* against a middleware calling `next()` more than once.
|
|
46
|
+
* @param {Function[]} middleware
|
|
47
|
+
* @param {{ request?: Request, args?: unknown[], signal?: AbortSignal }} baseCtx
|
|
48
|
+
* @param {() => unknown} finalFn
|
|
49
|
+
* @returns {Promise<unknown>}
|
|
50
|
+
*/
|
|
51
|
+
export function runActionChain(middleware, baseCtx, finalFn) {
|
|
52
|
+
if (!middleware || middleware.length === 0) return finalFn();
|
|
53
|
+
const context = {};
|
|
54
|
+
const ctx = { ...baseCtx, context };
|
|
55
|
+
return als.run(context, () => {
|
|
56
|
+
let lastCalled = -1;
|
|
57
|
+
const dispatch = (i) => {
|
|
58
|
+
if (i <= lastCalled) {
|
|
59
|
+
return Promise.reject(new Error('next() called multiple times in a webjs action middleware'));
|
|
60
|
+
}
|
|
61
|
+
lastCalled = i;
|
|
62
|
+
const mw = middleware[i];
|
|
63
|
+
if (!mw) return finalFn();
|
|
64
|
+
return mw(ctx, () => dispatch(i + 1));
|
|
65
|
+
};
|
|
66
|
+
return dispatch(0);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR action-result seeding (#472), follow-up to async render (#469).
|
|
3
|
+
*
|
|
4
|
+
* When a component's `async render()` does a bare `const u = await getUser(id)`
|
|
5
|
+
* during SSR, the action runs server-side and its result is baked into the
|
|
6
|
+
* first paint. On HYDRATION the client re-runs `async render()`, which re-calls
|
|
7
|
+
* the action over RPC. Stale-while-revalidate (#470) hides the flicker, but the
|
|
8
|
+
* redundant round-trip still happens once per async component on first load.
|
|
9
|
+
*
|
|
10
|
+
* This module captures each `'use server'` action result invoked DURING SSR and
|
|
11
|
+
* serializes it into the page (one `<script type="application/json">` block).
|
|
12
|
+
* The generated client RPC stub (`actions.js`) reads that seed on its FIRST call
|
|
13
|
+
* with matching args and resolves without the RPC; a later refetch / arg-change
|
|
14
|
+
* goes to the network as normal.
|
|
15
|
+
*
|
|
16
|
+
* ## How the capture works (no source transform, no build step)
|
|
17
|
+
*
|
|
18
|
+
* The framework promise is "what you write is what you see in the browser source
|
|
19
|
+
* tab", not "what you write is the exact function object that runs server-side"
|
|
20
|
+
* (the RPC stub already replaces the action on the client). So we install a
|
|
21
|
+
* SERVER-SIDE transparent facade via Node's synchronous `module.registerHooks`
|
|
22
|
+
* (Node 24+, main-thread): for a `'use server'` `*.server.*` module, the load
|
|
23
|
+
* hook returns a facade that re-exports each function wrapped in a `Proxy`. The
|
|
24
|
+
* Proxy records `(file, fn, args) -> result` into an ambient `AsyncLocalStorage`
|
|
25
|
+
* collector WHENEVER a collector is active, and is a pure passthrough otherwise.
|
|
26
|
+
*
|
|
27
|
+
* Recording is gated entirely by the ALS collector, which is established ONLY
|
|
28
|
+
* around the SSR page render (`collectSeeds`). The RPC endpoint path runs with
|
|
29
|
+
* NO collector, so the Proxy is a transparent passthrough there. The browser
|
|
30
|
+
* NEVER sees this module (it sees the RPC stub), and the on-disk source is
|
|
31
|
+
* unchanged, so the source-fidelity promise holds.
|
|
32
|
+
*
|
|
33
|
+
* ## Safety
|
|
34
|
+
*
|
|
35
|
+
* A key HIT returns the exact SSR value (correct by construction); a key MISS
|
|
36
|
+
* degrades to a normal RPC (never wrong data, only a missed optimization). The
|
|
37
|
+
* whole feature is therefore fail-open: any failure in the hook, the facade, or
|
|
38
|
+
* the serializer simply skips seeding and the client re-fetches as before.
|
|
39
|
+
*
|
|
40
|
+
* Disabled by default-off of the flag removes the hook entirely, so module
|
|
41
|
+
* loading is byte-identical to before the feature.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import * as nodeModule from 'node:module';
|
|
45
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
46
|
+
import { readFileSync } from 'node:fs';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
import { stringify } from '@webjsdev/core';
|
|
49
|
+
import { hashFile } from './actions.js';
|
|
50
|
+
|
|
51
|
+
/** Ambient per-render seed collector. `Map<key, value>` or undefined. */
|
|
52
|
+
const als = new AsyncLocalStorage();
|
|
53
|
+
|
|
54
|
+
/** Set once the load hook is installed (the flag is on at boot). */
|
|
55
|
+
let _enabled = false;
|
|
56
|
+
/** Idempotency guard: `module.registerHooks` must run at most once. */
|
|
57
|
+
let _registered = false;
|
|
58
|
+
|
|
59
|
+
/** Memoized `absPath -> hash` so a hot action call does not re-hash per call. */
|
|
60
|
+
const _hashCache = new Map();
|
|
61
|
+
|
|
62
|
+
/** This module's own URL, embedded into the generated facade's import. */
|
|
63
|
+
const SELF_URL = import.meta.url;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether seeding is active (the load hook is installed). The SSR emitter
|
|
67
|
+
* (`ssr.js`) checks this so a disabled app does ZERO extra work and produces
|
|
68
|
+
* byte-identical output.
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function seedingEnabled() {
|
|
72
|
+
return _enabled;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compute (and memoize) the action file's hash the SAME way the RPC stub /
|
|
77
|
+
* action index do (`hashFile` over the absolute path string), so the seed key
|
|
78
|
+
* the server emits matches the key the client stub looks up. A path mismatch
|
|
79
|
+
* (e.g. a symlinked appDir whose realpath differs) only yields a key MISS,
|
|
80
|
+
* which safely degrades to a normal RPC.
|
|
81
|
+
* @param {string} absPath
|
|
82
|
+
* @returns {Promise<string>}
|
|
83
|
+
*/
|
|
84
|
+
async function hashFor(absPath) {
|
|
85
|
+
let h = _hashCache.get(absPath);
|
|
86
|
+
if (h === undefined) {
|
|
87
|
+
h = await hashFile(absPath);
|
|
88
|
+
_hashCache.set(absPath, h);
|
|
89
|
+
}
|
|
90
|
+
return h;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Record one action call's resolved result into the active collector, keyed
|
|
95
|
+
* `hash/fn/stringify(args)`. The args key uses the SAME serializer the client
|
|
96
|
+
* stub uses to form its lookup key, so they match for identical args. Never
|
|
97
|
+
* throws into the caller's render: a serialization failure just drops the seed
|
|
98
|
+
* (the client re-fetches).
|
|
99
|
+
* @param {Map<string, unknown>} collector
|
|
100
|
+
* @param {string} file absolute action file path
|
|
101
|
+
* @param {string} fnName
|
|
102
|
+
* @param {unknown[]} args
|
|
103
|
+
* @param {unknown} value resolved action result
|
|
104
|
+
*/
|
|
105
|
+
async function recordSeed(collector, file, fnName, args, value) {
|
|
106
|
+
try {
|
|
107
|
+
const hash = await hashFor(file);
|
|
108
|
+
const argsKey = await stringify(args);
|
|
109
|
+
collector.set(`${hash}/${fnName}/${argsKey}`, value);
|
|
110
|
+
} catch {
|
|
111
|
+
// Drop the seed; the client stub falls back to a normal RPC.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Wrap one exported action function so that, when a collector is active, its
|
|
117
|
+
* resolved result is recorded. Outside a collector (the RPC endpoint path) it is
|
|
118
|
+
* a transparent passthrough. Non-functions (a `const VERSION = '1.0'` export)
|
|
119
|
+
* pass through untouched, and the Proxy forwards property reads, so `expose()` /
|
|
120
|
+
* `validateInput()` metadata stored on the function (`__webjsHttp`) still
|
|
121
|
+
* resolves through `getExposed()`.
|
|
122
|
+
* @param {string} file absolute action file path
|
|
123
|
+
* @param {string} fnName
|
|
124
|
+
* @param {unknown} orig
|
|
125
|
+
* @returns {unknown}
|
|
126
|
+
*/
|
|
127
|
+
export function __seedWrap(file, fnName, orig) {
|
|
128
|
+
if (typeof orig !== 'function') return orig;
|
|
129
|
+
return new Proxy(orig, {
|
|
130
|
+
apply(target, thisArg, args) {
|
|
131
|
+
const collector = als.getStore();
|
|
132
|
+
const result = Reflect.apply(target, thisArg, args);
|
|
133
|
+
if (!collector) return result;
|
|
134
|
+
if (result && typeof result.then === 'function') {
|
|
135
|
+
// Record the RESOLVED value, and return the same value to the caller so
|
|
136
|
+
// the awaiting `async render()` gets its data unchanged.
|
|
137
|
+
return result.then(async (value) => {
|
|
138
|
+
await recordSeed(collector, file, fnName, args, value);
|
|
139
|
+
return value;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
// A synchronous return (rare for an action): record best-effort. The
|
|
143
|
+
// record is async but fire-and-forget here; collectSeeds awaits the
|
|
144
|
+
// render's own microtasks, and a sync action that the render awaits will
|
|
145
|
+
// have settled the record before the render resolves.
|
|
146
|
+
recordSeed(collector, file, fnName, args, result);
|
|
147
|
+
return result;
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Extract the names of every named export from an action module's source, used
|
|
154
|
+
* to generate the facade's `export const NAME = wrap(...)` lines. Conservative:
|
|
155
|
+
* a name it misses simply is not wrapped (no seed for it, RPC fallback). A
|
|
156
|
+
* `export *` re-export cannot be enumerated statically, so its presence makes
|
|
157
|
+
* the caller skip faceting that module entirely (passthrough = no seeding for
|
|
158
|
+
* it), never producing a broken facade.
|
|
159
|
+
* @param {string} src
|
|
160
|
+
* @returns {{ names: string[], hasDefault: boolean, hasStar: boolean } }
|
|
161
|
+
*/
|
|
162
|
+
export function extractExportNames(src) {
|
|
163
|
+
const names = new Set();
|
|
164
|
+
let m;
|
|
165
|
+
const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
|
|
166
|
+
while ((m = reFn.exec(src))) names.add(m[1]);
|
|
167
|
+
const reVar = /\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g;
|
|
168
|
+
while ((m = reVar.exec(src))) names.add(m[1]);
|
|
169
|
+
const reClass = /\bexport\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/g;
|
|
170
|
+
while ((m = reClass.exec(src))) names.add(m[1]);
|
|
171
|
+
const reList = /\bexport\s*\{([^}]*)\}/g;
|
|
172
|
+
while ((m = reList.exec(src))) {
|
|
173
|
+
for (const part of m[1].split(',')) {
|
|
174
|
+
const seg = part.trim();
|
|
175
|
+
if (!seg) continue;
|
|
176
|
+
// `a` or `a as b` (the EXPORTED name is what an importer binds).
|
|
177
|
+
const as = seg.split(/\s+as\s+/);
|
|
178
|
+
const exported = (as[1] || as[0]).trim();
|
|
179
|
+
if (/^[A-Za-z_$][\w$]*$/.test(exported) && exported !== 'default') names.add(exported);
|
|
180
|
+
else if (exported === 'default') names.add('__default__');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const hasDefault = /\bexport\s+default\b/.test(src) || names.delete('__default__');
|
|
184
|
+
const hasStar = /\bexport\s*\*/.test(src);
|
|
185
|
+
return { names: [...names], hasDefault, hasStar };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Build the facade module source for a `'use server'` action module: it imports
|
|
190
|
+
* the REAL module via a `?webjs-seed-orig` query (which the hook passes through
|
|
191
|
+
* unwrapped) and re-exports each function wrapped through `__seedWrap`.
|
|
192
|
+
* @param {string} origUrl the real module URL, WITHOUT the seed query
|
|
193
|
+
* @param {string} absPath the real module's absolute file path (the hash basis)
|
|
194
|
+
* @param {{ names: string[], hasDefault: boolean }} exports
|
|
195
|
+
* @returns {string}
|
|
196
|
+
*/
|
|
197
|
+
function buildFacade(origUrl, absPath, exports) {
|
|
198
|
+
const sep = origUrl.includes('?') ? '&' : '?';
|
|
199
|
+
const origSpec = JSON.stringify(origUrl + sep + 'webjs-seed-orig');
|
|
200
|
+
const file = JSON.stringify(absPath);
|
|
201
|
+
let out = `import * as __orig from ${origSpec};\n`;
|
|
202
|
+
out += `import { __seedWrap as __w } from ${JSON.stringify(SELF_URL)};\n`;
|
|
203
|
+
for (const n of exports.names) {
|
|
204
|
+
const k = JSON.stringify(n);
|
|
205
|
+
out += `export const ${n} = __w(${file}, ${k}, __orig[${k}]);\n`;
|
|
206
|
+
}
|
|
207
|
+
if (exports.hasDefault) {
|
|
208
|
+
out += `export default __w(${file}, 'default', __orig.default);\n`;
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Match `*.server.{js,ts,mjs,mts}` (optionally with a query). */
|
|
214
|
+
const SERVER_FILE_RE = /\.server\.m?[jt]s(\?|$)/;
|
|
215
|
+
/** The `'use server'` directive in the file head. */
|
|
216
|
+
const USE_SERVER_RE = /^\s*(['"])use server\1\s*;?\s*$/m;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The synchronous `module.registerHooks` load hook. For a `'use server'`
|
|
220
|
+
* `*.server.*` module it returns a wrapping facade; for everything else
|
|
221
|
+
* (including the `?webjs-seed-orig` passthrough of the real module) it defers to
|
|
222
|
+
* `nextLoad`. Fail-open: any error defers to `nextLoad`, so a load that the hook
|
|
223
|
+
* cannot facade simply runs unwrapped (no seeding for it).
|
|
224
|
+
* @param {string} url
|
|
225
|
+
* @param {object} context
|
|
226
|
+
* @param {(u: string, c: object) => any} nextLoad
|
|
227
|
+
*/
|
|
228
|
+
function seedLoadHook(url, context, nextLoad) {
|
|
229
|
+
try {
|
|
230
|
+
if (!SERVER_FILE_RE.test(url)) return nextLoad(url, context);
|
|
231
|
+
// The facade's own `?webjs-seed-orig` import must load the REAL module.
|
|
232
|
+
if (url.includes('webjs-seed-orig')) return nextLoad(url, context);
|
|
233
|
+
const absPath = fileURLToPath(url.split('?')[0]);
|
|
234
|
+
const src = readFileSync(absPath, 'utf8');
|
|
235
|
+
const head = src.split('\n').slice(0, 5).join('\n');
|
|
236
|
+
if (!USE_SERVER_RE.test(head)) return nextLoad(url, context);
|
|
237
|
+
const exports = extractExportNames(src);
|
|
238
|
+
// A `export *` re-export cannot be enumerated; skip faceting (passthrough)
|
|
239
|
+
// so we never emit a facade that silently drops re-exported bindings.
|
|
240
|
+
if (exports.hasStar) return nextLoad(url, context);
|
|
241
|
+
if (exports.names.length === 0 && !exports.hasDefault) return nextLoad(url, context);
|
|
242
|
+
const source = buildFacade(url, absPath, exports);
|
|
243
|
+
return { source, format: 'module', shortCircuit: true };
|
|
244
|
+
} catch {
|
|
245
|
+
return nextLoad(url, context);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Install the seed load hook (idempotent). Called once at boot from `dev.js`
|
|
251
|
+
* when seeding is enabled, BEFORE any action module is imported (ESM caches by
|
|
252
|
+
* URL, so a module loaded before the hook would never be faceted). A no-op on a
|
|
253
|
+
* second call.
|
|
254
|
+
*/
|
|
255
|
+
export function registerSeedHooks() {
|
|
256
|
+
_enabled = true;
|
|
257
|
+
if (_registered) return;
|
|
258
|
+
_registered = true;
|
|
259
|
+
nodeModule.registerHooks({ load: seedLoadHook });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Run `fn` (the page render) inside a fresh ambient seed collector and return
|
|
264
|
+
* both its value and the collected seeds. Every action call made during the
|
|
265
|
+
* render (however deeply nested in the SSR walker / async render chain) records
|
|
266
|
+
* into this collector via the ambient ALS.
|
|
267
|
+
* @template T
|
|
268
|
+
* @param {() => Promise<T>} fn
|
|
269
|
+
* @returns {Promise<{ value: T, collector: Map<string, unknown> }>}
|
|
270
|
+
*/
|
|
271
|
+
export async function collectSeeds(fn) {
|
|
272
|
+
const collector = new Map();
|
|
273
|
+
const value = await als.run(collector, fn);
|
|
274
|
+
return { value, collector };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Serialize a collector into a `<script type="application/json">` block to embed
|
|
279
|
+
* in the page. Returns '' for an empty collector (so a render with no seeded
|
|
280
|
+
* action is byte-identical to before). The payload is rich-serialized (same wire
|
|
281
|
+
* format the RPC stub's `parse` reads) and HTML-escaped so it can never break out
|
|
282
|
+
* of the script element. A `type="application/json"` script is DATA, not
|
|
283
|
+
* executable JS, so it needs no CSP nonce.
|
|
284
|
+
* @param {Map<string, unknown>} collector
|
|
285
|
+
* @returns {Promise<string>}
|
|
286
|
+
*/
|
|
287
|
+
export async function buildSeedScript(collector) {
|
|
288
|
+
if (!collector || collector.size === 0) return '';
|
|
289
|
+
try {
|
|
290
|
+
const obj = {};
|
|
291
|
+
for (const [k, v] of collector) obj[k] = v;
|
|
292
|
+
const payload = await stringify(obj);
|
|
293
|
+
const safe = payload
|
|
294
|
+
.replace(/</g, '\\u003c')
|
|
295
|
+
.replace(/>/g, '\\u003e')
|
|
296
|
+
.replace(/&/g, '\\u0026')
|
|
297
|
+
.replace(/\u2028/g, '\\u2028')
|
|
298
|
+
.replace(/\u2029/g, '\\u2029');
|
|
299
|
+
return `<script type="application/json" id="__webjs-seeds">${safe}</script>`;
|
|
300
|
+
} catch {
|
|
301
|
+
return '';
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Test seam: clear the per-file hash memo (e.g. between fixtures). */
|
|
306
|
+
export function __clearSeedHashCache() {
|
|
307
|
+
_hashCache.clear();
|
|
308
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side action AbortSignal (#492). The RPC endpoint runs an action inside
|
|
3
|
+
* `runWithActionSignal(req.signal, fn)`, so the action can read the request's
|
|
4
|
+
* AbortSignal via `actionSignal()` and stop expensive work (a DB query, an
|
|
5
|
+
* upstream fetch) when the client disconnects or aborts the call.
|
|
6
|
+
*
|
|
7
|
+
* Isomorphic-friendly: `actionSignal()` returns a never-aborting signal when
|
|
8
|
+
* called outside an action (e.g. a direct server-to-server call), so the same
|
|
9
|
+
* `fetch(url, { signal: actionSignal() })` line is safe everywhere.
|
|
10
|
+
*/
|
|
11
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
12
|
+
|
|
13
|
+
const als = new AsyncLocalStorage();
|
|
14
|
+
|
|
15
|
+
/** A shared signal that never aborts, returned outside an action. */
|
|
16
|
+
const NEVER = (() => {
|
|
17
|
+
const c = new AbortController();
|
|
18
|
+
return c.signal;
|
|
19
|
+
})();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run `fn` with `signal` available to `actionSignal()` for its async extent.
|
|
23
|
+
* @template T
|
|
24
|
+
* @param {AbortSignal | undefined} signal
|
|
25
|
+
* @param {() => T} fn
|
|
26
|
+
* @returns {T}
|
|
27
|
+
*/
|
|
28
|
+
export function runWithActionSignal(signal, fn) {
|
|
29
|
+
return als.run(signal || NEVER, fn);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The current action's request AbortSignal (fires on client disconnect / abort),
|
|
34
|
+
* or a never-aborting signal outside an action.
|
|
35
|
+
* @returns {AbortSignal}
|
|
36
|
+
*/
|
|
37
|
+
export function actionSignal() {
|
|
38
|
+
return als.getStore() || NEVER;
|
|
39
|
+
}
|
package/src/actions.js
CHANGED
|
@@ -8,6 +8,15 @@ import { verify as verifyCsrf, CSRF_COOKIE, CSRF_HEADER } from './csrf.js';
|
|
|
8
8
|
import { getSerializer } from './serializer.js';
|
|
9
9
|
import { resolveOrigin } from './cors.js';
|
|
10
10
|
import { readTextBounded, payloadTooLarge, DEFAULT_MAX_BODY_BYTES } from './body-limit.js';
|
|
11
|
+
import {
|
|
12
|
+
actionMethod, actionFunctionNames, actionCache, actionConfigFn, resolveTags,
|
|
13
|
+
cacheControlFor, allowedRequestMethods, URL_ARG_VERBS, SAFE_VERBS, MAX_URL_ARGS,
|
|
14
|
+
RESERVED_CONFIG, actionMiddleware,
|
|
15
|
+
} from './action-config.js';
|
|
16
|
+
import { revalidateTags } from './cache-tags.js';
|
|
17
|
+
import { runWithActionSignal } from './action-signal.js';
|
|
18
|
+
import { runActionChain } from './action-middleware.js';
|
|
19
|
+
import { ifNoneMatchSatisfied } from './conditional-get.js';
|
|
11
20
|
import { getBodyLimits } from './context.js';
|
|
12
21
|
import { basePath } from './importmap.js';
|
|
13
22
|
import { withBasePath } from './base-path.js';
|
|
@@ -327,54 +336,116 @@ throw new Error(${JSON.stringify(msg)});
|
|
|
327
336
|
export async function serveActionStub(idx, absFile) {
|
|
328
337
|
const mod = await loadModule(absFile, idx.dev);
|
|
329
338
|
const hash = idx.fileToHash.get(absFile) || await hashFile(absFile);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
339
|
+
// HTTP-verb actions (#488): the file declares its verb via `export const
|
|
340
|
+
// method` (default POST). The function exports are the actions; the reserved
|
|
341
|
+
// config exports (method/cache/tags/invalidates/validate/middleware) are excluded.
|
|
342
|
+
const fnNames = actionFunctionNames(mod);
|
|
343
|
+
const method = actionMethod(mod);
|
|
334
344
|
// The RPC endpoint is a framework-emitted same-origin URL, so it must
|
|
335
345
|
// carry the basePath prefix under a sub-path deploy (#256), exactly like
|
|
336
346
|
// the importmap targets and the boot module specifiers. Without this the
|
|
337
347
|
// stub would POST to a bare /__webjs/action/... that the ingress strip
|
|
338
348
|
// 404s, breaking every server action when webjs.basePath is set.
|
|
339
349
|
const actionUrl = withBasePath(`/__webjs/action/${hash}/`, basePath());
|
|
340
|
-
const body =
|
|
341
|
-
`import { stringify as __wjStringify, parse as __wjParse } from '@webjsdev/core';\n` +
|
|
342
|
-
`function __csrf() {\n` +
|
|
343
|
-
` const m = document.cookie.match(/(?:^|;\\s*)${CSRF_COOKIE}=([^;]+)/);\n` +
|
|
344
|
-
` return m ? decodeURIComponent(m[1]) : '';\n` +
|
|
345
|
-
`}\n` +
|
|
346
|
-
`async function __rpc(fn, args) {\n` +
|
|
347
|
-
` const body = await __wjStringify(args);\n` +
|
|
348
|
-
` const res = await fetch(${JSON.stringify(actionUrl)} + fn, {\n` +
|
|
349
|
-
` method: 'POST',\n` +
|
|
350
|
-
` headers: {\n` +
|
|
351
|
-
` 'content-type': ${JSON.stringify(RPC_CONTENT_TYPE)},\n` +
|
|
352
|
-
` ${JSON.stringify(CSRF_HEADER)}: __csrf()\n` +
|
|
353
|
-
` },\n` +
|
|
354
|
-
` credentials: 'same-origin',\n` +
|
|
355
|
-
` body\n` +
|
|
356
|
-
` });\n` +
|
|
357
|
-
` const ct = res.headers.get('content-type') || '';\n` +
|
|
358
|
-
` const text = await res.text();\n` +
|
|
359
|
-
` const parsed = ct.includes(${JSON.stringify(RPC_CONTENT_TYPE)})\n` +
|
|
360
|
-
` ? __wjParse(text)\n` +
|
|
361
|
-
` : (ct.includes('application/json') ? JSON.parse(text) : text);\n` +
|
|
362
|
-
` if (!res.ok) {\n` +
|
|
363
|
-
` const msg = (parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status);\n` +
|
|
364
|
-
` throw new Error(msg);\n` +
|
|
365
|
-
` }\n` +
|
|
366
|
-
` return parsed;\n` +
|
|
367
|
-
`}\n` +
|
|
368
|
-
fnNames
|
|
369
|
-
.map((name) =>
|
|
370
|
-
name === 'default'
|
|
371
|
-
? `export default (...args) => __rpc('default', args);`
|
|
372
|
-
: `export const ${name} = (...args) => __rpc(${JSON.stringify(name)}, args);`
|
|
373
|
-
)
|
|
374
|
-
.join('\n') + '\n';
|
|
350
|
+
const body = buildStubBody({ hash, method, fnNames, actionUrl });
|
|
375
351
|
return body;
|
|
376
352
|
}
|
|
377
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Build the generated client stub source for a `'use server'` action file. The
|
|
356
|
+
* transport depends on the file's declared `method`:
|
|
357
|
+
* - GET: args ride the URL (`?a=`), the SSR seed (#472) is read first, the
|
|
358
|
+
* browser HTTP cache is consulted, and a tag-invalidated key revalidates;
|
|
359
|
+
* CSRF-exempt (a safe read). Over-large args fall back to a POST.
|
|
360
|
+
* - DELETE: args ride the URL, CSRF-protected, over-large args fall back to a
|
|
361
|
+
* POST.
|
|
362
|
+
* - POST / PUT / PATCH: rich body, CSRF-protected.
|
|
363
|
+
* Every path reads `X-Webjs-Invalidate` (mark tags stale) and a GET reads
|
|
364
|
+
* `X-Webjs-Tags` (register the key's tags), so a later read sees a mutation.
|
|
365
|
+
* @param {{ hash: string, method: string, fnNames: string[], actionUrl: string }} opts
|
|
366
|
+
* @returns {string}
|
|
367
|
+
*/
|
|
368
|
+
function buildStubBody({ hash, method, fnNames, actionUrl }) {
|
|
369
|
+
const URL_ARG = URL_ARG_VERBS.has(method);
|
|
370
|
+
const SAFE = SAFE_VERBS.has(method);
|
|
371
|
+
const J = JSON.stringify;
|
|
372
|
+
const lines = [];
|
|
373
|
+
lines.push(`// webjs: generated server-action stub (${method})`);
|
|
374
|
+
// Every verb reads the SSR seed (#472) first: an async-render READ invoked
|
|
375
|
+
// during SSR is seeded regardless of its declared verb (a read with no
|
|
376
|
+
// `method` is a default POST), so the first client call resolves from the
|
|
377
|
+
// seed with no hydration round-trip. The browser-cache staleness check is
|
|
378
|
+
// GET-only (only a GET is browser-cached).
|
|
379
|
+
const imports = ['stringify as __s', 'parse as __p', 'takeSeed as __seedTake', 'SEED_MISS as __MISS', 'markStale as __markStale', 'parseTagHeader as __tagHdr', 'activeActionSignal as __sig'];
|
|
380
|
+
if (method === 'GET') {
|
|
381
|
+
imports.push('registerKeyTags as __regTags', 'consumeStale as __stale', 'fetchMark as __mark');
|
|
382
|
+
}
|
|
383
|
+
lines.push(`import { ${imports.join(', ')} } from '@webjsdev/core';`);
|
|
384
|
+
lines.push(`const __URL = ${J(actionUrl)};`);
|
|
385
|
+
lines.push(`const __HASH = ${J(hash)};`);
|
|
386
|
+
lines.push(`const __METHOD = ${J(method)};`);
|
|
387
|
+
lines.push(`const __MAX = ${MAX_URL_ARGS};`);
|
|
388
|
+
lines.push(`const __CT = ${J(RPC_CONTENT_TYPE)};`);
|
|
389
|
+
lines.push(`function __csrf() { const m = document.cookie.match(/(?:^|;\\s*)${CSRF_COOKIE}=([^;]+)/); return m ? decodeURIComponent(m[1]) : ''; }`);
|
|
390
|
+
// Shared: parse a response, surface invalidation, register a GET's tags
|
|
391
|
+
// (stamped with the clock SAMPLED BEFORE the fetch, so a mutation in flight is
|
|
392
|
+
// caught on the next read).
|
|
393
|
+
lines.push(`async function __handle(res, fn, key, since) {`);
|
|
394
|
+
lines.push(` const inv = __tagHdr(res.headers.get('x-webjs-invalidate')); if (inv.length) __markStale(inv);`);
|
|
395
|
+
lines.push(` if (key != null) { const t = __tagHdr(res.headers.get('x-webjs-tags')); if (t.length) __regTags(key, t, since); }`);
|
|
396
|
+
lines.push(` const ct = res.headers.get('content-type') || '';`);
|
|
397
|
+
lines.push(` const text = await res.text();`);
|
|
398
|
+
lines.push(` const parsed = ct.includes(__CT) ? __p(text) : (ct.includes('application/json') ? JSON.parse(text) : text);`);
|
|
399
|
+
lines.push(` if (!res.ok) throw new Error((parsed && parsed.error) || ('webjs action ' + fn + ' -> ' + res.status));`);
|
|
400
|
+
lines.push(` return parsed;`);
|
|
401
|
+
lines.push(`}`);
|
|
402
|
+
// Body sender (POST/PUT/PATCH, and the URL-arg too-large fallback). `sig` is
|
|
403
|
+
// captured SYNCHRONOUSLY at __call entry (#492): the active render signal must
|
|
404
|
+
// be read before the stub's first await (stringify), or the render's
|
|
405
|
+
// synchronous window has already closed and cleared it.
|
|
406
|
+
lines.push(`async function __body(fn, body, m, csrf, sig) {`);
|
|
407
|
+
lines.push(` const headers = { 'content-type': __CT }; if (csrf) headers[${J(CSRF_HEADER)}] = __csrf();`);
|
|
408
|
+
lines.push(` const res = await fetch(__URL + fn, { method: m, headers, credentials: 'same-origin', body, signal: sig });`);
|
|
409
|
+
lines.push(` return __handle(res, fn, null);`);
|
|
410
|
+
lines.push(`}`);
|
|
411
|
+
if (URL_ARG) {
|
|
412
|
+
// GET/DELETE: args in the URL, with a POST fallback when too large.
|
|
413
|
+
lines.push(`async function __call(fn, args) {`);
|
|
414
|
+
lines.push(` const sig = __sig();`); // sync capture before any await
|
|
415
|
+
lines.push(` const key = await __s(args);`);
|
|
416
|
+
lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
|
|
417
|
+
lines.push(` if (key.length > __MAX) return __body(fn, key, 'POST', ${SAFE ? 'false' : 'true'}, sig);`);
|
|
418
|
+
if (method === 'GET') {
|
|
419
|
+
lines.push(` const bypass = __stale(key);`);
|
|
420
|
+
lines.push(` const since = __mark();`);
|
|
421
|
+
lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: 'GET', credentials: 'same-origin', cache: bypass ? 'no-cache' : 'default', signal: sig });`);
|
|
422
|
+
lines.push(` return __handle(res, fn, key, since);`);
|
|
423
|
+
} else {
|
|
424
|
+
lines.push(` const res = await fetch(__URL + fn + '?a=' + encodeURIComponent(key), { method: __METHOD, headers: { ${J(CSRF_HEADER)}: __csrf() }, credentials: 'same-origin', signal: sig });`);
|
|
425
|
+
lines.push(` return __handle(res, fn, null);`);
|
|
426
|
+
}
|
|
427
|
+
lines.push(`}`);
|
|
428
|
+
} else {
|
|
429
|
+
// POST/PUT/PATCH: rich body. The seed read makes a default-POST async-render
|
|
430
|
+
// read resolve from the SSR seed on hydration (#472); a true mutation is
|
|
431
|
+
// never SSR-invoked, so the seed simply misses.
|
|
432
|
+
lines.push(`async function __call(fn, args) {`);
|
|
433
|
+
lines.push(` const sig = __sig();`); // sync capture before any await
|
|
434
|
+
lines.push(` const key = await __s(args);`);
|
|
435
|
+
lines.push(` const seeded = __seedTake(__HASH, fn, key); if (seeded !== __MISS) return seeded;`);
|
|
436
|
+
lines.push(` return __body(fn, key, __METHOD, true, sig);`);
|
|
437
|
+
lines.push(`}`);
|
|
438
|
+
}
|
|
439
|
+
for (const name of fnNames) {
|
|
440
|
+
lines.push(
|
|
441
|
+
name === 'default'
|
|
442
|
+
? `export default (...args) => __call('default', args);`
|
|
443
|
+
: `export const ${name} = (...args) => __call(${J(name)}, args);`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return lines.join('\n') + '\n';
|
|
447
|
+
}
|
|
448
|
+
|
|
378
449
|
/**
|
|
379
450
|
* Invoke a server action via the internal RPC wire format.
|
|
380
451
|
* @param {ActionIndex} idx
|
|
@@ -387,34 +458,62 @@ export async function serveActionStub(idx, absFile) {
|
|
|
387
458
|
* it so a throwing sink can never affect the response.
|
|
388
459
|
*/
|
|
389
460
|
export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
390
|
-
if (!verifyCsrf(req)) {
|
|
391
|
-
return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
|
|
392
|
-
}
|
|
393
461
|
const file = idx.hashToFile.get(hash);
|
|
394
462
|
if (!file) return rpcResponse({ error: 'Unknown action' }, { status: 404 });
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
if (
|
|
463
|
+
const mod = await loadModule(file, idx.dev);
|
|
464
|
+
const fn = fnName === 'default' ? mod.default : mod[fnName];
|
|
465
|
+
// A reserved config export (method/cache/tags/invalidates/validate/middleware) is never
|
|
466
|
+
// a callable action even though some are functions (#488).
|
|
467
|
+
if (typeof fn !== 'function' || RESERVED_CONFIG.has(fnName)) {
|
|
468
|
+
return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// HTTP-verb dispatch (#488). The action declares its method (default POST). A
|
|
472
|
+
// URL-arg verb (GET/DELETE) also accepts a POST fallback for over-large args.
|
|
473
|
+
const method = actionMethod(mod);
|
|
474
|
+
const reqMethod = req.method.toUpperCase();
|
|
475
|
+
const allowed = allowedRequestMethods(method);
|
|
476
|
+
if (!allowed.has(reqMethod)) {
|
|
477
|
+
return rpcResponse(
|
|
478
|
+
{ error: `expected ${method} for ${fnName}, got ${reqMethod}` },
|
|
479
|
+
{ status: 405, headers: { allow: [...allowed].join(', ') } },
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
// CSRF: required for every verb except a safe GET (a read with no state
|
|
483
|
+
// change, which is also browser-cacheable so it cannot carry a fresh token).
|
|
484
|
+
if (!SAFE_VERBS.has(method) && !verifyCsrf(req)) {
|
|
485
|
+
return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Args ride the URL for a URL-arg request (GET / DELETE), else the body.
|
|
400
489
|
let args = [];
|
|
490
|
+
const fromUrl = reqMethod === 'GET' || reqMethod === 'DELETE';
|
|
401
491
|
try {
|
|
402
|
-
|
|
492
|
+
if (fromUrl) {
|
|
493
|
+
const a = new URL(req.url).searchParams.get('a');
|
|
494
|
+
args = a ? getSerializer().deserialize(a) : [];
|
|
495
|
+
} else {
|
|
496
|
+
const { tooLarge, text: body } = await readTextBounded(req, jsonBodyLimit());
|
|
497
|
+
if (tooLarge) return payloadTooLarge();
|
|
498
|
+
args = body ? getSerializer().deserialize(body) : [];
|
|
499
|
+
}
|
|
403
500
|
if (!Array.isArray(args)) args = [args];
|
|
404
501
|
} catch {
|
|
405
502
|
return rpcResponse({ error: 'Invalid request body' }, { status: 400 });
|
|
406
503
|
}
|
|
407
|
-
const mod = await loadModule(file, idx.dev);
|
|
408
|
-
const fn = fnName === 'default' ? mod.default : mod[fnName];
|
|
409
|
-
if (typeof fn !== 'function') return rpcResponse({ error: `Unknown action ${fnName}` }, { status: 404 });
|
|
410
504
|
// Input validation (#245): a validator attached via `validateInput(fn, ...)`
|
|
411
505
|
// or `expose(spec, fn, { validate })` runs SERVER-SIDE before the body on
|
|
412
506
|
// this RPC path too, not just the REST path. The RPC stub sends an args
|
|
413
507
|
// array; an action conventionally takes one input object, so validate the
|
|
414
508
|
// FIRST arg (matching how the REST path validates its single merged object).
|
|
509
|
+
// Validation is a BOUNDARY concern (#488): the validator is the `validate`
|
|
510
|
+
// config export (the new model), or the legacy `expose`/`validateInput`
|
|
511
|
+
// attachment. It runs on this RPC boundary on the FIRST arg (the conventional
|
|
512
|
+
// single input object), never on a direct server-to-server call.
|
|
415
513
|
const attached = getExposed(fn);
|
|
416
|
-
|
|
417
|
-
|
|
514
|
+
const validate = actionConfigFn(mod, 'validate') || (attached && attached.validate);
|
|
515
|
+
if (typeof validate === 'function') {
|
|
516
|
+
const v = runValidate(validate, args[0]);
|
|
418
517
|
if (!v.ok) {
|
|
419
518
|
if (v.thrown !== undefined) {
|
|
420
519
|
// A THROWN validator behaves like a thrown action: a sanitized error
|
|
@@ -432,14 +531,67 @@ export async function invokeAction(idx, hash, fnName, req, onError) {
|
|
|
432
531
|
args = [v.value, ...args.slice(1)];
|
|
433
532
|
}
|
|
434
533
|
try {
|
|
435
|
-
|
|
436
|
-
|
|
534
|
+
// Run inside the request's AbortSignal scope (#492) and the per-action
|
|
535
|
+
// middleware chain (#490): the chain wraps the action, can short-circuit
|
|
536
|
+
// (an auth middleware returning an ActionResult), and accumulates context
|
|
537
|
+
// the action reads via actionContext(). `ranAction` distinguishes a real
|
|
538
|
+
// action completion from a middleware short-circuit (the action never ran).
|
|
539
|
+
const middleware = actionMiddleware(mod);
|
|
540
|
+
let ranAction = false;
|
|
541
|
+
const result = await runWithActionSignal(req.signal, () =>
|
|
542
|
+
runActionChain(middleware, { request: req, args, signal: req.signal }, () => { ranAction = true; return fn(...args); }));
|
|
543
|
+
if (method === 'GET') {
|
|
544
|
+
// A short-circuit (the action did not run, e.g. an auth denial) is NEVER
|
|
545
|
+
// cached: serve the envelope no-store so a denial is not stored or shared.
|
|
546
|
+
if (!ranAction) return rpcResponse(result ?? null, { headers: { 'cache-control': 'no-store' } });
|
|
547
|
+
return await getActionResponse(result, mod, args, req);
|
|
548
|
+
}
|
|
549
|
+
// A mutation (POST/PUT/PATCH/DELETE): only a COMPLETED action evicts its
|
|
550
|
+
// `invalidates` tags; a short-circuit (the action never ran) does not.
|
|
551
|
+
const headers = {};
|
|
552
|
+
if (ranAction) {
|
|
553
|
+
const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
|
|
554
|
+
if (inv.length) {
|
|
555
|
+
await revalidateTags(inv);
|
|
556
|
+
headers['x-webjs-invalidate'] = inv.join(',');
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return rpcResponse(result ?? null, { headers });
|
|
437
560
|
} catch (e) {
|
|
438
561
|
if (typeof onError === 'function') onError(e);
|
|
439
562
|
return actionErrorResponse(e, idx.dev);
|
|
440
563
|
}
|
|
441
564
|
}
|
|
442
565
|
|
|
566
|
+
/**
|
|
567
|
+
* Build a GET action's response (#488): the serialized body plus a weak ETag
|
|
568
|
+
* (content hash), the `Cache-Control` from the `cache` config (else `no-store`),
|
|
569
|
+
* and `X-Webjs-Tags`. Answers `If-None-Match` with a 304 itself rather than
|
|
570
|
+
* relying on the conditional-GET funnel, which EXCLUDES `private` responses (the
|
|
571
|
+
* default here); a per-user browser cache may still revalidate to a 304.
|
|
572
|
+
* @param {unknown} result
|
|
573
|
+
* @param {Record<string, unknown>} mod
|
|
574
|
+
* @param {unknown[]} args
|
|
575
|
+
* @param {Request} req
|
|
576
|
+
* @returns {Promise<Response>}
|
|
577
|
+
*/
|
|
578
|
+
async function getActionResponse(result, mod, args, req) {
|
|
579
|
+
const s = getSerializer();
|
|
580
|
+
const bodyStr = await s.serialize(result ?? null);
|
|
581
|
+
const cache = actionCache(mod);
|
|
582
|
+
const etag = `W/"${(await digestHex('SHA-256', bodyStr)).slice(0, 16)}"`;
|
|
583
|
+
const headers = new Headers({ 'content-type': s.contentType, etag });
|
|
584
|
+
headers.set('cache-control', cacheControlFor('GET', cache) || 'no-store');
|
|
585
|
+
if (cache) {
|
|
586
|
+
const tags = resolveTags(actionConfigFn(mod, 'tags'), args);
|
|
587
|
+
if (tags.length) headers.set('x-webjs-tags', tags.join(','));
|
|
588
|
+
}
|
|
589
|
+
if (ifNoneMatchSatisfied(req.headers.get('if-none-match'), etag)) {
|
|
590
|
+
return new Response(null, { status: 304, headers });
|
|
591
|
+
}
|
|
592
|
+
return new Response(bodyStr, { status: 200, headers });
|
|
593
|
+
}
|
|
594
|
+
|
|
443
595
|
/**
|
|
444
596
|
* Match an incoming request against an expose()d action route.
|
|
445
597
|
* Returns the single matched route+params for normal methods.
|
|
@@ -591,8 +743,21 @@ export async function invokeExposedAction(idx, route, params, req, onError) {
|
|
|
591
743
|
const fn = mod[route.fnName];
|
|
592
744
|
if (typeof fn !== 'function') return new Response(`Unknown action ${route.fnName}`, { status: 404 });
|
|
593
745
|
try {
|
|
594
|
-
|
|
746
|
+
// The REST boundary wires the request signal (#492) and the per-action
|
|
747
|
+
// middleware chain (#490) too, symmetric with the RPC path. `ranAction`
|
|
748
|
+
// marks whether the action ran (vs a middleware short-circuit).
|
|
749
|
+
const middleware = actionMiddleware(mod);
|
|
750
|
+
let ranAction = false;
|
|
751
|
+
const result = await runWithActionSignal(req.signal, () =>
|
|
752
|
+
runActionChain(middleware, { request: req, args: [arg], signal: req.signal }, () => { ranAction = true; return fn(arg, { req, params }); }));
|
|
595
753
|
if (result instanceof Response) return result;
|
|
754
|
+
// A middleware short-circuit on the REST boundary maps the envelope's
|
|
755
|
+
// `status` to the HTTP status (like the validate failure above), so a
|
|
756
|
+
// non-webjs REST client sees the real status, not a 200 with it in the body.
|
|
757
|
+
if (!ranAction && result && typeof result === 'object' && typeof result.status === 'number') {
|
|
758
|
+
const { status, ...payload } = result;
|
|
759
|
+
return Response.json(payload, { status });
|
|
760
|
+
}
|
|
596
761
|
return Response.json(result ?? null);
|
|
597
762
|
} catch (e) {
|
|
598
763
|
if (typeof onError === 'function') onError(e);
|
package/src/check.js
CHANGED
|
@@ -10,6 +10,7 @@ import { buildModuleGraph, transitiveDeps } from './module-graph.js';
|
|
|
10
10
|
import { scanComponents } from './component-scanner.js';
|
|
11
11
|
import { buildRouteTable } from './router.js';
|
|
12
12
|
import { analyzeElision } from './component-elision.js';
|
|
13
|
+
import { RESERVED_CONFIG } from './action-config.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Convention validator for webjs apps.
|
|
@@ -107,6 +108,11 @@ export const RULES = [
|
|
|
107
108
|
description:
|
|
108
109
|
'A page / layout / component module that SHIPS to the browser (the build does NOT elide it) must not transitively import a server-only `.server.{ts,js}` module. The server module is replaced by a stub in the browser, so the import is fine while the page is display-only and elided (its server import is stripped), but the moment the page also does client work (it imports a component to register, enables the client router, uses a reactive primitive, …) it stops being elided and must load in the browser, dragging the server import with it. The stub then throws (or a server-only export like `auth` is missing) the instant the module loads, a runtime browser crash that `webjs typecheck` and the rest of `webjs check` miss. The rule reuses the build\'s own elision verdict, so it ONLY fires on modules that genuinely ship; a display-only page the framework elides is never flagged. The fix is to keep the server call off the browser-shipped module: gate the route in `middleware.ts`, call the server through a `\'use server\'` action, or register the component in a layout so the page elides again. Server-to-server imports (`.server.ts` importing `.server.ts`) and `middleware.ts` / `route.ts` (never shipped) are never flagged.',
|
|
109
110
|
},
|
|
111
|
+
{
|
|
112
|
+
name: 'one-action-per-configured-file',
|
|
113
|
+
description:
|
|
114
|
+
'A `\'use server\'` action file that declares HTTP-verb config (any of `method` / `cache` / `tags` / `invalidates` / `validate`, #488) must export exactly ONE callable action function. The config is file-level (it applies to the action in the file), so a second exported function would silently inherit the same verb / cache, which is almost never intended and makes the contract ambiguous. Move the extra function to its own `.server.ts` file (the one-function-per-file convention), or, if it is a private helper, do not export it. Files with no verb config are unaffected.',
|
|
115
|
+
},
|
|
110
116
|
{
|
|
111
117
|
name: 'no-scaffold-placeholder',
|
|
112
118
|
description:
|
|
@@ -727,6 +733,37 @@ export async function checkConventions(appDir) {
|
|
|
727
733
|
}
|
|
728
734
|
}
|
|
729
735
|
|
|
736
|
+
// --- Rule: one-action-per-configured-file (#488) ---
|
|
737
|
+
// A `'use server'` file that declares HTTP-verb config (method/cache/tags/
|
|
738
|
+
// invalidates/validate) must export exactly one callable action; the config
|
|
739
|
+
// is file-level, so a second exported function would silently inherit it.
|
|
740
|
+
{
|
|
741
|
+
for (const { rel, content, scan } of files) {
|
|
742
|
+
if (!/\.server\.m?[jt]s$/.test(rel)) continue;
|
|
743
|
+
if (!hasUseServerDirective(content)) continue;
|
|
744
|
+
if (!/\bexport\s+const\s+(?:method|cache|tags|invalidates|validate|middleware)\b/.test(scan)) continue;
|
|
745
|
+
const names = new Set();
|
|
746
|
+
let m;
|
|
747
|
+
const reFn = /\bexport\s+(?:async\s+)?function\s*\*?\s+([A-Za-z_$][\w$]*)/g;
|
|
748
|
+
while ((m = reFn.exec(scan))) names.add(m[1]);
|
|
749
|
+
// An arrow-const action: `export const x = (...) => ...`, the paren-less
|
|
750
|
+
// `export const x = id => ...`, or a function expression. The `=>` /
|
|
751
|
+
// `function` anchor keeps a plain `export const N = 5` from counting.
|
|
752
|
+
const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
|
|
753
|
+
while ((m = reArrow.exec(scan))) names.add(m[1]);
|
|
754
|
+
if (/\bexport\s+default\b/.test(scan)) names.add('default');
|
|
755
|
+
const actions = [...names].filter((n) => !RESERVED_CONFIG.has(n));
|
|
756
|
+
if (actions.length > 1) {
|
|
757
|
+
violations.push({
|
|
758
|
+
rule: 'one-action-per-configured-file',
|
|
759
|
+
file: rel,
|
|
760
|
+
message: `Configured action file exports ${actions.length} callable functions (${actions.join(', ')}); the verb/cache config is file-level, so only one action per file is allowed.`,
|
|
761
|
+
fix: 'Move the extra function to its own .server.{js,ts} file, or keep it private (do not export it).',
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
730
767
|
// --- Rule: tag-name-has-hyphen ---
|
|
731
768
|
{
|
|
732
769
|
for (const { rel, scan } of files) {
|
package/src/dev.js
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
hasUseServerDirective,
|
|
66
66
|
hashFile,
|
|
67
67
|
} from './actions.js';
|
|
68
|
+
import { registerSeedHooks } from './action-seed.js';
|
|
68
69
|
import { defaultLogger } from './logger.js';
|
|
69
70
|
import { assertNodeVersion } from './node-version.js';
|
|
70
71
|
import { applyEnvValidation } from './env-schema.js';
|
|
@@ -253,6 +254,48 @@ export async function readElideEnabled(appDir) {
|
|
|
253
254
|
return true;
|
|
254
255
|
}
|
|
255
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Read the `WEBJS_SEED` environment override, if set. Same grammar as
|
|
259
|
+
* `WEBJS_ELIDE`: `0`/`false`/`off`/`no` force seeding OFF, `1`/`true`/`on`/`yes`
|
|
260
|
+
* force it ON, anything else (or unset) falls through to the package.json
|
|
261
|
+
* switch. The deploy-time / ops escape hatch and the seam tests use to render
|
|
262
|
+
* an app with seeding on and off in one process.
|
|
263
|
+
* @returns {boolean | undefined}
|
|
264
|
+
*/
|
|
265
|
+
function seedEnvOverride() {
|
|
266
|
+
const raw = process.env.WEBJS_SEED;
|
|
267
|
+
if (raw == null || raw === '') return undefined;
|
|
268
|
+
const v = String(raw).trim().toLowerCase();
|
|
269
|
+
if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false;
|
|
270
|
+
if (v === '1' || v === 'true' || v === 'on' || v === 'yes') return true;
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Read the project-level SSR action-seeding switch (#472). Precedence: the
|
|
276
|
+
* `WEBJS_SEED` env override wins when set, otherwise the package.json
|
|
277
|
+
* `{ "webjs": { "seed": false } }` switch disables it (the client re-fetches on
|
|
278
|
+
* hydration as it did before the feature). Default ON (opt-out), mirroring
|
|
279
|
+
* elision: any value other than the literal `false` keeps seeding enabled.
|
|
280
|
+
*
|
|
281
|
+
* Seeding installs a process-global `module.registerHooks` load hook, so unlike
|
|
282
|
+
* elision it is read ONCE at boot (not re-read per rebuild): toggling it needs a
|
|
283
|
+
* restart, like a deploy.
|
|
284
|
+
* @param {string} appDir
|
|
285
|
+
* @returns {Promise<boolean>}
|
|
286
|
+
*/
|
|
287
|
+
export async function readSeedEnabled(appDir) {
|
|
288
|
+
const override = seedEnvOverride();
|
|
289
|
+
if (override !== undefined) return override;
|
|
290
|
+
try {
|
|
291
|
+
const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
|
|
292
|
+
if (pkg && pkg.webjs && pkg.webjs.seed === false) return false;
|
|
293
|
+
} catch {
|
|
294
|
+
// No package.json, malformed JSON, or unreadable. Keep the default.
|
|
295
|
+
}
|
|
296
|
+
return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
256
299
|
/**
|
|
257
300
|
* Read the per-path response-header config (`webjs.headers`) from the
|
|
258
301
|
* app's package.json and compile it to URLPattern rules. A missing,
|
|
@@ -484,6 +527,14 @@ export async function createRequestHandler(opts) {
|
|
|
484
527
|
// id is a stable deploy fingerprint independent of per-file content hashes.
|
|
485
528
|
setAssetRoots({ appDir, coreDir, enabled: !dev });
|
|
486
529
|
|
|
530
|
+
// SSR action-result seeding (#472). Install the process-global module load
|
|
531
|
+
// hook NOW, at boot, before any `'use server'` action module is imported (ESM
|
|
532
|
+
// caches by URL, so a module loaded before the hook would never be faceted).
|
|
533
|
+
// Read once (not per-rebuild): the hook is global and cannot be cleanly
|
|
534
|
+
// un-installed, so toggling needs a restart. Disabled -> no hook, no ambient
|
|
535
|
+
// collector wrap in ssr.js, and module loading stays byte-identical.
|
|
536
|
+
if (await readSeedEnabled(appDir)) registerSeedHooks();
|
|
537
|
+
|
|
487
538
|
// When an app commits a vendor pin (.webjs/vendor/importmap.json) it carries a
|
|
488
539
|
// deterministic vendor map that is cheap to read (one file, no analysis, no
|
|
489
540
|
// network). Resolve it AT BOOT and publish the build id immediately so the
|
|
@@ -1644,7 +1695,12 @@ async function handleCore(req, ctx) {
|
|
|
1644
1695
|
// Internal server-action RPC endpoint
|
|
1645
1696
|
const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
|
|
1646
1697
|
if (actMatch) {
|
|
1647
|
-
|
|
1698
|
+
// HTTP-verb actions (#488): any RPC verb may hit the endpoint; invokeAction
|
|
1699
|
+
// enforces the action's DECLARED method (405 + Allow on a mismatch) and
|
|
1700
|
+
// reads args from the URL (GET/DELETE) or the body (POST/PUT/PATCH).
|
|
1701
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
1702
|
+
return new Response('method not allowed', { status: 405 });
|
|
1703
|
+
}
|
|
1648
1704
|
// Pass the onError sink (issue #239): a server action that throws
|
|
1649
1705
|
// unexpectedly is reported to the APM hook before the sanitized 500.
|
|
1650
1706
|
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
package/src/ssr.js
CHANGED
|
@@ -7,6 +7,7 @@ import { withAssetHash } from './asset-hash.js';
|
|
|
7
7
|
import { jsonForScriptTag } from './script-tag-json.js';
|
|
8
8
|
import { readToken, newToken, cookieHeader } from './csrf.js';
|
|
9
9
|
import { transitiveDeps } from './module-graph.js';
|
|
10
|
+
import { seedingEnabled, collectSeeds, buildSeedScript } from './action-seed.js';
|
|
10
11
|
import { BUFFERED_MARKER, STREAM_MARKER } from './conditional-get.js';
|
|
11
12
|
import {
|
|
12
13
|
readRevalidate,
|
|
@@ -94,7 +95,21 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
94
95
|
const have = haveHeader
|
|
95
96
|
? new Set(haveHeader.split(',').map((s) => s.trim()).filter(Boolean))
|
|
96
97
|
: null;
|
|
97
|
-
|
|
98
|
+
// SSR action-result seeding (#472). When enabled, run the whole render
|
|
99
|
+
// inside an ambient seed collector so every `'use server'` action a
|
|
100
|
+
// component awaits in `async render()` records its (args -> result) for the
|
|
101
|
+
// hydration payload. Disabled -> the plain render, byte-identical to before.
|
|
102
|
+
let seedCollector = null;
|
|
103
|
+
let body;
|
|
104
|
+
if (seedingEnabled()) {
|
|
105
|
+
const seeded = await collectSeeds(() =>
|
|
106
|
+
renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule),
|
|
107
|
+
);
|
|
108
|
+
body = seeded.value;
|
|
109
|
+
seedCollector = seeded.collector;
|
|
110
|
+
} else {
|
|
111
|
+
body = await renderChain(route, ctx, opts.dev, suspenseCtx, have, opts.pageModule);
|
|
112
|
+
}
|
|
98
113
|
|
|
99
114
|
// Frame subtree render (#253). A `<webjs-frame src>` self-load (or a
|
|
100
115
|
// click-driven frame nav) sends `x-webjs-frame: <id>` and applies ONLY the
|
|
@@ -170,9 +185,20 @@ export async function ssrPage(route, params, url, opts) {
|
|
|
170
185
|
// shell. Either way the returned `prefix` ends just past the open <body>
|
|
171
186
|
// and `closer` is the matching `</body></html>`.
|
|
172
187
|
const { prefix, streamBody, closer } = buildDocumentParts(body, wrapOpts);
|
|
188
|
+
// Append the SSR action-seed payload (#472) to the non-streamed body so the
|
|
189
|
+
// client's first render reads it instead of re-issuing the RPC. Only for a
|
|
190
|
+
// fully-buffered (non-streaming) render: a streamed page's deferred
|
|
191
|
+
// boundaries resolve AFTER the first flush, so their seeds cannot ride this
|
|
192
|
+
// block (those slow regions keep the stale-while-revalidate refetch). An
|
|
193
|
+
// empty collector yields '' so the output stays byte-identical.
|
|
194
|
+
let outBody = streamBody;
|
|
195
|
+
if (seedCollector && suspenseCtx.pending.length === 0) {
|
|
196
|
+
const seedScript = await buildSeedScript(seedCollector);
|
|
197
|
+
if (seedScript) outBody = streamBody + seedScript;
|
|
198
|
+
}
|
|
173
199
|
const res = streamingHtmlResponse(
|
|
174
200
|
prefix,
|
|
175
|
-
|
|
201
|
+
outBody,
|
|
176
202
|
closer,
|
|
177
203
|
suspenseCtx,
|
|
178
204
|
// Normally 200. After a failed page `action` submission the caller passes
|
package/webjs-config.schema.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "https://webjs.dev/schemas/webjs-config.schema.json",
|
|
4
4
|
"title": "webjs config block",
|
|
5
5
|
"description": "Schema for the `webjs` object in a webjs app's package.json. Each key maps to a documented server reader. An unknown key is flagged (additionalProperties is false) so a typo no longer silently falls back to the default.",
|
|
6
|
-
"$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the server reader functions (readElideEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
|
|
6
|
+
"$comment": "Single source of truth lives in THREE co-located places that must stay in lockstep: this schema, the TS type at packages/core/src/webjs-config.d.ts, and the server reader functions (readElideEnabled in dev.js, readSeedEnabled in dev.js, compileHeaderRules in headers.js, compileRedirectRules / readTrailingSlashPolicy in redirects.js, readBasePath in base-path.js, readCspConfig in csp.js, readBodyLimits / computeServerTimeouts in body-limit.js). Adding a webjs.* key means updating all three. See packages/server/AGENTS.md for the one documented procedure.",
|
|
7
7
|
"type": "object",
|
|
8
8
|
"additionalProperties": false,
|
|
9
9
|
"properties": {
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
"type": "boolean",
|
|
13
13
|
"default": true
|
|
14
14
|
},
|
|
15
|
+
"seed": {
|
|
16
|
+
"description": "SSR action-result seeding switch (#472). Default true. When on, a 'use server' action's SSR result is serialized into the page so an async-render component does not re-fetch it over RPC on hydration. Set to false to disable (the client re-fetches as before). Read by readSeedEnabled in dev.js; the WEBJS_SEED env override wins over this.",
|
|
17
|
+
"type": "boolean",
|
|
18
|
+
"default": true
|
|
19
|
+
},
|
|
15
20
|
"headers": {
|
|
16
21
|
"description": "Per-path response-header rules, shaped like Next's. Each rule pairs a URLPattern source against header directives. Read by compileHeaderRules in headers.js.",
|
|
17
22
|
"type": "array",
|