@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,330 @@
1
+ /**
2
+ * Runtime adapters for SSR.
3
+ *
4
+ * Provide thin glue functions that turn a bQuery render handler into a
5
+ * runtime-native server callback. They share a common signature so the same
6
+ * application can be served by Bun, Deno, Node and any Web-`fetch` host.
7
+ *
8
+ * @module bquery/ssr
9
+ */
10
+
11
+ import type { SSRContext } from './context';
12
+ import { detectRuntime } from './runtime';
13
+
14
+ /** A handler that turns a request into a Response (Web-fetch style). */
15
+ export type SSRRequestHandler = (
16
+ request: Request,
17
+ context?: SSRContext
18
+ ) => Promise<Response> | Response;
19
+
20
+ /* ---------------------------------------------------------------------------
21
+ * Web (generic fetch) adapter
22
+ * ------------------------------------------------------------------------- */
23
+
24
+ /**
25
+ * Identity adapter for Web-`fetch` style hosts (Hono, Elysia, Workerd, edge
26
+ * runtimes). Exists for symmetry and future logging hooks.
27
+ */
28
+ export const createWebHandler = (handler: SSRRequestHandler): SSRRequestHandler => handler;
29
+
30
+ /* ---------------------------------------------------------------------------
31
+ * Bun adapter
32
+ * ------------------------------------------------------------------------- */
33
+
34
+ /**
35
+ * Wraps a handler for `Bun.serve()`. Returns a function with Bun's expected
36
+ * signature `(request, server) => Response | Promise<Response>`.
37
+ */
38
+ export const createBunHandler = (
39
+ handler: SSRRequestHandler
40
+ ): ((request: Request) => Promise<Response>) => {
41
+ return async (request) => Promise.resolve(handler(request));
42
+ };
43
+
44
+ /* ---------------------------------------------------------------------------
45
+ * Deno adapter
46
+ * ------------------------------------------------------------------------- */
47
+
48
+ /**
49
+ * Wraps a handler for `Deno.serve()`. Returns a function with Deno's expected
50
+ * signature `(request, info?) => Response | Promise<Response>`.
51
+ */
52
+ export const createDenoHandler = (
53
+ handler: SSRRequestHandler
54
+ ): ((request: Request) => Promise<Response>) => {
55
+ return async (request) => Promise.resolve(handler(request));
56
+ };
57
+
58
+ /* ---------------------------------------------------------------------------
59
+ * Node adapter (`node:http`)
60
+ * ------------------------------------------------------------------------- */
61
+
62
+ /** Minimal subset of `node:http` IncomingMessage we rely on. */
63
+ export interface NodeIncomingMessage {
64
+ url?: string;
65
+ method?: string;
66
+ headers: Record<string, string | string[] | undefined>;
67
+ on(event: 'data', listener: (chunk: Uint8Array | string) => void): void;
68
+ on(event: 'end', listener: () => void): void;
69
+ on(event: 'error', listener: (err: unknown) => void): void;
70
+ destroy?(error?: Error): void;
71
+ }
72
+
73
+ /** Minimal subset of `node:http` ServerResponse we rely on. */
74
+ export interface NodeServerResponse {
75
+ statusCode: number;
76
+ setHeader(name: string, value: string | number | readonly string[]): void;
77
+ write(chunk: Uint8Array | string): boolean;
78
+ end(chunk?: Uint8Array | string): void;
79
+ once?(event: 'drain' | 'error', listener: (error?: unknown) => void): void;
80
+ on?(event: 'drain' | 'error', listener: (error?: unknown) => void): void;
81
+ }
82
+
83
+ /** Optional hardening settings for the `node:http` adapter. */
84
+ export interface NodeHandlerOptions {
85
+ /** Reject request bodies that exceed this many bytes. Default: unlimited. */
86
+ maxBodyBytes?: number;
87
+ }
88
+
89
+ const shouldReadNodeBody = (method: string): boolean => method !== 'GET' && method !== 'HEAD';
90
+
91
+ class NodeRequestLimitError extends Error {
92
+ constructor(message: string) {
93
+ super(message);
94
+ this.name = 'NodeRequestLimitError';
95
+ }
96
+ }
97
+
98
+ const getSingleHeader = (
99
+ headers: NodeIncomingMessage['headers'],
100
+ name: string
101
+ ): string | undefined => {
102
+ const value = headers[name];
103
+ if (Array.isArray(value)) return value[0];
104
+ return value;
105
+ };
106
+
107
+ const getContentLength = (req: NodeIncomingMessage): number | null => {
108
+ const header = getSingleHeader(req.headers, 'content-length');
109
+ if (!header) return null;
110
+ const value = Number.parseInt(header, 10);
111
+ return Number.isSafeInteger(value) && value >= 0 ? value : null;
112
+ };
113
+
114
+ const readNodeBody = (req: NodeIncomingMessage, maxBodyBytes?: number): Promise<ArrayBuffer> =>
115
+ new Promise((resolve, reject) => {
116
+ const chunks: Uint8Array[] = [];
117
+ let total = 0;
118
+ let done = false;
119
+ const fail = (error: unknown): void => {
120
+ if (done) return;
121
+ done = true;
122
+ chunks.length = 0;
123
+ total = 0;
124
+ req.destroy?.(error instanceof Error ? error : undefined);
125
+ reject(error);
126
+ };
127
+
128
+ const declaredLength = getContentLength(req);
129
+ if (maxBodyBytes !== undefined && declaredLength !== null && declaredLength > maxBodyBytes) {
130
+ fail(new NodeRequestLimitError(`Request body exceeds ${maxBodyBytes} bytes.`));
131
+ return;
132
+ }
133
+
134
+ req.on('data', (chunk) => {
135
+ if (done) return;
136
+ const bytes = typeof chunk === 'string' ? new TextEncoder().encode(chunk) : chunk;
137
+ total += bytes.byteLength;
138
+ if (maxBodyBytes !== undefined && total > maxBodyBytes) {
139
+ fail(new NodeRequestLimitError(`Request body exceeds ${maxBodyBytes} bytes.`));
140
+ return;
141
+ }
142
+ chunks.push(bytes);
143
+ });
144
+ req.on('end', () => {
145
+ if (done) return;
146
+ done = true;
147
+ const buffer = new ArrayBuffer(total);
148
+ const body = new Uint8Array(buffer);
149
+ let offset = 0;
150
+ for (const chunk of chunks) {
151
+ body.set(chunk, offset);
152
+ offset += chunk.byteLength;
153
+ }
154
+ resolve(buffer);
155
+ });
156
+ req.on('error', fail);
157
+ });
158
+
159
+ const buildNodeUrl = (req: NodeIncomingMessage, protocol: string): URL => {
160
+ const fallbackOrigin = `${protocol}://localhost`;
161
+ const host = getSingleHeader(req.headers, 'host') || 'localhost';
162
+ try {
163
+ return new URL(req.url ?? '/', `${protocol}://${host}`);
164
+ } catch {
165
+ try {
166
+ return new URL(req.url ?? '/', fallbackOrigin);
167
+ } catch {
168
+ return new URL('/', fallbackOrigin);
169
+ }
170
+ }
171
+ };
172
+
173
+ const buildRequestFromNode = async (
174
+ req: NodeIncomingMessage,
175
+ options: NodeHandlerOptions = {}
176
+ ): Promise<Request> => {
177
+ // Only honour `x-forwarded-proto` when it advertises a known protocol.
178
+ // This adapter assumes deployment behind a trusted reverse proxy; callers
179
+ // exposing `node:http` directly to the public internet should strip
180
+ // `x-forwarded-*` headers in their proxy layer.
181
+ const forwardedProto =
182
+ typeof getSingleHeader(req.headers, 'x-forwarded-proto') === 'string'
183
+ ? (getSingleHeader(req.headers, 'x-forwarded-proto') as string)
184
+ .split(',')[0]
185
+ .trim()
186
+ .toLowerCase()
187
+ : '';
188
+ const protocol =
189
+ forwardedProto === 'http' || forwardedProto === 'https' ? forwardedProto : 'http';
190
+ const url = buildNodeUrl(req, protocol);
191
+
192
+ const headers = new Headers();
193
+ for (const [name, value] of Object.entries(req.headers)) {
194
+ if (value === undefined) continue;
195
+ if (Array.isArray(value)) {
196
+ for (const v of value) headers.append(name, v);
197
+ } else {
198
+ headers.append(name, value);
199
+ }
200
+ }
201
+
202
+ const upperMethod = (req.method ?? 'GET').toUpperCase();
203
+ const init: RequestInit = {
204
+ method: upperMethod,
205
+ headers,
206
+ };
207
+
208
+ if (shouldReadNodeBody(upperMethod)) {
209
+ init.body = await readNodeBody(req, options.maxBodyBytes);
210
+ }
211
+
212
+ return new Request(url.toString(), init);
213
+ };
214
+
215
+ type HeadersWithSetCookie = Headers & {
216
+ getSetCookie?: () => string[];
217
+ };
218
+
219
+ const getSetCookieHeaderValues = (headers: Headers): string[] => {
220
+ const setCookies = (headers as HeadersWithSetCookie).getSetCookie?.();
221
+ if (Array.isArray(setCookies) && setCookies.length > 0) {
222
+ return setCookies;
223
+ }
224
+ const fallback = headers.get('set-cookie');
225
+ return fallback ? [fallback] : [];
226
+ };
227
+
228
+ const waitForNodeDrain = (res: NodeServerResponse): Promise<void> =>
229
+ new Promise((resolve, reject) => {
230
+ const once = typeof res.once === 'function' ? res.once.bind(res) : undefined;
231
+ const on = typeof res.on === 'function' ? res.on.bind(res) : undefined;
232
+ const subscribe = once ?? on;
233
+ if (!subscribe) {
234
+ resolve();
235
+ return;
236
+ }
237
+ subscribe('drain', () => resolve());
238
+ subscribe('error', (error?: unknown) => {
239
+ reject(
240
+ error instanceof Error ? error : new Error('Node response stream errored while draining.')
241
+ );
242
+ });
243
+ });
244
+
245
+ const writeResponseToNode = async (response: Response, res: NodeServerResponse): Promise<void> => {
246
+ res.statusCode = response.status;
247
+ const setCookies = getSetCookieHeaderValues(response.headers);
248
+ if (setCookies.length > 0) {
249
+ res.setHeader('set-cookie', setCookies.length === 1 ? setCookies[0] : setCookies);
250
+ }
251
+ response.headers.forEach((value, name) => {
252
+ if (name.toLowerCase() === 'set-cookie') return;
253
+ res.setHeader(name, value);
254
+ });
255
+
256
+ if (!response.body) {
257
+ res.end();
258
+ return;
259
+ }
260
+
261
+ const reader = response.body.getReader();
262
+ while (true) {
263
+ const { value, done } = await reader.read();
264
+ if (done) break;
265
+ if (value && !res.write(value)) {
266
+ await waitForNodeDrain(res);
267
+ }
268
+ }
269
+ res.end();
270
+ };
271
+
272
+ /**
273
+ * Wraps a handler so it can be passed directly to a `node:http` server.
274
+ *
275
+ * @example
276
+ * ```ts
277
+ * import { createServer } from 'node:http';
278
+ * import { createNodeHandler, renderToResponse } from '@bquery/bquery/ssr';
279
+ *
280
+ * const handler = createNodeHandler(async (request) => {
281
+ * return renderToResponse('<div bq-text="msg"></div>', { msg: 'Hello' });
282
+ * });
283
+ *
284
+ * createServer(handler).listen(3000);
285
+ * ```
286
+ */
287
+ export const createNodeHandler = (
288
+ handler: SSRRequestHandler,
289
+ options: NodeHandlerOptions = {}
290
+ ): ((req: NodeIncomingMessage, res: NodeServerResponse) => Promise<void>) => {
291
+ return async (req, res) => {
292
+ let request: Request;
293
+ try {
294
+ request = await buildRequestFromNode(req, options);
295
+ } catch (error) {
296
+ if (error instanceof NodeRequestLimitError) {
297
+ await writeResponseToNode(new Response(error.message, { status: 413 }), res);
298
+ return;
299
+ }
300
+ throw error;
301
+ }
302
+ const response = await Promise.resolve(handler(request));
303
+ await writeResponseToNode(response, res);
304
+ };
305
+ };
306
+
307
+ /* ---------------------------------------------------------------------------
308
+ * Auto-detection
309
+ * ------------------------------------------------------------------------- */
310
+
311
+ /**
312
+ * Convenience helper that picks the right adapter based on the current
313
+ * runtime. Returns the same handler unchanged for Web/Bun/Deno (they share a
314
+ * fetch-style signature). On Node it returns the `node:http` adapter.
315
+ */
316
+ export const createSSRHandler = (
317
+ handler: SSRRequestHandler
318
+ ): SSRRequestHandler | ((req: NodeIncomingMessage, res: NodeServerResponse) => Promise<void>) => {
319
+ const runtime = detectRuntime();
320
+ switch (runtime) {
321
+ case 'node':
322
+ return createNodeHandler(handler);
323
+ case 'bun':
324
+ return createBunHandler(handler);
325
+ case 'deno':
326
+ return createDenoHandler(handler);
327
+ default:
328
+ return createWebHandler(handler);
329
+ }
330
+ };
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Async data helpers for SSR.
3
+ *
4
+ * These tiny utilities let `renderToStringAsync()`/`renderToStream()` await
5
+ * `Promise`-shaped values inside the binding context before the templates are
6
+ * evaluated, without coupling the synchronous renderer to async semantics.
7
+ *
8
+ * @module bquery/ssr
9
+ */
10
+
11
+ import { isComputed, isSignal, type Signal } from '../reactive/index';
12
+ import type { BindingContext } from '../view/types';
13
+ import { isPrototypePollutionKey } from '../core/utils/object';
14
+ import type { SSRContext } from './context';
15
+ import { DEFER_BRAND } from './defer-brand';
16
+
17
+ /** A loader function executed before render. */
18
+ export type SSRLoader<T = unknown> = (ctx: SSRContext) => T | Promise<T>;
19
+
20
+ /**
21
+ * Wraps a loader so it can be invoked or stored uniformly. The wrapper is
22
+ * tagged with the internal defer brand so `resolveContext()` recognises it
23
+ * and calls the loader with the active `SSRContext`.
24
+ */
25
+ export const defineLoader = <T>(loader: SSRLoader<T>): SSRLoader<T> => {
26
+ Object.defineProperty(loader, DEFER_BRAND, {
27
+ value: true,
28
+ enumerable: false,
29
+ configurable: true,
30
+ });
31
+ return loader;
32
+ };
33
+
34
+ interface DeferredValue<T> {
35
+ [DEFER_BRAND]: true;
36
+ promise: Promise<T>;
37
+ fallback?: unknown;
38
+ }
39
+
40
+ /**
41
+ * Marks a promise as "may resolve in parallel". When `renderToStringAsync()`
42
+ * sees a deferred value in the context, it awaits the underlying promise.
43
+ * Streaming renderers can flush a fallback first and patch the resolved value
44
+ * later (see `renderToStreamSuspense()`).
45
+ */
46
+ export const defer = <T>(promise: Promise<T> | T, fallback?: unknown): DeferredValue<T> => {
47
+ const p = promise instanceof Promise ? promise : Promise.resolve(promise);
48
+ return {
49
+ [DEFER_BRAND]: true,
50
+ promise: p,
51
+ fallback,
52
+ };
53
+ };
54
+
55
+ const isDeferred = (value: unknown): value is DeferredValue<unknown> =>
56
+ typeof value === 'object' &&
57
+ value !== null &&
58
+ (value as Record<symbol, unknown>)[DEFER_BRAND] === true;
59
+
60
+ /**
61
+ * Walks the binding context, awaits all promises and deferred values, and
62
+ * returns a new context with the resolved values. Signals/computeds are kept
63
+ * as-is so the renderer can still unwrap them lazily.
64
+ *
65
+ * @internal
66
+ */
67
+ export const resolveContext = async (
68
+ context: BindingContext,
69
+ ctx: SSRContext
70
+ ): Promise<BindingContext> => {
71
+ const out = Object.create(null) as BindingContext;
72
+ const entries = Object.entries(context);
73
+ await Promise.all(
74
+ entries.map(async ([key, value]) => {
75
+ if (isPrototypePollutionKey(key)) return;
76
+ if (isSignal(value) || isComputed(value)) {
77
+ out[key] = value;
78
+ return;
79
+ }
80
+ if (isDeferred(value)) {
81
+ try {
82
+ out[key] = await value.promise;
83
+ } catch (error) {
84
+ ctx.reportError(error);
85
+ out[key] = value.fallback;
86
+ }
87
+ return;
88
+ }
89
+ if (value && typeof (value as Promise<unknown>).then === 'function') {
90
+ try {
91
+ out[key] = await (value as Promise<unknown>);
92
+ } catch (error) {
93
+ ctx.reportError(error);
94
+ out[key] = undefined;
95
+ }
96
+ return;
97
+ }
98
+ if (
99
+ typeof value === 'function' &&
100
+ (value as unknown as { [DEFER_BRAND]?: unknown })[DEFER_BRAND]
101
+ ) {
102
+ // Allow loader-style functions tagged via defineLoader to opt in.
103
+ try {
104
+ out[key] = await Promise.resolve((value as SSRLoader)(ctx));
105
+ } catch (error) {
106
+ ctx.reportError(error);
107
+ out[key] = undefined;
108
+ }
109
+ return;
110
+ }
111
+ out[key] = value;
112
+ })
113
+ );
114
+ // Carry forward signals untouched so unwrap() in the evaluator still works.
115
+ for (const [key, value] of Object.entries(context)) {
116
+ if (
117
+ !isPrototypePollutionKey(key) &&
118
+ !Object.prototype.hasOwnProperty.call(out, key) &&
119
+ (isSignal(value) || isComputed(value))
120
+ ) {
121
+ out[key] = value as Signal<unknown>;
122
+ }
123
+ }
124
+ return out;
125
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Global SSR configuration.
3
+ *
4
+ * Lets users opt into a custom DOM implementation (`linkedom`, `happy-dom`,
5
+ * `jsdom`, …) for the legacy `DOMParser`-based renderer, or force the
6
+ * DOM-free renderer everywhere. Defaults are runtime-aware: if a global
7
+ * `DOMParser` exists, it is used (preserves legacy behaviour); otherwise the
8
+ * DOM-free renderer kicks in automatically.
9
+ *
10
+ * @module bquery/ssr
11
+ */
12
+
13
+ /**
14
+ * Backend used by `renderToString()` and `renderToStringAsync()`.
15
+ */
16
+ export type SSRRendererBackend = 'auto' | 'pure' | 'dom';
17
+
18
+ /**
19
+ * DOM implementation injected by `configureSSR()` for the `'dom'` backend.
20
+ */
21
+ export interface SSRDocumentImpl {
22
+ /** A `DOMParser` constructor compatible with the WHATWG spec. */
23
+ DOMParser: typeof globalThis.DOMParser;
24
+ }
25
+
26
+ interface SSRConfig {
27
+ backend: SSRRendererBackend;
28
+ documentImpl: SSRDocumentImpl | null;
29
+ }
30
+
31
+ const config: SSRConfig = {
32
+ backend: 'auto',
33
+ documentImpl: null,
34
+ };
35
+
36
+ /**
37
+ * Updates the global SSR configuration. All options are optional and merged
38
+ * shallowly with the existing configuration.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import { configureSSR } from '@bquery/bquery/ssr';
43
+ * import { DOMParser } from 'linkedom';
44
+ *
45
+ * configureSSR({ backend: 'dom', documentImpl: { DOMParser } });
46
+ * ```
47
+ */
48
+ export const configureSSR = (
49
+ options: Partial<{ backend: SSRRendererBackend; documentImpl: SSRDocumentImpl | null }>
50
+ ): void => {
51
+ if (options.backend !== undefined) config.backend = options.backend;
52
+ if (options.documentImpl !== undefined) config.documentImpl = options.documentImpl;
53
+ };
54
+
55
+ /**
56
+ * Returns a snapshot of the current SSR configuration.
57
+ */
58
+ export const getSSRConfig = (): Readonly<SSRConfig> => ({
59
+ backend: config.backend,
60
+ documentImpl: config.documentImpl,
61
+ });
62
+
63
+ /**
64
+ * Resolves the renderer backend that should actually be used right now.
65
+ * Honours `configureSSR()` and falls back to runtime feature detection.
66
+ *
67
+ * @internal
68
+ */
69
+ export const resolveBackend = (): 'pure' | 'dom' => {
70
+ if (config.backend === 'pure') return 'pure';
71
+ if (config.backend === 'dom') return 'dom';
72
+
73
+ if (config.documentImpl) return 'dom';
74
+ if (typeof globalThis.DOMParser === 'function') return 'dom';
75
+ return 'pure';
76
+ };
77
+
78
+ /**
79
+ * Returns the configured `DOMParser` constructor or the global one.
80
+ * @internal
81
+ */
82
+ export const getDOMParserImpl = (): typeof globalThis.DOMParser | null => {
83
+ if (config.documentImpl) return config.documentImpl.DOMParser;
84
+ if (typeof globalThis.DOMParser === 'function') return globalThis.DOMParser;
85
+ return null;
86
+ };