@webjsdev/server 0.9.0 → 0.10.1

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.
Files changed (102) hide show
  1. package/README.md +4 -1
  2. package/index.d.ts +793 -125
  3. package/index.js +8 -4
  4. package/package.json +3 -3
  5. package/src/action-flash.js +185 -0
  6. package/src/action-params.js +156 -0
  7. package/src/action-route.js +8 -9
  8. package/src/action-seed.js +44 -5
  9. package/src/actions.js +97 -15
  10. package/src/api.js +67 -10
  11. package/src/app-layout.js +133 -0
  12. package/src/asset-hash.js +82 -49
  13. package/src/auth.js +612 -108
  14. package/src/browser-entries.js +2 -17
  15. package/src/build-epoch.js +1 -1
  16. package/src/cache-tags.js +5 -1
  17. package/src/check/rules-actions.js +37 -1
  18. package/src/check/rules-components.js +114 -40
  19. package/src/check/rules-imports.js +175 -4
  20. package/src/check/rules-route-modules.js +200 -0
  21. package/src/check/rules-routes.js +55 -0
  22. package/src/check/rules-routing.js +143 -95
  23. package/src/check/rules-typescript.js +24 -2
  24. package/src/check/rules.js +104 -9
  25. package/src/check/runner-support.js +53 -171
  26. package/src/check/runner.js +22 -6
  27. package/src/client-signals.js +171 -0
  28. package/src/component-scanner.js +22 -16
  29. package/src/context.js +152 -16
  30. package/src/cookie-store.js +269 -0
  31. package/src/core-exports.js +190 -0
  32. package/src/csrf.js +21 -3
  33. package/src/dev/bun-app-source.js +95 -5
  34. package/src/dev/config.js +7 -51
  35. package/src/dev/dev-service-worker.js +48 -0
  36. package/src/dev/esm-hot-hooks.js +373 -0
  37. package/src/dev/handler.js +502 -155
  38. package/src/dev/helpers.js +127 -96
  39. package/src/dev/hot-host.js +33 -6
  40. package/src/dev/poke-state.js +110 -0
  41. package/src/dev/serve.js +177 -129
  42. package/src/dev/server.js +92 -33
  43. package/src/dev/specifier-versions.js +125 -0
  44. package/src/dev/watch-ignore.js +49 -4
  45. package/src/dev/watch-poll.js +78 -0
  46. package/src/dev/watch-tree.js +59 -22
  47. package/src/dev-classify.js +59 -26
  48. package/src/dev-error.js +104 -3
  49. package/src/dev-hot-classify.js +291 -0
  50. package/src/dev-hot-client.js +245 -0
  51. package/src/dev-import.js +22 -5
  52. package/src/dev-overlay.js +26 -0
  53. package/src/dev-ready-probe.js +69 -0
  54. package/src/dev-regenerate.js +2 -2
  55. package/src/dev-reload-hold.js +49 -20
  56. package/src/dev-reload-reason.js +61 -0
  57. package/src/dev-reload-worker.js +62 -13
  58. package/src/dev-secret.js +73 -0
  59. package/src/dev-source-locations-hook.js +4 -1
  60. package/src/dev-source-locations.js +33 -2
  61. package/src/dev.js +0 -1
  62. package/src/env-files.js +124 -0
  63. package/src/file-storage.js +2 -2
  64. package/src/foreign-imports.js +228 -0
  65. package/src/form-action-identity.js +52 -25
  66. package/src/form-dispatch.js +142 -66
  67. package/src/html-cache.js +35 -4
  68. package/src/importmap.js +2 -2
  69. package/src/instrumentation.js +59 -3
  70. package/src/js-scan.js +5 -4
  71. package/src/jwe.js +155 -0
  72. package/src/lazy-components.js +166 -0
  73. package/src/listener-core.js +4 -2
  74. package/src/lit-hints.js +172 -0
  75. package/src/middleware-load.js +248 -0
  76. package/src/module-graph.js +73 -47
  77. package/src/module-side-effect.js +284 -0
  78. package/src/public-files.js +73 -0
  79. package/src/redirect-location.js +19 -0
  80. package/src/redirects.js +9 -1
  81. package/src/route-frontier.js +151 -0
  82. package/src/route-report.js +130 -0
  83. package/src/router.js +120 -13
  84. package/src/session.js +32 -10
  85. package/src/ssr/client-router-flag.js +1 -1
  86. package/src/ssr/document.js +37 -13
  87. package/src/ssr/head.js +78 -23
  88. package/src/ssr/preloads.js +31 -45
  89. package/src/ssr/render.js +241 -55
  90. package/src/ssr/responses.js +51 -4
  91. package/src/ssr/shipped-modules.js +22 -16
  92. package/src/testing.d.ts +71 -2
  93. package/src/testing.js +103 -21
  94. package/src/thenable-params.js +26 -0
  95. package/src/vendor/jspm.js +12 -1
  96. package/src/vendor/pins.js +9 -8
  97. package/src/vendor/scanner.js +14 -9
  98. package/src/vendor.js +5 -3
  99. package/webjs-config.schema.json +1 -6
  100. package/src/component-elision.js +0 -1828
  101. package/src/elision-differential.js +0 -101
  102. package/src/elision-report.js +0 -193
package/src/actions.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { digestHex } from './crypto-utils.js';
2
+ import { headerSafeLocation } from './redirect-location.js';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { join, sep } from 'node:path';
4
5
  import { walk } from './fs-walk.js';
@@ -16,10 +17,10 @@ import { runActionChain } from './action-middleware.js';
16
17
  import { isStreamable, streamActionResponse } from './action-stream.js';
17
18
  import { isControlFlowThrow, errorDigest, GENERIC_ERROR_MESSAGE } from './action-error.js';
18
19
  import { ifNoneMatchSatisfied } from './conditional-get.js';
19
- import { getBodyLimits } from './context.js';
20
+ import { getBodyLimits, refreshHeaderValue } from './context.js';
20
21
  import { basePath } from './importmap.js';
21
22
  import { withBasePath } from './base-path.js';
22
- import { FORM_ACTION_ID_KEY, FORM_ACTION_FIELD } from '@webjsdev/core';
23
+ import { FORM_ACTION_ID_KEY, FORM_ACTION_FIELD, isRedirect } from '@webjsdev/core';
23
24
  import { devImport } from './dev-import.js';
24
25
  import { formInput, isFormData } from './action-input.js';
25
26
 
@@ -44,6 +45,49 @@ function jsonBodyLimit() {
44
45
  */
45
46
  export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
46
47
 
48
+ /**
49
+ * The `issues` array a schema library's validation error carries (zod, valibot,
50
+ * Standard Schema), or null for any other throw.
51
+ * @param {unknown} e
52
+ * @returns {Array<{ path?: unknown[], message?: string }> | null}
53
+ */
54
+ function schemaIssues(e) {
55
+ const issues = e && typeof e === 'object' ? /** @type {any} */ (e).issues : undefined;
56
+ return Array.isArray(issues) && issues.length > 0 ? issues : null;
57
+ }
58
+
59
+ /**
60
+ * A failure result from a schema error's issues: the first message per field
61
+ * as `fieldErrors` (a nested path joined with dots), and a form-level `error`
62
+ * for an issue with no path.
63
+ * @param {unknown} e
64
+ */
65
+ function issuesResult(e) {
66
+ /** @type {Record<string, string>} */
67
+ const fieldErrors = {};
68
+ let error;
69
+ for (const issue of schemaIssues(e) || []) {
70
+ // A segment is a key, or Standard Schema's { key }; a symbol key is
71
+ // converted explicitly, since join's implicit conversion throws on one.
72
+ const path = Array.isArray(issue.path)
73
+ ? issue.path.map((p) => {
74
+ const k = p && typeof p === 'object' && 'key' in p ? /** @type {any} */ (p).key : p;
75
+ return typeof k === 'symbol' ? (k.description ?? '') : String(k);
76
+ }).join('.')
77
+ : '';
78
+ const message = typeof issue.message === 'string' ? issue.message : 'Invalid value.';
79
+ if (path) {
80
+ // Own keys only (a field named `constructor` is not already present),
81
+ // and defined rather than assigned, so `__proto__` is a field, not the
82
+ // object's prototype.
83
+ if (!Object.hasOwn(fieldErrors, path)) {
84
+ Object.defineProperty(fieldErrors, path, { value: message, enumerable: true, writable: true, configurable: true });
85
+ }
86
+ } else if (!error) error = message;
87
+ }
88
+ return { success: false, fieldErrors, ...(error ? { error } : {}), status: 422 };
89
+ }
90
+
47
91
  /**
48
92
  * Run an attached input validator against an action's input, shared by the RPC
49
93
  * path (`invokeAction`) and the `route()` REST adapter so the contract is
@@ -53,21 +97,27 @@ export const RPC_CONTENT_TYPE = 'application/vnd.webjs+json';
53
97
  * @param {(input: any) => any} validate the attached validator
54
98
  * @param {any} input the value handed to the validator (the action's first arg
55
99
  * on RPC, the merged query/params/body object on REST)
56
- * @returns {{ ok: true, value: any } | { ok: false, result: { success: false, fieldErrors?: Record<string,string>, error?: string, status: number }, thrown?: unknown }}
100
+ * @returns {{ ok: true, value: any } | { ok: false, result: { success: false, fieldErrors?: Record<string,string>, error?: string, status: number }, thrown?: unknown, invalid?: boolean }}
57
101
  * `ok: true` carries the value to pass to the action (the validated `data`,
58
102
  * the transformed return, or the original input). `ok: false` carries the
59
103
  * structured failure result the caller serializes back; on a THROWN validator
60
104
  * it also carries the original error on `thrown` so a caller can salvage a
61
105
  * schema lib's structured `issues` (the REST path does, for back-compat).
62
106
  */
63
- export function runValidate(validate, input) {
107
+ export async function runValidate(validate, input) {
64
108
  let out;
65
109
  try {
66
- out = validate(input);
110
+ // Awaited, so an async validator (an async schema check, a lookup) works
111
+ // like a sync one; a returned Promise must never read as a transformed input.
112
+ out = await validate(input);
67
113
  } catch (e) {
68
- // Throw-to-reject (the classic `Schema.parse` contract). Map to a sanitized
69
- // failure result; a thrown validator is a 400 (bad input), not a 500.
70
- // Surface only the message; `thrown` lets the REST caller recover `issues`.
114
+ // A schema library's validation error (a throw carrying `issues`: zod's
115
+ // `.parse`, valibot, Standard Schema) is invalid INPUT, mapped once here so
116
+ // every transport answers it alike: the issues become fieldErrors with a
117
+ // 422, as a structured envelope failure is (`invalid` marks it).
118
+ if (schemaIssues(e)) return { ok: false, result: issuesResult(e), thrown: e, invalid: true };
119
+ // Any other throw-to-reject: a sanitized failure result, a 400 over REST;
120
+ // `thrown` lets the RPC path treat it as the crash it may be.
71
121
  const msg = e instanceof Error ? e.message : String(e);
72
122
  return { ok: false, result: { success: false, error: msg, status: 400 }, thrown: e };
73
123
  }
@@ -322,7 +372,7 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
322
372
  // `method` is a default POST), so the first client call resolves from the
323
373
  // seed with no hydration round-trip. The browser-cache staleness check is
324
374
  // GET-only (only a GET is browser-cached).
325
- const imports = ['stringify as __s', 'parse as __p', 'takeSeed as __seedTake', 'SEED_MISS as __MISS', 'markStale as __markStale', 'parseTagHeader as __tagHdr', 'activeActionSignal as __sig',
375
+ const imports = ['stringify as __s', 'parse as __p', 'takeSeed as __seedTake', 'SEED_MISS as __MISS', 'markStale as __markStale', 'parseTagHeader as __tagHdr', 'activeActionSignal as __sig', 'applyActionRefresh as __refresh', 'followActionRedirect as __redirect', 'installActionBind as __bindable',
326
376
  // Streaming RPC (#489): an action returning a stream / async iterable sends
327
377
  // back a framed body; the stub decodes it into an async iterable the caller
328
378
  // `for await`s. The imports are small constants + one decoder factory.
@@ -343,6 +393,12 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
343
393
  // caught on the next read).
344
394
  lines.push(`async function __handle(res, fn, key, since) {`);
345
395
  lines.push(` const inv = __tagHdr(res.headers.get('x-webjs-invalidate')); if (inv.length) __markStale(inv);`);
396
+ // redirect() in the action (Next parity): navigate there; the call's promise
397
+ // rejects with the redirect sentinel, which the page ignores unhandled.
398
+ lines.push(` const rd = res.headers.get('x-webjs-redirect'); if (rd) return __redirect(rd);`);
399
+ // revalidatePath() in the action: refresh the page on screen in place when
400
+ // it was revalidated, BEFORE the caller's await resolves (Next behaviour).
401
+ lines.push(` const rf = res.headers.get('x-webjs-refresh'); if (rf) await __refresh(rf);`);
346
402
  lines.push(` if (key != null) { const t = __tagHdr(res.headers.get('x-webjs-tags')); if (t.length) __regTags(key, t, since); }`);
347
403
  lines.push(` const ct = res.headers.get('content-type') || '';`);
348
404
  // A streamed result (#489): the action returned a stream / async iterable.
@@ -425,7 +481,9 @@ function buildStubBody({ hash, method, fnNames, actionUrl }) {
425
481
  // load-hook registry instead; both produce the same string, which is what
426
482
  // makes the SSR'd form and the re-rendered one submit the same value.
427
483
  // Non-enumerable so `import * as actions` and a spread stay unchanged.
428
- lines.push(`const __id = (fn, name) => { try { Object.defineProperty(fn, ${J(FORM_ACTION_ID_KEY)}, { value: __HASH + '/' + name }); } catch {} return fn; };`);
484
+ // `.bind(null, id)` keeps the identity too (installActionBind), so a bound
485
+ // stub re-renders to the same hidden field the server emitted.
486
+ lines.push(`const __id = (fn, name) => { try { Object.defineProperty(fn, ${J(FORM_ACTION_ID_KEY)}, { value: __HASH + '/' + name }); } catch {} return __bindable(fn); };`);
429
487
  for (const name of fnNames) {
430
488
  lines.push(
431
489
  name === 'default'
@@ -481,7 +539,7 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
481
539
  // CDN-cacheable. See csrf.js.
482
540
  if (!SAFE_VERBS.has(method)) {
483
541
  const v = verifyOrigin(req, allowedOrigins);
484
- if (!v.ok) return rpcResponse({ error: 'CSRF validation failed' }, { status: 403 });
542
+ if (!v.ok) return rpcResponse({ error: `CSRF validation failed: ${v.reason}` }, { status: 403 });
485
543
  }
486
544
 
487
545
  // Args ride the URL for a URL-arg request (GET / DELETE), else the body.
@@ -519,16 +577,17 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
519
577
  // server-to-server call.
520
578
  const validate = actionConfigFn(mod, 'validate');
521
579
  if (typeof validate === 'function') {
522
- const v = runValidate(validate, args[0]);
580
+ const v = await runValidate(validate, args[0]);
523
581
  if (!v.ok) {
524
- if (v.thrown !== undefined) {
582
+ if (v.thrown !== undefined && !v.invalid) {
525
583
  // A THROWN validator behaves like a thrown action: a sanitized error
526
584
  // response (non-200, so the client stub throws), with prod stack
527
585
  // sanitization. The onError sink sees the original error too.
528
586
  if (typeof onError === 'function') onError(v.thrown);
529
587
  return actionErrorResponse(v.thrown, idx.dev);
530
588
  }
531
- // A structured `{ success: false, fieldErrors }` envelope is a NORMAL
589
+ // A structured `{ success: false, fieldErrors }` envelope, or a schema
590
+ // error's issues mapped to one, is a NORMAL
532
591
  // result the action author renders (`result.fieldErrors`): serialized as
533
592
  // a 200 RPC payload so the client stub returns it as a real object rather
534
593
  // than throwing, the failure status riding inside the envelope.
@@ -536,6 +595,7 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
536
595
  }
537
596
  args = [v.value, ...args.slice(1)];
538
597
  }
598
+ let ranAction = false;
539
599
  try {
540
600
  // Run inside the request's AbortSignal scope (#492) and the per-action
541
601
  // middleware chain (#490): the chain wraps the action, can short-circuit
@@ -543,7 +603,6 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
543
603
  // the action reads via actionContext(). `ranAction` distinguishes a real
544
604
  // action completion from a middleware short-circuit (the action never ran).
545
605
  const middleware = actionMiddleware(mod);
546
- let ranAction = false;
547
606
  const result = await runWithActionSignal(req.signal, () =>
548
607
  runActionChain(middleware, { request: req, args, signal: req.signal }, () => { ranAction = true; return fn(...args); }));
549
608
  // Streaming result (#489): a COMPLETED action returning a ReadableStream /
@@ -557,6 +616,8 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
557
616
  const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
558
617
  if (inv.length) { await revalidateTags(inv); headers['x-webjs-invalidate'] = inv.join(','); }
559
618
  }
619
+ const refresh = refreshHeaderValue();
620
+ if (refresh) headers['x-webjs-refresh'] = refresh;
560
621
  return streamActionResponse(result, { signal: req.signal, onError, headers, dev: idx.dev });
561
622
  }
562
623
  if (method === 'GET') {
@@ -575,8 +636,29 @@ export async function invokeAction(idx, hash, fnName, req, onError, allowedOrigi
575
636
  headers['x-webjs-invalidate'] = inv.join(',');
576
637
  }
577
638
  }
639
+ // revalidatePath() during the action (Next parity): the browser refreshes
640
+ // those paths, and the page on screen in place when it is one of them.
641
+ const refresh = refreshHeaderValue();
642
+ if (refresh) headers['x-webjs-refresh'] = refresh;
578
643
  return rpcResponse(result ?? null, { headers });
579
644
  } catch (e) {
645
+ // redirect() / permanentRedirect() in an action called over RPC (Next.js
646
+ // parity): not an error. The response tells the generated stub where to
647
+ // go (`x-webjs-redirect: <url>;<push|replace>`, push by default as in a
648
+ // Next server action), and the client router navigates there. A mutation
649
+ // that ran still evicts its `invalidates` tags and reports revalidatePath.
650
+ if (isRedirect(e)) {
651
+ const r = /** @type {any} */ (e);
652
+ /** @type {Record<string, string>} */
653
+ const headers = { 'x-webjs-redirect': `${headerSafeLocation(r.url)};${r.redirectType === 'replace' ? 'replace' : 'push'}` };
654
+ if (ranAction && method !== 'GET') {
655
+ const inv = resolveTags(actionConfigFn(mod, 'invalidates'), args);
656
+ if (inv.length) { await revalidateTags(inv); headers['x-webjs-invalidate'] = inv.join(','); }
657
+ }
658
+ const refresh = refreshHeaderValue();
659
+ if (refresh) headers['x-webjs-refresh'] = refresh;
660
+ return rpcResponse(null, { headers: { ...headers, 'cache-control': 'no-store' } });
661
+ }
580
662
  if (typeof onError === 'function') onError(e);
581
663
  return actionErrorResponse(e, idx.dev);
582
664
  }
package/src/api.js CHANGED
@@ -1,14 +1,30 @@
1
+ import { isRedirect, isNotFound, isForbidden, isUnauthorized } from '@webjsdev/core';
2
+ import { headerSafeLocation } from './redirect-location.js';
1
3
  import { makeThenable } from './thenable-params.js';
2
4
  import { devImport } from './dev-import.js';
5
+ import { decorateRequest } from './middleware-load.js';
6
+
7
+ /** Every method a route module may export (Next's HTTP_METHODS). */
8
+ const HTTP_METHODS = ['GET', 'HEAD', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'PATCH'];
3
9
 
4
10
  /**
5
11
  * Dispatch an incoming request to a matched API route.
6
- * API modules export methods as named async functions: GET, POST, PUT, PATCH, DELETE.
12
+ * API modules export methods as named async functions: GET, HEAD, OPTIONS,
13
+ * POST, PUT, PATCH, DELETE.
7
14
  *
8
15
  * Handlers receive a standard `Request` and return a standard `Response`.
9
16
  *
17
+ * Next.js parity:
18
+ * - HEAD and OPTIONS are implemented automatically when the module does not
19
+ * export them (a port of Next's `autoImplementMethods`): HEAD runs GET and
20
+ * drops the body; OPTIONS answers 204 with a sorted `Allow`.
21
+ * - `redirect()` thrown from a handler answers 307 (308 from
22
+ * `permanentRedirect()`; an explicit status wins), and `notFound()` /
23
+ * `forbidden()` / `unauthorized()` answer 404 / 403 / 401 with no body.
24
+ * They are answered HERE, so no caller ever sees them as errors.
25
+ *
10
26
  * @param {import('./router.js').ApiRoute} route
11
- * @param {Record<string,string>} params
27
+ * @param {Record<string, string | string[]>} params
12
28
  * @param {Request} webRequest
13
29
  * @param {boolean} dev
14
30
  * @returns {Promise<Response>}
@@ -16,11 +32,20 @@ import { devImport } from './dev-import.js';
16
32
  export async function handleApi(route, params, webRequest, dev) {
17
33
  const mod = await devImport(route.file, dev);
18
34
  const method = webRequest.method.toUpperCase();
19
- const handler = mod[method];
35
+ const implemented = HTTP_METHODS.filter((m) => typeof mod[m] === 'function');
36
+ let handler = typeof mod[method] === 'function' ? mod[method] : null;
37
+ let headOnly = false;
38
+ if (!handler && method === 'HEAD' && typeof mod.GET === 'function') {
39
+ handler = mod.GET;
40
+ headOnly = true;
41
+ }
42
+ if (!handler && method === 'OPTIONS') {
43
+ return new Response(null, { status: 204, headers: { allow: allowHeader(implemented) } });
44
+ }
20
45
  if (!handler) {
21
46
  return new Response(`Method ${method} not allowed`, {
22
47
  status: 405,
23
- headers: { allow: allowedMethods(mod).join(', ') },
48
+ headers: { allow: allowHeader(implemented) },
24
49
  });
25
50
  }
26
51
  // Route-handler params are awaitable AND sync-readable (#848, Next parity:
@@ -28,6 +53,9 @@ export async function handleApi(route, params, webRequest, dev) {
28
53
  // spreads / JSON-stringifies params is unaffected.
29
54
  const thenableParams = makeThenable(params);
30
55
  /** @type any */ (webRequest).params = thenableParams;
56
+ // A Next.js route handler receives a NextRequest, so `request.cookies.get()`
57
+ // is how agents read a cookie there; it is the request's cookies() store.
58
+ decorateRequest(webRequest, dev);
31
59
  let result;
32
60
  try {
33
61
  result = await handler(webRequest, { params: thenableParams });
@@ -42,14 +70,43 @@ export async function handleApi(route, params, webRequest, dev) {
42
70
  headers: { 'content-type': 'text/plain; charset=utf-8' },
43
71
  });
44
72
  }
73
+ const control = controlFlowResponse(e);
74
+ if (control) return control;
45
75
  throw e;
46
76
  }
47
- if (result instanceof Response) return result;
48
- // Convenience: allow returning plain objects as JSON.
49
- return Response.json(result);
77
+ const res = result instanceof Response ? result : Response.json(result);
78
+ if (!headOnly) return res;
79
+ // A synthesized HEAD answers GET's status and headers with no body. GET's own
80
+ // body is never read, so it is cancelled: a streamed one (SSE, a DB cursor)
81
+ // would otherwise keep producing for no reader until it is collected.
82
+ if (res.body && !res.bodyUsed) res.body.cancel().catch(() => {});
83
+ return new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
50
84
  }
51
85
 
52
- /** @param {Record<string,unknown>} mod */
53
- function allowedMethods(mod) {
54
- return ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].filter((m) => typeof mod[m] === 'function');
86
+ /**
87
+ * The response Next.js gives a control-flow throw inside a route handler, or
88
+ * null when `e` is a real error.
89
+ * @param {unknown} e
90
+ * @returns {Response | null}
91
+ */
92
+ export function controlFlowResponse(e) {
93
+ if (isRedirect(e)) {
94
+ const r = /** @type any */ (e);
95
+ return new Response(null, { status: r.status || 307, headers: { location: headerSafeLocation(r.url) } });
96
+ }
97
+ if (isNotFound(e)) return new Response(null, { status: 404 });
98
+ if (isForbidden(e)) return new Response(null, { status: 403 });
99
+ if (isUnauthorized(e)) return new Response(null, { status: 401 });
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * The `Allow` header Next sends: the implemented methods plus OPTIONS (always
105
+ * answered) and HEAD (when GET exists), sorted.
106
+ * @param {string[]} implemented
107
+ */
108
+ function allowHeader(implemented) {
109
+ const allow = new Set(['OPTIONS', ...implemented]);
110
+ if (allow.has('GET')) allow.add('HEAD');
111
+ return [...allow].sort().join(', ');
55
112
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * App layout validation: file arrangements that can never serve correctly,
3
+ * found from the route table alone (no code is loaded).
4
+ *
5
+ * Next.js refuses these at build time. WebJs has no build, so the same
6
+ * refusal happens in three places, all fed by `validateAppLayout`:
7
+ * - `webjs check` reports each problem as a violation (its `rule` id);
8
+ * - production refuses to boot (`createRequestHandler` throws);
9
+ * - dev answers every app request with a 500 naming the problem and pushes
10
+ * it to the error overlay, until an edit fixes it.
11
+ *
12
+ * @module app-layout
13
+ */
14
+ import { relative, sep } from 'node:path';
15
+
16
+ /**
17
+ * @typedef {{ rule: string, file: string, message: string, fix: string }} LayoutProblem
18
+ */
19
+
20
+ /**
21
+ * The thrown form of one or more layout problems. `code` lets the request
22
+ * handler tell it apart from an ordinary analysis failure.
23
+ */
24
+ export class AppLayoutError extends Error {
25
+ /** @param {LayoutProblem[]} problems */
26
+ constructor(problems) {
27
+ super(problems.map((p) => `[${p.rule}] ${p.file}: ${p.message} Fix: ${p.fix}`).join('\n'));
28
+ this.name = 'AppLayoutError';
29
+ this.code = 'WEBJS_APP_LAYOUT';
30
+ this.problems = problems;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @param {string} appDir
36
+ * @param {string} file
37
+ */
38
+ function rel(appDir, file) {
39
+ return relative(appDir, file).split(sep).join('/');
40
+ }
41
+
42
+ /**
43
+ * Every layout problem in the app, in a stable order.
44
+ *
45
+ * @param {string} appDir
46
+ * @param {import('./router.js').RouteTable} table
47
+ * @param {Set<string>} [publicFiles] url paths of the files under `public/`
48
+ * @returns {LayoutProblem[]}
49
+ */
50
+ export function validateAppLayout(appDir, table, publicFiles) {
51
+ /** @type {LayoutProblem[]} */
52
+ const problems = [];
53
+
54
+ // A public file at the url of a static page, a route handler or a metadata
55
+ // route: one would silently shadow the other (public files are served
56
+ // before routes, as in Next, which refuses the same conflict). A DYNAMIC
57
+ // route is not a conflict: the public file simply wins for its one url.
58
+ if (publicFiles && publicFiles.size) {
59
+ const staticOwner = new Map();
60
+ for (const r of [...(table.pages || []), ...(table.apis || [])]) {
61
+ if (r.paramNames && r.paramNames.length) continue;
62
+ const dir = /** @type {any} */ (r).routeDir;
63
+ const url = '/' + (dir === '.' ? '' : dir.split('/').filter((s) => !(s.startsWith('(') && s.endsWith(')'))).join('/'));
64
+ staticOwner.set(url, r.file);
65
+ }
66
+ for (const m of table.metadataRoutes || []) staticOwner.set(m.urlPath, m.file);
67
+ for (const url of publicFiles) {
68
+ const owner = staticOwner.get(url);
69
+ if (!owner) continue;
70
+ problems.push({
71
+ rule: 'no-public-route-conflict',
72
+ file: `public${url}`,
73
+ message: `public${url} is served at ${url}, the same url as ${rel(appDir, owner)}, so one of them can never be reached.`,
74
+ fix: `Rename or move one of them: public files are served at the url root (public/logo.png is /logo.png), before every route.`,
75
+ });
76
+ }
77
+ }
78
+
79
+ // A page and a route handler answering the same URL. Next refuses this
80
+ // ("Conflicting route and page"); WebJs used to let the route handler win
81
+ // silently, so the page never rendered.
82
+ for (const c of table.conflicts || []) {
83
+ problems.push({
84
+ rule: 'no-page-route-conflict',
85
+ file: rel(appDir, c.route),
86
+ message: `${rel(appDir, c.page)} and ${rel(appDir, c.route)} both answer ${c.url}, so the page can never render.`,
87
+ fix: 'Keep one: move the route handler to its own folder (for example app/api/...), or delete the page.',
88
+ });
89
+ }
90
+ // A routing file written as `.tsx` / `.jsx`: JSX needs a build step, so the
91
+ // router never sees it and the route silently does not exist.
92
+ for (const file of table.ignoredJsx || []) {
93
+ const r = rel(appDir, file);
94
+ const ts = r.replace(/\.(t|j)sx$/, (_, t) => `.${t}s`);
95
+ problems.push({
96
+ rule: 'no-jsx-route-file',
97
+ file: r,
98
+ message: `${r} is a routing file written as JSX, which WebJs never loads (there is no build step), so this route does not exist.`,
99
+ fix: `Rename it to ${ts} and return an html\`...\` template from @webjsdev/core instead of JSX (interactive parts become a WebComponent).`,
100
+ });
101
+ }
102
+ return problems;
103
+ }
104
+
105
+ /** The fix every CSS-import refusal names. */
106
+ export const CSS_IMPORT_FIX = 'Load a stylesheet with <link rel="stylesheet" href="..."> in the root layout (Tailwind\'s output included), or scope CSS to a component with `static styles = css`...`` and `static shadow = true`.';
107
+
108
+ /**
109
+ * Every `import './x.css'` in the app's module graph, as layout problems. A
110
+ * CSS import is a bundler feature: Node refuses the extension, Bun silently
111
+ * turns it into a no-op on the server, and the browser refuses a stylesheet as
112
+ * a module script, so the importing module never loads there.
113
+ * @param {Map<string, Set<string>>} graph
114
+ * @param {(abs: string) => string} rel
115
+ * @returns {LayoutProblem[]}
116
+ */
117
+ export function cssImportProblems(graph, rel) {
118
+ /** @type {LayoutProblem[]} */
119
+ const out = [];
120
+ for (const [file, deps] of graph) {
121
+ if (/[\\/]node_modules[\\/]/.test(file)) continue;
122
+ for (const dep of deps) {
123
+ if (!/\.css$/i.test(dep)) continue;
124
+ out.push({
125
+ rule: 'no-css-import',
126
+ file: rel(file),
127
+ message: `Imports ${rel(dep)}, a stylesheet. WebJs has no bundler: the import fails on Node, is silently dropped on Bun, and stops the module from loading in the browser.`,
128
+ fix: CSS_IMPORT_FIX,
129
+ });
130
+ }
131
+ }
132
+ return out;
133
+ }