@bquery/bquery 1.10.0 → 1.11.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 +91 -65
- package/dist/{a11y-DG2i4iZN.js → a11y-DgUQ8-fI.js} +1 -1
- package/dist/{a11y-DG2i4iZN.js.map → a11y-DgUQ8-fI.js.map} +1 -1
- package/dist/a11y.es.mjs +1 -1
- package/dist/{component-DRotf1hl.js → component-D8ydhe58.js} +2 -2
- package/dist/{component-DRotf1hl.js.map → component-D8ydhe58.js.map} +1 -1
- package/dist/component.es.mjs +1 -1
- package/dist/concurrency-BU1wPEsZ.js.map +1 -1
- package/dist/{constraints-CqjhmpZC.js → constraints-Dlbx_m1b.js} +1 -1
- package/dist/{constraints-CqjhmpZC.js.map → constraints-Dlbx_m1b.js.map} +1 -1
- package/dist/{core-EMYSLzaT.js → core-tOP6QOrY.js} +2 -2
- package/dist/{core-EMYSLzaT.js.map → core-tOP6QOrY.js.map} +1 -1
- package/dist/core.es.mjs +1 -1
- package/dist/{custom-directives-BjFzFhuf.js → custom-directives-5DlKqvd2.js} +1 -1
- package/dist/{custom-directives-BjFzFhuf.js.map → custom-directives-5DlKqvd2.js.map} +1 -1
- package/dist/{devtools-C5FExMwv.js → devtools-QosAqo0T.js} +2 -2
- package/dist/{devtools-C5FExMwv.js.map → devtools-QosAqo0T.js.map} +1 -1
- package/dist/devtools.es.mjs +1 -1
- package/dist/{dnd-BAqzPlSo.js → dnd-d2OU4len.js} +1 -1
- package/dist/{dnd-BAqzPlSo.js.map → dnd-d2OU4len.js.map} +1 -1
- package/dist/dnd.es.mjs +1 -1
- package/dist/{forms-Dx1Scvh0.js → forms-BLx4ZzT7.js} +1 -1
- package/dist/{forms-Dx1Scvh0.js.map → forms-BLx4ZzT7.js.map} +1 -1
- package/dist/forms.es.mjs +1 -1
- package/dist/full.d.ts +4 -2
- package/dist/full.d.ts.map +1 -1
- package/dist/full.es.mjs +258 -219
- package/dist/full.iife.js +41 -37
- package/dist/full.iife.js.map +1 -1
- package/dist/full.umd.js +41 -37
- package/dist/full.umd.js.map +1 -1
- package/dist/{i18n-Cazyk9RD.js → i18n--p7PM-9r.js} +1 -1
- package/dist/{i18n-Cazyk9RD.js.map → i18n--p7PM-9r.js.map} +1 -1
- package/dist/i18n.es.mjs +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.es.mjs +291 -252
- package/dist/match-CrZRVC4z.js +174 -0
- package/dist/match-CrZRVC4z.js.map +1 -0
- package/dist/{media-dAKIGPk3.js → media-gjbWNq50.js} +1 -1
- package/dist/{media-dAKIGPk3.js.map → media-gjbWNq50.js.map} +1 -1
- package/dist/media.es.mjs +1 -1
- package/dist/motion-BBMso9Ir.js.map +1 -1
- package/dist/{mount-C8O2vXkQ.js → mount-0A9qtcRJ.js} +3 -3
- package/dist/{mount-C8O2vXkQ.js.map → mount-0A9qtcRJ.js.map} +1 -1
- package/dist/platform-BPHIXbw8.js.map +1 -1
- package/dist/{plugin-DjTqWg-P.js → plugin-SZEirbwq.js} +2 -2
- package/dist/{plugin-DjTqWg-P.js.map → plugin-SZEirbwq.js.map} +1 -1
- package/dist/plugin.es.mjs +1 -1
- package/dist/reactive-BAd2hfl8.js.map +1 -1
- package/dist/{registry-Cr6VH8CR.js → registry-jpUQHf4E.js} +1 -1
- package/dist/{registry-Cr6VH8CR.js.map → registry-jpUQHf4E.js.map} +1 -1
- package/dist/router-C4weu0QL.js +333 -0
- package/dist/router-C4weu0QL.js.map +1 -0
- package/dist/router.es.mjs +1 -1
- package/dist/{sanitize-B1V4JswB.js → sanitize-DOMkRO9G.js} +12 -7
- package/dist/{sanitize-B1V4JswB.js.map → sanitize-DOMkRO9G.js.map} +1 -1
- package/dist/security.es.mjs +1 -1
- package/dist/server/create-server.d.ts +25 -0
- package/dist/server/create-server.d.ts.map +1 -0
- package/dist/server/index.d.ts +11 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/types.d.ts +396 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server-QdyKtCS1.js +349 -0
- package/dist/server-QdyKtCS1.js.map +1 -0
- package/dist/server.es.mjs +6 -0
- package/dist/ssr/adapters.d.ts +74 -0
- package/dist/ssr/adapters.d.ts.map +1 -0
- package/dist/ssr/async.d.ts +40 -0
- package/dist/ssr/async.d.ts.map +1 -0
- package/dist/ssr/config.d.ts +60 -0
- package/dist/ssr/config.d.ts.map +1 -0
- package/dist/ssr/context.d.ts +73 -0
- package/dist/ssr/context.d.ts.map +1 -0
- package/dist/ssr/defer-brand.d.ts +5 -0
- package/dist/ssr/defer-brand.d.ts.map +1 -0
- package/dist/ssr/escape.d.ts +17 -0
- package/dist/ssr/escape.d.ts.map +1 -0
- package/dist/ssr/expression.d.ts +44 -0
- package/dist/ssr/expression.d.ts.map +1 -0
- package/dist/ssr/hash.d.ts +39 -0
- package/dist/ssr/hash.d.ts.map +1 -0
- package/dist/ssr/head.d.ts +102 -0
- package/dist/ssr/head.d.ts.map +1 -0
- package/dist/ssr/html-parser.d.ts +58 -0
- package/dist/ssr/html-parser.d.ts.map +1 -0
- package/dist/ssr/index.d.ts +49 -43
- package/dist/ssr/index.d.ts.map +1 -1
- package/dist/ssr/mismatch.d.ts +60 -0
- package/dist/ssr/mismatch.d.ts.map +1 -0
- package/dist/ssr/render-async.d.ts +84 -0
- package/dist/ssr/render-async.d.ts.map +1 -0
- package/dist/ssr/render.d.ts.map +1 -1
- package/dist/ssr/renderer.d.ts +25 -0
- package/dist/ssr/renderer.d.ts.map +1 -0
- package/dist/ssr/resumability.d.ts +65 -0
- package/dist/ssr/resumability.d.ts.map +1 -0
- package/dist/ssr/router-bridge.d.ts +101 -0
- package/dist/ssr/router-bridge.d.ts.map +1 -0
- package/dist/ssr/runtime.d.ts +63 -0
- package/dist/ssr/runtime.d.ts.map +1 -0
- package/dist/ssr/serialize.d.ts.map +1 -1
- package/dist/ssr/store-snapshot.d.ts +87 -0
- package/dist/ssr/store-snapshot.d.ts.map +1 -0
- package/dist/ssr/strategies.d.ts +43 -0
- package/dist/ssr/strategies.d.ts.map +1 -0
- package/dist/ssr/suspense.d.ts +47 -0
- package/dist/ssr/suspense.d.ts.map +1 -0
- package/dist/ssr/types.d.ts +17 -0
- package/dist/ssr/types.d.ts.map +1 -1
- package/dist/ssr-Bt6BQA3J.js +2127 -0
- package/dist/ssr-Bt6BQA3J.js.map +1 -0
- package/dist/ssr.es.mjs +42 -7
- package/dist/{store-CjmEeX9-.js → store-DnXuu6Li.js} +2 -2
- package/dist/{store-CjmEeX9-.js.map → store-DnXuu6Li.js.map} +1 -1
- package/dist/store.es.mjs +2 -2
- package/dist/storybook.es.mjs +1 -1
- package/dist/{testing-TdfaL7VE.js → testing-CeMUwrRD.js} +2 -2
- package/dist/{testing-TdfaL7VE.js.map → testing-CeMUwrRD.js.map} +1 -1
- package/dist/testing.es.mjs +1 -1
- package/dist/view.es.mjs +1 -1
- package/package.json +19 -14
- package/src/full.ts +99 -0
- package/src/index.ts +5 -2
- package/src/server/create-server.ts +754 -0
- package/src/server/index.ts +33 -0
- package/src/server/types.ts +490 -0
- package/src/ssr/adapters.ts +330 -0
- package/src/ssr/async.ts +125 -0
- package/src/ssr/config.ts +86 -0
- package/src/ssr/context.ts +245 -0
- package/src/ssr/defer-brand.ts +3 -0
- package/src/ssr/escape.ts +25 -0
- package/src/ssr/expression.ts +669 -0
- package/src/ssr/hash.ts +71 -0
- package/src/ssr/head.ts +240 -0
- package/src/ssr/html-parser.ts +387 -0
- package/src/ssr/index.ts +136 -43
- package/src/ssr/mismatch.ts +110 -0
- package/src/ssr/render-async.ts +286 -0
- package/src/ssr/render.ts +130 -59
- package/src/ssr/renderer.ts +453 -0
- package/src/ssr/resumability.ts +142 -0
- package/src/ssr/router-bridge.ts +177 -0
- package/src/ssr/runtime.ts +131 -0
- package/src/ssr/serialize.ts +1 -27
- package/src/ssr/store-snapshot.ts +209 -0
- package/src/ssr/strategies.ts +245 -0
- package/src/ssr/suspense.ts +504 -0
- package/src/ssr/types.ts +18 -0
- package/dist/router-CCepRMpC.js +0 -493
- package/dist/router-CCepRMpC.js.map +0 -1
- package/dist/ssr-D-1IPcfw.js +0 -248
- package/dist/ssr-D-1IPcfw.js.map +0 -1
package/src/ssr/index.ts
CHANGED
|
@@ -1,56 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SSR / Pre-rendering module for bQuery.js.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* and hydrating the pre-rendered DOM on the client.
|
|
4
|
+
* Server-side rendering, hydration, store-state serialization and runtime
|
|
5
|
+
* adapters for bQuery applications. The module is **runtime-agnostic** and
|
|
6
|
+
* runs on Bun, Deno and Node.js ≥ 24 without any external dependency.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
8
|
+
* The synchronous `renderToString()` keeps its previous behaviour for
|
|
9
|
+
* backward compatibility but now automatically falls back to a fully
|
|
10
|
+
* DOM-free renderer when no `DOMParser` is available — that is what makes
|
|
11
|
+
* the same code path work on every server runtime.
|
|
10
12
|
*
|
|
11
|
-
*
|
|
12
|
-
* template to an `SSRResult` containing an `html` string with directive evaluation.
|
|
13
|
-
* - **`hydrateMount(selector, context, { hydrate: true })`** — Reuse
|
|
14
|
-
* existing server-rendered DOM and attach reactive bindings.
|
|
15
|
-
* - **`serializeStoreState(options?)`** — Serialize store state into a
|
|
16
|
-
* `<script>` tag for client-side pickup.
|
|
17
|
-
* - **`deserializeStoreState()`** — Read serialized state on the client.
|
|
18
|
-
* - **`hydrateStore(id, state)` / `hydrateStores(stateMap)`** — Apply
|
|
19
|
-
* server state to client stores.
|
|
13
|
+
* ## Highlights
|
|
20
14
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* ```
|
|
36
|
-
*
|
|
37
|
-
* ### Client
|
|
38
|
-
* ```ts
|
|
39
|
-
* import { hydrateMount, deserializeStoreState, hydrateStores } from '@bquery/bquery/ssr';
|
|
40
|
-
* import { signal } from '@bquery/bquery/reactive';
|
|
41
|
-
*
|
|
42
|
-
* // Restore store state from SSR
|
|
43
|
-
* const ssrState = deserializeStoreState();
|
|
44
|
-
* hydrateStores(ssrState);
|
|
45
|
-
*
|
|
46
|
-
* // Hydrate the DOM with reactive bindings
|
|
47
|
-
* const title = signal('Welcome');
|
|
48
|
-
* hydrateMount('#app', { title }, { hydrate: true });
|
|
49
|
-
* ```
|
|
15
|
+
* - **`renderToString(template, data)`** — synchronous render to HTML.
|
|
16
|
+
* - **`renderToStringAsync(template, data, ctx?)`** — awaits Promises and
|
|
17
|
+
* `defer()` values in the binding context.
|
|
18
|
+
* - **`renderToStream(template, data, ctx?)`** — Web `ReadableStream<Uint8Array>`.
|
|
19
|
+
* - **`renderToResponse(template, data, ctx?)`** — high-level `Response`
|
|
20
|
+
* wrapper with ETag, Cache-Control, head & store-state injection.
|
|
21
|
+
* - **`createSSRContext(...)`** — request/response context bag.
|
|
22
|
+
* - **`createHeadManager()`** — `<title>`, `<meta>`, `<link>` and
|
|
23
|
+
* `<script>` collection.
|
|
24
|
+
* - **`hydrateMount` / `hydrateOnVisible` / `hydrateOnIdle` /
|
|
25
|
+
* `hydrateOnInteraction` / `hydrateOnMedia` / `hydrateIsland`** — full
|
|
26
|
+
* progressive-hydration toolkit.
|
|
27
|
+
* - **Runtime adapters** — `createWebHandler`, `createBunHandler`,
|
|
28
|
+
* `createDenoHandler`, `createNodeHandler`, `createSSRHandler`.
|
|
50
29
|
*
|
|
51
30
|
* @module bquery/ssr
|
|
52
31
|
*/
|
|
53
32
|
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Existing public API (unchanged)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
54
36
|
export { hydrateMount } from './hydrate';
|
|
55
37
|
export type { HydrateMountOptions } from './hydrate';
|
|
56
38
|
export { renderToString } from './render';
|
|
@@ -68,3 +50,114 @@ export type {
|
|
|
68
50
|
SSRResult,
|
|
69
51
|
SerializeOptions,
|
|
70
52
|
} from './types';
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Runtime detection
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
export { detectRuntime, getSSRRuntimeFeatures, isBrowserRuntime, isServerRuntime } from './runtime';
|
|
58
|
+
export type { SSRRuntime, SSRRuntimeFeatures } from './runtime';
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Configuration
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
export { configureSSR, getSSRConfig } from './config';
|
|
64
|
+
export type { SSRDocumentImpl, SSRRendererBackend } from './config';
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Async/streaming render pipeline
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
export { renderToResponse, renderToStream, renderToStringAsync } from './render-async';
|
|
70
|
+
export type { AsyncRenderOptions, AsyncSSRResult, RenderToResponseOptions } from './render-async';
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// SSR context
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
export { createSSRContext } from './context';
|
|
76
|
+
export type { CreateSSRContextOptions, SSRContext } from './context';
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Head + assets + nonce
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
export { createAssetManager, createHeadManager } from './head';
|
|
82
|
+
export type {
|
|
83
|
+
AssetManager,
|
|
84
|
+
HeadManager,
|
|
85
|
+
SSRAsset,
|
|
86
|
+
SSRHeadState,
|
|
87
|
+
SSRLink,
|
|
88
|
+
SSRMeta,
|
|
89
|
+
SSRScript,
|
|
90
|
+
UseHeadOptions,
|
|
91
|
+
} from './head';
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Async loaders / defer
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
export { defer, defineLoader } from './async';
|
|
97
|
+
export type { SSRLoader } from './async';
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Hydration strategies
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
export {
|
|
103
|
+
hydrateIsland,
|
|
104
|
+
hydrateOnIdle,
|
|
105
|
+
hydrateOnInteraction,
|
|
106
|
+
hydrateOnMedia,
|
|
107
|
+
hydrateOnVisible,
|
|
108
|
+
} from './strategies';
|
|
109
|
+
export type { HydrationHandle } from './strategies';
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Hydration mismatch detection
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
export { verifyHydration } from './mismatch';
|
|
115
|
+
export type { HydrationMismatch, VerifyHydrationOptions } from './mismatch';
|
|
116
|
+
export { HYDRATION_HASH_ATTR } from './hash';
|
|
117
|
+
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Suspense / out-of-order streaming
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
export { renderToStreamSuspense } from './suspense';
|
|
122
|
+
export type { SuspenseStreamOptions } from './suspense';
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Router ↔ SSR bridge
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
export { createSSRRouterContext, resolveSSRRoute, runRouteLoaders } from './router-bridge';
|
|
128
|
+
export type { ResolvedSSRRoute, SSRRouteLoader } from './router-bridge';
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Versioned store snapshots
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
export { hydrateStoreSnapshot, readStoreSnapshot, serializeStoreSnapshot } from './store-snapshot';
|
|
134
|
+
export type {
|
|
135
|
+
HydrateSnapshotOptions,
|
|
136
|
+
HydrateSnapshotResult,
|
|
137
|
+
SerializeSnapshotOptions,
|
|
138
|
+
SerializeSnapshotResult,
|
|
139
|
+
SSRStoreSnapshot,
|
|
140
|
+
} from './store-snapshot';
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Resumability
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
export { createResumableState, resumeState } from './resumability';
|
|
146
|
+
export type { CreateResumableStateOptions, ResumableState, ResumeReader } from './resumability';
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Runtime adapters
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
export {
|
|
152
|
+
createBunHandler,
|
|
153
|
+
createDenoHandler,
|
|
154
|
+
createNodeHandler,
|
|
155
|
+
createSSRHandler,
|
|
156
|
+
createWebHandler,
|
|
157
|
+
} from './adapters';
|
|
158
|
+
export type {
|
|
159
|
+
NodeHandlerOptions,
|
|
160
|
+
NodeIncomingMessage,
|
|
161
|
+
NodeServerResponse,
|
|
162
|
+
SSRRequestHandler,
|
|
163
|
+
} from './adapters';
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hydration mismatch detection.
|
|
3
|
+
*
|
|
4
|
+
* The DOM-free SSR renderer can annotate every element that carries a `bq-*`
|
|
5
|
+
* directive with a small `data-bq-h` hash (see `RenderOptions.annotateHydration`).
|
|
6
|
+
* On the client, `verifyHydration()` walks the live DOM, recomputes the same
|
|
7
|
+
* hash for each annotated element and reports any divergence.
|
|
8
|
+
*
|
|
9
|
+
* The check is intentionally cheap and safe: collisions only result in false
|
|
10
|
+
* negatives (a mismatch slips through), never in false positives (a stable
|
|
11
|
+
* tree never reports a mismatch).
|
|
12
|
+
*
|
|
13
|
+
* @module bquery/ssr
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { cheapHash, collectDirectiveSignatureFromElement, HYDRATION_HASH_ATTR } from './hash';
|
|
17
|
+
import { detectDevEnvironment } from '../core/env';
|
|
18
|
+
|
|
19
|
+
/** A single hydration mismatch entry returned by `verifyHydration()`. */
|
|
20
|
+
export interface HydrationMismatch {
|
|
21
|
+
/** The DOM element whose annotation diverged. */
|
|
22
|
+
element: Element;
|
|
23
|
+
/** The hash that the server emitted (`data-bq-h` value). */
|
|
24
|
+
expected: string;
|
|
25
|
+
/** The hash recomputed from the live element. */
|
|
26
|
+
actual: string;
|
|
27
|
+
/** The directive signature that was hashed (useful for diagnostics). */
|
|
28
|
+
signature: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Options for `verifyHydration`. */
|
|
32
|
+
export interface VerifyHydrationOptions {
|
|
33
|
+
/** Directive prefix to match. Default: `'bq'`. */
|
|
34
|
+
prefix?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Whether to log a `console.warn` for each mismatch. Defaults to `true` in
|
|
37
|
+
* non-production environments and `false` otherwise. Pass an explicit
|
|
38
|
+
* boolean to override.
|
|
39
|
+
*/
|
|
40
|
+
warn?: boolean;
|
|
41
|
+
/** Optional callback invoked once per mismatch. */
|
|
42
|
+
onMismatch?: (mismatch: HydrationMismatch) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Walks `[data-bq-h]` elements within `root`, recomputes the directive hash
|
|
47
|
+
* and reports mismatches. Returns the list of mismatches; callers can react
|
|
48
|
+
* however they want (throw in tests, log in dev, ignore in production).
|
|
49
|
+
*
|
|
50
|
+
* Safe to call in any environment. When the runtime has no DOM (server-side)
|
|
51
|
+
* or `root` has no `querySelectorAll`, the function returns an empty array
|
|
52
|
+
* without throwing.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* import { detectDevEnvironment } from '@bquery/bquery';
|
|
57
|
+
* import { hydrateMount, verifyHydration } from '@bquery/bquery/ssr';
|
|
58
|
+
*
|
|
59
|
+
* const view = hydrateMount('#app', context);
|
|
60
|
+
* if (detectDevEnvironment()) {
|
|
61
|
+
* verifyHydration(document.getElementById('app')!);
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export const verifyHydration = (
|
|
66
|
+
root: Element | Document,
|
|
67
|
+
options: VerifyHydrationOptions = {}
|
|
68
|
+
): HydrationMismatch[] => {
|
|
69
|
+
const prefix = options.prefix ?? 'bq';
|
|
70
|
+
const warn = options.warn ?? detectDevEnvironment();
|
|
71
|
+
const onMismatch = options.onMismatch;
|
|
72
|
+
|
|
73
|
+
const mismatches: HydrationMismatch[] = [];
|
|
74
|
+
|
|
75
|
+
if (!root || typeof (root as Element).querySelectorAll !== 'function') {
|
|
76
|
+
return mismatches;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Include the root itself if it carries the annotation.
|
|
80
|
+
const annotated: Element[] = [];
|
|
81
|
+
if (
|
|
82
|
+
typeof (root as Element).getAttribute === 'function' &&
|
|
83
|
+
(root as Element).getAttribute(HYDRATION_HASH_ATTR) !== null
|
|
84
|
+
) {
|
|
85
|
+
annotated.push(root as Element);
|
|
86
|
+
}
|
|
87
|
+
for (const el of Array.from(root.querySelectorAll(`[${HYDRATION_HASH_ATTR}]`))) {
|
|
88
|
+
annotated.push(el);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const el of annotated) {
|
|
92
|
+
const expected = el.getAttribute(HYDRATION_HASH_ATTR) ?? '';
|
|
93
|
+
const signature = collectDirectiveSignatureFromElement(el, prefix);
|
|
94
|
+
const actual = cheapHash(signature);
|
|
95
|
+
if (actual !== expected) {
|
|
96
|
+
const mismatch: HydrationMismatch = { element: el, expected, actual, signature };
|
|
97
|
+
mismatches.push(mismatch);
|
|
98
|
+
onMismatch?.(mismatch);
|
|
99
|
+
if (warn) {
|
|
100
|
+
console.warn(
|
|
101
|
+
`[bQuery SSR] Hydration mismatch on <${el.tagName.toLowerCase()}>: ` +
|
|
102
|
+
`server="${expected}" client="${actual}" signature="${signature}".`,
|
|
103
|
+
el
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return mismatches;
|
|
110
|
+
};
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async / streaming render entry points.
|
|
3
|
+
*
|
|
4
|
+
* Builds on top of the synchronous `renderToString()` and adds:
|
|
5
|
+
* - `renderToStringAsync()` — awaits Promise/`defer()` values in the context.
|
|
6
|
+
* - `renderToStream()` — emits the HTML as a Web `ReadableStream<Uint8Array>`.
|
|
7
|
+
* - `renderToResponse()` — wraps the stream in a `Response` with sensible
|
|
8
|
+
* defaults (`Content-Type`, `Cache-Control`, ETag, head injection, store
|
|
9
|
+
* state injection).
|
|
10
|
+
*
|
|
11
|
+
* All three run on Bun, Deno and Node ≥ 24 without external dependencies.
|
|
12
|
+
*
|
|
13
|
+
* @module bquery/ssr
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { BindingContext } from '../view/types';
|
|
17
|
+
import { resolveContext } from './async';
|
|
18
|
+
import { createSSRContext, type SSRContext } from './context';
|
|
19
|
+
import { renderToString } from './render';
|
|
20
|
+
import { serializeStoreState } from './serialize';
|
|
21
|
+
import type { RenderOptions, SSRResult } from './types';
|
|
22
|
+
|
|
23
|
+
const escapeAttr = (value: string): string =>
|
|
24
|
+
value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* HTML ASCII whitespace used between tag names and attributes. Includes form
|
|
28
|
+
* feed (`\f`) because the HTML tokenizer treats it as whitespace alongside
|
|
29
|
+
* spaces, tabs, CR and LF.
|
|
30
|
+
*/
|
|
31
|
+
const isHtmlWhitespace = (ch: string | undefined): boolean =>
|
|
32
|
+
ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r' || ch === '\f';
|
|
33
|
+
|
|
34
|
+
const injectScriptNonce = (scriptTag: string, nonce: string): string => {
|
|
35
|
+
const scriptPrefix = '<script';
|
|
36
|
+
if (scriptTag.slice(0, scriptPrefix.length).toLowerCase() !== scriptPrefix) {
|
|
37
|
+
return scriptTag;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const next = scriptTag[scriptPrefix.length];
|
|
41
|
+
if (next !== '>' && !isHtmlWhitespace(next)) {
|
|
42
|
+
return scriptTag;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return `<script nonce="${escapeAttr(nonce)}"${scriptTag.slice(scriptPrefix.length)}`;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Options accepted by the async render APIs. Extends the base `RenderOptions`
|
|
50
|
+
* with response-shaping switches.
|
|
51
|
+
*/
|
|
52
|
+
export interface AsyncRenderOptions extends RenderOptions {
|
|
53
|
+
/** Pre-built SSR context. Created automatically if omitted. */
|
|
54
|
+
context?: SSRContext;
|
|
55
|
+
/**
|
|
56
|
+
* Whether to inject the head manager output, asset manifest and store-state
|
|
57
|
+
* `<script>` tag into the output HTML when the template contains
|
|
58
|
+
* `</head>`/`</body>` markers. Default: `true`.
|
|
59
|
+
*/
|
|
60
|
+
injectHead?: boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Custom store-state script ID/global key forwarded to `serializeStoreState()`.
|
|
63
|
+
*/
|
|
64
|
+
storeScriptId?: string;
|
|
65
|
+
storeGlobalKey?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Result of an async render call. */
|
|
69
|
+
export interface AsyncSSRResult extends SSRResult {
|
|
70
|
+
/** SSR context that produced this result. */
|
|
71
|
+
context: SSRContext;
|
|
72
|
+
/** Aggregated head HTML (already injected when `injectHead` is true). */
|
|
73
|
+
headHtml: string;
|
|
74
|
+
/** Aggregated asset preload HTML (already injected when `injectHead` is true). */
|
|
75
|
+
assetsHtml: string;
|
|
76
|
+
/** `<script>` tag with serialized store state, if any. */
|
|
77
|
+
storeScriptTag: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const injectIntoHead = (html: string, fragment: string): string => {
|
|
81
|
+
if (!fragment) return html;
|
|
82
|
+
const idx = html.toLowerCase().indexOf('</head>');
|
|
83
|
+
if (idx === -1) return html;
|
|
84
|
+
return html.slice(0, idx) + fragment + html.slice(idx);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const injectBeforeBodyEnd = (html: string, fragment: string): string => {
|
|
88
|
+
if (!fragment) return html;
|
|
89
|
+
const idx = html.toLowerCase().lastIndexOf('</body>');
|
|
90
|
+
if (idx === -1) return html;
|
|
91
|
+
return html.slice(0, idx) + fragment + html.slice(idx);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Async-aware render. Resolves all `Promise`/`defer()` values in the context,
|
|
96
|
+
* then delegates to `renderToString()` and applies head/asset/store-state
|
|
97
|
+
* injection based on the SSR context.
|
|
98
|
+
*/
|
|
99
|
+
export const renderToStringAsync = async (
|
|
100
|
+
template: string,
|
|
101
|
+
data: BindingContext,
|
|
102
|
+
options: AsyncRenderOptions = {}
|
|
103
|
+
): Promise<AsyncSSRResult> => {
|
|
104
|
+
const context = options.context ?? createSSRContext({ mode: 'string' });
|
|
105
|
+
|
|
106
|
+
if (context.signal.aborted) {
|
|
107
|
+
throw new DOMException('SSR render aborted', 'AbortError');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const resolvedData = await resolveContext(data, context);
|
|
111
|
+
|
|
112
|
+
if (context.signal.aborted) {
|
|
113
|
+
throw new DOMException('SSR render aborted', 'AbortError');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const baseOptions: RenderOptions = {
|
|
117
|
+
prefix: options.prefix,
|
|
118
|
+
stripDirectives: options.stripDirectives,
|
|
119
|
+
includeStoreState: false,
|
|
120
|
+
annotateHydration: options.annotateHydration,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
let { html, storeState } = renderToString(template, resolvedData, baseOptions);
|
|
124
|
+
|
|
125
|
+
const headHtml = context.head.render({ nonce: context.nonce });
|
|
126
|
+
const assetsHtml = context.assets.render({ nonce: context.nonce });
|
|
127
|
+
|
|
128
|
+
let storeScriptTag = '';
|
|
129
|
+
if (options.includeStoreState) {
|
|
130
|
+
const storeIds = Array.isArray(options.includeStoreState)
|
|
131
|
+
? options.includeStoreState
|
|
132
|
+
: undefined;
|
|
133
|
+
const result = serializeStoreState({
|
|
134
|
+
storeIds,
|
|
135
|
+
scriptId: options.storeScriptId,
|
|
136
|
+
globalKey: options.storeGlobalKey,
|
|
137
|
+
});
|
|
138
|
+
storeState = result.stateJson;
|
|
139
|
+
storeScriptTag = result.scriptTag;
|
|
140
|
+
if (context.nonce) {
|
|
141
|
+
// Inject nonce into the script tag.
|
|
142
|
+
storeScriptTag = injectScriptNonce(storeScriptTag, context.nonce);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (options.injectHead !== false) {
|
|
147
|
+
html = injectIntoHead(html, headHtml + assetsHtml);
|
|
148
|
+
html = injectBeforeBodyEnd(html, storeScriptTag);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
html,
|
|
153
|
+
storeState,
|
|
154
|
+
context,
|
|
155
|
+
headHtml,
|
|
156
|
+
assetsHtml,
|
|
157
|
+
storeScriptTag,
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const getEncoder = (): TextEncoder => {
|
|
162
|
+
if (typeof TextEncoder === 'undefined') {
|
|
163
|
+
throw new Error('bQuery SSR: TextEncoder is not available in this runtime.');
|
|
164
|
+
}
|
|
165
|
+
return new TextEncoder();
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Renders a template into a Web `ReadableStream<Uint8Array>`. The stream is
|
|
170
|
+
* single-chunk for now (the HTML is fully resolved before flushing) but is
|
|
171
|
+
* exposed as a stream so adapters can pipe it directly into Bun/Deno/Node
|
|
172
|
+
* responses without buffering into memory twice.
|
|
173
|
+
*
|
|
174
|
+
* Future Suspense-style streaming patches will reuse the same return type.
|
|
175
|
+
*/
|
|
176
|
+
export const renderToStream = (
|
|
177
|
+
template: string,
|
|
178
|
+
data: BindingContext,
|
|
179
|
+
options: AsyncRenderOptions = {}
|
|
180
|
+
): ReadableStream<Uint8Array> => {
|
|
181
|
+
if (typeof ReadableStream === 'undefined') {
|
|
182
|
+
throw new Error('bQuery SSR: ReadableStream is not available in this runtime.');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const encoder = getEncoder();
|
|
186
|
+
const ctx = options.context ?? createSSRContext({ ...options, mode: 'stream' });
|
|
187
|
+
const merged: AsyncRenderOptions = { ...options, context: ctx };
|
|
188
|
+
|
|
189
|
+
return new ReadableStream<Uint8Array>({
|
|
190
|
+
async start(controller) {
|
|
191
|
+
const onAbort = () => {
|
|
192
|
+
try {
|
|
193
|
+
controller.error(new DOMException('SSR stream aborted', 'AbortError'));
|
|
194
|
+
} catch {
|
|
195
|
+
/* already closed */
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
if (ctx.signal.aborted) {
|
|
199
|
+
onAbort();
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const result = await renderToStringAsync(template, data, merged);
|
|
206
|
+
controller.enqueue(encoder.encode(result.html));
|
|
207
|
+
controller.close();
|
|
208
|
+
} catch (error) {
|
|
209
|
+
ctx.signal.removeEventListener('abort', onAbort);
|
|
210
|
+
try {
|
|
211
|
+
controller.error(error);
|
|
212
|
+
} catch {
|
|
213
|
+
/* already errored */
|
|
214
|
+
}
|
|
215
|
+
} finally {
|
|
216
|
+
ctx.signal.removeEventListener('abort', onAbort);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const computeWeakEtag = async (text: string): Promise<string | null> => {
|
|
223
|
+
const subtle = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto?.subtle;
|
|
224
|
+
if (!subtle) return null;
|
|
225
|
+
try {
|
|
226
|
+
const digest = await subtle.digest('SHA-1', getEncoder().encode(text));
|
|
227
|
+
const bytes = new Uint8Array(digest);
|
|
228
|
+
let hex = '';
|
|
229
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
|
|
230
|
+
return `W/"${hex.slice(0, 27)}"`;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
/** Options for `renderToResponse()`. */
|
|
237
|
+
export interface RenderToResponseOptions extends AsyncRenderOptions {
|
|
238
|
+
/** Override the response status code. */
|
|
239
|
+
status?: number;
|
|
240
|
+
/** Override the `Content-Type` header. Default: `text/html; charset=utf-8`. */
|
|
241
|
+
contentType?: string;
|
|
242
|
+
/** Set a `Cache-Control` header value. */
|
|
243
|
+
cacheControl?: string;
|
|
244
|
+
/** Whether to compute a weak ETag from the rendered HTML. Default: `false`. */
|
|
245
|
+
etag?: boolean;
|
|
246
|
+
/** Extra headers merged into the response. */
|
|
247
|
+
headers?: HeadersInit;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Renders a template and returns a `Response` ready to be returned from a
|
|
252
|
+
* `fetch`-style handler (`Bun.serve`, `Deno.serve`, Hono, Elysia, etc.).
|
|
253
|
+
*
|
|
254
|
+
* Honours `SSRContext.signal` for cancellation and `SSRContext.responseHeaders`
|
|
255
|
+
* for headers added during the render path.
|
|
256
|
+
*/
|
|
257
|
+
export const renderToResponse = async (
|
|
258
|
+
template: string,
|
|
259
|
+
data: BindingContext,
|
|
260
|
+
options: RenderToResponseOptions = {}
|
|
261
|
+
): Promise<Response> => {
|
|
262
|
+
const ctx = options.context ?? createSSRContext({ ...options, mode: 'string' });
|
|
263
|
+
const merged: AsyncRenderOptions = { ...options, context: ctx };
|
|
264
|
+
const result = await renderToStringAsync(template, data, merged);
|
|
265
|
+
const status = options.status ?? ctx.status ?? 200;
|
|
266
|
+
|
|
267
|
+
const headers = new Headers(options.headers);
|
|
268
|
+
for (const [k, v] of ctx.responseHeaders) headers.append(k, v);
|
|
269
|
+
if (!headers.has('content-type')) {
|
|
270
|
+
headers.set('content-type', options.contentType ?? 'text/html; charset=utf-8');
|
|
271
|
+
}
|
|
272
|
+
if (options.cacheControl) headers.set('cache-control', options.cacheControl);
|
|
273
|
+
|
|
274
|
+
if (options.etag) {
|
|
275
|
+
const etag = await computeWeakEtag(result.html);
|
|
276
|
+
if (etag) {
|
|
277
|
+
headers.set('etag', etag);
|
|
278
|
+
const ifNoneMatch = ctx.headers.get('if-none-match');
|
|
279
|
+
if (ifNoneMatch && ifNoneMatch === etag) {
|
|
280
|
+
return new Response(null, { status: 304, headers });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return new Response(result.html, { status, headers });
|
|
286
|
+
};
|