@bquery/bquery 1.10.0 → 1.11.0

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 +44 -19
  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 +1 -0
  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 +17 -12
  124. package/src/full.ts +99 -0
  125. package/src/index.ts +3 -0
  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,177 @@
1
+ /**
2
+ * Router ↔ SSR bridge.
3
+ *
4
+ * On the server you typically need to:
5
+ * 1. Match the incoming URL against your router's route table.
6
+ * 2. Run any data loaders attached to the matched route.
7
+ * 3. Inject the resolved route + loader data into the SSR binding context.
8
+ *
9
+ * `resolveSSRRoute()` and `runRouteLoaders()` perform steps 1 and 2 without
10
+ * coupling the SSR module to the actual `createRouter()` runtime — only the
11
+ * pure `matchRoute()` helper from `@bquery/bquery/router` is used. Loaders
12
+ * live on `RouteDefinition.meta.loader` (additive, no existing field changes).
13
+ *
14
+ * @module bquery/ssr
15
+ */
16
+
17
+ import { matchRoute } from '../router/match';
18
+ import { parseQuery } from '../router/query';
19
+ import type { Route, RouteDefinition } from '../router/types';
20
+ import type { SSRContext } from './context';
21
+
22
+ /**
23
+ * Loader signature for SSR routes. Attach as `meta.loader` on a
24
+ * `RouteDefinition` and `runRouteLoaders()` will invoke it with the matched
25
+ * route + the active SSR context.
26
+ */
27
+ export type SSRRouteLoader<T = unknown> = (args: {
28
+ route: Route;
29
+ ctx: SSRContext;
30
+ }) => T | Promise<T>;
31
+
32
+ const getLoader = (route: RouteDefinition | null): SSRRouteLoader | undefined => {
33
+ if (!route || !route.meta) return undefined;
34
+ const loader = (route.meta as { loader?: unknown }).loader;
35
+ return typeof loader === 'function' ? (loader as SSRRouteLoader) : undefined;
36
+ };
37
+
38
+ /** Result of `resolveSSRRoute()`. */
39
+ export interface ResolvedSSRRoute {
40
+ /** Route-like snapshot for the requested URL, with `matched` set when found. */
41
+ route: Route;
42
+ /** Whether a route definition was actually matched. */
43
+ matched: boolean;
44
+ /** Whether the matched route has a `redirectTo` target. */
45
+ isRedirect: boolean;
46
+ /** Redirect target, if any. */
47
+ redirectTo?: string;
48
+ }
49
+
50
+ /**
51
+ * Matches a URL against a route table without instantiating a full router.
52
+ *
53
+ * Designed to be called on the server before the SSR render so userland can
54
+ * branch on `matched`/`isRedirect` (e.g. issue a 302 instead of rendering).
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * import { resolveSSRRoute } from '@bquery/bquery/ssr';
59
+ *
60
+ * const { route, matched, isRedirect, redirectTo } = resolveSSRRoute({
61
+ * url: new URL(request.url),
62
+ * routes,
63
+ * });
64
+ *
65
+ * if (isRedirect) return Response.redirect(redirectTo!, 302);
66
+ * if (!matched) return new Response('Not Found', { status: 404 });
67
+ * ```
68
+ */
69
+ export const resolveSSRRoute = (options: {
70
+ url: string | URL;
71
+ routes: RouteDefinition[];
72
+ /** Strip a base path before matching. Default: `''`. */
73
+ base?: string;
74
+ }): ResolvedSSRRoute => {
75
+ const url =
76
+ typeof options.url === 'string' ? new URL(options.url, 'http://localhost/') : options.url;
77
+ const base = options.base ?? '';
78
+ let pathname = url.pathname;
79
+ if (base) {
80
+ if (pathname === base) {
81
+ pathname = '/';
82
+ } else if (pathname.startsWith(`${base}/`)) {
83
+ pathname = pathname.slice(base.length) || '/';
84
+ }
85
+ }
86
+
87
+ const result = matchRoute(pathname, options.routes);
88
+ const route: Route = {
89
+ path: pathname,
90
+ params: result?.params ?? {},
91
+ query: parseQuery(url.search),
92
+ matched: result?.matched ?? null,
93
+ hash: url.hash.replace(/^#/, ''),
94
+ };
95
+ const matched = result !== null;
96
+ const matchedDef = result?.matched ?? null;
97
+ const isRedirect =
98
+ !!matchedDef && 'redirectTo' in matchedDef && typeof matchedDef.redirectTo === 'string';
99
+ return {
100
+ route,
101
+ matched,
102
+ isRedirect,
103
+ redirectTo: isRedirect ? (matchedDef as { redirectTo: string }).redirectTo : undefined,
104
+ };
105
+ };
106
+
107
+ /**
108
+ * Runs the loader attached to the matched route (`meta.loader`), if any.
109
+ * Returns the resolved data, or `undefined` if no loader is configured.
110
+ */
111
+ export const runRouteLoaders = async <T = unknown>(
112
+ route: Route,
113
+ ctx: SSRContext
114
+ ): Promise<T | undefined> => {
115
+ const loader = getLoader(route.matched);
116
+ if (!loader) return undefined;
117
+ try {
118
+ return (await loader({ route, ctx })) as T;
119
+ } catch (error) {
120
+ ctx.reportError(error);
121
+ return undefined;
122
+ }
123
+ };
124
+
125
+ /**
126
+ * Convenience wrapper that resolves a route, runs its loader, and returns a
127
+ * binding-context fragment ready to be merged into the data passed to
128
+ * `renderToStringAsync()` / `renderToResponse()`.
129
+ *
130
+ * @example
131
+ * ```ts
132
+ * import {
133
+ * createSSRContext,
134
+ * createSSRRouterContext,
135
+ * renderToResponse,
136
+ * } from '@bquery/bquery/ssr';
137
+ *
138
+ * const ctx = createSSRContext();
139
+ * const router = await createSSRRouterContext({ url: request.url, routes, ctx });
140
+ * if (router.isRedirect) return Response.redirect(router.redirectTo!, 302);
141
+ *
142
+ * return renderToResponse(template, { ...router.bindings }, { context: ctx });
143
+ * ```
144
+ */
145
+ export const createSSRRouterContext = async (options: {
146
+ url: string | URL;
147
+ routes: RouteDefinition[];
148
+ base?: string;
149
+ ctx: SSRContext;
150
+ }): Promise<{
151
+ route: Route;
152
+ matched: boolean;
153
+ isRedirect: boolean;
154
+ redirectTo?: string;
155
+ data: unknown;
156
+ bindings: Record<string, unknown>;
157
+ }> => {
158
+ const resolved = resolveSSRRoute({
159
+ url: options.url,
160
+ routes: options.routes,
161
+ base: options.base,
162
+ });
163
+ const data = resolved.matched ? await runRouteLoaders(resolved.route, options.ctx) : undefined;
164
+ return {
165
+ route: resolved.route,
166
+ matched: resolved.matched,
167
+ isRedirect: resolved.isRedirect,
168
+ redirectTo: resolved.redirectTo,
169
+ data,
170
+ bindings: {
171
+ route: resolved.route,
172
+ params: resolved.route.params,
173
+ query: resolved.route.query,
174
+ data,
175
+ },
176
+ };
177
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Runtime detection helpers for the SSR module.
3
+ *
4
+ * Detects whether the current runtime is Bun, Deno, Node.js, a browser,
5
+ * or a Web-Worker / edge runtime (Cloudflare Workers / `workerd`).
6
+ * Detection is feature-based and never throws; calling these helpers is
7
+ * safe in any environment that provides `globalThis`.
8
+ *
9
+ * @module bquery/ssr
10
+ */
11
+
12
+ /**
13
+ * Identifier for a recognised JavaScript runtime.
14
+ */
15
+ export type SSRRuntime = 'bun' | 'deno' | 'node' | 'browser' | 'workerd' | 'unknown';
16
+
17
+ interface BunGlobal {
18
+ version?: string;
19
+ }
20
+
21
+ interface DenoGlobal {
22
+ version?: { deno?: string };
23
+ }
24
+
25
+ interface NodeProcess {
26
+ versions?: { node?: string };
27
+ }
28
+
29
+ const safeGlobal = (): Record<string, unknown> => {
30
+ return (typeof globalThis !== 'undefined' ? globalThis : {}) as Record<string, unknown>;
31
+ };
32
+
33
+ /**
34
+ * Detects the current runtime via feature checks on `globalThis`.
35
+ * Order matters: Bun and Deno expose Node compatibility shims, so they are
36
+ * checked first.
37
+ *
38
+ * @returns The detected runtime identifier, or `'unknown'` if none match.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import { detectRuntime } from '@bquery/bquery/ssr';
43
+ *
44
+ * if (detectRuntime() === 'deno') {
45
+ * // Deno-specific behaviour
46
+ * }
47
+ * ```
48
+ */
49
+ export const detectRuntime = (): SSRRuntime => {
50
+ const g = safeGlobal();
51
+
52
+ if (typeof (g.Bun as BunGlobal | undefined)?.version === 'string') {
53
+ return 'bun';
54
+ }
55
+
56
+ if (typeof (g.Deno as DenoGlobal | undefined)?.version?.deno === 'string') {
57
+ return 'deno';
58
+ }
59
+
60
+ // workerd / Cloudflare Workers expose `navigator.userAgent === 'Cloudflare-Workers'`
61
+ // and lack `process.versions.node`.
62
+ const navigator = g.navigator as { userAgent?: string } | undefined;
63
+ if (
64
+ typeof navigator?.userAgent === 'string' &&
65
+ navigator.userAgent.toLowerCase().includes('cloudflare-workers')
66
+ ) {
67
+ return 'workerd';
68
+ }
69
+
70
+ if (typeof (g.process as NodeProcess | undefined)?.versions?.node === 'string') {
71
+ return 'node';
72
+ }
73
+
74
+ if (typeof g.window !== 'undefined' && typeof g.document !== 'undefined') {
75
+ return 'browser';
76
+ }
77
+
78
+ return 'unknown';
79
+ };
80
+
81
+ /**
82
+ * Returns `true` when called inside a server-side runtime (Bun, Deno, Node,
83
+ * Cloudflare Workers / `workerd`).
84
+ */
85
+ export const isServerRuntime = (): boolean => {
86
+ const rt = detectRuntime();
87
+ return rt === 'bun' || rt === 'deno' || rt === 'node' || rt === 'workerd';
88
+ };
89
+
90
+ /**
91
+ * Returns `true` when called inside a browser-like runtime (full DOM available).
92
+ */
93
+ export const isBrowserRuntime = (): boolean => detectRuntime() === 'browser';
94
+
95
+ /**
96
+ * Lightweight feature-detection report for runtime capabilities relevant to SSR.
97
+ */
98
+ export interface SSRRuntimeFeatures {
99
+ /** Whether `Request`/`Response`/`fetch` are available on `globalThis`. */
100
+ fetchApi: boolean;
101
+ /** Whether `ReadableStream` is available on `globalThis`. */
102
+ webStreams: boolean;
103
+ /** Whether `TextEncoder` is available on `globalThis`. */
104
+ textEncoder: boolean;
105
+ /** Whether `crypto.subtle` is available (used for ETag hashing). */
106
+ subtleCrypto: boolean;
107
+ /** Whether `crypto.randomUUID()` is available. */
108
+ randomUuid: boolean;
109
+ /** Whether the global `DOMParser` is available. */
110
+ domParser: boolean;
111
+ }
112
+
113
+ /**
114
+ * Returns a feature-detection report for the current runtime. All checks are
115
+ * non-throwing; missing globals yield `false`.
116
+ */
117
+ export const getSSRRuntimeFeatures = (): SSRRuntimeFeatures => {
118
+ const g = safeGlobal();
119
+ const crypto = g.crypto as { subtle?: unknown; randomUUID?: () => string } | undefined;
120
+ return {
121
+ fetchApi:
122
+ typeof g.Request === 'function' &&
123
+ typeof g.Response === 'function' &&
124
+ typeof g.fetch === 'function',
125
+ webStreams: typeof g.ReadableStream === 'function',
126
+ textEncoder: typeof g.TextEncoder === 'function',
127
+ subtleCrypto: typeof crypto?.subtle === 'object' && crypto?.subtle !== null,
128
+ randomUuid: typeof crypto?.randomUUID === 'function',
129
+ domParser: typeof g.DOMParser === 'function',
130
+ };
131
+ };
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { getStore, listStores } from '../store/index';
11
11
  import { isPrototypePollutionKey } from '../core/utils/object';
12
+ import { escapeForHtmlAttribute, escapeForScript } from './escape';
12
13
  import type { DeserializedStoreState, SerializeOptions } from './types';
13
14
 
14
15
  const isStoreStateObject = (value: unknown): value is Record<string, unknown> =>
@@ -33,33 +34,6 @@ export type SerializeResult = {
33
34
  scriptTag: string;
34
35
  };
35
36
 
36
- /**
37
- * Escapes a string for safe embedding in a `<script>` tag.
38
- * Prevents XSS via `</script>` injection and HTML entities.
39
- *
40
- * @internal
41
- */
42
- const escapeForScript = (str: string): string => {
43
- return str
44
- .replace(/</g, '\\u003c')
45
- .replace(/>/g, '\\u003e')
46
- .replace(/\//g, '\\u002f')
47
- .replace(/\u2028/g, '\\u2028')
48
- .replace(/\u2029/g, '\\u2029');
49
- };
50
-
51
- /**
52
- * Escapes a string for safe embedding in an HTML attribute value.
53
- * @internal
54
- */
55
- const escapeForHtmlAttribute = (str: string): string => {
56
- return str
57
- .replace(/&/g, '&amp;')
58
- .replace(/"/g, '&quot;')
59
- .replace(/</g, '&lt;')
60
- .replace(/>/g, '&gt;');
61
- };
62
-
63
37
  /**
64
38
  * Serializes the state of registered stores into a JSON string and
65
39
  * a `<script>` tag suitable for embedding in server-rendered HTML.
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Versioned store snapshots and strict drift-checking hydration.
3
+ *
4
+ * Sits *on top of* the simple `serializeStoreState()` / `hydrateStore()` pair
5
+ * to give applications a way to:
6
+ * - tag the snapshot with a schema version so a stale client can refuse to
7
+ * apply server data that no longer matches its store shape;
8
+ * - opt into strict mode where unknown keys cause a warning;
9
+ * - selectively serialize / hydrate a subset of stores.
10
+ *
11
+ * Backwards compatible: the existing helpers stay untouched and remain the
12
+ * primary entry-point for simple use cases.
13
+ *
14
+ * @module bquery/ssr
15
+ */
16
+
17
+ import { getStore, listStores } from '../store/index';
18
+ import { isPrototypePollutionKey } from '../core/utils/object';
19
+ import { escapeForHtmlAttribute, escapeForScript } from './escape';
20
+
21
+ const isStateObject = (value: unknown): value is Record<string, unknown> =>
22
+ typeof value === 'object' && value !== null && !Array.isArray(value);
23
+
24
+ const sanitize = (value: Record<string, unknown>): Record<string, unknown> => {
25
+ const out: Record<string, unknown> = {};
26
+ for (const [k, v] of Object.entries(value)) {
27
+ if (isPrototypePollutionKey(k)) continue;
28
+ out[k] = v;
29
+ }
30
+ return out;
31
+ };
32
+
33
+ /** Versioned store snapshot. */
34
+ export interface SSRStoreSnapshot {
35
+ /** Application-defined version string. Stable per schema. */
36
+ version: string;
37
+ /** Map of store ID → sanitized state. */
38
+ state: Record<string, Record<string, unknown>>;
39
+ }
40
+
41
+ /** Result of `serializeStoreSnapshot()`. */
42
+ export interface SerializeSnapshotResult {
43
+ snapshot: SSRStoreSnapshot;
44
+ /** JSON-serialized snapshot. */
45
+ json: string;
46
+ /** `<script>` tag ready to embed (CSP-nonce-aware via `options.nonce`). */
47
+ scriptTag: string;
48
+ }
49
+
50
+ /** Options for `serializeStoreSnapshot()`. */
51
+ export interface SerializeSnapshotOptions {
52
+ /** Schema version. Required: hydration only succeeds when versions match. */
53
+ version: string;
54
+ /** Subset of store IDs to serialize. Defaults to all registered stores. */
55
+ storeIds?: string[];
56
+ /** Element ID for the generated `<script>` tag. */
57
+ scriptId?: string;
58
+ /** Global window key where the snapshot is assigned. */
59
+ globalKey?: string;
60
+ /** CSP nonce applied to the generated `<script>`. */
61
+ nonce?: string;
62
+ }
63
+
64
+ /**
65
+ * Captures every registered store's state into a versioned snapshot and
66
+ * returns both the JSON payload and a ready-to-embed `<script>` tag.
67
+ */
68
+ export const serializeStoreSnapshot = (
69
+ options: SerializeSnapshotOptions
70
+ ): SerializeSnapshotResult => {
71
+ const {
72
+ version,
73
+ storeIds,
74
+ scriptId = '__BQUERY_STORE_SNAPSHOT__',
75
+ globalKey = '__BQUERY_STORE_SNAPSHOT__',
76
+ nonce,
77
+ } = options;
78
+
79
+ if (typeof version !== 'string' || version.length === 0) {
80
+ throw new Error(
81
+ 'serializeStoreSnapshot: `version` is required and must be a non-empty string.'
82
+ );
83
+ }
84
+ if (isPrototypePollutionKey(scriptId) || isPrototypePollutionKey(globalKey)) {
85
+ throw new Error('serializeStoreSnapshot: invalid scriptId/globalKey.');
86
+ }
87
+
88
+ const ids = storeIds ?? listStores();
89
+ const state = Object.create(null) as Record<string, Record<string, unknown>>;
90
+ for (const id of ids) {
91
+ if (isPrototypePollutionKey(id)) continue;
92
+ const store = getStore<{ $state: Record<string, unknown> }>(id);
93
+ if (store) state[id] = sanitize(store.$state);
94
+ }
95
+ const snapshot: SSRStoreSnapshot = { version, state };
96
+ const json = JSON.stringify(snapshot);
97
+
98
+ const escapedJson = escapeForScript(json);
99
+ const escapedKey = escapeForScript(JSON.stringify(globalKey));
100
+ const escapedId = escapeForHtmlAttribute(scriptId);
101
+ const nonceAttr = nonce ? ` nonce="${escapeForHtmlAttribute(nonce)}"` : '';
102
+ const scriptTag = `<script id="${escapedId}"${nonceAttr}>window[${escapedKey}]=${escapedJson}</script>`;
103
+ return { snapshot, json, scriptTag };
104
+ };
105
+
106
+ /** Options for `hydrateStoreSnapshot()`. */
107
+ export interface HydrateSnapshotOptions {
108
+ /**
109
+ * If set, the snapshot's `version` must match this value. Otherwise the
110
+ * function returns early (and warns when `strict` is true).
111
+ */
112
+ expectedVersion?: string;
113
+ /**
114
+ * Strict mode: warn on version mismatch + warn on unknown store IDs (i.e.
115
+ * the snapshot has IDs that aren't currently registered). Default: `false`.
116
+ */
117
+ strict?: boolean;
118
+ }
119
+
120
+ /** Result of `hydrateStoreSnapshot()`. */
121
+ export interface HydrateSnapshotResult {
122
+ /** Whether the snapshot was applied. */
123
+ applied: boolean;
124
+ /** Reason when not applied (`'version-mismatch' | 'invalid-shape'`). */
125
+ reason?: 'version-mismatch' | 'invalid-shape';
126
+ /** IDs that were applied. */
127
+ appliedIds: string[];
128
+ /** IDs in the snapshot that no store exists for. */
129
+ unknownIds: string[];
130
+ }
131
+
132
+ const isStoreSnapshot = (value: unknown): value is SSRStoreSnapshot => {
133
+ if (!isStateObject(value)) return false;
134
+ const v = (value as { version: unknown }).version;
135
+ const s = (value as { state: unknown }).state;
136
+ return typeof v === 'string' && isStateObject(s);
137
+ };
138
+
139
+ /**
140
+ * Applies a previously-serialized `SSRStoreSnapshot` to the registered stores.
141
+ *
142
+ * Returns a structured result; never throws on drift unless an explicit error
143
+ * is thrown by a store's `$patch()` implementation.
144
+ */
145
+ export const hydrateStoreSnapshot = (
146
+ snapshot: unknown,
147
+ options: HydrateSnapshotOptions = {}
148
+ ): HydrateSnapshotResult => {
149
+ if (!isStoreSnapshot(snapshot)) {
150
+ if (options.strict) {
151
+ console.warn('[bQuery SSR] hydrateStoreSnapshot: snapshot has invalid shape.');
152
+ }
153
+ return { applied: false, reason: 'invalid-shape', appliedIds: [], unknownIds: [] };
154
+ }
155
+
156
+ if (typeof options.expectedVersion === 'string' && options.expectedVersion !== snapshot.version) {
157
+ if (options.strict) {
158
+ console.warn(
159
+ `[bQuery SSR] hydrateStoreSnapshot: version mismatch — server="${snapshot.version}" client="${options.expectedVersion}". Skipping.`
160
+ );
161
+ }
162
+ return { applied: false, reason: 'version-mismatch', appliedIds: [], unknownIds: [] };
163
+ }
164
+
165
+ const appliedIds: string[] = [];
166
+ const unknownIds: string[] = [];
167
+ for (const [id, state] of Object.entries(snapshot.state)) {
168
+ if (isPrototypePollutionKey(id) || !isStateObject(state)) continue;
169
+ const store = getStore<{ $patch?: (partial: Record<string, unknown>) => void }>(id);
170
+ if (!store || typeof store.$patch !== 'function') {
171
+ unknownIds.push(id);
172
+ if (options.strict) {
173
+ console.warn(
174
+ `[bQuery SSR] hydrateStoreSnapshot: store "${id}" is not registered; skipping.`
175
+ );
176
+ }
177
+ continue;
178
+ }
179
+ store.$patch(sanitize(state));
180
+ appliedIds.push(id);
181
+ }
182
+ return { applied: appliedIds.length > 0, appliedIds, unknownIds };
183
+ };
184
+
185
+ /**
186
+ * Reads the snapshot emitted by `serializeStoreSnapshot()` from `window`,
187
+ * cleans up the global, and returns the parsed `SSRStoreSnapshot`.
188
+ *
189
+ * Returns `null` when no snapshot was found or when it has the wrong shape.
190
+ */
191
+ export const readStoreSnapshot = (
192
+ globalKey = '__BQUERY_STORE_SNAPSHOT__',
193
+ scriptId = '__BQUERY_STORE_SNAPSHOT__'
194
+ ): SSRStoreSnapshot | null => {
195
+ if (isPrototypePollutionKey(globalKey) || isPrototypePollutionKey(scriptId)) return null;
196
+ if (typeof window === 'undefined') return null;
197
+ const raw = (window as unknown as Record<string, unknown>)[globalKey];
198
+ try {
199
+ delete (window as unknown as Record<string, unknown>)[globalKey];
200
+ } catch {
201
+ (window as unknown as Record<string, unknown>)[globalKey] = undefined;
202
+ }
203
+ if (typeof document !== 'undefined' && typeof document.getElementById === 'function') {
204
+ const el = document.getElementById(scriptId);
205
+ if (el) el.remove();
206
+ }
207
+ if (!isStoreSnapshot(raw)) return null;
208
+ return raw;
209
+ };