@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.
- package/README.md +4 -1
- package/index.d.ts +793 -125
- 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 +97 -15
- 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 +269 -0
- package/src/core-exports.js +190 -0
- package/src/csrf.js +21 -3
- 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 +142 -66
- 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 +71 -2
- package/src/testing.js +103 -21
- 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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lexical signals of browser code, shared by the `webjs check` rules for the
|
|
3
|
+
* server / browser line (#1686) and the route-module hints `webjs status` and
|
|
4
|
+
* `webjs doctor` report. Nothing here decides what ships: a component always
|
|
5
|
+
* ships, and these only spot code that would silently never run.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
import { maskComments, redactToPlaceholders } from './js-scan.js';
|
|
10
|
+
import { resolveImport } from './module-graph.js';
|
|
11
|
+
|
|
12
|
+
/** A `.server.{js,ts,mjs,mts}` file. */
|
|
13
|
+
const SERVER_FILE_RE = /\.server\.m?[jt]s$/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Named imports from a `@webjsdev/core` specifier that imply the
|
|
17
|
+
* component does reactive or async client work. Importing any of these
|
|
18
|
+
* forces the module to ship: signals re-render on change, Task resolves
|
|
19
|
+
* on the client, the streaming directives settle on the client, and
|
|
20
|
+
* Context consumers subscribe after upgrade.
|
|
21
|
+
*
|
|
22
|
+
* @type {ReadonlySet<string>}
|
|
23
|
+
*/
|
|
24
|
+
export const REACTIVE_IMPORTS = new Set([
|
|
25
|
+
'signal',
|
|
26
|
+
'computed',
|
|
27
|
+
'effect',
|
|
28
|
+
'watch',
|
|
29
|
+
// `optimistic(signal, value, action)` mutates a signal (client work) and
|
|
30
|
+
// settles after a server round-trip, so a component importing it is not
|
|
31
|
+
// display-only and must ship.
|
|
32
|
+
'optimistic',
|
|
33
|
+
'Task',
|
|
34
|
+
'until',
|
|
35
|
+
'asyncAppend',
|
|
36
|
+
'asyncReplace',
|
|
37
|
+
'ContextProvider',
|
|
38
|
+
'ContextConsumer',
|
|
39
|
+
'connectWS',
|
|
40
|
+
// `renderStream(payload)` applies a server stream-action payload to the live
|
|
41
|
+
// DOM (append/replace/remove), so a component importing it does client work
|
|
42
|
+
// and must ship (#248).
|
|
43
|
+
'renderStream',
|
|
44
|
+
// Client-only directives. `ref` / `createRef` fire a callback against the
|
|
45
|
+
// live element (focus, measure, third-party mount); `live` syncs an input
|
|
46
|
+
// value against the DOM. All produce identical SSR HTML but do real work
|
|
47
|
+
// only after upgrade, so a component using them must ship. Render-time
|
|
48
|
+
// directives (repeat, unsafeHTML, keyed, guard, cache, map) are NOT here:
|
|
49
|
+
// they are SSR-renderable and common in display-only components.
|
|
50
|
+
'ref',
|
|
51
|
+
'createRef',
|
|
52
|
+
'live',
|
|
53
|
+
// Custom directive authoring (Lit's `directive()`, `Directive`,
|
|
54
|
+
// `AsyncDirective`, from `@webjsdev/core/directive` and
|
|
55
|
+
// `@webjsdev/core/async-directive`). A directive class may hold per-part
|
|
56
|
+
// state and an AsyncDirective pushes values after upgrade through
|
|
57
|
+
// `setValue`, so the analyser cannot prove the SSR output is the whole
|
|
58
|
+
// story and a component importing one ships.
|
|
59
|
+
'directive',
|
|
60
|
+
'Directive',
|
|
61
|
+
'AsyncDirective',
|
|
62
|
+
// `ContextRoot` buffers context requests until a late provider upgrades,
|
|
63
|
+
// and `ContextProviderEvent` hands subscribers to a nearer provider; both
|
|
64
|
+
// only happen in the browser.
|
|
65
|
+
'ContextRoot',
|
|
66
|
+
'ContextProviderEvent',
|
|
67
|
+
// Lit's `SignalWatcher` mixin (an identity mixin here, watching is built
|
|
68
|
+
// in): a component that names it reacts to signals after upgrade.
|
|
69
|
+
'SignalWatcher',
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/** An event binding (`@event=${...}`), quoted or unquoted (invariant 4). */
|
|
73
|
+
export const EVENT_BINDING_RE = /@[A-Za-z][\w-]*\s*=\s*["']?\$\{/;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Identifiers that only exist in a browser; their presence means client work.
|
|
77
|
+
* Both this and COMPONENT_CLIENT_GLOBAL_RE are tested against the
|
|
78
|
+
* PLACEHOLDER redaction (`redactToPlaceholders`), never against raw or
|
|
79
|
+
* comment-masked source: a quoted string body or a template literal's TEXT is
|
|
80
|
+
* data, not a reference, so `'lucide-app-window'` must not read as `window`,
|
|
81
|
+
* while code inside a `${...}` hole stays real code and still matches. A global
|
|
82
|
+
* written in template text is SSR output (an inline `<script>` runs from the
|
|
83
|
+
* served HTML whether or not the module ships), the reasoning #623 applied to
|
|
84
|
+
* route modules, now extended to every module.
|
|
85
|
+
*/
|
|
86
|
+
export const CLIENT_GLOBAL_RE = /\b(?:window|document|navigator|localStorage|sessionStorage|customElements|matchMedia|addEventListener)\b/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Custom-element tag names a module references in its templates. A tag
|
|
90
|
+
* must contain a hyphen (HTML custom-element spec), which excludes
|
|
91
|
+
* native elements. Over-detection is safe: it only forces more modules
|
|
92
|
+
* to ship.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} src raw module source
|
|
95
|
+
* @returns {Set<string>}
|
|
96
|
+
*/
|
|
97
|
+
export function extractRenderedTags(src) {
|
|
98
|
+
/** @type {Set<string>} */
|
|
99
|
+
const tags = new Set();
|
|
100
|
+
// Mask comments first so a `<some-tag>` written in a doc comment is not read
|
|
101
|
+
// as a rendered tag (#179). String and template content is kept, so real tags
|
|
102
|
+
// inside `html` templates are still found.
|
|
103
|
+
const masked = maskComments(src);
|
|
104
|
+
const re = /<([a-z][a-z0-9]*-[a-z0-9-]*)\b/g;
|
|
105
|
+
let m;
|
|
106
|
+
while ((m = re.exec(masked)) !== null) tags.add(m[1]);
|
|
107
|
+
return tags;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Reactive primitives: state a route module can hold but never update in a browser. */
|
|
111
|
+
const REACTIVE_NAMES = new Set(['signal', 'computed', 'effect', 'watch']);
|
|
112
|
+
const IMPORT_STMT_RE = /(?:^|[;\n])\s*import\s+(?:(type\s+)?([^'"`;]*?)\s+from\s+)?['"]__STR_(\d+)__['"]/g;
|
|
113
|
+
const FIX_HINT = 'Move it into a component, or into `instrumentation-client.ts` for app-wide browser code.';
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Advisory notes on a ROUTE module's source (#1686): code that looks meant for
|
|
117
|
+
* the browser but runs only on the server, because a page, layout or boundary
|
|
118
|
+
* never loads there. Never a refusal: each pattern is legal on the server and
|
|
119
|
+
* merely does nothing useful, so the dev server and `webjs doctor`
|
|
120
|
+
* (`ROUTE_BROWSER_CODE`) surface these as hints.
|
|
121
|
+
*
|
|
122
|
+
* Reads the placeholder redaction, so a browser global written in a string or
|
|
123
|
+
* in template TEXT (an inline `<script>` that runs from the rendered HTML) is
|
|
124
|
+
* not a hint, while one in code or in a `${...}` hole is.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} src the route module's source
|
|
127
|
+
* @param {{ file: string, appDir: string, isComponent: (abs: string) => boolean, carriesComponent?: (abs: string) => boolean }} ctx
|
|
128
|
+
* `carriesComponent` says whether a module leads to a component (a barrel of
|
|
129
|
+
* component imports), whose components the boot already loads in its place
|
|
130
|
+
* @returns {string[]}
|
|
131
|
+
*/
|
|
132
|
+
export function routeModuleBrowserHints(src, { file, appDir, isComponent, carriesComponent }) {
|
|
133
|
+
/** @type {string[]} */
|
|
134
|
+
const hints = [];
|
|
135
|
+
const { redacted, literals } = redactToPlaceholders(src);
|
|
136
|
+
const global = CLIENT_GLOBAL_RE.exec(redacted);
|
|
137
|
+
if (global) {
|
|
138
|
+
hints.push(`\`${global[0]}\` is used, but this module runs only on the server, so that code never runs in a browser. ${FIX_HINT}`);
|
|
139
|
+
}
|
|
140
|
+
for (const m of redacted.matchAll(IMPORT_STMT_RE)) {
|
|
141
|
+
if (m[1]) continue; // `import type` is erased
|
|
142
|
+
const spec = literals[Number(m[3])];
|
|
143
|
+
const bindings = m[2];
|
|
144
|
+
if (spec === undefined) continue;
|
|
145
|
+
if (spec === '@webjsdev/core/client-router') {
|
|
146
|
+
hints.push('`@webjsdev/core/client-router` is imported, but this module never loads in a browser. The router starts by itself once any component loads, or import it from `instrumentation-client.ts`.');
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (bindings && /^@webjsdev\/core(\/signals)?$/.test(spec)) {
|
|
150
|
+
const names = (bindings.match(/\{([^}]*)\}/)?.[1] || '').split(',')
|
|
151
|
+
.map((n) => n.trim().split(/\s+as\s+/)[0].trim()).filter((n) => REACTIVE_NAMES.has(n));
|
|
152
|
+
if (names.length) {
|
|
153
|
+
hints.push(`\`${names[0]}\` is imported, but this module never hydrates, so its state never changes in a browser. Keep reactive state in a component.`);
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (bindings) continue;
|
|
158
|
+
// A side-effect import: anything it sets up happens on the server only.
|
|
159
|
+
if (/\.css$/.test(spec) || SERVER_FILE_RE.test(spec)) continue;
|
|
160
|
+
const local = spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('#');
|
|
161
|
+
if (local) {
|
|
162
|
+
const abs = resolveImport(spec, file, appDir);
|
|
163
|
+
if (!abs || isComponent(abs) || SERVER_FILE_RE.test(abs)) continue;
|
|
164
|
+
if (carriesComponent && carriesComponent(abs)) continue;
|
|
165
|
+
} else if (spec.startsWith('@webjsdev/') || spec.startsWith('node:')) {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
hints.push(`The side-effect import of \`${spec}\` runs only on the server. If it sets something up in the browser, import it from a component or from \`instrumentation-client.ts\`.`);
|
|
169
|
+
}
|
|
170
|
+
return hints;
|
|
171
|
+
}
|
package/src/component-scanner.js
CHANGED
|
@@ -169,7 +169,7 @@ export async function primeComponentRegistry(appDir, components) {
|
|
|
169
169
|
* Per-file results of the orphan scan, validated on the exact source (#1575):
|
|
170
170
|
* the dev server runs `findOrphanComponents` after every rebuild, and on a
|
|
171
171
|
* large app re-scanning every unchanged file cost about 100 ms an edit.
|
|
172
|
-
* @type {Map<string, { src: string, declared: Set<string>, registered: string[] }>}
|
|
172
|
+
* @type {Map<string, { src: string, declared: Set<string>, registered: string[], extended: Set<string> }>}
|
|
173
173
|
*/
|
|
174
174
|
const ORPHAN_SCAN_CACHE = new Map();
|
|
175
175
|
|
|
@@ -177,7 +177,7 @@ const ORPHAN_SCAN_CACHE = new Map();
|
|
|
177
177
|
* The classes one file declares as WebComponents and the ones it registers.
|
|
178
178
|
* @param {string} file
|
|
179
179
|
* @param {string} src
|
|
180
|
-
* @returns {{ declared: Set<string>, registered: string[] }}
|
|
180
|
+
* @returns {{ declared: Set<string>, registered: string[], extended: Set<string> }}
|
|
181
181
|
*/
|
|
182
182
|
function orphanScan(file, src) {
|
|
183
183
|
const hit = ORPHAN_SCAN_CACHE.get(file);
|
|
@@ -198,7 +198,14 @@ function orphanScan(file, src) {
|
|
|
198
198
|
// component NOR an orphan and vanished from every surface, which is the
|
|
199
199
|
// exact shape the orphan report exists to catch.
|
|
200
200
|
const registered = extractComponents(src).map((c) => c.className);
|
|
201
|
-
|
|
201
|
+
// Every class some other class extends. A base class its registered
|
|
202
|
+
// subclasses share (the kit's UiCarouselButton under UiCarouselPrevious and
|
|
203
|
+
// UiCarouselNext) is never meant to have a tag of its own, so it is not an
|
|
204
|
+
// orphan (#1686: the website warned about two of them on every boot).
|
|
205
|
+
const extended = new Set();
|
|
206
|
+
const baseRe = /\bclass\s+[A-Za-z_$][\w$]*\s+extends\s+([A-Z][A-Za-z0-9_$]*)\b/g;
|
|
207
|
+
while ((m = baseRe.exec(redacted)) !== null) if (m[1] !== 'WebComponent') extended.add(m[1]);
|
|
208
|
+
const entry = { src, declared, registered, extended };
|
|
202
209
|
ORPHAN_SCAN_CACHE.set(file, entry);
|
|
203
210
|
return entry;
|
|
204
211
|
}
|
|
@@ -217,18 +224,14 @@ function orphanScan(file, src) {
|
|
|
217
224
|
* cross-reference is app-wide precisely so a class registered by a sibling
|
|
218
225
|
* module is not accused of this.
|
|
219
226
|
* - A registration whose tag is COMPUTED (`X.register(TAG)`). That call is
|
|
220
|
-
* ordinary code, so it runs IF the module reaches the browser
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* import-only page emits in its place, and a page that renders a real
|
|
226
|
-
* component alongside the orphan is import-only unless it ALSO does its
|
|
227
|
-
* own client work (which ships it whole, #963). Shipping whole is the
|
|
228
|
-
* narrower case, so treat the upgrade as lost until proven otherwise.
|
|
227
|
+
* ordinary code, so it runs only IF the module reaches the browser. A
|
|
228
|
+
* route module never does (#1686), and an orphan is not in
|
|
229
|
+
* `componentFiles`, so it never joins the frontier a route module emits in
|
|
230
|
+
* its place. It loads only when a shipping component imports it, so treat
|
|
231
|
+
* the upgrade as lost until proven otherwise.
|
|
229
232
|
*
|
|
230
|
-
* Lost in EVERY case, whichever shape: the
|
|
231
|
-
*
|
|
233
|
+
* Lost in EVERY case, whichever shape: the tag-to-module registry entry and
|
|
234
|
+
* the modulepreload hint. That, not the upgrade, is the
|
|
232
235
|
* part that is always true, and it is what every message about this should
|
|
233
236
|
* lead with.
|
|
234
237
|
*
|
|
@@ -262,6 +265,8 @@ export async function findOrphanComponents(appDir) {
|
|
|
262
265
|
const declaredPerFile = [];
|
|
263
266
|
/** @type {Set<string>} every class name registered ANYWHERE in the app */
|
|
264
267
|
const registeredAnywhere = new Set();
|
|
268
|
+
/** @type {Set<string>} every class name another class extends, anywhere */
|
|
269
|
+
const extendedAnywhere = new Set();
|
|
265
270
|
|
|
266
271
|
for await (const file of walk(appDir, filter)) {
|
|
267
272
|
let src;
|
|
@@ -280,14 +285,15 @@ export async function findOrphanComponents(appDir) {
|
|
|
280
285
|
// blank-strings argument: the default form keeps plain-string bodies and
|
|
281
286
|
// single-line untagged templates verbatim, so a sample written either way
|
|
282
287
|
// would match again and the false orphan would be back.
|
|
283
|
-
const { declared, registered } = orphanScan(file, src);
|
|
288
|
+
const { declared, registered, extended } = orphanScan(file, src);
|
|
284
289
|
for (const cls of registered) registeredAnywhere.add(cls);
|
|
290
|
+
for (const cls of extended) extendedAnywhere.add(cls);
|
|
285
291
|
if (declared.size) declaredPerFile.push({ file, declared });
|
|
286
292
|
}
|
|
287
293
|
|
|
288
294
|
for (const { file, declared } of declaredPerFile) {
|
|
289
295
|
for (const cls of declared) {
|
|
290
|
-
if (!registeredAnywhere.has(cls)) orphans.push({ className: cls, file });
|
|
296
|
+
if (!registeredAnywhere.has(cls) && !extendedAnywhere.has(cls)) orphans.push({ className: cls, file });
|
|
291
297
|
}
|
|
292
298
|
}
|
|
293
299
|
return orphans;
|
package/src/context.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
-
import {
|
|
2
|
+
import { CookieStore, cookiesView } from './cookie-store.js';
|
|
3
3
|
import { setCspNonceProvider, cspNonce } from '@webjsdev/core';
|
|
4
4
|
import { shared } from './process-shared.js';
|
|
5
5
|
|
|
@@ -36,7 +36,10 @@ import { shared } from './process-shared.js';
|
|
|
36
36
|
* wrong `revalidate` on a cookie-reading page fails safe (uncached) instead of
|
|
37
37
|
* leaking one visitor's view to another.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
39
|
+
* `revalidatedPaths` collects every `revalidatePath()` call made while serving
|
|
40
|
+
* the request, so an RPC action response can tell the browser to refresh them.
|
|
41
|
+
*
|
|
42
|
+
* @typedef {{ req: Request, cspNonce?: string, bodyLimits?: { json: number, multipart: number }, requestId?: string, dynamicAccess?: boolean, revalidatedPaths?: { path: string, layout: boolean }[], cookieStore?: CookieStore, cookiesSealed?: boolean, componentErrorSink?: (tag: string, error: unknown) => void }} Store
|
|
40
43
|
*/
|
|
41
44
|
|
|
42
45
|
/** @type {AsyncLocalStorage<Store>} */
|
|
@@ -97,6 +100,43 @@ export function getBodyLimits() {
|
|
|
97
100
|
return als.getStore()?.bodyLimits ?? null;
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Set the app's `webjs.allowedOrigins` on the current store, so a check that
|
|
105
|
+
* runs inside a route handler (the auth routes' origin check, #1686) honours
|
|
106
|
+
* the same allowlist the action endpoint does.
|
|
107
|
+
* @param {string[]} origins
|
|
108
|
+
*/
|
|
109
|
+
export function setAllowedOrigins(origins) {
|
|
110
|
+
const store = als.getStore();
|
|
111
|
+
if (store) store.allowedOrigins = origins;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The app's cross-origin allowlist for this request, or [] outside one. */
|
|
115
|
+
export function getAllowedOrigins() {
|
|
116
|
+
return als.getStore()?.allowedOrigins ?? [];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Set where the current request's isolated component render failures go. The
|
|
121
|
+
* dev handler sets one per request so a component that throws during SSR,
|
|
122
|
+
* which renders its error state in place and leaves the page at 200, still
|
|
123
|
+
* reaches the dev status `webjs status` reads.
|
|
124
|
+
*
|
|
125
|
+
* @param {(tag: string, error: unknown) => void} sink
|
|
126
|
+
*/
|
|
127
|
+
export function setComponentErrorSink(sink) {
|
|
128
|
+
const store = als.getStore();
|
|
129
|
+
if (store) store.componentErrorSink = sink;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The core renderer has no server imports, so it reports an isolated component
|
|
133
|
+
// failure through this process-wide hook, which forwards to the sink of the
|
|
134
|
+
// request being rendered. Outside a request, or with no sink set, it is a no-op.
|
|
135
|
+
globalThis[Symbol.for('webjs.ssr.componentError')] = (tag, error) => {
|
|
136
|
+
const sink = als.getStore()?.componentErrorSink;
|
|
137
|
+
if (typeof sink === 'function') sink(tag, error);
|
|
138
|
+
};
|
|
139
|
+
|
|
100
140
|
/**
|
|
101
141
|
* Set the per-request correlation id on the current store (issue #239). The
|
|
102
142
|
* handler calls this at the start of every request with either an inbound
|
|
@@ -195,28 +235,124 @@ export function headers() {
|
|
|
195
235
|
// Accept-Language / Cookie read varies the output), so mark the request
|
|
196
236
|
// dynamic so the HTML cache excludes it even under `revalidate` (#241).
|
|
197
237
|
markDynamicAccess();
|
|
198
|
-
|
|
238
|
+
let ro = _readonlyHeaders.get(req);
|
|
239
|
+
if (!ro) {
|
|
240
|
+
ro = new ReadonlyHeaders(req.headers);
|
|
241
|
+
_readonlyHeaders.set(req, ro);
|
|
242
|
+
}
|
|
243
|
+
return ro;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** One read-only copy per request, so repeated `headers()` calls agree. */
|
|
247
|
+
const _readonlyHeaders = shared('readonly-headers', () => new WeakMap());
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The request headers `headers()` returns, read-only as Next.js's
|
|
251
|
+
* `ReadonlyHeaders` is (#1686): writing them changed nothing anyone reads, so
|
|
252
|
+
* a `set` meant for the RESPONSE vanished silently. A write throws naming the
|
|
253
|
+
* places a response header or a cookie is actually set.
|
|
254
|
+
*/
|
|
255
|
+
class ReadonlyHeaders extends Headers {
|
|
256
|
+
/** @returns {never} */
|
|
257
|
+
append() { throw readonlyHeadersError(); }
|
|
258
|
+
/** @returns {never} */
|
|
259
|
+
set() { throw readonlyHeadersError(); }
|
|
260
|
+
/** @returns {never} */
|
|
261
|
+
delete() { throw readonlyHeadersError(); }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function readonlyHeadersError() {
|
|
265
|
+
const e = new Error(
|
|
266
|
+
'headers() is read-only, as in Next.js: it returns the REQUEST headers. To send a response '
|
|
267
|
+
+ 'header, return it from a route handler or middleware (new Response(body, { headers })) or '
|
|
268
|
+
+ 'set metadata.cacheControl on a page; to set a cookie, use cookies().set().',
|
|
269
|
+
);
|
|
270
|
+
e.name = 'ReadonlyHeadersError';
|
|
271
|
+
return e;
|
|
199
272
|
}
|
|
200
273
|
|
|
201
274
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* `
|
|
205
|
-
*
|
|
275
|
+
* The in-flight request's cookie store (Next.js's `cookies()` from
|
|
276
|
+
* `next/headers`). `get(name)` returns `{ name, value }` or undefined, so
|
|
277
|
+
* `cookies().get('theme')?.value` reads a value; `getAll`, `has`, `size` and
|
|
278
|
+
* iteration read; `set(name, value, options)` / `set({ name, value, ... })`
|
|
279
|
+
* and `delete(name)` write a `Set-Cookie` onto this request's response
|
|
280
|
+
* (applied by the request handler), and a later read in the same request sees
|
|
281
|
+
* the write. The result is also awaitable, so Next 15's
|
|
282
|
+
* `(await cookies()).get('theme')` works too. Throws outside a request.
|
|
283
|
+
*
|
|
284
|
+
* `options.maxAge` is in SECONDS (Next's unit) and `path` defaults to `/`.
|
|
206
285
|
*
|
|
207
|
-
* @returns {
|
|
286
|
+
* @returns {ReturnType<typeof cookiesView>}
|
|
208
287
|
*/
|
|
209
288
|
export function cookies() {
|
|
210
|
-
const
|
|
211
|
-
if (!
|
|
289
|
+
const store = als.getStore();
|
|
290
|
+
if (!store) throw new Error('cookies(): called outside a request scope');
|
|
212
291
|
// Reading cookies makes the render per-user (a logged-in vs logged-out body
|
|
213
292
|
// keys off an auth cookie), so mark the request dynamic so the HTML cache
|
|
214
293
|
// excludes it even under `revalidate`, the core leak defense (#241).
|
|
215
294
|
markDynamicAccess();
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
295
|
+
return cookiesView(requestCookieStore(store));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The request's cookie store, created on first use. Shared by `cookies()` and
|
|
300
|
+
* middleware's `request.cookies`, so a write through either is one write.
|
|
301
|
+
* @param {Store} [store]
|
|
302
|
+
* @returns {CookieStore}
|
|
303
|
+
*/
|
|
304
|
+
export function requestCookieStore(store = als.getStore()) {
|
|
305
|
+
if (!store) throw new Error('cookies(): called outside a request scope');
|
|
306
|
+
if (!store.cookieStore) {
|
|
307
|
+
store.cookieStore = new CookieStore(store.req.headers.get('cookie'));
|
|
308
|
+
if (store.cookiesSealed) store.cookieStore.sealed = true;
|
|
309
|
+
}
|
|
310
|
+
return store.cookieStore;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Seal the request's cookies once its response headers are decided: a later
|
|
315
|
+
* `cookies().set()` / `.delete()` (a component rendering inside a streamed
|
|
316
|
+
* boundary) throws instead of being silently lost. Reads stay allowed.
|
|
317
|
+
*/
|
|
318
|
+
export function sealCookies() {
|
|
319
|
+
const store = als.getStore();
|
|
320
|
+
if (!store) return;
|
|
321
|
+
store.cookiesSealed = true;
|
|
322
|
+
if (store.cookieStore) store.cookieStore.sealed = true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The current request's cookie store if one was created, for the handler to
|
|
327
|
+
* apply its pending `Set-Cookie` writes. Null when no code touched cookies.
|
|
328
|
+
* @returns {CookieStore | null}
|
|
329
|
+
*/
|
|
330
|
+
export function currentCookieStore() {
|
|
331
|
+
return als.getStore()?.cookieStore || null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Record a `revalidatePath()` call against the current request (a no-op with
|
|
336
|
+
* no ambient request).
|
|
337
|
+
* @param {string} path app-root-relative pathname, or a route pattern
|
|
338
|
+
* @param {boolean} layout Next's `'layout'` type
|
|
339
|
+
*/
|
|
340
|
+
export function recordRevalidatedPath(path, layout) {
|
|
341
|
+
const store = als.getStore();
|
|
342
|
+
if (!store) return;
|
|
343
|
+
(store.revalidatedPaths ||= []).push({ path, layout });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* The `x-webjs-refresh` header value for the paths revalidated during this
|
|
348
|
+
* request, or null when there were none. Each path is percent-encoded (a path
|
|
349
|
+
* may contain a comma) and a layout entry carries a `;layout` suffix.
|
|
350
|
+
* @returns {string | null}
|
|
351
|
+
*/
|
|
352
|
+
export function refreshHeaderValue() {
|
|
353
|
+
const list = als.getStore()?.revalidatedPaths;
|
|
354
|
+
if (!list || !list.length) return null;
|
|
355
|
+
const seen = new Set();
|
|
356
|
+
for (const { path, layout } of list) seen.add(encodeURIComponent(path) + (layout ? ';layout' : ''));
|
|
357
|
+
return [...seen].join(',');
|
|
222
358
|
}
|