@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/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
- * Two surfaces share one origin-resolution + header-building core:
5
- *
6
- * 1. `cors(options)` returns a webjs MIDDLEWARE `(req, next) => Response`,
7
- * usable in `middleware.js` (root or per-segment) or wrapped around a
8
- * `route.js` handler. This is the public app-facing API.
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. `expose()`d REST endpoints are *not*
24
- * CSRF-protected by default because they're intended for external
25
- * consumers; those endpoints should carry their own auth (bearer token,
26
- * signed request, API key) which the app provides via middleware.
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
- if (method !== 'POST') return new Response('POST only', { status: 405 });
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, expose, setCspNonceProvider). Node-side
269
- // consumers resolve via the package.json exports `default`
270
- // condition and land on `index.js` instead.
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',