@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
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
121
|
+
|
|
122
|
+
const escapeAttr = (s: string): string => escapeHtml(s).replace(/"/g, '"');
|
|
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
|
/**
|