@webjsdev/server 0.9.0 → 0.10.0
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/README.md +4 -1
- package/index.d.ts +427 -109
- package/index.js +8 -4
- package/package.json +3 -3
- package/src/action-flash.js +185 -0
- package/src/action-params.js +156 -0
- package/src/action-route.js +8 -9
- package/src/action-seed.js +44 -5
- package/src/actions.js +96 -14
- package/src/api.js +67 -10
- package/src/app-layout.js +133 -0
- package/src/asset-hash.js +82 -49
- package/src/auth.js +612 -108
- package/src/browser-entries.js +2 -17
- package/src/build-epoch.js +1 -1
- package/src/cache-tags.js +5 -1
- package/src/check/rules-actions.js +37 -1
- package/src/check/rules-components.js +114 -40
- package/src/check/rules-imports.js +175 -4
- package/src/check/rules-route-modules.js +200 -0
- package/src/check/rules-routes.js +55 -0
- package/src/check/rules-routing.js +143 -95
- package/src/check/rules-typescript.js +24 -2
- package/src/check/rules.js +104 -9
- package/src/check/runner-support.js +53 -171
- package/src/check/runner.js +22 -6
- package/src/client-signals.js +171 -0
- package/src/component-scanner.js +22 -16
- package/src/context.js +152 -16
- package/src/cookie-store.js +262 -0
- package/src/core-exports.js +190 -0
- package/src/dev/bun-app-source.js +95 -5
- package/src/dev/config.js +7 -51
- package/src/dev/dev-service-worker.js +48 -0
- package/src/dev/esm-hot-hooks.js +373 -0
- package/src/dev/handler.js +502 -155
- package/src/dev/helpers.js +127 -96
- package/src/dev/hot-host.js +33 -6
- package/src/dev/poke-state.js +110 -0
- package/src/dev/serve.js +177 -129
- package/src/dev/server.js +92 -33
- package/src/dev/specifier-versions.js +125 -0
- package/src/dev/watch-ignore.js +49 -4
- package/src/dev/watch-poll.js +78 -0
- package/src/dev/watch-tree.js +59 -22
- package/src/dev-classify.js +59 -26
- package/src/dev-error.js +104 -3
- package/src/dev-hot-classify.js +291 -0
- package/src/dev-hot-client.js +245 -0
- package/src/dev-import.js +22 -5
- package/src/dev-overlay.js +26 -0
- package/src/dev-ready-probe.js +69 -0
- package/src/dev-regenerate.js +2 -2
- package/src/dev-reload-hold.js +49 -20
- package/src/dev-reload-reason.js +61 -0
- package/src/dev-reload-worker.js +62 -13
- package/src/dev-secret.js +73 -0
- package/src/dev-source-locations-hook.js +4 -1
- package/src/dev-source-locations.js +33 -2
- package/src/dev.js +0 -1
- package/src/env-files.js +124 -0
- package/src/file-storage.js +2 -2
- package/src/foreign-imports.js +228 -0
- package/src/form-action-identity.js +52 -25
- package/src/form-dispatch.js +140 -65
- package/src/html-cache.js +35 -4
- package/src/importmap.js +2 -2
- package/src/instrumentation.js +59 -3
- package/src/js-scan.js +5 -4
- package/src/jwe.js +155 -0
- package/src/lazy-components.js +166 -0
- package/src/listener-core.js +4 -2
- package/src/lit-hints.js +172 -0
- package/src/middleware-load.js +248 -0
- package/src/module-graph.js +73 -47
- package/src/module-side-effect.js +284 -0
- package/src/public-files.js +73 -0
- package/src/redirect-location.js +19 -0
- package/src/redirects.js +9 -1
- package/src/route-frontier.js +151 -0
- package/src/route-report.js +130 -0
- package/src/router.js +120 -13
- package/src/session.js +32 -10
- package/src/ssr/client-router-flag.js +1 -1
- package/src/ssr/document.js +37 -13
- package/src/ssr/head.js +78 -23
- package/src/ssr/preloads.js +31 -45
- package/src/ssr/render.js +241 -55
- package/src/ssr/responses.js +51 -4
- package/src/ssr/shipped-modules.js +22 -16
- package/src/testing.d.ts +31 -2
- package/src/testing.js +80 -17
- package/src/thenable-params.js +26 -0
- package/src/vendor/jspm.js +12 -1
- package/src/vendor/pins.js +9 -8
- package/src/vendor/scanner.js +14 -9
- package/src/vendor.js +5 -3
- package/webjs-config.schema.json +1 -6
- package/src/component-elision.js +0 -1828
- package/src/elision-differential.js +0 -101
- 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
|
-
|
|
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
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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
|
-
|
|
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'
|
|
@@ -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
|
|
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,
|
|
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
|
|
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:
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|