evolit 0.1.8 → 0.2.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 +19 -6
- package/package.json +1 -1
- package/src/navigation-client.js +99 -10
- package/src/navigation-server.js +0 -1
- package/src/render.js +125 -38
- package/src/request-context-browser.js +1 -0
- package/src/request-context.js +56 -1
- package/src/server-api.js +7 -0
package/README.md
CHANGED
|
@@ -20,8 +20,8 @@ It focuses on the core contract that matters first:
|
|
|
20
20
|
- route modules live in `app/**/page.*`
|
|
21
21
|
- layout modules live in `app/**/layout.*`
|
|
22
22
|
- page and layout modules export a default async function
|
|
23
|
-
- pages receive `{ params, searchParams, request }`
|
|
24
|
-
- layouts receive `{ children, params, searchParams, request }`
|
|
23
|
+
- pages receive `{ params, searchParams, navigationContext, request }`
|
|
24
|
+
- layouts receive `{ children, params, searchParams, navigationContext, request }`
|
|
25
25
|
- SSR document rendering is delegated to `@litsx/ssr`
|
|
26
26
|
|
|
27
27
|
Supported authored module extensions:
|
|
@@ -162,9 +162,10 @@ from a connected client component.
|
|
|
162
162
|
|
|
163
163
|
Navigation context is intended for compact UI state that must survive back/forward navigation but
|
|
164
164
|
does not belong in the public URL. It must be a JSON-safe object and is limited to 8 KiB after UTF-8
|
|
165
|
-
serialization. Evolit includes it in browser
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
serialization. Evolit includes it in browser and response-cache identity. Segment caching and delta
|
|
166
|
+
identity track only the top-level context keys each layout or page reads, so unrelated context
|
|
167
|
+
changes preserve mounted layouts while consumers still update. Invalid context is rejected at the
|
|
168
|
+
client boundary; malformed context received by the server is ignored.
|
|
168
169
|
|
|
169
170
|
Client components can also read the active route state with browser-only hooks:
|
|
170
171
|
|
|
@@ -266,20 +267,24 @@ Server pages and layouts can access the active Web Request context through `evol
|
|
|
266
267
|
```js
|
|
267
268
|
import {
|
|
268
269
|
cookies,
|
|
270
|
+
getRouteState,
|
|
269
271
|
headers,
|
|
270
272
|
notFound,
|
|
273
|
+
permanentRedirect,
|
|
271
274
|
redirect,
|
|
272
275
|
requestUrl,
|
|
273
276
|
responseHeaders,
|
|
274
277
|
} from "evolit/server";
|
|
275
278
|
|
|
276
279
|
export default async function AccountPage() {
|
|
280
|
+
const { params, searchParams, navigationContext } = getRouteState();
|
|
281
|
+
|
|
277
282
|
if (!cookies().has("session")) {
|
|
278
283
|
redirect("/sign-in");
|
|
279
284
|
}
|
|
280
285
|
|
|
281
286
|
responseHeaders().set("x-account-page", "1");
|
|
282
|
-
return `<p>${headers().get("user-agent")} ${requestUrl().pathname}</p>`;
|
|
287
|
+
return `<p>${headers().get("user-agent")} ${requestUrl().pathname} ${params.account ?? ""}</p>`;
|
|
283
288
|
}
|
|
284
289
|
```
|
|
285
290
|
|
|
@@ -288,6 +293,14 @@ headers. `redirect()` and `permanentRedirect()` end rendering with `307` and `30
|
|
|
288
293
|
`notFound()` renders a `404`. Reading headers, cookies, or the request URL makes the completed
|
|
289
294
|
render dynamic, so it is not stored by the route response cache.
|
|
290
295
|
|
|
296
|
+
`getRouteState()` is the request-scoped server counterpart for reading the active route from nested
|
|
297
|
+
server components and helpers that do not receive route props directly. It returns the current
|
|
298
|
+
`{ url, params, searchParams, navigationContext }` as a read-only snapshot. It deliberately has no
|
|
299
|
+
`push`, `replace`, `refresh`, pending state, or history mutation methods: those belong to the browser
|
|
300
|
+
`useNavigation()` API. Redirects and `notFound()` remain separate server control-flow functions.
|
|
301
|
+
Reading `getRouteState().url` has the same dynamic-rendering semantics as `requestUrl()`; reading
|
|
302
|
+
`params` and `searchParams` participates in the normal segment-cache key tracking.
|
|
303
|
+
|
|
291
304
|
## Extensions
|
|
292
305
|
|
|
293
306
|
Optional integrations are configured explicitly in `evolit.config.js`. Core only coordinates their
|
package/package.json
CHANGED
package/src/navigation-client.js
CHANGED
|
@@ -194,6 +194,79 @@ function syncDocumentState(nextState, documentRef) {
|
|
|
194
194
|
if (script) script.textContent = JSON.stringify(nextState);
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
function readElementAttributes(element) {
|
|
198
|
+
return Object.fromEntries([...element.attributes].map((attribute) => [
|
|
199
|
+
attribute.name,
|
|
200
|
+
attribute.value,
|
|
201
|
+
]));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function isRootBodyProjection(segment, projection) {
|
|
205
|
+
return segment?.kind === "layout"
|
|
206
|
+
&& segment?.depth === 0
|
|
207
|
+
&& typeof projection?.html === "string"
|
|
208
|
+
&& /<body(?:\s|>)/iu.test(projection.html);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseRootBodyProjection(projectionHtml, documentRef) {
|
|
212
|
+
const DOMParserConstructor = documentRef.defaultView?.DOMParser ?? globalThis.DOMParser;
|
|
213
|
+
if (typeof DOMParserConstructor !== "function") return null;
|
|
214
|
+
return new DOMParserConstructor().parseFromString(projectionHtml, "text/html").body;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function collectRouteRuntimeScripts(root) {
|
|
218
|
+
const scripts = [...root.querySelectorAll("script")];
|
|
219
|
+
return scripts.filter((script) =>
|
|
220
|
+
script.id === "__EVOLIT_ROUTE__"
|
|
221
|
+
|| script.id === "__EVOLIT_DOCUMENT__"
|
|
222
|
+
|| script.id === "__LITSX_HYDRATION__"
|
|
223
|
+
|| script.hasAttribute("data-evolit-live-reload")
|
|
224
|
+
|| (script.type === "module" && (
|
|
225
|
+
script.textContent?.includes("getNavigation")
|
|
226
|
+
|| script.textContent?.includes("hydratePage")
|
|
227
|
+
)),
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function replaceRootBodyProjection({
|
|
232
|
+
bodyProjection,
|
|
233
|
+
delta,
|
|
234
|
+
documentRef,
|
|
235
|
+
fragment,
|
|
236
|
+
target,
|
|
237
|
+
}) {
|
|
238
|
+
const body = documentRef.body;
|
|
239
|
+
if (!body || !bodyProjection) return false;
|
|
240
|
+
|
|
241
|
+
const authoredAttributes = readElementAttributes(bodyProjection);
|
|
242
|
+
const managedAttributes = normalizeManagedAttributes(delta.document?.bodyAttributes);
|
|
243
|
+
syncElementAttributes(
|
|
244
|
+
body,
|
|
245
|
+
readElementAttributes(body),
|
|
246
|
+
{ ...authoredAttributes, ...managedAttributes },
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const runtimeScripts = collectRouteRuntimeScripts(documentRef);
|
|
250
|
+
body.replaceChildren(target.start, fragment, target.end, ...runtimeScripts);
|
|
251
|
+
|
|
252
|
+
// HTML parsing can leave nodes from an authored root <body> beside the live
|
|
253
|
+
// body. Once the stable body owns the committed projection and runtime
|
|
254
|
+
// scripts, discard only those invalid element siblings.
|
|
255
|
+
for (const element of [...documentRef.documentElement.children]) {
|
|
256
|
+
if (element !== documentRef.head && element !== body) element.remove();
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function syncRouteStateScripts(delta, documentRef) {
|
|
262
|
+
const routeScript = documentRef.getElementById("__EVOLIT_ROUTE__");
|
|
263
|
+
if (routeScript) routeScript.textContent = JSON.stringify(delta.route);
|
|
264
|
+
const hydrationScript = documentRef.getElementById("__LITSX_HYDRATION__");
|
|
265
|
+
if (hydrationScript && delta.hydrationData) {
|
|
266
|
+
hydrationScript.textContent = JSON.stringify(delta.hydrationData);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
197
270
|
function versionClientImport(specifier, version) {
|
|
198
271
|
if (!version || typeof specifier !== "string") return specifier;
|
|
199
272
|
const url = new URL(specifier, globalThis.location?.href ?? "http://evolit.local/");
|
|
@@ -260,9 +333,18 @@ async function applyRouteDelta(delta, documentRef = document, signal, options =
|
|
|
260
333
|
throwIfAborted(signal);
|
|
261
334
|
const projection = next.projections[offset] ?? next.projections[0];
|
|
262
335
|
if (!projection) continue;
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
336
|
+
const bodyProjection = isRootBodyProjection(next, projection)
|
|
337
|
+
? parseRootBodyProjection(projection.html, documentRef)
|
|
338
|
+
: null;
|
|
339
|
+
const fragment = documentRef.createDocumentFragment();
|
|
340
|
+
if (bodyProjection) {
|
|
341
|
+
for (const script of collectRouteRuntimeScripts(bodyProjection)) script.remove();
|
|
342
|
+
fragment.append(...bodyProjection.childNodes);
|
|
343
|
+
} else {
|
|
344
|
+
const template = documentRef.createElement("template");
|
|
345
|
+
template.innerHTML = projection.html;
|
|
346
|
+
fragment.append(template.content);
|
|
347
|
+
}
|
|
266
348
|
materializeDeclarativeShadowDom(fragment);
|
|
267
349
|
// Restore opaque SSR forwarded refs while the fragment is detached, before
|
|
268
350
|
// its custom elements upgrade on insertion.
|
|
@@ -279,11 +361,19 @@ async function applyRouteDelta(delta, documentRef = document, signal, options =
|
|
|
279
361
|
roots.map(({ root, element }) => ({ ...root, element })),
|
|
280
362
|
delta.hydrationData,
|
|
281
363
|
);
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
364
|
+
if (!replaceRootBodyProjection({
|
|
365
|
+
bodyProjection,
|
|
366
|
+
delta,
|
|
367
|
+
documentRef,
|
|
368
|
+
fragment,
|
|
369
|
+
target,
|
|
370
|
+
})) {
|
|
371
|
+
const range = documentRef.createRange();
|
|
372
|
+
range.setStartAfter(target.start);
|
|
373
|
+
range.setEndBefore(target.end);
|
|
374
|
+
range.deleteContents();
|
|
375
|
+
range.insertNode(fragment);
|
|
376
|
+
}
|
|
287
377
|
insertedRoots.push(...roots);
|
|
288
378
|
target.start.data = `evolit:segment:start:${next.id}`;
|
|
289
379
|
target.end.data = `evolit:segment:end:${next.id}`;
|
|
@@ -292,8 +382,7 @@ async function applyRouteDelta(delta, documentRef = document, signal, options =
|
|
|
292
382
|
// document to point persistent layout refs at the new target, or clear them
|
|
293
383
|
// when the replacement page no longer exposes one.
|
|
294
384
|
prepareForwardedRefs(documentRef);
|
|
295
|
-
|
|
296
|
-
if (script) script.textContent = JSON.stringify(delta.route);
|
|
385
|
+
syncRouteStateScripts(delta, documentRef);
|
|
297
386
|
if (delta.title) documentRef.title = delta.title;
|
|
298
387
|
for (const { root, element } of insertedRoots) {
|
|
299
388
|
throwIfAborted(signal);
|
package/src/navigation-server.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
export function getNavigation() { throw new Error("Navigation is only available in a browser context."); }
|
|
2
1
|
export { createHref } from "./navigation-url.js";
|
|
3
2
|
export function useNavigation() { throw new Error("useNavigation() is only available in a browser component."); }
|
|
4
3
|
export function useParams() { throw new Error("useParams() is only available in a browser component."); }
|
package/src/render.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
getRequestContextResponse,
|
|
15
15
|
isEvolitHttpSignal,
|
|
16
16
|
runWithRequestContext,
|
|
17
|
+
runWithRouteState,
|
|
17
18
|
} from "./request-context.js";
|
|
18
19
|
import { createDevelopmentEventReporter } from "./development-events.js";
|
|
19
20
|
import {
|
|
@@ -294,6 +295,67 @@ function createTrackedRouteValues(values) {
|
|
|
294
295
|
};
|
|
295
296
|
}
|
|
296
297
|
|
|
298
|
+
function createTrackedNavigationContext(values) {
|
|
299
|
+
const tracked = createTrackedRouteValues(values ?? {});
|
|
300
|
+
let accessed = false;
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
value: values == null ? null : tracked.value,
|
|
304
|
+
read() {
|
|
305
|
+
accessed = true;
|
|
306
|
+
return values == null ? null : tracked.value;
|
|
307
|
+
},
|
|
308
|
+
profile() {
|
|
309
|
+
return { accessed, ...tracked.profile() };
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function createTrackedSegmentProps(
|
|
315
|
+
trackedParams,
|
|
316
|
+
trackedSearchParams,
|
|
317
|
+
trackedNavigationContext,
|
|
318
|
+
props,
|
|
319
|
+
) {
|
|
320
|
+
const result = {
|
|
321
|
+
params: trackedParams.value,
|
|
322
|
+
searchParams: trackedSearchParams.value,
|
|
323
|
+
...props,
|
|
324
|
+
};
|
|
325
|
+
Object.defineProperty(result, "navigationContext", {
|
|
326
|
+
enumerable: true,
|
|
327
|
+
get: () => trackedNavigationContext.read(),
|
|
328
|
+
});
|
|
329
|
+
return result;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function createSegmentDependencyProfile(
|
|
333
|
+
trackedParams,
|
|
334
|
+
trackedSearchParams,
|
|
335
|
+
trackedNavigationContext,
|
|
336
|
+
) {
|
|
337
|
+
return {
|
|
338
|
+
params: trackedParams.profile(),
|
|
339
|
+
searchParams: trackedSearchParams.profile(),
|
|
340
|
+
navigationContext: trackedNavigationContext.profile(),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function selectTrackedValues(profile, values) {
|
|
345
|
+
const normalized = profile ?? { all: false, keys: [] };
|
|
346
|
+
return Object.fromEntries(
|
|
347
|
+
(normalized.all ? Object.keys(values ?? {}) : normalized.keys ?? [])
|
|
348
|
+
.sort()
|
|
349
|
+
.map((key) => [key, values?.[key]]),
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function selectTrackedNavigationContext(profile, navigationContext) {
|
|
354
|
+
if (!profile?.accessed) return undefined;
|
|
355
|
+
if (navigationContext == null) return null;
|
|
356
|
+
return selectTrackedValues(profile, navigationContext);
|
|
357
|
+
}
|
|
358
|
+
|
|
297
359
|
function createSegmentCacheKey(
|
|
298
360
|
segment,
|
|
299
361
|
idPrefix,
|
|
@@ -302,30 +364,26 @@ function createSegmentCacheKey(
|
|
|
302
364
|
searchParams,
|
|
303
365
|
navigationContext,
|
|
304
366
|
) {
|
|
305
|
-
const select = (values) => Object.fromEntries(
|
|
306
|
-
(profile.all ? Object.keys(values) : profile.keys)
|
|
307
|
-
.sort()
|
|
308
|
-
.map((key) => [key, values[key]]),
|
|
309
|
-
);
|
|
310
367
|
return JSON.stringify({
|
|
311
368
|
modulePath: segment.modulePath,
|
|
312
369
|
idPrefix,
|
|
313
|
-
params:
|
|
314
|
-
searchParams:
|
|
315
|
-
navigationContext:
|
|
370
|
+
params: selectTrackedValues(profile.params, params),
|
|
371
|
+
searchParams: selectTrackedValues(profile.searchParams, searchParams),
|
|
372
|
+
navigationContext: selectTrackedNavigationContext(
|
|
373
|
+
profile.navigationContext,
|
|
374
|
+
navigationContext,
|
|
375
|
+
),
|
|
316
376
|
});
|
|
317
377
|
}
|
|
318
378
|
|
|
319
379
|
function createSegmentInputKey(profile, params, searchParams, navigationContext) {
|
|
320
|
-
const select = (values) => Object.fromEntries(
|
|
321
|
-
(profile.all ? Object.keys(values) : profile.keys)
|
|
322
|
-
.sort()
|
|
323
|
-
.map((key) => [key, values[key]]),
|
|
324
|
-
);
|
|
325
380
|
return JSON.stringify({
|
|
326
|
-
params:
|
|
327
|
-
searchParams:
|
|
328
|
-
navigationContext:
|
|
381
|
+
params: selectTrackedValues(profile.params, params),
|
|
382
|
+
searchParams: selectTrackedValues(profile.searchParams, searchParams),
|
|
383
|
+
navigationContext: selectTrackedNavigationContext(
|
|
384
|
+
profile.navigationContext,
|
|
385
|
+
navigationContext,
|
|
386
|
+
),
|
|
329
387
|
});
|
|
330
388
|
}
|
|
331
389
|
|
|
@@ -456,18 +514,37 @@ async function renderSegmentedComponentTree(
|
|
|
456
514
|
const idPrefix = `${segment.id}-p${projectionPath.join("-") || "0"}`;
|
|
457
515
|
|
|
458
516
|
if (index === layoutComponents.length) {
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
517
|
+
const trackedParams = createTrackedRouteValues(requestContext.params);
|
|
518
|
+
const trackedSearchParams = createTrackedRouteValues(requestContext.searchParams);
|
|
519
|
+
const trackedNavigationContext = createTrackedNavigationContext(
|
|
520
|
+
requestContext.navigationContext,
|
|
521
|
+
);
|
|
522
|
+
const pageValue = await runWithRouteState(requestContext, {
|
|
523
|
+
params: trackedParams.value,
|
|
524
|
+
searchParams: trackedSearchParams.value,
|
|
525
|
+
navigationContext: trackedNavigationContext.value,
|
|
526
|
+
trackNavigationContext: () => trackedNavigationContext.read(),
|
|
527
|
+
}, () => component(createTrackedSegmentProps(
|
|
528
|
+
trackedParams,
|
|
529
|
+
trackedSearchParams,
|
|
530
|
+
trackedNavigationContext,
|
|
531
|
+
{ request, ...extraProps },
|
|
532
|
+
), incomingRef));
|
|
466
533
|
const pageResult = await renderToString(wrapRouteSegment(segment, pageValue), {
|
|
467
534
|
assetResolver: options.assetResolver,
|
|
468
535
|
context: { idPrefix },
|
|
469
536
|
});
|
|
470
537
|
pageResult.segmentModulePath = segment.modulePath;
|
|
538
|
+
segment.inputKey = createSegmentInputKey(
|
|
539
|
+
createSegmentDependencyProfile(
|
|
540
|
+
trackedParams,
|
|
541
|
+
trackedSearchParams,
|
|
542
|
+
trackedNavigationContext,
|
|
543
|
+
),
|
|
544
|
+
requestContext.params,
|
|
545
|
+
requestContext.searchParams,
|
|
546
|
+
requestContext.navigationContext,
|
|
547
|
+
);
|
|
471
548
|
results.push(pageResult);
|
|
472
549
|
return pageResult.html;
|
|
473
550
|
}
|
|
@@ -485,26 +562,36 @@ async function renderSegmentedComponentTree(
|
|
|
485
562
|
if (!layoutResult) {
|
|
486
563
|
const trackedParams = createTrackedRouteValues(requestContext.params);
|
|
487
564
|
const trackedSearchParams = createTrackedRouteValues(requestContext.searchParams);
|
|
565
|
+
const trackedNavigationContext = createTrackedNavigationContext(
|
|
566
|
+
requestContext.navigationContext,
|
|
567
|
+
);
|
|
488
568
|
const didUseDynamicRequestData = requestContext.didUseDynamicRequestData;
|
|
489
|
-
|
|
569
|
+
layoutResult = await runWithRouteState(requestContext, {
|
|
490
570
|
params: trackedParams.value,
|
|
491
571
|
searchParams: trackedSearchParams.value,
|
|
492
|
-
navigationContext:
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
572
|
+
navigationContext: trackedNavigationContext.value,
|
|
573
|
+
trackNavigationContext: () => trackedNavigationContext.read(),
|
|
574
|
+
}, async () => {
|
|
575
|
+
const layoutValue = await layoutComponents[index](createTrackedSegmentProps(
|
|
576
|
+
trackedParams,
|
|
577
|
+
trackedSearchParams,
|
|
578
|
+
trackedNavigationContext,
|
|
579
|
+
{
|
|
580
|
+
request,
|
|
581
|
+
children: withForwardedChildRef(html`${unsafeHTML(childrenMarker)}`, childRef),
|
|
582
|
+
},
|
|
583
|
+
), incomingRef);
|
|
584
|
+
return renderToString(wrapRouteSegment(segment, layoutValue), {
|
|
585
|
+
assetResolver: options.assetResolver,
|
|
586
|
+
context: { idPrefix },
|
|
587
|
+
});
|
|
499
588
|
});
|
|
500
589
|
layoutResult.segmentModulePath = segment.modulePath;
|
|
501
|
-
profile =
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
])].sort(),
|
|
507
|
-
};
|
|
590
|
+
profile = createSegmentDependencyProfile(
|
|
591
|
+
trackedParams,
|
|
592
|
+
trackedSearchParams,
|
|
593
|
+
trackedNavigationContext,
|
|
594
|
+
);
|
|
508
595
|
if (!didUseDynamicRequestData && !requestContext.didUseDynamicRequestData) {
|
|
509
596
|
options.segmentCache?.set(
|
|
510
597
|
segment,
|
|
@@ -10,3 +10,4 @@ export function redirect() { return throwServerOnlyApi("redirect"); }
|
|
|
10
10
|
export function requestUrl() { return throwServerOnlyApi("requestUrl"); }
|
|
11
11
|
export function responseHeaders() { return throwServerOnlyApi("responseHeaders"); }
|
|
12
12
|
export function getRequestContext() { return throwServerOnlyApi("getRequestContext"); }
|
|
13
|
+
export function getRouteState() { return throwServerOnlyApi("getRouteState"); }
|
package/src/request-context.js
CHANGED
|
@@ -2,9 +2,11 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
2
2
|
import { readNavigationContext } from "./navigation-context.js";
|
|
3
3
|
|
|
4
4
|
const REQUEST_CONTEXT_STORAGE = Symbol.for("evolit.request-context.storage");
|
|
5
|
+
const ROUTE_STATE_STORAGE = Symbol.for("evolit.route-state.storage");
|
|
5
6
|
const HTTP_SIGNAL_CLASS = Symbol.for("evolit.request-context.http-signal");
|
|
6
7
|
|
|
7
8
|
const requestContextStorage = globalThis[REQUEST_CONTEXT_STORAGE] ??= new AsyncLocalStorage();
|
|
9
|
+
const routeStateStorage = globalThis[ROUTE_STATE_STORAGE] ??= new AsyncLocalStorage();
|
|
8
10
|
const EvolitHttpSignal = globalThis[HTTP_SIGNAL_CLASS] ??= class EvolitHttpSignal extends Error {
|
|
9
11
|
constructor(type, options = {}) {
|
|
10
12
|
super(type);
|
|
@@ -111,11 +113,14 @@ export function createRequestContext({
|
|
|
111
113
|
extensionValues = {},
|
|
112
114
|
didUseDynamicRequestData = false,
|
|
113
115
|
}) {
|
|
116
|
+
const routeNavigationContext = navigationContext == null
|
|
117
|
+
? null
|
|
118
|
+
: Object.freeze({ ...navigationContext });
|
|
114
119
|
const context = {
|
|
115
120
|
request,
|
|
116
121
|
params: Object.freeze({ ...params }),
|
|
117
122
|
searchParams: Object.freeze({ ...searchParams }),
|
|
118
|
-
navigationContext,
|
|
123
|
+
navigationContext: routeNavigationContext,
|
|
119
124
|
responseHeaders: new Headers(),
|
|
120
125
|
responseCookies: [],
|
|
121
126
|
didUseDynamicRequestData: didUseDynamicRequestData === true,
|
|
@@ -130,6 +135,56 @@ export function runWithRequestContext(context, callback) {
|
|
|
130
135
|
return requestContextStorage.run(context, callback);
|
|
131
136
|
}
|
|
132
137
|
|
|
138
|
+
export function runWithRouteState(context, routeState, callback) {
|
|
139
|
+
return routeStateStorage.run({ context, ...routeState }, callback);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Returns a read-only snapshot of the active SSR route. Unlike the browser
|
|
144
|
+
* navigation controller, this state has no history or mutation methods.
|
|
145
|
+
* Reading `url` follows the same dynamic-rendering semantics as requestUrl().
|
|
146
|
+
*
|
|
147
|
+
* @returns {{
|
|
148
|
+
* url: URL,
|
|
149
|
+
* params: Readonly<Record<string, string | string[] | undefined>>,
|
|
150
|
+
* searchParams: Readonly<Record<string, string | string[] | undefined>>,
|
|
151
|
+
* navigationContext: Readonly<Record<string, unknown>> | null,
|
|
152
|
+
* }}
|
|
153
|
+
*/
|
|
154
|
+
export function getRouteState() {
|
|
155
|
+
const context = getActiveContext();
|
|
156
|
+
const trackedState = routeStateStorage.getStore();
|
|
157
|
+
const routeState = trackedState?.context === context ? trackedState : context;
|
|
158
|
+
const state = {};
|
|
159
|
+
|
|
160
|
+
Object.defineProperties(state, {
|
|
161
|
+
url: {
|
|
162
|
+
enumerable: true,
|
|
163
|
+
get() {
|
|
164
|
+
context.didUseDynamicRequestData = true;
|
|
165
|
+
return new URL(context.request.url);
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
params: {
|
|
169
|
+
enumerable: true,
|
|
170
|
+
value: routeState.params,
|
|
171
|
+
},
|
|
172
|
+
searchParams: {
|
|
173
|
+
enumerable: true,
|
|
174
|
+
value: routeState.searchParams,
|
|
175
|
+
},
|
|
176
|
+
navigationContext: {
|
|
177
|
+
enumerable: true,
|
|
178
|
+
get() {
|
|
179
|
+
trackedState?.trackNavigationContext?.();
|
|
180
|
+
return routeState.navigationContext;
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return Object.freeze(state);
|
|
186
|
+
}
|
|
187
|
+
|
|
133
188
|
/**
|
|
134
189
|
* Returns serializable values supplied by configured server extensions for
|
|
135
190
|
* the active request. The object is isolated through AsyncLocalStorage and is
|
package/src/server-api.js
CHANGED
|
@@ -52,3 +52,10 @@ export { responseHeaders } from "./request-context.js";
|
|
|
52
52
|
|
|
53
53
|
/** Returns the active request's extension-provided serializable values. */
|
|
54
54
|
export { getRequestContext } from "./request-context.js";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns a read-only snapshot of the active SSR route. This is the server
|
|
58
|
+
* counterpart for reading navigation state; redirects remain separate
|
|
59
|
+
* control-flow functions and browser history methods are intentionally absent.
|
|
60
|
+
*/
|
|
61
|
+
export { getRouteState } from "./request-context.js";
|