react-bun-ssr 0.1.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 +205 -0
- package/bin/rbssr.ts +2 -0
- package/framework/cli/commands.ts +343 -0
- package/framework/cli/main.ts +63 -0
- package/framework/cli/scaffold.ts +97 -0
- package/framework/index.ts +1 -0
- package/framework/runtime/build-tools.ts +234 -0
- package/framework/runtime/bun-route-adapter.ts +200 -0
- package/framework/runtime/client-runtime.tsx +62 -0
- package/framework/runtime/config.ts +142 -0
- package/framework/runtime/deferred.ts +115 -0
- package/framework/runtime/helpers.ts +40 -0
- package/framework/runtime/index.ts +24 -0
- package/framework/runtime/io.ts +146 -0
- package/framework/runtime/markdown-routes.ts +319 -0
- package/framework/runtime/matcher.ts +89 -0
- package/framework/runtime/middleware.ts +26 -0
- package/framework/runtime/module-loader.ts +192 -0
- package/framework/runtime/render.tsx +370 -0
- package/framework/runtime/route-api.ts +17 -0
- package/framework/runtime/route-scanner.ts +242 -0
- package/framework/runtime/server.ts +744 -0
- package/framework/runtime/tree.tsx +77 -0
- package/framework/runtime/types.ts +213 -0
- package/framework/runtime/utils.ts +115 -0
- package/package.json +59 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiRouteDefinition,
|
|
3
|
+
PageRouteDefinition,
|
|
4
|
+
Params,
|
|
5
|
+
RouteMatch,
|
|
6
|
+
RouteSegment,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
// Bun FileSystemRouter is the runtime matcher used by the server.
|
|
10
|
+
// This matcher is retained for lightweight unit coverage and internal utilities.
|
|
11
|
+
function normalizePathname(pathname: string): string[] {
|
|
12
|
+
if (!pathname || pathname === "/") {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return pathname
|
|
17
|
+
.replace(/^\/+/, "")
|
|
18
|
+
.replace(/\/+$/, "")
|
|
19
|
+
.split("/")
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.map(part => decodeURIComponent(part));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function matchSegments(segments: RouteSegment[], pathname: string): Params | null {
|
|
25
|
+
const pathParts = normalizePathname(pathname);
|
|
26
|
+
const params: Params = {};
|
|
27
|
+
|
|
28
|
+
let i = 0;
|
|
29
|
+
let j = 0;
|
|
30
|
+
|
|
31
|
+
while (i < segments.length) {
|
|
32
|
+
const segment = segments[i]!;
|
|
33
|
+
|
|
34
|
+
if (segment.kind === "catchall") {
|
|
35
|
+
params[segment.value] = pathParts.slice(j).join("/");
|
|
36
|
+
return params;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const current = pathParts[j];
|
|
40
|
+
if (current === undefined) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (segment.kind === "static") {
|
|
45
|
+
if (segment.value !== current) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
params[segment.value] = current;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
i += 1;
|
|
53
|
+
j += 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (j !== pathParts.length) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return params;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function matchPageRoute(
|
|
64
|
+
routes: PageRouteDefinition[],
|
|
65
|
+
pathname: string,
|
|
66
|
+
): RouteMatch<PageRouteDefinition> | null {
|
|
67
|
+
for (const route of routes) {
|
|
68
|
+
const params = matchSegments(route.segments, pathname);
|
|
69
|
+
if (params) {
|
|
70
|
+
return { route, params };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function matchApiRoute(
|
|
78
|
+
routes: ApiRouteDefinition[],
|
|
79
|
+
pathname: string,
|
|
80
|
+
): RouteMatch<ApiRouteDefinition> | null {
|
|
81
|
+
for (const route of routes) {
|
|
82
|
+
const params = matchSegments(route.segments, pathname);
|
|
83
|
+
if (params) {
|
|
84
|
+
return { route, params };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Middleware, RequestContext } from "./types";
|
|
2
|
+
|
|
3
|
+
export async function runMiddlewareChain(
|
|
4
|
+
middlewares: Middleware[],
|
|
5
|
+
ctx: RequestContext,
|
|
6
|
+
handler: () => Promise<Response>,
|
|
7
|
+
): Promise<Response> {
|
|
8
|
+
let index = -1;
|
|
9
|
+
|
|
10
|
+
const dispatch = async (nextIndex: number): Promise<Response> => {
|
|
11
|
+
if (nextIndex <= index) {
|
|
12
|
+
throw new Error("Middleware next() called multiple times");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
index = nextIndex;
|
|
16
|
+
const middleware = middlewares[nextIndex];
|
|
17
|
+
|
|
18
|
+
if (!middleware) {
|
|
19
|
+
return handler();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return middleware(ctx, () => dispatch(nextIndex + 1));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return dispatch(0);
|
|
26
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ensureDir, existsPath } from "./io";
|
|
3
|
+
import type {
|
|
4
|
+
ApiRouteModule,
|
|
5
|
+
Middleware,
|
|
6
|
+
RouteModule,
|
|
7
|
+
RouteModuleBundle,
|
|
8
|
+
} from "./types";
|
|
9
|
+
import { stableHash, toFileImportUrl } from "./utils";
|
|
10
|
+
|
|
11
|
+
const serverBundlePathCache = new Map<string, Promise<string>>();
|
|
12
|
+
|
|
13
|
+
export async function importModule<T>(
|
|
14
|
+
filePath: string,
|
|
15
|
+
cacheBustKey?: string,
|
|
16
|
+
): Promise<T> {
|
|
17
|
+
const baseUrl = toFileImportUrl(filePath);
|
|
18
|
+
const url = cacheBustKey ? `${baseUrl}?v=${cacheBustKey}` : baseUrl;
|
|
19
|
+
return (await import(url)) as T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function toRouteModule(filePath: string, moduleValue: unknown): RouteModule {
|
|
23
|
+
const value = moduleValue as Partial<RouteModule>;
|
|
24
|
+
const component = value.default;
|
|
25
|
+
|
|
26
|
+
if (typeof component !== "function") {
|
|
27
|
+
throw new Error(`Route module ${filePath} must export a default React component`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...value,
|
|
32
|
+
default: component,
|
|
33
|
+
} as RouteModule;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isCompilableRouteModule(filePath: string): boolean {
|
|
37
|
+
return /\.(tsx|jsx|ts|js)$/.test(filePath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function buildServerModule(filePath: string, cacheBustKey?: string): Promise<string> {
|
|
41
|
+
const absoluteFilePath = path.resolve(filePath);
|
|
42
|
+
if (!isCompilableRouteModule(absoluteFilePath)) {
|
|
43
|
+
return absoluteFilePath;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const cacheKey = `${absoluteFilePath}|${cacheBustKey ?? "prod"}`;
|
|
47
|
+
const existing = serverBundlePathCache.get(cacheKey);
|
|
48
|
+
if (existing) {
|
|
49
|
+
return existing;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const pending = (async () => {
|
|
53
|
+
const outDir = path.join(
|
|
54
|
+
process.cwd(),
|
|
55
|
+
".rbssr",
|
|
56
|
+
"cache",
|
|
57
|
+
"server-modules",
|
|
58
|
+
stableHash(cacheKey),
|
|
59
|
+
);
|
|
60
|
+
await ensureDir(outDir);
|
|
61
|
+
|
|
62
|
+
const buildResult = await Bun.build({
|
|
63
|
+
entrypoints: [absoluteFilePath],
|
|
64
|
+
outdir: outDir,
|
|
65
|
+
target: "bun",
|
|
66
|
+
format: "esm",
|
|
67
|
+
splitting: false,
|
|
68
|
+
sourcemap: "none",
|
|
69
|
+
minify: false,
|
|
70
|
+
naming: "entry-[hash].[ext]",
|
|
71
|
+
external: [
|
|
72
|
+
"react",
|
|
73
|
+
"react-dom",
|
|
74
|
+
"react-dom/server",
|
|
75
|
+
"react/jsx-runtime",
|
|
76
|
+
"react/jsx-dev-runtime",
|
|
77
|
+
"react-bun-ssr",
|
|
78
|
+
"react-bun-ssr/route",
|
|
79
|
+
],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!buildResult.success) {
|
|
83
|
+
const messages = buildResult.logs.map(log => log.message).join("\n");
|
|
84
|
+
throw new Error(`Server module build failed for ${absoluteFilePath}\n${messages}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const outputPath = buildResult.outputs.find(output => output.path.endsWith(".js"))?.path;
|
|
88
|
+
if (!outputPath) {
|
|
89
|
+
throw new Error(`Server module build produced no JavaScript output for ${absoluteFilePath}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return outputPath;
|
|
93
|
+
})();
|
|
94
|
+
|
|
95
|
+
serverBundlePathCache.set(cacheKey, pending);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return await pending;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
serverBundlePathCache.delete(cacheKey);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function loadRouteModule(
|
|
106
|
+
filePath: string,
|
|
107
|
+
cacheBustKey?: string,
|
|
108
|
+
): Promise<RouteModule> {
|
|
109
|
+
const bundledModulePath = await buildServerModule(filePath, cacheBustKey);
|
|
110
|
+
const moduleValue = await importModule<unknown>(bundledModulePath);
|
|
111
|
+
return toRouteModule(filePath, moduleValue);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function loadRouteModules(options: {
|
|
115
|
+
rootFilePath: string;
|
|
116
|
+
layoutFiles: string[];
|
|
117
|
+
routeFilePath: string;
|
|
118
|
+
cacheBustKey?: string;
|
|
119
|
+
}): Promise<RouteModuleBundle> {
|
|
120
|
+
const [root, layouts, route] = await Promise.all([
|
|
121
|
+
loadRouteModule(options.rootFilePath, options.cacheBustKey),
|
|
122
|
+
Promise.all(
|
|
123
|
+
options.layoutFiles.map(layoutFilePath => loadRouteModule(layoutFilePath, options.cacheBustKey)),
|
|
124
|
+
),
|
|
125
|
+
loadRouteModule(options.routeFilePath, options.cacheBustKey),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
root,
|
|
130
|
+
layouts,
|
|
131
|
+
route,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function normalizeMiddlewareExport(value: unknown): Middleware[] {
|
|
136
|
+
if (!value) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(value)) {
|
|
141
|
+
return value.filter((item): item is Middleware => typeof item === "function");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof value === "function") {
|
|
145
|
+
return [value as Middleware];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function loadGlobalMiddleware(
|
|
152
|
+
middlewareFilePath: string,
|
|
153
|
+
cacheBustKey?: string,
|
|
154
|
+
): Promise<Middleware[]> {
|
|
155
|
+
if (!(await existsPath(middlewareFilePath))) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const raw = await importModule<Record<string, unknown>>(middlewareFilePath, cacheBustKey);
|
|
160
|
+
|
|
161
|
+
return [
|
|
162
|
+
...normalizeMiddlewareExport(raw.default),
|
|
163
|
+
...normalizeMiddlewareExport(raw.middleware),
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function loadNestedMiddleware(
|
|
168
|
+
middlewareFilePaths: string[],
|
|
169
|
+
cacheBustKey?: string,
|
|
170
|
+
): Promise<Middleware[]> {
|
|
171
|
+
const rawModules = await Promise.all(
|
|
172
|
+
middlewareFilePaths.map(middlewareFilePath => {
|
|
173
|
+
return importModule<Record<string, unknown>>(middlewareFilePath, cacheBustKey);
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
return rawModules.flatMap((raw) => {
|
|
178
|
+
return [...normalizeMiddlewareExport(raw.default), ...normalizeMiddlewareExport(raw.middleware)];
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function extractRouteMiddleware(module: RouteModule): Middleware[] {
|
|
183
|
+
return normalizeMiddlewareExport(module.middleware);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function loadApiRouteModule(
|
|
187
|
+
filePath: string,
|
|
188
|
+
cacheBustKey?: string,
|
|
189
|
+
): Promise<ApiRouteModule> {
|
|
190
|
+
const raw = await importModule<ApiRouteModule>(filePath, cacheBustKey);
|
|
191
|
+
return raw;
|
|
192
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
isValidElement,
|
|
5
|
+
Suspense,
|
|
6
|
+
use,
|
|
7
|
+
type ComponentType,
|
|
8
|
+
type ReactElement,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { renderToReadableStream, renderToStaticMarkup, renderToString } from "react-dom/server";
|
|
12
|
+
import type { DeferredSettleEntry } from "./deferred";
|
|
13
|
+
import type {
|
|
14
|
+
HydrationDocumentAssets,
|
|
15
|
+
RenderPayload,
|
|
16
|
+
RouteModule,
|
|
17
|
+
RouteModuleBundle,
|
|
18
|
+
} from "./types";
|
|
19
|
+
import { safeJsonSerialize } from "./utils";
|
|
20
|
+
import { createRouteTree } from "./tree";
|
|
21
|
+
|
|
22
|
+
function resolveErrorBoundary(modules: RouteModuleBundle): ComponentType<{ error: unknown }> | null {
|
|
23
|
+
if (modules.route.ErrorBoundary) {
|
|
24
|
+
return modules.route.ErrorBoundary;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (let index = modules.layouts.length - 1; index >= 0; index -= 1) {
|
|
28
|
+
const candidate = modules.layouts[index]!.ErrorBoundary;
|
|
29
|
+
if (candidate) {
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return modules.root.ErrorBoundary ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function resolveNotFoundBoundary(modules: RouteModuleBundle): ComponentType | null {
|
|
38
|
+
if (modules.route.NotFound) {
|
|
39
|
+
return modules.route.NotFound;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (let index = modules.layouts.length - 1; index >= 0; index -= 1) {
|
|
43
|
+
const candidate = modules.layouts[index]!.NotFound;
|
|
44
|
+
if (candidate) {
|
|
45
|
+
return candidate;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return modules.root.NotFound ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function renderPageApp(modules: RouteModuleBundle, payload: RenderPayload): string {
|
|
53
|
+
const Leaf = modules.route.default;
|
|
54
|
+
return renderToString(createRouteTree(modules, <Leaf />, payload));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function renderErrorApp(
|
|
58
|
+
modules: RouteModuleBundle,
|
|
59
|
+
payload: RenderPayload,
|
|
60
|
+
error: unknown,
|
|
61
|
+
): string | null {
|
|
62
|
+
const tree = createErrorAppTree(modules, payload, error);
|
|
63
|
+
return tree ? renderToString(tree) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderNotFoundApp(modules: RouteModuleBundle, payload: RenderPayload): string | null {
|
|
67
|
+
const tree = createNotFoundAppTree(modules, payload);
|
|
68
|
+
return tree ? renderToString(tree) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createPageAppTree(modules: RouteModuleBundle, payload: RenderPayload): ReactElement {
|
|
72
|
+
const Leaf = modules.route.default;
|
|
73
|
+
return createRouteTree(modules, <Leaf />, payload);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createErrorAppTree(
|
|
77
|
+
modules: RouteModuleBundle,
|
|
78
|
+
payload: RenderPayload,
|
|
79
|
+
error: unknown,
|
|
80
|
+
): ReactElement | null {
|
|
81
|
+
const Boundary = resolveErrorBoundary(modules);
|
|
82
|
+
if (!Boundary) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const boundaryPayload: RenderPayload = {
|
|
87
|
+
...payload,
|
|
88
|
+
error: {
|
|
89
|
+
message: error instanceof Error ? error.message : String(error),
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return createRouteTree(modules, <Boundary error={error} />, boundaryPayload);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function createNotFoundAppTree(modules: RouteModuleBundle, payload: RenderPayload): ReactElement | null {
|
|
97
|
+
const Boundary = resolveNotFoundBoundary(modules);
|
|
98
|
+
if (!Boundary) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return createRouteTree(modules, <Boundary />, payload);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function escapeHtml(value: string): string {
|
|
106
|
+
return value
|
|
107
|
+
.replace(/&/g, "&")
|
|
108
|
+
.replace(/</g, "<")
|
|
109
|
+
.replace(/>/g, ">")
|
|
110
|
+
.replace(/\"/g, """)
|
|
111
|
+
.replace(/'/g, "'");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function toTitleText(children: ReactNode): string {
|
|
115
|
+
return Children.toArray(children)
|
|
116
|
+
.map(child => {
|
|
117
|
+
if (typeof child === "string" || typeof child === "number" || typeof child === "bigint") {
|
|
118
|
+
return String(child);
|
|
119
|
+
}
|
|
120
|
+
if (child === null || child === undefined || typeof child === "boolean") {
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
// Avoid React <title> array warnings by stringifying non-primitive children.
|
|
124
|
+
return String(child);
|
|
125
|
+
})
|
|
126
|
+
.join("");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeTitleChildren(node: ReactNode): ReactNode {
|
|
130
|
+
if (Array.isArray(node)) {
|
|
131
|
+
return node.map(value => normalizeTitleChildren(value));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!isValidElement(node)) {
|
|
135
|
+
return node;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (node.type === "title") {
|
|
139
|
+
return <title>{toTitleText((node.props as { children?: ReactNode }).children)}</title>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const props = node.props as { children?: ReactNode };
|
|
143
|
+
if (props.children === undefined) {
|
|
144
|
+
return node;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const nextChildren = Children.map(props.children, child => normalizeTitleChildren(child));
|
|
148
|
+
return cloneElement(node, undefined, nextChildren);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function moduleHeadToElements(moduleValue: RouteModule, payload: RenderPayload, keyPrefix: string): ReactNode[] {
|
|
152
|
+
const tags: ReactNode[] = [];
|
|
153
|
+
|
|
154
|
+
const context = {
|
|
155
|
+
data: payload.data,
|
|
156
|
+
params: payload.params,
|
|
157
|
+
url: new URL(payload.url),
|
|
158
|
+
error: payload.error,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (moduleValue.head) {
|
|
162
|
+
const headResult = moduleValue.head(context);
|
|
163
|
+
if (typeof headResult === "string") {
|
|
164
|
+
tags.push(<title key={`${keyPrefix}:title`}>{headResult}</title>);
|
|
165
|
+
} else if (headResult !== null && headResult !== undefined) {
|
|
166
|
+
const nodes = Children.toArray(normalizeTitleChildren(headResult));
|
|
167
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
168
|
+
const node = nodes[index]!;
|
|
169
|
+
if (isValidElement(node)) {
|
|
170
|
+
tags.push(cloneElement(node, { key: `${keyPrefix}:head:${index}` }));
|
|
171
|
+
} else {
|
|
172
|
+
tags.push(node);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (moduleValue.meta) {
|
|
179
|
+
const metaResult = typeof moduleValue.meta === "function" ? moduleValue.meta(context) : moduleValue.meta;
|
|
180
|
+
for (const [name, content] of Object.entries(metaResult)) {
|
|
181
|
+
tags.push(<meta key={`${keyPrefix}:meta:${name}`} name={name} content={content} />);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return tags;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function collectHeadElements(modules: RouteModuleBundle, payload: RenderPayload): ReactNode[] {
|
|
189
|
+
return [
|
|
190
|
+
...moduleHeadToElements(modules.root, payload, "root"),
|
|
191
|
+
...modules.layouts.flatMap((layout, index) => moduleHeadToElements(layout, payload, `layout:${index}`)),
|
|
192
|
+
...moduleHeadToElements(modules.route, payload, "route"),
|
|
193
|
+
];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function collectHeadMarkup(modules: RouteModuleBundle, payload: RenderPayload): string {
|
|
197
|
+
const elements = collectHeadElements(modules, payload);
|
|
198
|
+
return renderToStaticMarkup(<>{elements}</>);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function buildDevReloadClientScript(version: number): string {
|
|
202
|
+
return `(() => {\n const currentVersion = ${version};\n const source = new EventSource('/__rbssr/events');\n\n source.addEventListener('reload', event => {\n const nextVersion = Number(event.data);\n if (Number.isFinite(nextVersion) && nextVersion > currentVersion) {\n location.reload();\n }\n });\n\n window.addEventListener('beforeunload', () => {\n source.close();\n });\n})();`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildDeferredBootstrapScript(): string {
|
|
206
|
+
return `(() => {\n if (window.__RBSSR_DEFERRED__) {\n return;\n }\n\n const entries = new Map();\n\n const ensure = (id) => {\n const existing = entries.get(id);\n if (existing) {\n return existing;\n }\n\n let resolve;\n let reject;\n const promise = new Promise((res, rej) => {\n resolve = res;\n reject = rej;\n });\n\n const created = {\n status: 'pending',\n promise,\n resolve,\n reject,\n };\n\n entries.set(id, created);\n return created;\n };\n\n window.__RBSSR_DEFERRED__ = {\n get(id) {\n return ensure(id).promise;\n },\n resolve(id, value) {\n const entry = ensure(id);\n if (entry.status !== 'pending') {\n return;\n }\n entry.status = 'fulfilled';\n entry.resolve(value);\n },\n reject(id, message) {\n const entry = ensure(id);\n if (entry.status !== 'pending') {\n return;\n }\n entry.status = 'rejected';\n entry.reject(new Error(String(message)));\n },\n };\n})();`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function withVersionQuery(url: string, version?: number): string {
|
|
210
|
+
if (typeof version !== "number") {
|
|
211
|
+
return url;
|
|
212
|
+
}
|
|
213
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
214
|
+
return `${url}${separator}v=${version}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function toDeferredScript(entry: DeferredSettleEntry): ReactElement {
|
|
218
|
+
return (
|
|
219
|
+
<Suspense fallback={null} key={entry.id}>
|
|
220
|
+
<DeferredScriptItem entry={entry} />
|
|
221
|
+
</Suspense>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function DeferredScriptItem(options: { entry: DeferredSettleEntry }): ReactElement {
|
|
226
|
+
const settled = use(options.entry.settled);
|
|
227
|
+
const serializedId = safeJsonSerialize(options.entry.id);
|
|
228
|
+
const code = settled.ok
|
|
229
|
+
? `window.__RBSSR_DEFERRED__.resolve(${serializedId}, ${safeJsonSerialize(settled.value)});`
|
|
230
|
+
: `window.__RBSSR_DEFERRED__.reject(${serializedId}, ${safeJsonSerialize(settled.error)});`;
|
|
231
|
+
|
|
232
|
+
return <script dangerouslySetInnerHTML={{ __html: code }} />;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function HtmlDocument(options: {
|
|
236
|
+
appTree: ReactElement;
|
|
237
|
+
payload: RenderPayload;
|
|
238
|
+
assets: HydrationDocumentAssets;
|
|
239
|
+
headElements: ReactNode[];
|
|
240
|
+
deferredSettleEntries: DeferredSettleEntry[];
|
|
241
|
+
}): ReactElement {
|
|
242
|
+
const { appTree, payload, assets, headElements, deferredSettleEntries } = options;
|
|
243
|
+
const versionedScript = assets.script ? withVersionQuery(assets.script, assets.devVersion) : undefined;
|
|
244
|
+
const cssLinks = assets.css.map((href, index) => {
|
|
245
|
+
const versionedHref = withVersionQuery(href, assets.devVersion);
|
|
246
|
+
return <link key={`css:${index}:${versionedHref}`} rel="stylesheet" href={versionedHref} />;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<html lang="en">
|
|
251
|
+
<head>
|
|
252
|
+
<meta charSet="utf-8" />
|
|
253
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
254
|
+
{headElements}
|
|
255
|
+
{cssLinks}
|
|
256
|
+
</head>
|
|
257
|
+
<body>
|
|
258
|
+
<div id="rbssr-root">{appTree}</div>
|
|
259
|
+
<script
|
|
260
|
+
dangerouslySetInnerHTML={{
|
|
261
|
+
__html: buildDeferredBootstrapScript(),
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
<script
|
|
265
|
+
id="__RBSSR_PAYLOAD__"
|
|
266
|
+
type="application/json"
|
|
267
|
+
dangerouslySetInnerHTML={{ __html: safeJsonSerialize(payload) }}
|
|
268
|
+
/>
|
|
269
|
+
{versionedScript ? <script type="module" src={versionedScript} /> : null}
|
|
270
|
+
{typeof assets.devVersion === "number" ? (
|
|
271
|
+
<script
|
|
272
|
+
dangerouslySetInnerHTML={{
|
|
273
|
+
__html: buildDevReloadClientScript(assets.devVersion),
|
|
274
|
+
}}
|
|
275
|
+
/>
|
|
276
|
+
) : null}
|
|
277
|
+
{deferredSettleEntries.map(toDeferredScript)}
|
|
278
|
+
</body>
|
|
279
|
+
</html>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function prependDoctype(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
|
|
284
|
+
const encoder = new TextEncoder();
|
|
285
|
+
const doctype = encoder.encode("<!doctype html>");
|
|
286
|
+
|
|
287
|
+
return new ReadableStream<Uint8Array>({
|
|
288
|
+
async start(controller) {
|
|
289
|
+
controller.enqueue(doctype);
|
|
290
|
+
const reader = stream.getReader();
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
while (true) {
|
|
294
|
+
const result = await reader.read();
|
|
295
|
+
if (result.done) {
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
controller.enqueue(result.value);
|
|
299
|
+
}
|
|
300
|
+
controller.close();
|
|
301
|
+
} catch (error) {
|
|
302
|
+
controller.error(error);
|
|
303
|
+
} finally {
|
|
304
|
+
reader.releaseLock();
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
cancel(reason) {
|
|
308
|
+
void stream.cancel(reason);
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function renderDocumentStream(options: {
|
|
314
|
+
appTree: ReactElement;
|
|
315
|
+
payload: RenderPayload;
|
|
316
|
+
assets: HydrationDocumentAssets;
|
|
317
|
+
headElements: ReactNode[];
|
|
318
|
+
deferredSettleEntries?: DeferredSettleEntry[];
|
|
319
|
+
}): Promise<ReadableStream<Uint8Array>> {
|
|
320
|
+
const stream = await renderToReadableStream(
|
|
321
|
+
<HtmlDocument
|
|
322
|
+
appTree={options.appTree}
|
|
323
|
+
payload={options.payload}
|
|
324
|
+
assets={options.assets}
|
|
325
|
+
headElements={options.headElements}
|
|
326
|
+
deferredSettleEntries={options.deferredSettleEntries ?? []}
|
|
327
|
+
/>,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
return prependDoctype(stream);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function renderDocument(options: {
|
|
334
|
+
appMarkup: string;
|
|
335
|
+
payload: RenderPayload;
|
|
336
|
+
assets: HydrationDocumentAssets;
|
|
337
|
+
headMarkup: string;
|
|
338
|
+
}): string {
|
|
339
|
+
const { appMarkup, payload, assets, headMarkup } = options;
|
|
340
|
+
const versionedScript = assets.script ? withVersionQuery(assets.script, assets.devVersion) : undefined;
|
|
341
|
+
const cssLinks = assets.css
|
|
342
|
+
.map(href => `<link rel="stylesheet" href="${escapeHtml(withVersionQuery(href, assets.devVersion))}"/>`)
|
|
343
|
+
.join("\n");
|
|
344
|
+
|
|
345
|
+
const payloadScript = `<script id="__RBSSR_PAYLOAD__" type="application/json">${safeJsonSerialize(payload)}</script>`;
|
|
346
|
+
const entryScript = versionedScript
|
|
347
|
+
? `<script type="module" src="${escapeHtml(versionedScript)}"></script>`
|
|
348
|
+
: "";
|
|
349
|
+
const devScript = typeof assets.devVersion === "number"
|
|
350
|
+
? `<script>${buildDevReloadClientScript(assets.devVersion)}</script>`
|
|
351
|
+
: "";
|
|
352
|
+
const deferredBootstrapScript = `<script>${buildDeferredBootstrapScript()}</script>`;
|
|
353
|
+
|
|
354
|
+
return `<!doctype html>
|
|
355
|
+
<html lang="en">
|
|
356
|
+
<head>
|
|
357
|
+
<meta charset="utf-8" />
|
|
358
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
359
|
+
${headMarkup}
|
|
360
|
+
${cssLinks}
|
|
361
|
+
</head>
|
|
362
|
+
<body>
|
|
363
|
+
<div id="rbssr-root">${appMarkup}</div>
|
|
364
|
+
${deferredBootstrapScript}
|
|
365
|
+
${payloadScript}
|
|
366
|
+
${entryScript}
|
|
367
|
+
${devScript}
|
|
368
|
+
</body>
|
|
369
|
+
</html>`;
|
|
370
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
Action,
|
|
3
|
+
ActionContext,
|
|
4
|
+
ActionResult,
|
|
5
|
+
DeferredLoaderResult,
|
|
6
|
+
DeferredToken,
|
|
7
|
+
Loader,
|
|
8
|
+
LoaderContext,
|
|
9
|
+
LoaderResult,
|
|
10
|
+
Middleware,
|
|
11
|
+
Params,
|
|
12
|
+
RedirectResult,
|
|
13
|
+
RequestContext,
|
|
14
|
+
} from "./types";
|
|
15
|
+
|
|
16
|
+
export { defer, json, redirect } from "./helpers";
|
|
17
|
+
export { Outlet, useLoaderData, useParams, useRequestUrl, useRouteError } from "./tree";
|