@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.
Files changed (155) hide show
  1. package/README.md +91 -65
  2. package/dist/{a11y-DG2i4iZN.js → a11y-DgUQ8-fI.js} +1 -1
  3. package/dist/{a11y-DG2i4iZN.js.map → a11y-DgUQ8-fI.js.map} +1 -1
  4. package/dist/a11y.es.mjs +1 -1
  5. package/dist/{component-DRotf1hl.js → component-D8ydhe58.js} +2 -2
  6. package/dist/{component-DRotf1hl.js.map → component-D8ydhe58.js.map} +1 -1
  7. package/dist/component.es.mjs +1 -1
  8. package/dist/concurrency-BU1wPEsZ.js.map +1 -1
  9. package/dist/{constraints-CqjhmpZC.js → constraints-Dlbx_m1b.js} +1 -1
  10. package/dist/{constraints-CqjhmpZC.js.map → constraints-Dlbx_m1b.js.map} +1 -1
  11. package/dist/{core-EMYSLzaT.js → core-tOP6QOrY.js} +2 -2
  12. package/dist/{core-EMYSLzaT.js.map → core-tOP6QOrY.js.map} +1 -1
  13. package/dist/core.es.mjs +1 -1
  14. package/dist/{custom-directives-BjFzFhuf.js → custom-directives-5DlKqvd2.js} +1 -1
  15. package/dist/{custom-directives-BjFzFhuf.js.map → custom-directives-5DlKqvd2.js.map} +1 -1
  16. package/dist/{devtools-C5FExMwv.js → devtools-QosAqo0T.js} +2 -2
  17. package/dist/{devtools-C5FExMwv.js.map → devtools-QosAqo0T.js.map} +1 -1
  18. package/dist/devtools.es.mjs +1 -1
  19. package/dist/{dnd-BAqzPlSo.js → dnd-d2OU4len.js} +1 -1
  20. package/dist/{dnd-BAqzPlSo.js.map → dnd-d2OU4len.js.map} +1 -1
  21. package/dist/dnd.es.mjs +1 -1
  22. package/dist/{forms-Dx1Scvh0.js → forms-BLx4ZzT7.js} +1 -1
  23. package/dist/{forms-Dx1Scvh0.js.map → forms-BLx4ZzT7.js.map} +1 -1
  24. package/dist/forms.es.mjs +1 -1
  25. package/dist/full.d.ts +4 -2
  26. package/dist/full.d.ts.map +1 -1
  27. package/dist/full.es.mjs +258 -219
  28. package/dist/full.iife.js +41 -37
  29. package/dist/full.iife.js.map +1 -1
  30. package/dist/full.umd.js +41 -37
  31. package/dist/full.umd.js.map +1 -1
  32. package/dist/{i18n-Cazyk9RD.js → i18n--p7PM-9r.js} +1 -1
  33. package/dist/{i18n-Cazyk9RD.js.map → i18n--p7PM-9r.js.map} +1 -1
  34. package/dist/i18n.es.mjs +1 -1
  35. package/dist/index.d.ts +3 -2
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.es.mjs +291 -252
  38. package/dist/match-CrZRVC4z.js +174 -0
  39. package/dist/match-CrZRVC4z.js.map +1 -0
  40. package/dist/{media-dAKIGPk3.js → media-gjbWNq50.js} +1 -1
  41. package/dist/{media-dAKIGPk3.js.map → media-gjbWNq50.js.map} +1 -1
  42. package/dist/media.es.mjs +1 -1
  43. package/dist/motion-BBMso9Ir.js.map +1 -1
  44. package/dist/{mount-C8O2vXkQ.js → mount-0A9qtcRJ.js} +3 -3
  45. package/dist/{mount-C8O2vXkQ.js.map → mount-0A9qtcRJ.js.map} +1 -1
  46. package/dist/platform-BPHIXbw8.js.map +1 -1
  47. package/dist/{plugin-DjTqWg-P.js → plugin-SZEirbwq.js} +2 -2
  48. package/dist/{plugin-DjTqWg-P.js.map → plugin-SZEirbwq.js.map} +1 -1
  49. package/dist/plugin.es.mjs +1 -1
  50. package/dist/reactive-BAd2hfl8.js.map +1 -1
  51. package/dist/{registry-Cr6VH8CR.js → registry-jpUQHf4E.js} +1 -1
  52. package/dist/{registry-Cr6VH8CR.js.map → registry-jpUQHf4E.js.map} +1 -1
  53. package/dist/router-C4weu0QL.js +333 -0
  54. package/dist/router-C4weu0QL.js.map +1 -0
  55. package/dist/router.es.mjs +1 -1
  56. package/dist/{sanitize-B1V4JswB.js → sanitize-DOMkRO9G.js} +12 -7
  57. package/dist/{sanitize-B1V4JswB.js.map → sanitize-DOMkRO9G.js.map} +1 -1
  58. package/dist/security.es.mjs +1 -1
  59. package/dist/server/create-server.d.ts +25 -0
  60. package/dist/server/create-server.d.ts.map +1 -0
  61. package/dist/server/index.d.ts +11 -0
  62. package/dist/server/index.d.ts.map +1 -0
  63. package/dist/server/types.d.ts +396 -0
  64. package/dist/server/types.d.ts.map +1 -0
  65. package/dist/server-QdyKtCS1.js +349 -0
  66. package/dist/server-QdyKtCS1.js.map +1 -0
  67. package/dist/server.es.mjs +6 -0
  68. package/dist/ssr/adapters.d.ts +74 -0
  69. package/dist/ssr/adapters.d.ts.map +1 -0
  70. package/dist/ssr/async.d.ts +40 -0
  71. package/dist/ssr/async.d.ts.map +1 -0
  72. package/dist/ssr/config.d.ts +60 -0
  73. package/dist/ssr/config.d.ts.map +1 -0
  74. package/dist/ssr/context.d.ts +73 -0
  75. package/dist/ssr/context.d.ts.map +1 -0
  76. package/dist/ssr/defer-brand.d.ts +5 -0
  77. package/dist/ssr/defer-brand.d.ts.map +1 -0
  78. package/dist/ssr/escape.d.ts +17 -0
  79. package/dist/ssr/escape.d.ts.map +1 -0
  80. package/dist/ssr/expression.d.ts +44 -0
  81. package/dist/ssr/expression.d.ts.map +1 -0
  82. package/dist/ssr/hash.d.ts +39 -0
  83. package/dist/ssr/hash.d.ts.map +1 -0
  84. package/dist/ssr/head.d.ts +102 -0
  85. package/dist/ssr/head.d.ts.map +1 -0
  86. package/dist/ssr/html-parser.d.ts +58 -0
  87. package/dist/ssr/html-parser.d.ts.map +1 -0
  88. package/dist/ssr/index.d.ts +49 -43
  89. package/dist/ssr/index.d.ts.map +1 -1
  90. package/dist/ssr/mismatch.d.ts +60 -0
  91. package/dist/ssr/mismatch.d.ts.map +1 -0
  92. package/dist/ssr/render-async.d.ts +84 -0
  93. package/dist/ssr/render-async.d.ts.map +1 -0
  94. package/dist/ssr/render.d.ts.map +1 -1
  95. package/dist/ssr/renderer.d.ts +25 -0
  96. package/dist/ssr/renderer.d.ts.map +1 -0
  97. package/dist/ssr/resumability.d.ts +65 -0
  98. package/dist/ssr/resumability.d.ts.map +1 -0
  99. package/dist/ssr/router-bridge.d.ts +101 -0
  100. package/dist/ssr/router-bridge.d.ts.map +1 -0
  101. package/dist/ssr/runtime.d.ts +63 -0
  102. package/dist/ssr/runtime.d.ts.map +1 -0
  103. package/dist/ssr/serialize.d.ts.map +1 -1
  104. package/dist/ssr/store-snapshot.d.ts +87 -0
  105. package/dist/ssr/store-snapshot.d.ts.map +1 -0
  106. package/dist/ssr/strategies.d.ts +43 -0
  107. package/dist/ssr/strategies.d.ts.map +1 -0
  108. package/dist/ssr/suspense.d.ts +47 -0
  109. package/dist/ssr/suspense.d.ts.map +1 -0
  110. package/dist/ssr/types.d.ts +17 -0
  111. package/dist/ssr/types.d.ts.map +1 -1
  112. package/dist/ssr-Bt6BQA3J.js +2127 -0
  113. package/dist/ssr-Bt6BQA3J.js.map +1 -0
  114. package/dist/ssr.es.mjs +42 -7
  115. package/dist/{store-CjmEeX9-.js → store-DnXuu6Li.js} +2 -2
  116. package/dist/{store-CjmEeX9-.js.map → store-DnXuu6Li.js.map} +1 -1
  117. package/dist/store.es.mjs +2 -2
  118. package/dist/storybook.es.mjs +1 -1
  119. package/dist/{testing-TdfaL7VE.js → testing-CeMUwrRD.js} +2 -2
  120. package/dist/{testing-TdfaL7VE.js.map → testing-CeMUwrRD.js.map} +1 -1
  121. package/dist/testing.es.mjs +1 -1
  122. package/dist/view.es.mjs +1 -1
  123. package/package.json +19 -14
  124. package/src/full.ts +99 -0
  125. package/src/index.ts +5 -2
  126. package/src/server/create-server.ts +754 -0
  127. package/src/server/index.ts +33 -0
  128. package/src/server/types.ts +490 -0
  129. package/src/ssr/adapters.ts +330 -0
  130. package/src/ssr/async.ts +125 -0
  131. package/src/ssr/config.ts +86 -0
  132. package/src/ssr/context.ts +245 -0
  133. package/src/ssr/defer-brand.ts +3 -0
  134. package/src/ssr/escape.ts +25 -0
  135. package/src/ssr/expression.ts +669 -0
  136. package/src/ssr/hash.ts +71 -0
  137. package/src/ssr/head.ts +240 -0
  138. package/src/ssr/html-parser.ts +387 -0
  139. package/src/ssr/index.ts +136 -43
  140. package/src/ssr/mismatch.ts +110 -0
  141. package/src/ssr/render-async.ts +286 -0
  142. package/src/ssr/render.ts +130 -59
  143. package/src/ssr/renderer.ts +453 -0
  144. package/src/ssr/resumability.ts +142 -0
  145. package/src/ssr/router-bridge.ts +177 -0
  146. package/src/ssr/runtime.ts +131 -0
  147. package/src/ssr/serialize.ts +1 -27
  148. package/src/ssr/store-snapshot.ts +209 -0
  149. package/src/ssr/strategies.ts +245 -0
  150. package/src/ssr/suspense.ts +504 -0
  151. package/src/ssr/types.ts +18 -0
  152. package/dist/router-CCepRMpC.js +0 -493
  153. package/dist/router-CCepRMpC.js.map +0 -1
  154. package/dist/ssr-D-1IPcfw.js +0 -248
  155. 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
- * Provides server-side rendering, hydration, and store state serialization
5
- * utilities for bQuery applications. Enables rendering bQuery templates
6
- * to HTML strings on the server, serializing store state for client pickup,
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
- * ## Features
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
- * - **`renderToString(template, data)`** — Server-side render a bQuery
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
- * ## Usage
22
- *
23
- * ### Server
24
- * ```ts
25
- * import { renderToString, serializeStoreState } from '@bquery/bquery/ssr';
26
- *
27
- * const { html } = renderToString(
28
- * '<div id="app"><h1 bq-text="title"></h1></div>',
29
- * { title: 'Welcome' }
30
- * );
31
- *
32
- * const { scriptTag } = serializeStoreState();
33
- *
34
- * // Send to client: html + scriptTag
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ };