@webjsdev/server 0.8.24 → 0.8.26
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 +26 -2
- package/index.js +3 -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-route.js +147 -0
- package/src/action-seed.js +8 -3
- package/src/action-signal.js +39 -0
- package/src/action-stream.js +158 -0
- package/src/actions.js +271 -316
- package/src/check.js +43 -0
- package/src/cors.js +5 -8
- package/src/csrf.js +5 -4
- package/src/dev.js +6 -34
- package/src/importmap.js +3 -3
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,43 @@ 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
|
+
// An OPTIONAL `: Type` annotation may sit between the name and the `=`
|
|
753
|
+
// (#495); the type itself can contain a function-type `=>`, so the
|
|
754
|
+
// annotation matcher consumes any non-`=` char OR a literal `=>`, and the
|
|
755
|
+
// assignment is the first `=` NOT followed by `>` (`=(?!>)`). The
|
|
756
|
+
// alternation is unambiguous at each position (a `=` can only start `=>`),
|
|
757
|
+
// so there is no catastrophic backtracking.
|
|
758
|
+
const reArrow = /\bexport\s+const\s+([A-Za-z_$][\w$]*)\s*(?::(?:[^=]|=>)*?)?=(?!>)\s*(?:async\s+)?(?:function\b|(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>)/g;
|
|
759
|
+
while ((m = reArrow.exec(scan))) names.add(m[1]);
|
|
760
|
+
if (/\bexport\s+default\b/.test(scan)) names.add('default');
|
|
761
|
+
const actions = [...names].filter((n) => !RESERVED_CONFIG.has(n));
|
|
762
|
+
if (actions.length > 1) {
|
|
763
|
+
violations.push({
|
|
764
|
+
rule: 'one-action-per-configured-file',
|
|
765
|
+
file: rel,
|
|
766
|
+
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.`,
|
|
767
|
+
fix: 'Move the extra function to its own .server.{js,ts} file, or keep it private (do not export it).',
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
730
773
|
// --- Rule: tag-name-has-hyphen ---
|
|
731
774
|
{
|
|
732
775
|
for (const { rel, scan } of files) {
|
package/src/cors.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reusable CORS primitive for webjs.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* 2. The `expose()` REST path (`actions.js`) reuses `resolveOrigin` and
|
|
10
|
-
* `applyCorsHeaders` so a route's `cors` config and a standalone
|
|
11
|
-
* `cors()` middleware compute identical headers.
|
|
4
|
+
* `cors(options)` returns a webjs MIDDLEWARE `(req, next) => Response`,
|
|
5
|
+
* usable in `middleware.js` (root or per-segment) or wrapped around a
|
|
6
|
+
* `route.js` handler. This is the public app-facing API. The shared
|
|
7
|
+
* origin-resolution + header-building core (`resolveOrigin` /
|
|
8
|
+
* `applyCorsHeaders`) is exported for reuse.
|
|
12
9
|
*
|
|
13
10
|
* Web-standards-first: the option shape mirrors the well-trodden
|
|
14
11
|
* `cors` npm package and the Fetch CORS protocol. We reflect the
|
package/src/csrf.js
CHANGED
|
@@ -20,10 +20,11 @@ const webCrypto = /** @type {Crypto} */ (globalThis.crypto);
|
|
|
20
20
|
* they cannot set the header to the matching value.
|
|
21
21
|
*
|
|
22
22
|
* Notes on scope:
|
|
23
|
-
* - Applies to internal RPC only. `
|
|
24
|
-
* CSRF-protected
|
|
25
|
-
* consumers;
|
|
26
|
-
* signed request, API key)
|
|
23
|
+
* - Applies to internal RPC only. A `route.ts` REST endpoint (hand-written
|
|
24
|
+
* or via the `route()` adapter) is *not* CSRF-protected because it is
|
|
25
|
+
* intended for external consumers; such an endpoint should carry its own
|
|
26
|
+
* auth (bearer token, signed request, API key) the app provides via
|
|
27
|
+
* middleware.
|
|
27
28
|
*/
|
|
28
29
|
|
|
29
30
|
export const CSRF_COOKIE = 'webjs_csrf';
|
package/src/dev.js
CHANGED
|
@@ -56,11 +56,6 @@ import {
|
|
|
56
56
|
serveActionStub,
|
|
57
57
|
serveServerOnlyStub,
|
|
58
58
|
invokeAction,
|
|
59
|
-
matchExposedAction,
|
|
60
|
-
matchAllAtPath,
|
|
61
|
-
invokeExposedAction,
|
|
62
|
-
buildPreflightResponse,
|
|
63
|
-
withCors,
|
|
64
59
|
isServerFile,
|
|
65
60
|
hasUseServerDirective,
|
|
66
61
|
hashFile,
|
|
@@ -1695,41 +1690,18 @@ async function handleCore(req, ctx) {
|
|
|
1695
1690
|
// Internal server-action RPC endpoint
|
|
1696
1691
|
const actMatch = /^\/__webjs\/action\/([a-f0-9]+)\/([A-Za-z0-9_$]+)$/.exec(path);
|
|
1697
1692
|
if (actMatch) {
|
|
1698
|
-
|
|
1693
|
+
// HTTP-verb actions (#488): any RPC verb may hit the endpoint; invokeAction
|
|
1694
|
+
// enforces the action's DECLARED method (405 + Allow on a mismatch) and
|
|
1695
|
+
// reads args from the URL (GET/DELETE) or the body (POST/PUT/PATCH).
|
|
1696
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
1697
|
+
return new Response('method not allowed', { status: 405 });
|
|
1698
|
+
}
|
|
1699
1699
|
// Pass the onError sink (issue #239): a server action that throws
|
|
1700
1700
|
// unexpectedly is reported to the APM hook before the sanitized 500.
|
|
1701
1701
|
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1702
1702
|
return invokeAction(state.actionIndex, actMatch[1], actMatch[2], req, onActionError);
|
|
1703
1703
|
}
|
|
1704
1704
|
|
|
1705
|
-
// expose()d server actions (first-class REST), with optional CORS support.
|
|
1706
|
-
if (method === 'OPTIONS') {
|
|
1707
|
-
const allAtPath = matchAllAtPath(state.actionIndex, path);
|
|
1708
|
-
if (allAtPath.length) {
|
|
1709
|
-
const corsRoute = allAtPath.find((r) => r.cors);
|
|
1710
|
-
const methods = [...new Set(allAtPath.map((r) => r.method))];
|
|
1711
|
-
if (corsRoute) {
|
|
1712
|
-
// Preflight: respond with cors headers + the union of methods at this path.
|
|
1713
|
-
const preflight = buildPreflightResponse(corsRoute, req);
|
|
1714
|
-
const newHeaders = new Headers(preflight.headers);
|
|
1715
|
-
newHeaders.set('access-control-allow-methods', `${methods.join(', ')}, OPTIONS`);
|
|
1716
|
-
return new Response(null, { status: preflight.status, headers: newHeaders });
|
|
1717
|
-
}
|
|
1718
|
-
return new Response(null, { status: 204, headers: { allow: `${methods.join(', ')}, OPTIONS` } });
|
|
1719
|
-
}
|
|
1720
|
-
} else {
|
|
1721
|
-
const exposed = matchExposedAction(state.actionIndex, method, path);
|
|
1722
|
-
if (exposed) {
|
|
1723
|
-
// Pass the onError sink (issue #239): an exposed REST handler that throws
|
|
1724
|
-
// unexpectedly is reported to the APM hook before the sanitized 500, the
|
|
1725
|
-
// same as the RPC action path (phase 'action' covers both server-action
|
|
1726
|
-
// invocation shapes).
|
|
1727
|
-
const onActionError = reportError ? (e) => reportError(e, req, 'action') : undefined;
|
|
1728
|
-
const resp = await invokeExposedAction(state.actionIndex, exposed.route, exposed.params, req, onActionError);
|
|
1729
|
-
return withCors(resp, exposed.route, req);
|
|
1730
|
-
}
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
1705
|
// Static: /public/*
|
|
1734
1706
|
if (path.startsWith('/public/') || path === '/favicon.ico') {
|
|
1735
1707
|
const p = path === '/favicon.ico' ? '/public/favicon.ico' : path;
|
package/src/importmap.js
CHANGED
|
@@ -265,9 +265,9 @@ export function buildCoreEntries(coreDir, distMode) {
|
|
|
265
265
|
/** @type {Record<string, string>} */
|
|
266
266
|
const out = {
|
|
267
267
|
// Bare specifier: browser-only entry, slim by design (drops
|
|
268
|
-
// render-server,
|
|
269
|
-
//
|
|
270
|
-
//
|
|
268
|
+
// render-server, setCspNonceProvider). Node-side consumers
|
|
269
|
+
// resolve via the package.json exports `default` condition and
|
|
270
|
+
// land on `index.js` instead.
|
|
271
271
|
'@webjsdev/core': distMode
|
|
272
272
|
? '/__webjs/core/dist/webjs-core-browser.js'
|
|
273
273
|
: '/__webjs/core/index-browser.js',
|