@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
@@ -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
+ }
@@ -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
- const entry = { src, declared, registered };
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, which
221
- * requires the importing module to ship WHOLE. An inert, import-only, or
222
- * elided importer is dropped from the boot and takes the import with it,
223
- * and then the element does not upgrade either. Assume it does not. An
224
- * orphan is not in `componentFiles`, so it never joins the frontier an
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 elision verdict, the tag-to-module
231
- * registry entry, and the modulepreload hint. That, not the upgrade, is the
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 { parseCookies } from './csrf.js';
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
- * @typedef {{ req: Request, cspNonce?: string, bodyLimits?: { json: number, multipart: number }, requestId?: string, dynamicAccess?: boolean }} Store
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
- return req.headers;
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
- * Read-only cookie jar for the in-flight request. Returns an object with
203
- * `.get(name)`, `.has(name)`, `.entries()`. To SET a cookie, return a
204
- * `Response` whose headers include `Set-Cookie` (route handlers and
205
- * exposed actions can do this directly).
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 {{ get: (name: string) => string | undefined, has: (name: string) => boolean, entries: () => [string, string][] }}
286
+ * @returns {ReturnType<typeof cookiesView>}
208
287
  */
209
288
  export function cookies() {
210
- const req = getRequest();
211
- if (!req) throw new Error('cookies(): called outside a request scope');
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
- const map = parseCookies(req);
217
- return {
218
- get: (name) => map[name],
219
- has: (name) => Object.prototype.hasOwnProperty.call(map, name),
220
- entries: () => Object.entries(map),
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
  }