@nifrajs/web 5.6.0 → 5.7.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/CHANGELOG.md +89 -0
- package/README.md +21 -11
- package/dist/build.d.ts +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +1 -0
- package/dist/build.js.map +1 -1
- package/dist/client.d.ts +1 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +39 -16
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/codegen.d.ts.map +1 -1
- package/dist/internal/codegen.js +8 -2
- package/dist/internal/codegen.js.map +1 -1
- package/dist/internal/identity-epoch.d.ts +2 -2
- package/dist/internal/identity-epoch.d.ts.map +1 -1
- package/dist/internal/identity-epoch.js +15 -14
- package/dist/internal/identity-epoch.js.map +1 -1
- package/dist/internal/live-contract.d.ts +5 -0
- package/dist/internal/live-contract.d.ts.map +1 -1
- package/dist/internal/live-contract.js.map +1 -1
- package/dist/internal/page-execution.d.ts +2 -0
- package/dist/internal/page-execution.d.ts.map +1 -1
- package/dist/internal/page-execution.js +22 -5
- package/dist/internal/page-execution.js.map +1 -1
- package/dist/internal/parity.d.ts +3 -0
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +28 -5
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/ppr/runtime.d.ts +1 -1
- package/dist/internal/ppr/runtime.d.ts.map +1 -1
- package/dist/internal/ppr/runtime.js +4 -1
- package/dist/internal/ppr/runtime.js.map +1 -1
- package/dist/internal/ppr/server.d.ts.map +1 -1
- package/dist/internal/ppr/server.js +4 -2
- package/dist/internal/ppr/server.js.map +1 -1
- package/dist/internal/ppr/shell-store.d.ts +1 -0
- package/dist/internal/ppr/shell-store.d.ts.map +1 -1
- package/dist/internal/ppr/shell-store.js +17 -1
- package/dist/internal/ppr/shell-store.js.map +1 -1
- package/dist/internal/present.d.ts +11 -0
- package/dist/internal/present.d.ts.map +1 -0
- package/dist/internal/present.js +13 -0
- package/dist/internal/present.js.map +1 -0
- package/dist/internal/render-document.d.ts +6 -63
- package/dist/internal/render-document.d.ts.map +1 -1
- package/dist/internal/render-document.js +3 -50
- package/dist/internal/render-document.js.map +1 -1
- package/dist/internal/status-page.d.ts +68 -0
- package/dist/internal/status-page.d.ts.map +1 -0
- package/dist/internal/status-page.js +66 -0
- package/dist/internal/status-page.js.map +1 -0
- package/dist/internal/web-app.d.ts +3 -2
- package/dist/internal/web-app.d.ts.map +1 -1
- package/dist/internal/web-app.js +1 -0
- package/dist/internal/web-app.js.map +1 -1
- package/dist/keep-alive.d.ts.map +1 -1
- package/dist/keep-alive.js +46 -13
- package/dist/keep-alive.js.map +1 -1
- package/dist/live/bus.d.ts +16 -2
- package/dist/live/bus.d.ts.map +1 -1
- package/dist/live/bus.js +23 -0
- package/dist/live/bus.js.map +1 -1
- package/dist/live/hub.d.ts +4 -0
- package/dist/live/hub.d.ts.map +1 -1
- package/dist/live/hub.js +5 -0
- package/dist/live/hub.js.map +1 -1
- package/dist/live/keyring.d.ts +5 -0
- package/dist/live/keyring.d.ts.map +1 -1
- package/dist/live/keyring.js +6 -1
- package/dist/live/keyring.js.map +1 -1
- package/dist/live-client.d.ts.map +1 -1
- package/dist/live-client.js +45 -7
- package/dist/live-client.js.map +1 -1
- package/dist/live-redis.d.ts +14 -2
- package/dist/live-redis.d.ts.map +1 -1
- package/dist/live-redis.js +16 -5
- package/dist/live-redis.js.map +1 -1
- package/dist/live.d.ts +57 -1
- package/dist/live.d.ts.map +1 -1
- package/dist/live.js +119 -16
- package/dist/live.js.map +1 -1
- package/dist/render-seam.d.ts +2 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +2 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +49 -37
- package/dist/router.js.map +1 -1
- package/dist/server-components/definition.d.ts.map +1 -1
- package/dist/server-components/definition.js +2 -1
- package/dist/server-components/definition.js.map +1 -1
- package/dist/server-components/runtime.d.ts.map +1 -1
- package/dist/server-components/runtime.js +2 -1
- package/dist/server-components/runtime.js.map +1 -1
- package/dist/server-components/vite.d.ts.map +1 -1
- package/dist/server-components/vite.js +26 -9
- package/dist/server-components/vite.js.map +1 -1
- package/package.json +3 -3
- package/src/build.ts +1 -0
- package/src/client.ts +42 -19
- package/src/index.ts +1 -4
- package/src/internal/codegen.ts +8 -1
- package/src/internal/identity-epoch.ts +20 -16
- package/src/internal/live-contract.ts +5 -0
- package/src/internal/page-execution.ts +29 -8
- package/src/internal/parity.ts +35 -6
- package/src/internal/ppr/runtime.ts +4 -1
- package/src/internal/ppr/server.ts +4 -2
- package/src/internal/ppr/shell-store.ts +14 -1
- package/src/internal/present.ts +20 -0
- package/src/internal/render-document.ts +10 -92
- package/src/internal/status-page.ts +95 -0
- package/src/internal/web-app.ts +4 -2
- package/src/keep-alive.ts +55 -16
- package/src/live/bus.ts +29 -2
- package/src/live/hub.ts +5 -0
- package/src/live/keyring.ts +6 -1
- package/src/live-client.ts +45 -7
- package/src/live-redis.ts +31 -6
- package/src/live.ts +178 -17
- package/src/render-seam.ts +2 -0
- package/src/router.ts +47 -38
- package/src/server-components/definition.ts +2 -1
- package/src/server-components/runtime.ts +2 -1
- package/src/server-components/vite.ts +33 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nifrajs/web",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.7.0",
|
|
4
4
|
"description": "Framework-agnostic SSR core for nifra - the render seam + HTML document orchestration.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -360,8 +360,8 @@
|
|
|
360
360
|
"access": "public"
|
|
361
361
|
},
|
|
362
362
|
"dependencies": {
|
|
363
|
-
"@nifrajs/core": "5.
|
|
364
|
-
"@nifrajs/island-trigger": "5.
|
|
363
|
+
"@nifrajs/core": "5.7.0",
|
|
364
|
+
"@nifrajs/island-trigger": "5.7.0",
|
|
365
365
|
"parse5": "8.0.1"
|
|
366
366
|
},
|
|
367
367
|
"peerDependencies": {
|
package/src/build.ts
CHANGED
|
@@ -1338,6 +1338,7 @@ export const SERVER_ENTRY_OPTIONS = [
|
|
|
1338
1338
|
"i18n",
|
|
1339
1339
|
"hostPolicy",
|
|
1340
1340
|
"live",
|
|
1341
|
+
"keepAlive",
|
|
1341
1342
|
] as const
|
|
1342
1343
|
export type ServerEntryOption = (typeof SERVER_ENTRY_OPTIONS)[number]
|
|
1343
1344
|
/** Each importable option mapped to the specifier of the module that exports it. */
|
package/src/client.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* a full page load). DOM-only - never imported on the server, so the store stays SSR-safe.
|
|
6
6
|
*/
|
|
7
7
|
import { trustedHeadAttributes } from "./internal/head-attributes.ts"
|
|
8
|
+
import { setPresenter } from "./internal/present.ts"
|
|
8
9
|
import { appUrl } from "./internal/route-path.ts"
|
|
9
10
|
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./internal/script-types.ts"
|
|
10
11
|
import type { Meta } from "./manifest.ts"
|
|
@@ -44,6 +45,9 @@ export { identityChanged } from "./identity.ts"
|
|
|
44
45
|
* before hydrating. The fix is for the entry not to name the server module in the first place.
|
|
45
46
|
*/
|
|
46
47
|
export { mergeHeads, resolveChainMeta, resolveMeta } from "./internal/head-merge.ts"
|
|
48
|
+
// For route helpers shared by a loader and a component, which the browser loads too: imported from the
|
|
49
|
+
// root, they put its `node:crypto` import into Vite dev's browser deps on every page.
|
|
50
|
+
export { gone, notFound, statusPage } from "./internal/status-page.ts"
|
|
47
51
|
export {
|
|
48
52
|
closeBoundary,
|
|
49
53
|
getBrowserNavigate,
|
|
@@ -211,6 +215,10 @@ export function waitForStyles(options: WaitForStylesOptions = {}): Promise<void>
|
|
|
211
215
|
return Promise.all(promises).then(() => undefined)
|
|
212
216
|
}
|
|
213
217
|
|
|
218
|
+
// How long a navigation's view transition waits for the frame that captures the page before its
|
|
219
|
+
// animation is skipped: several frames at any refresh rate, and short enough to go unnoticed.
|
|
220
|
+
const CAPTURE_DEADLINE_MS = 100
|
|
221
|
+
|
|
214
222
|
/**
|
|
215
223
|
* Resolve `href` against this page and keep it only when history may move there without a load: same
|
|
216
224
|
* scheme and host as the document, or null. Compared by protocol + host rather than `URL.origin`,
|
|
@@ -325,17 +333,45 @@ export function installHistory(
|
|
|
325
333
|
return document.getElementById(id)
|
|
326
334
|
}
|
|
327
335
|
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
//
|
|
336
|
+
// A navigation loads first and hands only its page swap here, so where the browser has View
|
|
337
|
+
// Transitions the page keeps painting, pending UI included, while the data loads. The browser runs
|
|
338
|
+
// the swap once a frame has captured the page; one drawing no frames while it reports itself visible
|
|
339
|
+
// (an occluded window) would hold it forever, so the deadline skips the animation, running the swap.
|
|
331
340
|
type ViewTransition = {
|
|
332
341
|
readonly ready: Promise<unknown>
|
|
333
342
|
readonly finished: Promise<unknown>
|
|
334
343
|
readonly updateCallbackDone: Promise<unknown>
|
|
344
|
+
skipTransition(): void
|
|
335
345
|
}
|
|
336
346
|
type ViewTransitionDocument = Document & {
|
|
337
347
|
startViewTransition?: (cb: () => unknown) => ViewTransition
|
|
338
348
|
}
|
|
349
|
+
setPresenter((swap) => {
|
|
350
|
+
const doc = document as ViewTransitionDocument
|
|
351
|
+
const start = doc.startViewTransition
|
|
352
|
+
if (typeof start !== "function") {
|
|
353
|
+
swap()
|
|
354
|
+
return Promise.resolve()
|
|
355
|
+
}
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
let swapped = false
|
|
358
|
+
const vt = start.call(doc, () => {
|
|
359
|
+
swapped = true
|
|
360
|
+
try {
|
|
361
|
+
swap()
|
|
362
|
+
resolve()
|
|
363
|
+
} catch (error) {
|
|
364
|
+
reject(error)
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
setTimeout(() => {
|
|
368
|
+
if (!swapped) vt.skipTransition()
|
|
369
|
+
}, CAPTURE_DEADLINE_MS)
|
|
370
|
+
// A newer navigation's transition, or the deadline, skips this one and rejects its promises -
|
|
371
|
+
// InvalidStateError or AbortError by engine. The swap still runs, so these are expected.
|
|
372
|
+
for (const p of [vt.ready, vt.finished, vt.updateCallbackDone]) p.catch(() => {})
|
|
373
|
+
})
|
|
374
|
+
})
|
|
339
375
|
// A navigation that loads takes the page from a partially prerendered first document, which then
|
|
340
376
|
// stops filling its holes and cancels the ones still loading on the server. `navigate` publishes
|
|
341
377
|
// its `pendingPath` before its first await; a client-only search change, or a boundary already
|
|
@@ -353,20 +389,6 @@ export function installHistory(
|
|
|
353
389
|
return run
|
|
354
390
|
}
|
|
355
391
|
|
|
356
|
-
const transition = (run: () => Promise<void>): void => {
|
|
357
|
-
const doc = document as ViewTransitionDocument
|
|
358
|
-
if (typeof doc.startViewTransition !== "function") {
|
|
359
|
-
void run()
|
|
360
|
-
return
|
|
361
|
-
}
|
|
362
|
-
const vt = doc.startViewTransition(run)
|
|
363
|
-
// Rapid in-app clicks supersede an in-flight transition; the browser skips it and rejects its
|
|
364
|
-
// promises. Reasons vary by engine - InvalidStateError "Transition was aborted because of
|
|
365
|
-
// invalid state", or AbortError "Transition was skipped". The navigation still completes, so
|
|
366
|
-
// we swallow these expected aborts rather than let them surface as unhandled rejections.
|
|
367
|
-
for (const p of [vt.ready, vt.finished, vt.updateCallbackDone]) p.catch(() => {})
|
|
368
|
-
}
|
|
369
|
-
|
|
370
392
|
// Parse a path into the `{ pathname, search, hash }` a `BlockerFunction` decides on.
|
|
371
393
|
const locationOf = (path: string): BlockerLocation => {
|
|
372
394
|
const url = new URL(path, location.href)
|
|
@@ -443,7 +465,7 @@ export function installHistory(
|
|
|
443
465
|
here = url.pathname + url.search + url.hash
|
|
444
466
|
expected = routePath
|
|
445
467
|
// The data layer fetches by path+search; the #hash is client-only (never sent to the server).
|
|
446
|
-
|
|
468
|
+
void navigateFrom(routePath, path)
|
|
447
469
|
settle()
|
|
448
470
|
}
|
|
449
471
|
|
|
@@ -600,7 +622,7 @@ export function installHistory(
|
|
|
600
622
|
pendingScroll = { pos: scrollOf(history.state) }
|
|
601
623
|
const routePath = location.pathname + location.search
|
|
602
624
|
expected = routePath
|
|
603
|
-
|
|
625
|
+
void navigateFrom(routePath, dest)
|
|
604
626
|
settle()
|
|
605
627
|
}
|
|
606
628
|
|
|
@@ -682,6 +704,7 @@ export function installHistory(
|
|
|
682
704
|
inView?.disconnect()
|
|
683
705
|
setBrowserNavigate(undefined) // stop routing `useNavigate` to a torn-down router
|
|
684
706
|
setBlockerController(undefined) // stop routing `useBlocker` to a torn-down registry
|
|
707
|
+
setPresenter(undefined)
|
|
685
708
|
document.removeEventListener("click", onClick)
|
|
686
709
|
document.removeEventListener("pointerover", onPrefetch)
|
|
687
710
|
document.removeEventListener("focusin", onPrefetch)
|
package/src/index.ts
CHANGED
|
@@ -107,8 +107,6 @@ export {
|
|
|
107
107
|
} from "./internal/identity-epoch.ts"
|
|
108
108
|
export type { PrerenderedPages } from "./internal/prerendered-pages.ts"
|
|
109
109
|
export {
|
|
110
|
-
gone,
|
|
111
|
-
notFound,
|
|
112
110
|
type RedirectOptions,
|
|
113
111
|
type RenderAssemblyCache,
|
|
114
112
|
type RenderedPage,
|
|
@@ -120,9 +118,7 @@ export {
|
|
|
120
118
|
renderPage,
|
|
121
119
|
renderPageResult,
|
|
122
120
|
revalidate,
|
|
123
|
-
type StatusPageOptions,
|
|
124
121
|
serializeData,
|
|
125
|
-
statusPage,
|
|
126
122
|
unsafeInlineScript,
|
|
127
123
|
} from "./internal/render-document.ts"
|
|
128
124
|
export {
|
|
@@ -137,6 +133,7 @@ export {
|
|
|
137
133
|
* stubbed by both build pipelines and waved through by the guard meant to stop it leaking.
|
|
138
134
|
*/
|
|
139
135
|
export { SERVER_FN_MODULE } from "./internal/server-boundary.ts"
|
|
136
|
+
export { gone, notFound, type StatusPageOptions, statusPage } from "./internal/status-page.ts"
|
|
140
137
|
export {
|
|
141
138
|
type CreateWebAppOptions,
|
|
142
139
|
createWebApp,
|
package/src/internal/codegen.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
DATA_GLOBAL,
|
|
11
11
|
HANDOVER_ID,
|
|
12
12
|
LAYOUT_DATA_GLOBAL,
|
|
13
|
+
LIVE_GLOBAL,
|
|
13
14
|
LOCALES_GLOBAL,
|
|
14
15
|
ROOT_ATTRIBUTE,
|
|
15
16
|
ROUTE_GLOBAL,
|
|
@@ -354,7 +355,11 @@ export function generateClientEntry(
|
|
|
354
355
|
"const handover = handovers[handovers.length - 1]",
|
|
355
356
|
"if (handover !== undefined) {",
|
|
356
357
|
' const state = JSON.parse(handover.textContent || "{}")',
|
|
357
|
-
` for (const key of ${jsValueLiteral(
|
|
358
|
+
` for (const key of ${jsValueLiteral([
|
|
359
|
+
...HANDOVER_GLOBALS,
|
|
360
|
+
...(options.copyChunks === true ? [COPY_CHUNKS_GLOBAL] : []),
|
|
361
|
+
...(live === undefined ? [] : [LIVE_GLOBAL]),
|
|
362
|
+
])}) {`,
|
|
358
363
|
" if (Object.prototype.hasOwnProperty.call(state, key)) Reflect.set(window, key, state[key])",
|
|
359
364
|
" }",
|
|
360
365
|
"}",
|
|
@@ -386,6 +391,8 @@ export function generateClientEntry(
|
|
|
386
391
|
` layoutData: window.${LAYOUT_DATA_GLOBAL} && window.${LAYOUT_DATA_GLOBAL}.map(mapDeferred),`,
|
|
387
392
|
` boundaries: mapDeferred(window.${BOUNDARY_GLOBAL}),`,
|
|
388
393
|
` serverComponents: window.${COMPONENT_GLOBAL},`,
|
|
394
|
+
// The seed the server rendered the page with: the live client exchanges it for its first grant.
|
|
395
|
+
...(live === undefined ? [] : [` live: window.${LIVE_GLOBAL},`]),
|
|
389
396
|
// actionData (only set after a form POST) is in the initial state so the binding hydrates
|
|
390
397
|
// consistently with the server-rendered markup; mapped through `mapDeferred` too so a deferred
|
|
391
398
|
// action's placeholders become registry markers (a no-op when the action didn't defer).
|
|
@@ -14,8 +14,8 @@ import { EPOCH_COOKIE, LOCAL_EPOCH_COOKIE } from "../identity.ts"
|
|
|
14
14
|
export interface KeepAliveServerOptions {
|
|
15
15
|
/**
|
|
16
16
|
* Cookies whose change means a different visitor, on top of the session cookies of
|
|
17
|
-
* `@nifrajs/auth
|
|
18
|
-
* here, or kept pages survive a sign-in or sign-out through it.
|
|
17
|
+
* `@nifrajs/auth` and Auth.js under their default names and Better Auth's under any `cookiePrefix`.
|
|
18
|
+
* Name a custom session cookie here, or kept pages survive a sign-in or sign-out through it.
|
|
19
19
|
*/
|
|
20
20
|
readonly identityCookies?: readonly string[]
|
|
21
21
|
/** `HttpOnly` cookies that say nothing about who is signed in; development stops asking about them. */
|
|
@@ -33,16 +33,9 @@ interface RequestView {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
// Session cookies by their default names, without a `__Secure-` / `__Host-` prefix.
|
|
36
|
-
const IDENTITY_DEFAULTS = [
|
|
37
|
-
"nifra_session",
|
|
38
|
-
"better-auth.session_token",
|
|
39
|
-
"authjs.session-token",
|
|
40
|
-
"next-auth.session-token",
|
|
41
|
-
]
|
|
36
|
+
const IDENTITY_DEFAULTS = ["nifra_session", "authjs.session-token", "next-auth.session-token"]
|
|
42
37
|
// `HttpOnly` cookies the same integrations write that carry no identity of their own.
|
|
43
38
|
const IGNORE_DEFAULTS = [
|
|
44
|
-
"better-auth.session_data",
|
|
45
|
-
"better-auth.dont_remember",
|
|
46
39
|
"authjs.csrf-token",
|
|
47
40
|
"authjs.callback-url",
|
|
48
41
|
"authjs.pkce.code_verifier",
|
|
@@ -56,6 +49,11 @@ const IGNORE_DEFAULTS = [
|
|
|
56
49
|
"__nifra_draft",
|
|
57
50
|
]
|
|
58
51
|
|
|
52
|
+
// Better Auth names its cookies `<advanced.cookiePrefix>.<name>`, "better-auth" unless the app sets
|
|
53
|
+
// one, so these count under any prefix.
|
|
54
|
+
const BETTER_AUTH_SESSION = ".session_token"
|
|
55
|
+
const BETTER_AUTH_IGNORED = [".session_data", ".dont_remember"]
|
|
56
|
+
|
|
59
57
|
// `__Secure-a.0` and `__Host-a` are the cookie `a`: a prefix, then Auth.js's chunk index.
|
|
60
58
|
const baseName = (name: string): string =>
|
|
61
59
|
name.replace(/^__(?:secure|host)-/i, "").replace(/\.\d+$/, "")
|
|
@@ -148,8 +146,14 @@ export function identityEpochHook(
|
|
|
148
146
|
options: KeepAliveServerOptions,
|
|
149
147
|
development: boolean,
|
|
150
148
|
): (headers: HeaderView, request: RequestView) => void {
|
|
151
|
-
const
|
|
152
|
-
const
|
|
149
|
+
const named = new Set([...IDENTITY_DEFAULTS, ...(options.identityCookies ?? []).map(baseName)])
|
|
150
|
+
const told = new Set((options.ignoreCookies ?? []).map(baseName))
|
|
151
|
+
const ignored = new Set([...IGNORE_DEFAULTS, ...told])
|
|
152
|
+
// An app's own `ignoreCookies` entry outranks the Better Auth suffix, never a name it listed.
|
|
153
|
+
const isIdentity = (base: string): boolean =>
|
|
154
|
+
named.has(base) || (base.endsWith(BETTER_AUTH_SESSION) && !told.has(base))
|
|
155
|
+
const isIgnored = (base: string): boolean =>
|
|
156
|
+
ignored.has(base) || BETTER_AUTH_IGNORED.some((suffix) => base.endsWith(suffix))
|
|
153
157
|
const reported = new Set<string>()
|
|
154
158
|
return (headers, request) => {
|
|
155
159
|
const joined = headers.get("set-cookie")
|
|
@@ -166,20 +170,20 @@ export function identityEpochHook(
|
|
|
166
170
|
renewed === undefined &&
|
|
167
171
|
!cookie.deleted &&
|
|
168
172
|
(cookie.maxAge !== undefined || cookie.expires !== undefined) &&
|
|
169
|
-
|
|
173
|
+
isIdentity(base)
|
|
170
174
|
)
|
|
171
175
|
renewed = cookie
|
|
172
176
|
continue
|
|
173
177
|
}
|
|
174
|
-
if (
|
|
178
|
+
if (isIdentity(base)) {
|
|
175
179
|
// The first identity cookie still set gives the lifetime; a sign-out deletes them all.
|
|
176
180
|
if (changed === undefined || (changed.deleted && !cookie.deleted)) changed = cookie
|
|
177
181
|
continue
|
|
178
182
|
}
|
|
179
|
-
if (!development || !cookie.httpOnly ||
|
|
183
|
+
if (!development || !cookie.httpOnly || isIgnored(base) || reported.has(base)) continue
|
|
180
184
|
reported.add(base)
|
|
181
185
|
console.warn(
|
|
182
|
-
`[nifra/web] NIFRA_KEEPALIVE_IDENTITY_COOKIE: this app keeps pages alive, and a response changed the HttpOnly cookie "${cookie.name}", which nifra does not know as a sign-in cookie. If it says who is signed in,
|
|
186
|
+
`[nifra/web] NIFRA_KEEPALIVE_IDENTITY_COOKIE: this app keeps pages alive, and a response changed the HttpOnly cookie "${cookie.name}", which nifra does not know as a sign-in cookie. If it says who is signed in, name it in keepAlive: { identityCookies: ["${base}"] } so a sign-in or sign-out drops the pages kept for the previous visitor; if it does not, in keepAlive.ignoreCookies. An app run by \`nifra dev\` and \`nifra build\` exports keepAlive from backend/framework.ts; a server you write passes it to createWebApp.`,
|
|
183
187
|
)
|
|
184
188
|
}
|
|
185
189
|
if (changed !== undefined) {
|
|
@@ -30,6 +30,11 @@ export interface LiveAttachment {
|
|
|
30
30
|
readonly onRequest: (req: Request) => Promise<Request | Response> | undefined
|
|
31
31
|
/** The live load a page request belongs to, or `undefined` for every request that is not one. */
|
|
32
32
|
readonly loadOf: (req: Request) => LiveLoad | undefined
|
|
33
|
+
/**
|
|
34
|
+
* The seed a server-rendered page of `routeId` collects its keys into, or `undefined` when none can
|
|
35
|
+
* be issued now (the runtime not started, the bus down, Workers). Never admitted: it is no live load.
|
|
36
|
+
*/
|
|
37
|
+
readonly seedOf: (req: Request, routeId: string) => LiveLoad | undefined
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/** One live load, from the page handler's side. */
|
|
@@ -71,23 +71,18 @@ import {
|
|
|
71
71
|
} from "./ppr/contract.ts"
|
|
72
72
|
import {
|
|
73
73
|
EMPTY_RETAIN,
|
|
74
|
-
type HeadersLike,
|
|
75
74
|
isControlFlow,
|
|
76
75
|
isResponseResult,
|
|
77
76
|
isRevalidateResult,
|
|
78
|
-
isStatusSignal,
|
|
79
77
|
type LoadedLayoutModules,
|
|
80
78
|
layoutErrorId,
|
|
81
|
-
notFound,
|
|
82
79
|
type RenderAssemblyCache,
|
|
83
80
|
type RenderedPage,
|
|
84
81
|
type RenderPageOptions,
|
|
85
82
|
type RevalidateResult,
|
|
86
83
|
redirect,
|
|
87
84
|
renderPageResult,
|
|
88
|
-
STATUS_SIGNAL,
|
|
89
85
|
STATUS_TEXT,
|
|
90
|
-
type StatusSignal,
|
|
91
86
|
scopeParams,
|
|
92
87
|
tagLayoutError,
|
|
93
88
|
withDuplicateInstanceHint,
|
|
@@ -105,6 +100,13 @@ import {
|
|
|
105
100
|
privateOutcome,
|
|
106
101
|
varyOnDataHeader,
|
|
107
102
|
} from "./response-controls.ts"
|
|
103
|
+
import {
|
|
104
|
+
type HeadersLike,
|
|
105
|
+
isStatusSignal,
|
|
106
|
+
notFound,
|
|
107
|
+
STATUS_SIGNAL,
|
|
108
|
+
type StatusSignal,
|
|
109
|
+
} from "./status-page.ts"
|
|
108
110
|
|
|
109
111
|
export type { NonceResolver } from "../nonce.ts"
|
|
110
112
|
|
|
@@ -409,6 +411,8 @@ export interface PageExecutionOptions<Env = unknown> {
|
|
|
409
411
|
) => Promise<void> | undefined
|
|
410
412
|
/** The live load a request belongs to; only set when the app configures `live`. */
|
|
411
413
|
readonly liveLoadOf?: ((request: Request) => LiveLoad | undefined) | undefined
|
|
414
|
+
/** The seed a rendered document collects its keys into; only set when the app configures `live`. */
|
|
415
|
+
readonly liveSeedOf?: ((request: Request, routeId: string) => LiveLoad | undefined) | undefined
|
|
412
416
|
readonly onLoaderError?: (
|
|
413
417
|
error: unknown,
|
|
414
418
|
ctx: {
|
|
@@ -544,6 +548,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
544
548
|
)
|
|
545
549
|
const routeById = new Map(manifest.routes.map((route) => [route.id, route]))
|
|
546
550
|
const liveLoadOf = options.liveLoadOf
|
|
551
|
+
const liveSeedOf = options.liveSeedOf
|
|
547
552
|
|
|
548
553
|
/** A live load holds one admission slot from before its middleware until its body is complete. */
|
|
549
554
|
const admitLive =
|
|
@@ -1345,6 +1350,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1345
1350
|
personalized: boolean,
|
|
1346
1351
|
serverComponents?: import("../server-components/types.ts").ComponentState,
|
|
1347
1352
|
live?: LiveEnvelope | Deferred<LiveEnvelope>,
|
|
1353
|
+
liveSeed?: LiveEnvelope,
|
|
1348
1354
|
): Promise<PageResponse> => {
|
|
1349
1355
|
// Every layout loader settled before the page rendered, so a status or error page can reuse them.
|
|
1350
1356
|
const settled: LayoutChainSource = async (_page, count) =>
|
|
@@ -1436,6 +1442,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1436
1442
|
...localeOption(req),
|
|
1437
1443
|
}
|
|
1438
1444
|
if (serverComponents !== undefined) page.serverComponents = serverComponents
|
|
1445
|
+
if (liveSeed?.grant !== undefined) page.live = liveSeed
|
|
1439
1446
|
if (progressiveActionData.has(req)) page.actionData = progressiveActionData.get(req)
|
|
1440
1447
|
const swapSlot = swapSlotOf(req)
|
|
1441
1448
|
if (swapSlot !== undefined) page.swapSlot = swapSlot
|
|
@@ -1796,7 +1803,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1796
1803
|
c: PageRouteContext<Env>,
|
|
1797
1804
|
controls: PageResponseControls,
|
|
1798
1805
|
): Promise<PageResponse> => {
|
|
1799
|
-
|
|
1806
|
+
let live = liveLoadOf?.(c.req)
|
|
1800
1807
|
const componentRuntime = options.serverComponents
|
|
1801
1808
|
const componentOwner = componentRuntime?.owns(route.id) === true
|
|
1802
1809
|
let componentRequest:
|
|
@@ -1813,6 +1820,17 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
1813
1820
|
}
|
|
1814
1821
|
const loaded = loadModule(route)
|
|
1815
1822
|
const mod = cacheModules && !(loaded instanceof Promise) ? loaded : await loaded
|
|
1823
|
+
// A document's loaders declare their keys into a seed, which the page exchanges for a grant
|
|
1824
|
+
// once its socket opens, instead of loading itself again. Partially prerendered pages, whose
|
|
1825
|
+
// holes settle after the shell, and server-component routes load as before.
|
|
1826
|
+
const seeding =
|
|
1827
|
+
live === undefined &&
|
|
1828
|
+
liveSeedOf !== undefined &&
|
|
1829
|
+
!componentOwner &&
|
|
1830
|
+
componentRequest === undefined &&
|
|
1831
|
+
(options.ppr === undefined || mod.shellLoader === undefined) &&
|
|
1832
|
+
!isDataRequest(c)
|
|
1833
|
+
if (seeding) live = liveSeedOf(c.req, route.id)
|
|
1816
1834
|
const draft = await draftFlag(c.req)
|
|
1817
1835
|
// Unpublished content is the editor's alone: whatever cache-control a loader sets, no cache keeps it.
|
|
1818
1836
|
if (draft) controls.personalize()
|
|
@@ -2094,7 +2112,7 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
2094
2112
|
}
|
|
2095
2113
|
const isData = isDataRequest(c)
|
|
2096
2114
|
const liveEnvelope =
|
|
2097
|
-
live === undefined || !isData || isStatusSignal(data) || isControlFlow(data)
|
|
2115
|
+
live === undefined || (!isData && !seeding) || isStatusSignal(data) || isControlFlow(data)
|
|
2098
2116
|
? undefined
|
|
2099
2117
|
: await liveEnvelopeOf(
|
|
2100
2118
|
live,
|
|
@@ -2125,7 +2143,10 @@ export function createPageRequestExecutor<Env = unknown>(
|
|
|
2125
2143
|
responseHeaders,
|
|
2126
2144
|
controls.personalized,
|
|
2127
2145
|
componentState,
|
|
2128
|
-
liveEnvelope,
|
|
2146
|
+
seeding ? undefined : liveEnvelope,
|
|
2147
|
+
seeding && liveEnvelope !== undefined && !("__nifra_deferred" in liveEnvelope)
|
|
2148
|
+
? liveEnvelope
|
|
2149
|
+
: undefined,
|
|
2129
2150
|
)
|
|
2130
2151
|
}
|
|
2131
2152
|
return withResponseControls(liveLoadOf === undefined ? page : admitLive(page))
|
package/src/internal/parity.ts
CHANGED
|
@@ -99,6 +99,9 @@ export interface IdentityParityFinding {
|
|
|
99
99
|
* before anything loads. Reported, never fatal - see `SingleCopyCoverage`.
|
|
100
100
|
*/
|
|
101
101
|
readonly deduplicated: boolean
|
|
102
|
+
/** Unbundled commands that still load both copies of a deduplicated package, because bunfig.toml
|
|
103
|
+
* does not preload the registrar for them. Absent when both are covered. */
|
|
104
|
+
readonly unenforced?: readonly ("bun run" | "bun test")[]
|
|
102
105
|
}
|
|
103
106
|
|
|
104
107
|
/**
|
|
@@ -743,11 +746,34 @@ const describeProvenance = (copies: readonly IdentityParityCopy[]): string | und
|
|
|
743
746
|
return `reached through ${one ? "a symlink" : "symlinks"} that ${one ? "points" : "point"} outside the importer's install: ${lines.join(", ")}. Unless the importer declares ${one ? "it" : "them"} as a \`link:\` dependency, ${one ? "the link was" : "the links were"} planted by hand, by another project, or by \`bun link\` - remove ${one ? "it" : "them"} and reinstall there, so the importer resolves its own install's copy.`
|
|
744
747
|
}
|
|
745
748
|
|
|
746
|
-
/**
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
749
|
+
/**
|
|
750
|
+
* What is left to do about a duplicate the declaration already covers. Bun reads the top-level
|
|
751
|
+
* `preload` for `bun run` (and the nifra CLI, which runs under the app's bunfig) and `[test] preload`
|
|
752
|
+
* for `bun test`, never one for the other, so each half is reported on its own.
|
|
753
|
+
*/
|
|
754
|
+
const deduplicatedRemediation = (registration: SingleCopyRegistration): string => {
|
|
755
|
+
if (registration.run && registration.test)
|
|
756
|
+
return "Declared single-copy and enforced: every duplicate is rewritten to this app's copy, so one module instance loads."
|
|
757
|
+
const preload = `preload = ["${SINGLE_COPY_REGISTER_SPECIFIER}"]`
|
|
758
|
+
const ignored =
|
|
759
|
+
registration.ignored === undefined
|
|
760
|
+
? ""
|
|
761
|
+
: ` The ${preload} under [${registration.ignored.join("], [")}] belongs to that table, so Bun never runs it.`
|
|
762
|
+
if (registration.test)
|
|
763
|
+
return `Declared single-copy and enforced under \`bun test\` only: \`bun run\`, \`nifra dev\` and the CLI's app imports still load both copies until bunfig.toml carries ${preload} at the top level, above the first [table].${ignored}`
|
|
764
|
+
if (registration.run)
|
|
765
|
+
return `Declared single-copy and enforced under \`bun run\` only: \`bun test\` still loads both copies until bunfig.toml carries ${preload} under [test].`
|
|
766
|
+
return `Declared single-copy, so bundled output is deduplicated by the build. Unbundled runs are NOT: Bun's runtime never offers a bare specifier to a resolver, so add ${preload} to bunfig.toml above the first [table], and again under [test], or \`bun run\` and \`bun test\` still load both copies.${ignored}`
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const unenforcedCommands = (
|
|
770
|
+
registration: SingleCopyRegistration,
|
|
771
|
+
): readonly ("bun run" | "bun test")[] => {
|
|
772
|
+
const commands: ("bun run" | "bun test")[] = []
|
|
773
|
+
if (!registration.run) commands.push("bun run")
|
|
774
|
+
if (!registration.test) commands.push("bun test")
|
|
775
|
+
return commands
|
|
776
|
+
}
|
|
751
777
|
|
|
752
778
|
/**
|
|
753
779
|
* Read the declaration and its runtime proof, preferring the directory the caller is in.
|
|
@@ -861,6 +887,7 @@ export async function collectIdentityParity(
|
|
|
861
887
|
// so the skew stays fatal and the ranges have to be fixed.
|
|
862
888
|
const covered =
|
|
863
889
|
cause === "duplicate-path" && matchesSingleCopyDeclaration(singleCopy.declared, name)
|
|
890
|
+
const unenforced = covered ? unenforcedCommands(singleCopy.registration) : []
|
|
864
891
|
const finding: IdentityParityFinding = {
|
|
865
892
|
package: name,
|
|
866
893
|
copies: resolvedCopies,
|
|
@@ -880,7 +907,9 @@ export async function collectIdentityParity(
|
|
|
880
907
|
...(provenance === undefined ? {} : { provenance }),
|
|
881
908
|
deduplicated: covered,
|
|
882
909
|
}
|
|
883
|
-
;(covered ? deduplicated : findings).push(
|
|
910
|
+
;(covered ? deduplicated : findings).push(
|
|
911
|
+
unenforced.length === 0 ? finding : { ...finding, unenforced },
|
|
912
|
+
)
|
|
884
913
|
}
|
|
885
914
|
return {
|
|
886
915
|
workspaceRoot: scanRoot,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hashSource } from "../../csp.ts"
|
|
2
|
+
import { REVIVE_DATES_SOURCE } from "../revive-dates.ts"
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The PPR client runtime: one inline script in the shell, constant per release, listed in the CSP by
|
|
@@ -14,7 +15,9 @@ import { hashSource } from "../../csp.ts"
|
|
|
14
15
|
* HTML before framing it; the policy only names the one sink.
|
|
15
16
|
*/
|
|
16
17
|
export const PPR_RUNTIME =
|
|
17
|
-
|
|
18
|
+
"(()=>{" +
|
|
19
|
+
REVIVE_DATES_SOURCE +
|
|
20
|
+
';var w=window,d=document,h={},s={},dead=0,p;var pol=function(){if(!p){try{p=w.trustedTypes?w.trustedTypes.createPolicy("nifra-ppr",{createHTML:function(x){return x}}):null}catch(_){}p=p||{createHTML:function(x){return x}}}return p};var ap=function(t,n,v){t.innerHTML=pol().createHTML(typeof v.html==="string"?v.html:"");t.setAttribute("data-nifra-filled","");t.setAttribute("aria-busy","false");if(v.error)t.setAttribute("data-nifra-hole-error","");else t.removeAttribute("data-nifra-hole-error");var o={props:__nifraDates(v.props),error:!!v.error};if(v.component){o.component=v.component;o.html=v.html}h[n]=o;return o};var f=function(n){if(dead)return;var e=d.getElementById("nifra-ppr:"+n);if(!e)return;var v;try{v=JSON.parse(e.textContent)}catch(_){return}e.remove();var t=d.querySelector(\'[data-nifra-hole="\'+n+\'"]\');if(!t||t.hasAttribute("data-nifra-filled"))return;var o=ap(t,n,v);(s[n]||[]).slice().forEach(function(c){c(o)})};f.put=function(n,v,t){if(v&&typeof v==="object"&&t&&t.getAttribute&&t.getAttribute("data-nifra-hole")===n)ap(t,n,v)};f.get=function(n){return h[n]};f.subscribe=function(n,c){(s[n]||(s[n]=[])).push(c);return function(){s[n]=(s[n]||[]).filter(function(x){return x!==c})}};f.leave=function(){if(dead)return;dead=1;s={};var r=d.getElementById("nifra-ppr-req");if(!r)return;var q;try{q=JSON.parse(r.textContent)}catch(_){return}if(q.cancel&&q.holes.some(function(n){return!h[n]})&&navigator.sendBeacon)navigator.sendBeacon(q.cancel,q.token)};f.dead=function(){return!!dead};f.boot=function(){var c=d.currentScript,e=c&&c.previousElementSibling;if(e&&e.rel==="modulepreload"&&e.href)import(e.href)};w.__nifraPpr=f})()'
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* What loads each module script of a shell, after a `<link rel="modulepreload">` naming it. WebKit
|
|
@@ -10,7 +10,8 @@ import { type CspPolicy, cspHeaderWithSources, prepareCspPolicy } from "../../cs
|
|
|
10
10
|
import { defer } from "../../deferred.ts"
|
|
11
11
|
import type { RenderAdapter } from "../../render-seam.ts"
|
|
12
12
|
import { HOLE_ERROR_HTML } from "../hole-runtime.ts"
|
|
13
|
-
import { isControlFlow
|
|
13
|
+
import { isControlFlow } from "../render-document.ts"
|
|
14
|
+
import { isStatusSignal, notFound } from "../status-page.ts"
|
|
14
15
|
import { HoleAdmission, type HoleRun, type HoleSlot, watchAbort } from "./admission.ts"
|
|
15
16
|
import type { ShellCheckRecord } from "./compile-shell.ts"
|
|
16
17
|
import {
|
|
@@ -178,8 +179,9 @@ const pendingStates = (holes: readonly HoleRegistration[]): BoundaryStates => {
|
|
|
178
179
|
const shellKey = (deployment: string, request: PprRequest): string =>
|
|
179
180
|
`ppr:v1|${deployment}|${request.routeId}|${request.locale ?? ""}|${request.path}|${variationQuery(request.variations)}`
|
|
180
181
|
|
|
182
|
+
// No `?.`: a bundler's NODE_ENV define replaces only the plain member read, and this runs per request.
|
|
181
183
|
const isProduction = (): boolean =>
|
|
182
|
-
typeof process !== "undefined" && process.env
|
|
184
|
+
typeof process !== "undefined" && process.env.NODE_ENV === "production"
|
|
183
185
|
|
|
184
186
|
const concat = (chunks: readonly Uint8Array[]): Uint8Array => {
|
|
185
187
|
let length = 0
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* fails the check is a miss.
|
|
6
6
|
*/
|
|
7
7
|
import type { CachedResponse, CacheStore } from "../../isr.ts"
|
|
8
|
+
import { DATE_TAG } from "../revive-dates.ts"
|
|
8
9
|
import { storeSigner } from "../store-signature.ts"
|
|
10
|
+
import { wireDate } from "../wire-date.ts"
|
|
9
11
|
|
|
10
12
|
/** A shell or a shared hole's frame as the shared store holds it: everything a process needs to
|
|
11
13
|
* serve it. */
|
|
@@ -61,6 +63,7 @@ const isStoredEntry = (value: unknown): value is StoredEntry => {
|
|
|
61
63
|
)
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** Read and write signed deployment-scoped PPR entries, treating malformed data as cache misses. */
|
|
64
67
|
export function sharedShellStore(
|
|
65
68
|
store: CacheStore,
|
|
66
69
|
secret: string,
|
|
@@ -81,7 +84,17 @@ export function sharedShellStore(
|
|
|
81
84
|
}
|
|
82
85
|
let payload: unknown
|
|
83
86
|
try {
|
|
84
|
-
|
|
87
|
+
// Match page-data revival, but keep the server's tagged toJSON for subsequent responses.
|
|
88
|
+
// JSON.parse defines own properties (including __proto__) without invoking setters.
|
|
89
|
+
payload = JSON.parse(cached.body, (_key, value: unknown) => {
|
|
90
|
+
if (typeof value !== "object" || value === null) return value
|
|
91
|
+
const tagged: unknown = Reflect.get(value, DATE_TAG)
|
|
92
|
+
if (typeof tagged !== "string" || Object.keys(value).length !== 1) return value
|
|
93
|
+
const time = Date.parse(tagged)
|
|
94
|
+
// An invalid wireDate would throw later from toJSON, outside this cache-miss boundary.
|
|
95
|
+
if (!Number.isFinite(time)) throw new RangeError("invalid cached date tag")
|
|
96
|
+
return wireDate(time)
|
|
97
|
+
})
|
|
85
98
|
} catch {
|
|
86
99
|
return undefined
|
|
87
100
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a client navigation's page swap reaches the screen. The router loads a page first and hands only
|
|
3
|
+
* the swap here, so the history layer can wrap the swap alone in a view transition rather than the
|
|
4
|
+
* whole load. Module-scoped: one app per page; unset on the server and without `installHistory`.
|
|
5
|
+
*/
|
|
6
|
+
export type Presenter = (swap: () => void) => Promise<void>
|
|
7
|
+
|
|
8
|
+
let presenter: Presenter | undefined
|
|
9
|
+
|
|
10
|
+
/** Set (or clear, with `undefined`) the presenter - called by `installHistory`. */
|
|
11
|
+
export function setPresenter(next: Presenter | undefined): void {
|
|
12
|
+
presenter = next
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Run `swap` through the presenter, or at once when none is set. */
|
|
16
|
+
export function present(swap: () => void): Promise<void> | undefined {
|
|
17
|
+
if (presenter !== undefined) return presenter(swap)
|
|
18
|
+
swap()
|
|
19
|
+
return undefined
|
|
20
|
+
}
|