@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
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
DATA_GLOBAL,
|
|
35
35
|
HANDOVER_ID,
|
|
36
36
|
LAYOUT_DATA_GLOBAL,
|
|
37
|
+
LIVE_GLOBAL,
|
|
37
38
|
LOCALES_GLOBAL,
|
|
38
39
|
ROOT_ATTRIBUTE,
|
|
39
40
|
ROUTE_GLOBAL,
|
|
@@ -45,6 +46,7 @@ import { isStaticMeta, mergeHeads } from "./head-merge.ts"
|
|
|
45
46
|
import { reportDeferredRejection } from "./request-abort.ts"
|
|
46
47
|
import { PRE_HYDRATION_GUARD } from "./runtime-contract.ts"
|
|
47
48
|
import { EXECUTABLE_SCRIPT_TYPES, INERT_SCRIPT_TYPES } from "./script-types.ts"
|
|
49
|
+
import type { HeadersLike } from "./status-page.ts"
|
|
48
50
|
|
|
49
51
|
const TEXT_ENCODER = new TextEncoder()
|
|
50
52
|
|
|
@@ -108,6 +110,11 @@ export interface RenderPageOptions {
|
|
|
108
110
|
/** An action's data return (POST only) - surfaced to the page as `actionData` + serialized
|
|
109
111
|
* for the client so post-POST hydration matches. Omit on GETs. */
|
|
110
112
|
readonly actionData?: unknown
|
|
113
|
+
/**
|
|
114
|
+
* A live runtime's seed for this page (`createWebApp({ live })` sets it): handed to the client
|
|
115
|
+
* entry, which exchanges it for a grant instead of loading the page again.
|
|
116
|
+
*/
|
|
117
|
+
readonly live?: unknown
|
|
111
118
|
/**
|
|
112
119
|
* URL of the built client entry (loaded as a module script).
|
|
113
120
|
*
|
|
@@ -416,6 +423,8 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
416
423
|
options.serverComponents === undefined
|
|
417
424
|
? ""
|
|
418
425
|
: `,"__NIFRA_COMPONENTS__":${serializeData(options.serverComponents)}`
|
|
426
|
+
const liveTail =
|
|
427
|
+
options.live === undefined ? "" : `,"${LIVE_GLOBAL}":${serializeData(options.live)}`
|
|
419
428
|
const deferredRuntime =
|
|
420
429
|
allDeferred.length > 0 ? `<script${nonceAttr}>${DEFERRED_RUNTIME}</script>` : ""
|
|
421
430
|
// The regex only injects the CSP nonce into the (constant) hydration head; with no nonce it's a
|
|
@@ -528,7 +537,7 @@ export function renderPageResult(options: RenderPageInput): MaybePromise<Rendere
|
|
|
528
537
|
// Tail - the page-state handover + the client module. Module scripts defer (run after parse), so
|
|
529
538
|
// the handover + every streamed deferred resolution are in the document before the entry hydrates.
|
|
530
539
|
const tailHtml = hydrate
|
|
531
|
-
? `${slot.tailPre}${serializeData(forClient)}${action}${layoutTail}${boundaryTail}${componentTail}${slot.tailPost}`
|
|
540
|
+
? `${slot.tailPre}${serializeData(forClient)}${action}${layoutTail}${boundaryTail}${componentTail}${liveTail}${slot.tailPost}`
|
|
532
541
|
: (slot.tailPost as string)
|
|
533
542
|
const headers: Record<string, string> = { "content-type": "text/html; charset=utf-8" }
|
|
534
543
|
if (locale !== undefined) headers["content-language"] = lang
|
|
@@ -941,24 +950,6 @@ export function redirect(location: string, options: RedirectOptions = {}): Respo
|
|
|
941
950
|
})
|
|
942
951
|
}
|
|
943
952
|
|
|
944
|
-
/**
|
|
945
|
-
* Brand marking a `Response` as a **terminal-status signal** rather than a response to serve verbatim.
|
|
946
|
-
*
|
|
947
|
-
* A loader that throws a bare `Response` has always been passed straight through - that is how
|
|
948
|
-
* `throw redirect(...)` works, and apps rely on it. So "render the `_404` boundary at 404" cannot be
|
|
949
|
-
* expressed by throwing a plain `Response`: the framework would have no way to tell it apart from a
|
|
950
|
-
* hand-rolled body the app wants served exactly as written. The brand is that distinction, and
|
|
951
|
-
* checking it *before* the pass-through is what keeps every existing throw working unchanged.
|
|
952
|
-
*
|
|
953
|
-
* `Symbol.for` matches `RESPONSE_RESULT` above: a registry symbol survives two copies of this module,
|
|
954
|
-
* which a `unique symbol` would not - and a duplicate-install turning a 404 into a raw 404 body would
|
|
955
|
-
* be a maddening bug to trace.
|
|
956
|
-
*/
|
|
957
|
-
/** Derived from the `Headers` constructor rather than the DOM lib's `HeadersInit`: `@nifrajs/web`
|
|
958
|
-
* is consumed from DOM-free programs (the root tsconfig runs `types: ["bun"]`), and naming the DOM
|
|
959
|
-
* type here would break their build. Same workaround as `SSEInit.headers` in core. */
|
|
960
|
-
export type HeadersLike = ConstructorParameters<typeof Headers>[0]
|
|
961
|
-
|
|
962
953
|
/**
|
|
963
954
|
* The signatures a **duplicate module instance** produces during SSR, across engines.
|
|
964
955
|
*
|
|
@@ -1061,8 +1052,6 @@ export const layoutErrorId = (err: unknown): string | undefined =>
|
|
|
1061
1052
|
? ((err as Record<symbol, unknown>)[LAYOUT_ERROR_ID] as string | undefined)
|
|
1062
1053
|
: undefined
|
|
1063
1054
|
|
|
1064
|
-
export const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
|
|
1065
|
-
|
|
1066
1055
|
/** Reason phrases for the plain-text fallback, used only when the app authored no `_<status>` and no
|
|
1067
1056
|
* `_404` page. Deliberately not exhaustive - anything unlisted falls back to "Error", which is more
|
|
1068
1057
|
* useful than shipping a table of every RFC status for a body almost nobody will see. */
|
|
@@ -1077,77 +1066,6 @@ export const STATUS_TEXT: Readonly<Record<number, string>> = {
|
|
|
1077
1066
|
500: "Internal Server Error",
|
|
1078
1067
|
}
|
|
1079
1068
|
|
|
1080
|
-
export interface StatusSignal extends Response {
|
|
1081
|
-
readonly [STATUS_SIGNAL]: { readonly status: number; readonly headers?: HeadersLike }
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
export function isStatusSignal(value: unknown): value is StatusSignal {
|
|
1085
|
-
return value instanceof Response && STATUS_SIGNAL in value
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
function statusSignal(status: number, options: StatusPageOptions): never {
|
|
1089
|
-
if (!Number.isInteger(status) || status < 400 || status > 599) {
|
|
1090
|
-
throw new Error(
|
|
1091
|
-
`[nifra/web] statusPage(${JSON.stringify(status)}) must be an integer 4xx or 5xx status. Use redirect() for 3xx, and return data for a successful render.`,
|
|
1092
|
-
)
|
|
1093
|
-
}
|
|
1094
|
-
const response = new Response(null, { status })
|
|
1095
|
-
// Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
|
|
1096
|
-
Object.defineProperty(response, STATUS_SIGNAL, {
|
|
1097
|
-
value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
|
|
1098
|
-
enumerable: false,
|
|
1099
|
-
})
|
|
1100
|
-
throw response
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
/** Options shared by {@link notFound}, {@link gone}, and {@link statusPage}. */
|
|
1104
|
-
export interface StatusPageOptions {
|
|
1105
|
-
/**
|
|
1106
|
-
* Extra headers for the rendered response - `cache-control` above all.
|
|
1107
|
-
*
|
|
1108
|
-
* The defaults differ by status for a reason: a 404 may be a race with publication, so it wants a
|
|
1109
|
-
* short TTL, while a 410 is a promise that the URL is permanently gone and can be cached hard.
|
|
1110
|
-
* Getting this wrong is the difference between a crawler dropping a dead URL and re-fetching it
|
|
1111
|
-
* for weeks.
|
|
1112
|
-
*/
|
|
1113
|
-
readonly headers?: HeadersLike
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
/**
|
|
1117
|
-
* Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
|
|
1118
|
-
* exist.
|
|
1119
|
-
*
|
|
1120
|
-
* This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
|
|
1121
|
-
* answer 404, so the path of least resistance is to return empty data and render "not found" inside a
|
|
1122
|
-
* **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
|
|
1123
|
-
* shipped - while search engines penalise it and keep the dead URL indexed.
|
|
1124
|
-
*
|
|
1125
|
-
* Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
|
|
1126
|
-
* the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
|
|
1127
|
-
*/
|
|
1128
|
-
export function notFound(options: StatusPageOptions = {}): never {
|
|
1129
|
-
return statusSignal(404, options)
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
/**
|
|
1133
|
-
* Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
|
|
1134
|
-
* was deliberately removed - a withdrawn listing, a deleted post.
|
|
1135
|
-
*
|
|
1136
|
-
* 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
|
|
1137
|
-
* on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
|
|
1138
|
-
*/
|
|
1139
|
-
export function gone(options: StatusPageOptions = {}): never {
|
|
1140
|
-
return statusSignal(410, options)
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
/**
|
|
1144
|
-
* Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
|
|
1145
|
-
* {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
|
|
1146
|
-
*/
|
|
1147
|
-
export function statusPage(status: number, options: StatusPageOptions = {}): never {
|
|
1148
|
-
return statusSignal(status, options)
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
1069
|
/** The wrapper `revalidate()` returns: the action's `data` plus the paths it changed. A plain tagged
|
|
1152
1070
|
* shape (not a class) so `@nifrajs/client`'s `ActionData` can unwrap it structurally without importing
|
|
1153
1071
|
* from `@nifrajs/web`. `createWebApp` strips the wrapper - the client receives `data` as the body and
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal-status helpers and their brand, free of server imports: `@nifrajs/web/client` re-exports
|
|
3
|
+
* them for route helpers the browser also loads, which the root entry would drag `node:crypto` into.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Derived from the `Headers` constructor rather than the DOM lib's `HeadersInit`: `@nifrajs/web`
|
|
7
|
+
* is consumed from DOM-free programs (the root tsconfig runs `types: ["bun"]`), and naming the DOM
|
|
8
|
+
* type here would break their build. Same workaround as `SSEInit.headers` in core. */
|
|
9
|
+
export type HeadersLike = ConstructorParameters<typeof Headers>[0]
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Brand marking a `Response` as a **terminal-status signal** rather than a response to serve verbatim.
|
|
13
|
+
*
|
|
14
|
+
* A loader that throws a bare `Response` has always been passed straight through - that is how
|
|
15
|
+
* `throw redirect(...)` works, and apps rely on it. So "render the `_404` boundary at 404" cannot be
|
|
16
|
+
* expressed by throwing a plain `Response`: the framework would have no way to tell it apart from a
|
|
17
|
+
* hand-rolled body the app wants served exactly as written. The brand is that distinction, and
|
|
18
|
+
* checking it *before* the pass-through is what keeps every existing throw working unchanged.
|
|
19
|
+
*
|
|
20
|
+
* `Symbol.for` matches core's `RESPONSE_RESULT`: a registry symbol survives two copies of this module,
|
|
21
|
+
* which a `unique symbol` would not - and a duplicate-install turning a 404 into a raw 404 body would
|
|
22
|
+
* be a maddening bug to trace.
|
|
23
|
+
*/
|
|
24
|
+
export const STATUS_SIGNAL = Symbol.for("nifra.web.status-signal")
|
|
25
|
+
|
|
26
|
+
export interface StatusSignal extends Response {
|
|
27
|
+
readonly [STATUS_SIGNAL]: { readonly status: number; readonly headers?: HeadersLike }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isStatusSignal(value: unknown): value is StatusSignal {
|
|
31
|
+
return value instanceof Response && STATUS_SIGNAL in value
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function statusSignal(status: number, options: StatusPageOptions): never {
|
|
35
|
+
if (!Number.isInteger(status) || status < 400 || status > 599) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
`[nifra/web] statusPage(${JSON.stringify(status)}) must be an integer 4xx or 5xx status. Use redirect() for 3xx, and return data for a successful render.`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
const response = new Response(null, { status })
|
|
41
|
+
// Non-enumerable so the brand never lands in a structured clone or a JSON log of the response.
|
|
42
|
+
Object.defineProperty(response, STATUS_SIGNAL, {
|
|
43
|
+
value: { status, ...(options.headers !== undefined ? { headers: options.headers } : {}) },
|
|
44
|
+
enumerable: false,
|
|
45
|
+
})
|
|
46
|
+
throw response
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Options shared by {@link notFound}, {@link gone}, and {@link statusPage}. */
|
|
50
|
+
export interface StatusPageOptions {
|
|
51
|
+
/**
|
|
52
|
+
* Extra headers for the rendered response - `cache-control` above all.
|
|
53
|
+
*
|
|
54
|
+
* The defaults differ by status for a reason: a 404 may be a race with publication, so it wants a
|
|
55
|
+
* short TTL, while a 410 is a promise that the URL is permanently gone and can be cached hard.
|
|
56
|
+
* Getting this wrong is the difference between a crawler dropping a dead URL and re-fetching it
|
|
57
|
+
* for weeks.
|
|
58
|
+
*/
|
|
59
|
+
readonly headers?: HeadersLike
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render the nearest `_404` page at status **404**. `throw` it from a loader when the record does not
|
|
64
|
+
* exist.
|
|
65
|
+
*
|
|
66
|
+
* This is the fix for the soft 404: a matched route whose loader finds nothing has otherwise no way to
|
|
67
|
+
* answer 404, so the path of least resistance is to return empty data and render "not found" inside a
|
|
68
|
+
* **200**. That looks correct in a browser and is invisible in review, which is why it ships and stays
|
|
69
|
+
* shipped - while search engines penalise it and keep the dead URL indexed.
|
|
70
|
+
*
|
|
71
|
+
* Returns `never`: these throw, so a loader narrows without a redundant `return`, and the type states
|
|
72
|
+
* the thing the "loaders `throw` redirect, actions `return` it" rule already trips people on.
|
|
73
|
+
*/
|
|
74
|
+
export function notFound(options: StatusPageOptions = {}): never {
|
|
75
|
+
return statusSignal(404, options)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Render a terminal page at status **410 Gone**. `throw` it from a loader for a record that existed and
|
|
80
|
+
* was deliberately removed - a withdrawn listing, a deleted post.
|
|
81
|
+
*
|
|
82
|
+
* 410 is not a pedantic 404: it tells a crawler to **drop** the URL rather than re-fetch it for weeks
|
|
83
|
+
* on the assumption the 404 was transient. Uses `_410.tsx` if the app has one, otherwise `_404`.
|
|
84
|
+
*/
|
|
85
|
+
export function gone(options: StatusPageOptions = {}): never {
|
|
86
|
+
return statusSignal(410, options)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render a terminal page at any 4xx/5xx status - the escape hatch behind {@link notFound} and
|
|
91
|
+
* {@link gone} (402, 451, …). Uses `_<status>.tsx` if present, otherwise `_404`.
|
|
92
|
+
*/
|
|
93
|
+
export function statusPage(status: number, options: StatusPageOptions = {}): never {
|
|
94
|
+
return statusSignal(status, options)
|
|
95
|
+
}
|
package/src/internal/web-app.ts
CHANGED
|
@@ -299,8 +299,9 @@ export interface CreateWebAppOptions<Env = unknown> {
|
|
|
299
299
|
/**
|
|
300
300
|
* Which cookies say who is signed in, for an app with a `keepAlive` route. A response that changes
|
|
301
301
|
* or deletes one rotates the readable identity epoch cookie, and every tab drops the pages it kept
|
|
302
|
-
* for the previous visitor. The session cookies of `@nifrajs/auth
|
|
303
|
-
* under
|
|
302
|
+
* for the previous visitor. The session cookies of `@nifrajs/auth` and Auth.js count under their
|
|
303
|
+
* default names, Better Auth's under any `cookiePrefix`; name any other in `identityCookies`. An app
|
|
304
|
+
* run by `nifra dev` and `nifra build` exports this from `backend/framework.ts`. Development reports, once per
|
|
304
305
|
* name, an `HttpOnly` cookie that changes and is in neither list (`NIFRA_KEEPALIVE_IDENTITY_COOKIE`).
|
|
305
306
|
*/
|
|
306
307
|
readonly keepAlive?: KeepAliveServerOptions
|
|
@@ -654,6 +655,7 @@ export function createWebApp<Env = unknown>(
|
|
|
654
655
|
...(ppr === undefined ? {} : { ppr }),
|
|
655
656
|
invalidate,
|
|
656
657
|
liveLoadOf: liveAttachment?.loadOf,
|
|
658
|
+
liveSeedOf: liveAttachment?.seedOf,
|
|
657
659
|
})
|
|
658
660
|
if (ppr !== undefined) {
|
|
659
661
|
Object.defineProperty(app, PPR_SERVER, { value: ppr.server })
|
package/src/keep-alive.ts
CHANGED
|
@@ -106,6 +106,7 @@ interface Kept {
|
|
|
106
106
|
readonly page: KeptPage
|
|
107
107
|
readonly index: number
|
|
108
108
|
readonly epoch: string
|
|
109
|
+
readonly identity: number
|
|
109
110
|
readonly sources: Sources
|
|
110
111
|
readonly hiddenAt: number
|
|
111
112
|
readonly maxAge: number
|
|
@@ -118,6 +119,7 @@ interface Active {
|
|
|
118
119
|
readonly index: number
|
|
119
120
|
readonly settled: RouterState
|
|
120
121
|
readonly epoch: string
|
|
122
|
+
readonly identity: number
|
|
121
123
|
readonly sources: Sources
|
|
122
124
|
}
|
|
123
125
|
|
|
@@ -281,6 +283,8 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
281
283
|
const wrappedFetchers = new Map<string, Fetcher>()
|
|
282
284
|
let keptPages: readonly KeptPage[] | undefined
|
|
283
285
|
let lastEpoch = readEpoch()
|
|
286
|
+
// Explicit identity signals also cover bearer auth, where the cookie never changes.
|
|
287
|
+
let identity = 0
|
|
284
288
|
|
|
285
289
|
const first = readEntry()
|
|
286
290
|
const initial = inner.snapshot()
|
|
@@ -294,10 +298,13 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
294
298
|
index: first.index,
|
|
295
299
|
settled: initial,
|
|
296
300
|
epoch: lastEpoch,
|
|
301
|
+
identity,
|
|
297
302
|
sources: sourcesOf(initial),
|
|
298
303
|
}
|
|
299
304
|
stampEntry(active.instance)
|
|
300
|
-
|
|
305
|
+
// Browser-only code reads `process.env.NODE_ENV` bare: every bundler replaces it, but none replaces
|
|
306
|
+
// `typeof process`, and a browser has no `process`, so that guard turned these off in Vite dev.
|
|
307
|
+
if (process.env.NODE_ENV !== "production")
|
|
301
308
|
Reflect.set(globalThis, KEPT_PAGES_GLOBAL, {
|
|
302
309
|
pages: () => describeKept(records.values()),
|
|
303
310
|
})
|
|
@@ -385,8 +392,15 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
385
392
|
dropKept("identity")
|
|
386
393
|
}
|
|
387
394
|
|
|
388
|
-
const keep = ({
|
|
389
|
-
|
|
395
|
+
const keep = ({
|
|
396
|
+
instance,
|
|
397
|
+
index,
|
|
398
|
+
settled: state,
|
|
399
|
+
epoch,
|
|
400
|
+
identity: loadedIdentity,
|
|
401
|
+
sources,
|
|
402
|
+
}: Active): void => {
|
|
403
|
+
if (!keepable(state) || epoch !== readEpoch() || loadedIdentity !== identity) return
|
|
390
404
|
const setting = keepAliveOf(state.routeId)
|
|
391
405
|
const settings = typeof setting === "object" ? setting : {}
|
|
392
406
|
records.delete(instance)
|
|
@@ -394,6 +408,7 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
394
408
|
page: { instance, state },
|
|
395
409
|
index,
|
|
396
410
|
epoch,
|
|
411
|
+
identity: loadedIdentity,
|
|
397
412
|
sources,
|
|
398
413
|
hiddenAt: now(),
|
|
399
414
|
maxAge: settings.maxAge ?? KEEP_ALIVE_MAX_AGE_MS,
|
|
@@ -404,8 +419,13 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
404
419
|
if (records.size > max) drop([...records.keys()].slice(0, records.size - max), "lru")
|
|
405
420
|
}
|
|
406
421
|
|
|
422
|
+
let loading: { readonly identity: number; readonly epoch: string } | undefined
|
|
423
|
+
|
|
407
424
|
const settle = (next: RouterState): void => {
|
|
408
425
|
const entry = readEntry()
|
|
426
|
+
const loadedIdentity = loading?.identity ?? identity
|
|
427
|
+
const loadedEpoch = loading?.epoch ?? readEpoch()
|
|
428
|
+
loading = undefined
|
|
409
429
|
if (sameInstance(active.settled, next)) {
|
|
410
430
|
// Data the router did not load again is still the data it was loaded with.
|
|
411
431
|
const same = next.data === active.settled.data
|
|
@@ -413,7 +433,8 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
413
433
|
instance: active.instance,
|
|
414
434
|
index: entry.index,
|
|
415
435
|
settled: next,
|
|
416
|
-
epoch: same ? active.epoch :
|
|
436
|
+
epoch: same ? active.epoch : loadedEpoch,
|
|
437
|
+
identity: same ? active.identity : loadedIdentity,
|
|
417
438
|
sources: same ? active.sources : sourcesOf(next),
|
|
418
439
|
}
|
|
419
440
|
} else {
|
|
@@ -437,7 +458,8 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
437
458
|
instance: reuse ?? newInstance(),
|
|
438
459
|
index: entry.index,
|
|
439
460
|
settled: next,
|
|
440
|
-
epoch:
|
|
461
|
+
epoch: loadedEpoch,
|
|
462
|
+
identity: loadedIdentity,
|
|
441
463
|
sources: sourcesOf(next),
|
|
442
464
|
}
|
|
443
465
|
}
|
|
@@ -488,7 +510,10 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
488
510
|
instance: active.instance,
|
|
489
511
|
index: active.index,
|
|
490
512
|
settled: next,
|
|
491
|
-
epoch:
|
|
513
|
+
// The epoch and identity the restore started under: a reload's response, or the kept
|
|
514
|
+
// response a failed reload falls back to, never belongs to a visitor who arrived since.
|
|
515
|
+
epoch: active.epoch,
|
|
516
|
+
identity: active.identity,
|
|
492
517
|
// A reload that failed shows the kept data, from the kept sources.
|
|
493
518
|
sources: sourcesOf(next) ?? active.sources,
|
|
494
519
|
}
|
|
@@ -504,6 +529,8 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
504
529
|
settle(next)
|
|
505
530
|
return
|
|
506
531
|
}
|
|
532
|
+
// A response belongs to the identity that started its load, not the visitor at arrival.
|
|
533
|
+
loading ??= { identity, epoch: readEpoch() }
|
|
507
534
|
shown = next
|
|
508
535
|
flags = undefined
|
|
509
536
|
leaving = next.routeId !== active.settled.routeId && !leftGone && keepable(active.settled)
|
|
@@ -511,13 +538,21 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
511
538
|
})
|
|
512
539
|
|
|
513
540
|
const restore = (path: string, instance: string, record: Kept): Promise<void> => {
|
|
541
|
+
loading = undefined
|
|
514
542
|
const entry = readEntry()
|
|
515
543
|
records.delete(instance)
|
|
516
544
|
keptPages = undefined
|
|
517
545
|
if (!leftGone) keep(active)
|
|
518
546
|
const kept = record.page.state
|
|
519
547
|
const { sources } = record
|
|
520
|
-
active = {
|
|
548
|
+
active = {
|
|
549
|
+
instance,
|
|
550
|
+
index: entry.index,
|
|
551
|
+
settled: kept,
|
|
552
|
+
epoch: record.epoch,
|
|
553
|
+
identity: record.identity,
|
|
554
|
+
sources,
|
|
555
|
+
}
|
|
521
556
|
fresh.clear()
|
|
522
557
|
// Data a client loader built is shown again only from what it was built from.
|
|
523
558
|
const unsourced =
|
|
@@ -572,6 +607,7 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
572
607
|
return restore(path, instance, record)
|
|
573
608
|
}
|
|
574
609
|
endRestore()
|
|
610
|
+
loading = { identity, epoch: readEpoch() }
|
|
575
611
|
return inner.navigate(path)
|
|
576
612
|
}
|
|
577
613
|
|
|
@@ -594,6 +630,7 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
594
630
|
typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(IDENTITY_CHANNEL)
|
|
595
631
|
const identityDrop = (): void => {
|
|
596
632
|
lastEpoch = readEpoch()
|
|
633
|
+
identity++
|
|
597
634
|
dropKept("identity")
|
|
598
635
|
}
|
|
599
636
|
channel?.addEventListener("message", identityDrop)
|
|
@@ -629,9 +666,14 @@ export function keepAliveClient(options: KeepAliveClientOptions): KeepAliveClien
|
|
|
629
666
|
invalidate: (paths) => {
|
|
630
667
|
markStale(paths)
|
|
631
668
|
// At once when nothing is restoring: the live client names its load around this call.
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
669
|
+
const invalidate = (): Promise<void> => {
|
|
670
|
+
const capture = { identity, epoch: readEpoch() }
|
|
671
|
+
loading = capture
|
|
672
|
+
return inner.invalidate(paths).finally(() => {
|
|
673
|
+
if (loading === capture) loading = undefined
|
|
674
|
+
})
|
|
675
|
+
}
|
|
676
|
+
return restoring === undefined ? invalidate() : afterRestore().then(invalidate)
|
|
635
677
|
},
|
|
636
678
|
prefetch: (path) => inner.prefetch(path),
|
|
637
679
|
fetcher: (key) => {
|
|
@@ -1104,8 +1146,7 @@ export function createSlotParking(): SlotParking {
|
|
|
1104
1146
|
ref = (element) => {
|
|
1105
1147
|
if (element === null) return
|
|
1106
1148
|
slotOf(key).page = element
|
|
1107
|
-
if (
|
|
1108
|
-
noteKeptView(key, () => [element])
|
|
1149
|
+
if (process.env.NODE_ENV !== "production") noteKeptView(key, () => [element])
|
|
1109
1150
|
}
|
|
1110
1151
|
pageRefs.set(key, ref)
|
|
1111
1152
|
}
|
|
@@ -1162,8 +1203,7 @@ export function createSlotParking(): SlotParking {
|
|
|
1162
1203
|
slot.focus = undefined
|
|
1163
1204
|
if (!slot.shown && !slot.checked) {
|
|
1164
1205
|
slot.checked = true
|
|
1165
|
-
if (
|
|
1166
|
-
checkSlotCss(slot.entry, page)
|
|
1206
|
+
if (process.env.NODE_ENV !== "production") checkSlotCss(slot.entry, page)
|
|
1167
1207
|
}
|
|
1168
1208
|
slot.shown = true
|
|
1169
1209
|
}
|
|
@@ -1174,8 +1214,7 @@ export function createSlotParking(): SlotParking {
|
|
|
1174
1214
|
slots.delete(key)
|
|
1175
1215
|
entryRefs.delete(key)
|
|
1176
1216
|
pageRefs.delete(key)
|
|
1177
|
-
if (
|
|
1178
|
-
noteKeptView(key, undefined)
|
|
1217
|
+
if (process.env.NODE_ENV !== "production") noteKeptView(key, undefined)
|
|
1179
1218
|
}
|
|
1180
1219
|
},
|
|
1181
1220
|
}
|
package/src/live/bus.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* keeps counting upward, and an entry's age can be read off its seq.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { KEY_PATTERN } from "./keyring.ts"
|
|
11
|
+
|
|
10
12
|
/** Something a loader declared with `ctx.live` changed. */
|
|
11
13
|
export interface LiveChange {
|
|
12
14
|
readonly type: "change"
|
|
@@ -35,8 +37,11 @@ export interface LiveBusListener {
|
|
|
35
37
|
* unknown, and delivery resumes with the events after it.
|
|
36
38
|
*/
|
|
37
39
|
gap(head: number): void
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
+
/**
|
|
41
|
+
* The bus is unreachable; the next {@link gap} reports where delivery resumes. `error` is what the
|
|
42
|
+
* last read failed with, when the bus knows.
|
|
43
|
+
*/
|
|
44
|
+
down(error?: unknown): void
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
export interface LiveBusSubscription {
|
|
@@ -75,3 +80,25 @@ export function memoryBus(): LiveBus {
|
|
|
75
80
|
},
|
|
76
81
|
}
|
|
77
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Publish changes from a process that serves no live pages: a worker, or another app whose writes
|
|
86
|
+
* change what the pages show. Give it the bus the serving processes read; each applies the change as
|
|
87
|
+
* if it had published it. It needs no grant secret, and reads nothing from the bus.
|
|
88
|
+
*
|
|
89
|
+
* ```ts
|
|
90
|
+
* const publish = livePublisher(redisLiveBus({ send: (command, args) => redis.call(command, ...args) }))
|
|
91
|
+
* await publish(`org:${orgId}:usage`)
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function livePublisher(bus: Pick<LiveBus, "append">): (...keys: string[]) => Promise<void> {
|
|
95
|
+
return async (...keys) => {
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
if (typeof key !== "string" || !KEY_PATTERN.test(key)) {
|
|
98
|
+
throw new TypeError("[nifra/web] livePublisher takes keys in the ctx.live() charset")
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (keys.length === 0) return
|
|
102
|
+
await bus.append({ type: "change", keys: [...new Set(keys)] })
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/live/hub.ts
CHANGED
|
@@ -18,6 +18,8 @@ export const CLOSE_REVOKED = 4403
|
|
|
18
18
|
export const CLOSE_SCOPE = 4409
|
|
19
19
|
export const CLOSE_CAPACITY = 1013
|
|
20
20
|
export const CLOSE_PROTOCOL = 1008
|
|
21
|
+
/** The runtime closed: reconnect, most likely to another process. */
|
|
22
|
+
export const CLOSE_GOING_AWAY = 1001
|
|
21
23
|
/** Nothing to watch: a tab with no tag to hear about reconnects only once it has one. */
|
|
22
24
|
export const CLOSE_UNWATCHED = 1000
|
|
23
25
|
|
|
@@ -119,6 +121,8 @@ interface Pending {
|
|
|
119
121
|
export class LiveHub {
|
|
120
122
|
/** Bus unreachable: no watch is accepted and no socket kept. */
|
|
121
123
|
down = false
|
|
124
|
+
/** `changed` frames sent, one per subscribed socket: each makes a page reload. */
|
|
125
|
+
notified = 0
|
|
122
126
|
private readonly sockets = new Set<NifraWebSocket<LiveSocket>>()
|
|
123
127
|
/** Local subscribers per tag: the intern table, and the input to `spread`. */
|
|
124
128
|
private readonly members = new Map<string, number>()
|
|
@@ -416,6 +420,7 @@ export class LiveHub {
|
|
|
416
420
|
for (const tag of tags) {
|
|
417
421
|
const subscribers = this.members.get(tag)
|
|
418
422
|
if (subscribers === undefined) continue
|
|
423
|
+
this.notified += subscribers
|
|
419
424
|
const frame = JSON.stringify({ t: "changed", tag, spread: this.spread(subscribers) })
|
|
420
425
|
for (const publish of this.publishers) publish(this.topic(tag), frame)
|
|
421
426
|
}
|
package/src/live/keyring.ts
CHANGED
|
@@ -18,6 +18,11 @@ export const MAX_KEYS_PER_FRAME = 64
|
|
|
18
18
|
/** The cache-tag charset: a letter, then up to 127 of `A-Z a-z 0-9 . _ : / -`. */
|
|
19
19
|
export const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9._:/-]{0,127}$/
|
|
20
20
|
export const SID_PATTERN = /^[0-9a-f]{32}$/
|
|
21
|
+
/**
|
|
22
|
+
* The socket id of a seed: the grant a server-rendered page carries before it has a socket. No socket
|
|
23
|
+
* has it, so no watch accepts a seed; the load endpoint exchanges one for a grant bound to a socket.
|
|
24
|
+
*/
|
|
25
|
+
export const SEED_SID = ""
|
|
21
26
|
|
|
22
27
|
const KID_BYTES = 16
|
|
23
28
|
const NONCE_BYTES = 12
|
|
@@ -309,7 +314,7 @@ function validClaims(
|
|
|
309
314
|
if (!isRecord(value)) return false
|
|
310
315
|
const { v, sid, sub, path, frame, owners, s, r, iat, exp, aud } = value
|
|
311
316
|
if (v !== 1 || aud !== appId) return false
|
|
312
|
-
if (typeof sid !== "string" || !SID_PATTERN.test(sid)) return false
|
|
317
|
+
if (typeof sid !== "string" || (sid !== SEED_SID && !SID_PATTERN.test(sid))) return false
|
|
313
318
|
if (sub !== null && typeof sub !== "string") return false
|
|
314
319
|
if (typeof path !== "string" || typeof frame !== "string") return false
|
|
315
320
|
if (!isCount(s) || !isCount(r) || !isInteger(iat) || !isInteger(exp)) return false
|