@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
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Suspense-style out-of-order streaming for SSR.
3
+ *
4
+ * Renders the synchronous shell with `defer(...)` placeholders wrapped in
5
+ * `<bq-slot id="bq-s-N">…</bq-slot>` markers, flushes that initial chunk,
6
+ * then streams a `<template id="bq-r-N">…</template>` plus a tiny inline
7
+ * patch script for every resolved promise. The patch script swaps the
8
+ * template content into the placeholder and removes both.
9
+ *
10
+ * Honours `SSRContext.signal` for cancellation and propagates the context
11
+ * nonce onto every emitted `<script>` tag for CSP-strict environments.
12
+ *
13
+ * @module bquery/ssr
14
+ */
15
+
16
+ import { isComputed, isSignal, type Signal } from '../reactive/index';
17
+ import type { BindingContext } from '../view/types';
18
+ import { createSSRContext, type SSRContext } from './context';
19
+ import { DEFER_BRAND } from './defer-brand';
20
+ import { parseTemplate, serializeTree, type SSRElement, type SSRNode } from './html-parser';
21
+ import { renderToString } from './render';
22
+ import type { AsyncRenderOptions } from './render-async';
23
+
24
+ interface DeferredLike<T = unknown> {
25
+ [DEFER_BRAND]: true;
26
+ promise: Promise<T>;
27
+ fallback?: unknown;
28
+ }
29
+
30
+ const isDeferredLike = (value: unknown): value is DeferredLike =>
31
+ typeof value === 'object' &&
32
+ value !== null &&
33
+ (value as Record<symbol, unknown>)[DEFER_BRAND] === true;
34
+
35
+ const isReactive = (value: unknown): boolean => isSignal(value) || isComputed(value);
36
+
37
+ /** A single slot collected by `renderToStreamSuspense`. */
38
+ interface SuspenseSlot {
39
+ id: string;
40
+ key: string;
41
+ promise: Promise<unknown>;
42
+ }
43
+
44
+ type SettledSuspenseSlot =
45
+ | { index: number; slot: SuspenseSlot; ok: true; value: unknown }
46
+ | { index: number; slot: SuspenseSlot; ok: false; error: unknown };
47
+
48
+ /**
49
+ * Whitelist regex for slot/template IDs. The IDs end up inside an inline
50
+ * `<script>` patch, and while `escapeScriptBody()` already protects against
51
+ * `</script>` injection, validating the prefix at the boundary is defense in
52
+ * depth. Allows ASCII letters, digits, `-` and `_`.
53
+ */
54
+ const SAFE_ID_RE = /^[A-Za-z][\w-]*$/;
55
+ /** Enforces a lowercase custom-element-style tag name with a required hyphen. */
56
+ const SAFE_SLOT_TAG_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
57
+
58
+ const sanitizeSlotPrefix = (prefix: string, fallback: string): string => {
59
+ if (typeof prefix !== 'string' || !SAFE_ID_RE.test(prefix)) return fallback;
60
+ return prefix;
61
+ };
62
+
63
+ const sanitizeSlotTag = (tag: string | undefined, fallback: string): string => {
64
+ if (typeof tag !== 'string' || !SAFE_SLOT_TAG_RE.test(tag)) return fallback;
65
+ return tag;
66
+ };
67
+
68
+ /**
69
+ * Derives a template ID prefix that cannot collide with placeholder slot IDs.
70
+ * The default `bq-s` becomes `bq-r`; custom prefixes without that suffix get
71
+ * `-r` appended, so `slot` produces `slot-r` instead of a duplicate `slot`.
72
+ * Prefixes already ending in `-r` get `-template` to avoid `-r-r`.
73
+ */
74
+ const getResolvedIdPrefix = (slotIdPrefix: string): string => {
75
+ const candidate = slotIdPrefix.replace(/-s$/, '-r');
76
+ if (candidate === slotIdPrefix && slotIdPrefix.endsWith('-r')) {
77
+ return `${slotIdPrefix}-template`;
78
+ }
79
+ return candidate === slotIdPrefix ? `${slotIdPrefix}-r` : candidate;
80
+ };
81
+
82
+ /**
83
+ * Build a synchronous rendering context where every `defer(...)` value is
84
+ * replaced by a placeholder string and every other Promise/loader is
85
+ * replaced by its fallback (`undefined`). The deferred values are recorded
86
+ * so the streaming loop can resolve them after the shell flushes.
87
+ */
88
+ const splitDeferred = (
89
+ context: BindingContext,
90
+ prefix: string
91
+ ): { syncContext: BindingContext; slots: SuspenseSlot[] } => {
92
+ const syncContext: BindingContext = {};
93
+ const slots: SuspenseSlot[] = [];
94
+ let counter = 0;
95
+ for (const [key, value] of Object.entries(context)) {
96
+ if (isReactive(value)) {
97
+ syncContext[key] = value as Signal<unknown>;
98
+ continue;
99
+ }
100
+ if (isDeferredLike(value)) {
101
+ const id = `${prefix}-${counter++}`;
102
+ slots.push({ id, key, promise: value.promise });
103
+ // Render with the fallback so the synchronous shell has *something*.
104
+ syncContext[key] = value.fallback;
105
+ continue;
106
+ }
107
+ if (value && typeof (value as Promise<unknown>).then === 'function') {
108
+ // Bare promises become deferred slots without a fallback.
109
+ const id = `${prefix}-${counter++}`;
110
+ slots.push({ id, key, promise: value as Promise<unknown> });
111
+ syncContext[key] = undefined;
112
+ continue;
113
+ }
114
+ syncContext[key] = value;
115
+ }
116
+ return { syncContext, slots };
117
+ };
118
+
119
+ const escapeHtml = (s: string): string =>
120
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
121
+
122
+ const escapeAttr = (s: string): string => escapeHtml(s).replace(/"/g, '&quot;');
123
+
124
+ const escapeScriptBody = (s: string): string =>
125
+ s
126
+ .replace(/<\/(script)/gi, '<\\/$1')
127
+ .replace(/<!--/g, '<\\!--')
128
+ .replace(/\u2028/g, '\\u2028')
129
+ .replace(/\u2029/g, '\\u2029');
130
+
131
+ /**
132
+ * Options for `renderToStreamSuspense`.
133
+ *
134
+ * Only `context`, `prefix`, `stripDirectives`, and `annotateHydration` are
135
+ * inherited from the base async render options. Head/store injection and other
136
+ * response-shaping options are intentionally unsupported here.
137
+ */
138
+ export interface SuspenseStreamOptions
139
+ extends Pick<AsyncRenderOptions, 'context' | 'prefix' | 'stripDirectives' | 'annotateHydration'> {
140
+ /**
141
+ * Prefix used for slot/template IDs. Default: `'bq-s'` for placeholders
142
+ * and `'bq-r'` for resolved templates.
143
+ */
144
+ slotIdPrefix?: string;
145
+ /**
146
+ * Tag name used for the placeholder element. Default: `'bq-slot'`.
147
+ * Must be a valid custom-element tag (contain a `-`) so the browser keeps
148
+ * inner content intact.
149
+ */
150
+ slotTag?: string;
151
+ }
152
+
153
+ /**
154
+ * Static patch script for streamed Suspense fragments.
155
+ *
156
+ * The server passes the variable slot/template IDs through escaped
157
+ * `data-bq-slot` / `data-bq-template` attributes on the `<script>` tag, and
158
+ * the script reads them from `document.currentScript`. This keeps the emitted
159
+ * code body constant while still letting each streamed patch target a
160
+ * different placeholder/template pair.
161
+ */
162
+ const PATCH_SCRIPT_BODY = escapeScriptBody(
163
+ '(()=>{var c=document.currentScript;if(!c)return;var slotId=c.getAttribute("data-bq-slot");var templateId=c.getAttribute("data-bq-template");if(!slotId||!templateId)return;var s=document.getElementById(slotId);var t=document.getElementById(templateId);if(!s||!t)return;var f=t.content?t.content.cloneNode(true):t;while(s.firstChild)s.removeChild(s.firstChild);s.appendChild(f);t.parentNode&&t.parentNode.removeChild(t);s.parentNode&&s.replaceWith(...s.childNodes);})();'
164
+ );
165
+
166
+ const buildPatchScript = (slotId: string, resolvedId: string, nonce?: string): string => {
167
+ const nonceAttr = nonce ? ` nonce="${escapeAttr(nonce)}"` : '';
168
+ return `<script${nonceAttr} data-bq-slot="${escapeAttr(slotId)}" data-bq-template="${escapeAttr(resolvedId)}">${PATCH_SCRIPT_BODY}</script>`;
169
+ };
170
+
171
+ const renderResolvedFragment = (
172
+ _template: string,
173
+ fullContext: BindingContext,
174
+ syncContext: BindingContext,
175
+ key: string,
176
+ resolved: unknown,
177
+ options: SuspenseStreamOptions
178
+ ): string => {
179
+ // Re-render only the wrapping placeholder content. We use the original
180
+ // template if the user provided a slot template via context (`__suspense_<key>`)
181
+ // or fall back to a stringification of the resolved value.
182
+ const slotTemplateKey = `__suspense_${key}`;
183
+ const slotTemplate = (fullContext as Record<string, unknown>)[slotTemplateKey];
184
+ if (typeof slotTemplate === 'string') {
185
+ return renderToString(
186
+ slotTemplate,
187
+ { ...syncContext, [key]: resolved },
188
+ {
189
+ prefix: options.prefix,
190
+ stripDirectives: options.stripDirectives,
191
+ annotateHydration: options.annotateHydration,
192
+ }
193
+ ).html;
194
+ }
195
+ // Default: stringify the resolved value (with HTML escaping).
196
+ if (resolved === null || resolved === undefined) return '';
197
+ if (typeof resolved === 'string') return escapeHtml(resolved);
198
+ return escapeHtml(String(resolved));
199
+ // NOTE: the simple template path also serves as a hint for `bq-text`-style
200
+ // bindings; rich nested rendering can be done by passing a slot template.
201
+ };
202
+
203
+ const removeAttr = (el: SSRElement, name: string): void => {
204
+ if (!(name in el.attributes)) return;
205
+ delete el.attributes[name];
206
+ const index = el.attributeOrder.indexOf(name);
207
+ if (index !== -1) el.attributeOrder.splice(index, 1);
208
+ };
209
+
210
+ const createSlotWrapper = (slotTag: string, slotId: string, children: SSRNode[] = []): SSRElement => ({
211
+ type: 'element',
212
+ tag: slotTag,
213
+ attributes: { id: slotId },
214
+ attributeOrder: ['id'],
215
+ children,
216
+ void: false,
217
+ raw: false,
218
+ });
219
+
220
+ const visitElements = (nodes: SSRNode[], visit: (element: SSRElement) => void): void => {
221
+ for (const node of nodes) {
222
+ if (node.type !== 'element') continue;
223
+ visit(node);
224
+ visitElements(node.children, visit);
225
+ }
226
+ };
227
+
228
+ const findElementByTag = (nodes: SSRNode[], tag: string): SSRElement | null => {
229
+ for (const node of nodes) {
230
+ if (node.type !== 'element') continue;
231
+ if (node.tag === tag) return node;
232
+ const nested = findElementByTag(node.children, tag);
233
+ if (nested) return nested;
234
+ }
235
+ return null;
236
+ };
237
+
238
+ const replaceSlotsInShell = (
239
+ html: string,
240
+ context: BindingContext,
241
+ slots: SuspenseSlot[],
242
+ options: SuspenseStreamOptions
243
+ ): string => {
244
+ // Wrap the original placeholder text within a `<slotTag>` element so we can
245
+ // patch it on the client. Only the parts that *come from* a deferred value
246
+ // need wrapping; the synchronous renderer already produced them as text
247
+ // (the fallback). We surround the *entire* fallback text in the slot tag.
248
+ // To keep the renderer agnostic, we wrap the resolved value's fallback
249
+ // wherever the renderer placed it. We rely on a marker attribute set by the
250
+ // user (`bq-defer="key"`) to mark where the slot wrapper goes.
251
+ // Without such a marker, the slot fallback is already inlined and we
252
+ // append the resolved templates at the end of <body>.
253
+ const slotTag = sanitizeSlotTag(options.slotTag, 'bq-slot');
254
+ const root = parseTemplate(html);
255
+ const slotsByKey = new Map(slots.map((slot) => [slot.key, slot]));
256
+ const placed = new Set<string>();
257
+
258
+ visitElements(root.children, (element) => {
259
+ const marker = element.attributes['data-bq-defer'];
260
+ if (!marker) return;
261
+ removeAttr(element, 'data-bq-defer');
262
+ const slot = slotsByKey.get(marker);
263
+ if (!slot || placed.has(slot.id)) return;
264
+ const wrappedChildren = element.children;
265
+ element.children = [createSlotWrapper(slotTag, slot.id, wrappedChildren)];
266
+ placed.add(slot.id);
267
+ });
268
+
269
+ // If we didn't find any markers but slots exist, append placeholders at the
270
+ // end of <body> (or the end of html) so the user can still see updates.
271
+ const body = findElementByTag(root.children, 'body');
272
+ const appendTarget = body?.children ?? root.children;
273
+ for (const slot of slots) {
274
+ if (placed.has(slot.id)) continue;
275
+ appendTarget.push(createSlotWrapper(slotTag, slot.id));
276
+ }
277
+ // `context` reference suppressed to keep the function side-effect free
278
+ // for the static analysis; the render uses syncContext where needed.
279
+ void context;
280
+ return serializeTree(root);
281
+ };
282
+
283
+ /** Returns true for characters that can appear immediately before an attribute name. */
284
+ const canPrecedeAttributeName = (ch: string | undefined): boolean => ch !== undefined && /\s/.test(ch);
285
+
286
+ /** Returns true for characters that can terminate an attribute name in a start tag. */
287
+ const canFollowAttributeName = (ch: string | undefined): boolean =>
288
+ ch === undefined || ch === '=' || ch === '>' || ch === '/' || /\s/.test(ch);
289
+
290
+ /**
291
+ * Converts author-facing `bq-defer` markers to an internal data attribute
292
+ * before rendering so `stripDirectives` can remove regular `bq-*` directives
293
+ * without losing Suspense slot placement. This is a linear scanner instead of
294
+ * a regex because templates are caller-provided strings.
295
+ */
296
+ const protectDeferMarkers = (template: string): string => {
297
+ let out = '';
298
+ let i = 0;
299
+ let inTag = false;
300
+ let quote: '"' | "'" | '' = '';
301
+ let tagNameEnded = false;
302
+ let tagLeadSeen = false;
303
+ let allowAttributeRewrite = true;
304
+ const marker = 'bq-defer';
305
+
306
+ while (i < template.length) {
307
+ const ch = template[i];
308
+
309
+ if (!inTag) {
310
+ if (ch === '<') {
311
+ inTag = true;
312
+ tagNameEnded = false;
313
+ tagLeadSeen = false;
314
+ allowAttributeRewrite = true;
315
+ }
316
+ out += ch;
317
+ i++;
318
+ continue;
319
+ }
320
+
321
+ if (quote) {
322
+ out += ch;
323
+ if (ch === quote) quote = '';
324
+ i++;
325
+ continue;
326
+ }
327
+
328
+ if (ch === '"' || ch === "'") {
329
+ quote = ch;
330
+ out += ch;
331
+ i++;
332
+ continue;
333
+ }
334
+
335
+ if (ch === '>') {
336
+ inTag = false;
337
+ out += ch;
338
+ i++;
339
+ continue;
340
+ }
341
+
342
+ if (!tagNameEnded) {
343
+ out += ch;
344
+ i++;
345
+
346
+ if (!tagLeadSeen) {
347
+ if (/\s/.test(ch)) continue;
348
+ tagLeadSeen = true;
349
+ if (ch === '/' || ch === '!' || ch === '?') {
350
+ allowAttributeRewrite = false;
351
+ }
352
+ continue;
353
+ }
354
+
355
+ if (allowAttributeRewrite && /\s/.test(ch)) {
356
+ tagNameEnded = true;
357
+ }
358
+ continue;
359
+ }
360
+
361
+ if (
362
+ allowAttributeRewrite &&
363
+ template.startsWith(marker, i) &&
364
+ canPrecedeAttributeName(template[i - 1]) &&
365
+ canFollowAttributeName(template[i + marker.length])
366
+ ) {
367
+ out += 'data-bq-defer';
368
+ i += marker.length;
369
+ continue;
370
+ }
371
+
372
+ out += ch;
373
+ i++;
374
+ }
375
+
376
+ return out;
377
+ };
378
+
379
+ const getEncoder = (): TextEncoder => {
380
+ if (typeof TextEncoder === 'undefined') {
381
+ throw new Error('bQuery SSR: TextEncoder is not available in this runtime.');
382
+ }
383
+ return new TextEncoder();
384
+ };
385
+
386
+ /**
387
+ * Renders a template into a Web `ReadableStream<Uint8Array>` with
388
+ * Suspense-style out-of-order streaming. The synchronous shell is flushed
389
+ * first; deferred slots stream in as their promises resolve.
390
+ *
391
+ * Use `bq-defer="key"` on an element whose content depends on a `defer()`
392
+ * value to mark where the placeholder wrapping should happen. Without the
393
+ * marker, resolved fragments are appended at the end of `<body>`.
394
+ */
395
+ export const renderToStreamSuspense = (
396
+ template: string,
397
+ data: BindingContext,
398
+ options: SuspenseStreamOptions = {}
399
+ ): ReadableStream<Uint8Array> => {
400
+ if (typeof ReadableStream === 'undefined') {
401
+ throw new Error('bQuery SSR: ReadableStream is not available in this runtime.');
402
+ }
403
+ const encoder = getEncoder();
404
+ const ctx: SSRContext = options.context ?? createSSRContext({ ...options, mode: 'stream' });
405
+ const slotIdPrefix = sanitizeSlotPrefix(options.slotIdPrefix ?? 'bq-s', 'bq-s');
406
+ const resolvedIdPrefix = sanitizeSlotPrefix(getResolvedIdPrefix(slotIdPrefix), 'bq-r');
407
+
408
+ return new ReadableStream<Uint8Array>({
409
+ async start(controller) {
410
+ const onAbort = () => {
411
+ try {
412
+ controller.error(new DOMException('SSR stream aborted', 'AbortError'));
413
+ } catch {
414
+ /* already closed */
415
+ }
416
+ };
417
+ if (ctx.signal.aborted) {
418
+ onAbort();
419
+ return;
420
+ }
421
+ ctx.signal.addEventListener('abort', onAbort, { once: true });
422
+
423
+ try {
424
+ const { syncContext, slots } = splitDeferred(data, slotIdPrefix);
425
+ const shellTemplate = protectDeferMarkers(template);
426
+ // Render the synchronous shell with fallbacks.
427
+ const shell = renderToString(shellTemplate, syncContext, {
428
+ prefix: options.prefix,
429
+ stripDirectives: options.stripDirectives,
430
+ annotateHydration: options.annotateHydration,
431
+ }).html;
432
+
433
+ const wrapped = replaceSlotsInShell(shell, syncContext, slots, options);
434
+ controller.enqueue(encoder.encode(wrapped));
435
+
436
+ // Resolve slots in arrival order so the network can flush as soon as
437
+ // each promise settles. Track entries by array position because
438
+ // multiple slots may intentionally share the same promise instance.
439
+ const settledQueue: SettledSuspenseSlot[] = [];
440
+ const waiters: Array<(settled: SettledSuspenseSlot) => void> = [];
441
+ let remaining = slots.length;
442
+ const enqueueSettled = (settled: SettledSuspenseSlot): void => {
443
+ const waiter = waiters.shift();
444
+ if (waiter) {
445
+ waiter(settled);
446
+ return;
447
+ }
448
+ settledQueue.push(settled);
449
+ };
450
+
451
+ for (const [index, slot] of slots.entries()) {
452
+ slot.promise.then<SettledSuspenseSlot, SettledSuspenseSlot>(
453
+ (value) => ({ index, slot, ok: true, value }),
454
+ (error) => ({ index, slot, ok: false, error })
455
+ ).then(enqueueSettled);
456
+ }
457
+
458
+ const nextSettled = async (): Promise<SettledSuspenseSlot> => {
459
+ const queued = settledQueue.shift();
460
+ if (queued) return queued;
461
+ return new Promise<SettledSuspenseSlot>((resolve) => {
462
+ waiters.push(resolve);
463
+ });
464
+ };
465
+
466
+ while (remaining > 0) {
467
+ if (ctx.signal.aborted) {
468
+ return;
469
+ }
470
+ const settled = await nextSettled();
471
+ remaining--;
472
+ const { slot } = settled;
473
+ const resolvedId = `${resolvedIdPrefix}-${slot.id.split('-').pop()}`;
474
+ let resolvedHtml: string;
475
+ if (!settled.ok) {
476
+ ctx.reportError(settled.error);
477
+ resolvedHtml = '';
478
+ } else {
479
+ resolvedHtml = renderResolvedFragment(
480
+ template,
481
+ data,
482
+ syncContext,
483
+ slot.key,
484
+ settled.value,
485
+ options
486
+ );
487
+ }
488
+ const tpl = `<template id="${escapeAttr(resolvedId)}">${resolvedHtml}</template>`;
489
+ const patch = buildPatchScript(slot.id, resolvedId, ctx.nonce);
490
+ controller.enqueue(encoder.encode(tpl + patch));
491
+ }
492
+ controller.close();
493
+ } catch (error) {
494
+ try {
495
+ controller.error(error);
496
+ } catch {
497
+ /* already errored */
498
+ }
499
+ } finally {
500
+ ctx.signal.removeEventListener('abort', onAbort);
501
+ }
502
+ },
503
+ });
504
+ };
package/src/ssr/types.ts CHANGED
@@ -29,6 +29,24 @@ export type RenderOptions = {
29
29
  * @default false
30
30
  */
31
31
  includeStoreState?: boolean | string[];
32
+
33
+ /**
34
+ * Whether to add a `data-bq-h` mismatch hash to every element that carries
35
+ * a `bq-*` directive. Used by `verifyHydration()` on the client to flag
36
+ * Server↔Client divergence in development. Adds ≈ 6–8 bytes per directive
37
+ * element; safe to leave on in production but only useful in dev builds.
38
+ *
39
+ * Do not combine this with `stripDirectives: true` if you plan to call
40
+ * `verifyHydration()` later: once the `bq-*` / `:` directives are stripped
41
+ * from the HTML, the client can no longer recompute the original signature,
42
+ * so verification will deterministically report mismatches.
43
+ *
44
+ * Currently honoured by the DOM-free renderer; the legacy DOM backend
45
+ * applies the same annotation when `DOMParser` is available.
46
+ *
47
+ * @default false
48
+ */
49
+ annotateHydration?: boolean;
32
50
  };
33
51
 
34
52
  /**