@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.
- package/README.md +44 -19
- 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 +1 -0
- 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 +17 -12
- package/src/full.ts +99 -0
- package/src/index.ts +3 -0
- 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,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
|
+
};
|
package/src/ssr/serialize.ts
CHANGED
|
@@ -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, '&')
|
|
58
|
-
.replace(/"/g, '"')
|
|
59
|
-
.replace(/</g, '<')
|
|
60
|
-
.replace(/>/g, '>');
|
|
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
|
+
};
|