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,234 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createBunRouteAdapter } from "./bun-route-adapter";
|
|
3
|
+
import {
|
|
4
|
+
ensureCleanDir,
|
|
5
|
+
ensureDir,
|
|
6
|
+
existsPath,
|
|
7
|
+
glob,
|
|
8
|
+
statPath,
|
|
9
|
+
writeTextIfChanged,
|
|
10
|
+
} from "./io";
|
|
11
|
+
import type {
|
|
12
|
+
BuildManifest,
|
|
13
|
+
BuildRouteAsset,
|
|
14
|
+
PageRouteDefinition,
|
|
15
|
+
ResolvedConfig,
|
|
16
|
+
RouteManifest,
|
|
17
|
+
} from "./types";
|
|
18
|
+
import { normalizeSlashes, stableHash, toImportPath } from "./utils";
|
|
19
|
+
|
|
20
|
+
interface ClientEntryFile {
|
|
21
|
+
routeId: string;
|
|
22
|
+
entryFilePath: string;
|
|
23
|
+
route: PageRouteDefinition;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function walkFiles(rootDir: string): Promise<string[]> {
|
|
27
|
+
if (!(await existsPath(rootDir))) {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return glob("**/*", { cwd: rootDir, absolute: true });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildClientEntrySource(options: {
|
|
35
|
+
generatedDir: string;
|
|
36
|
+
route: PageRouteDefinition;
|
|
37
|
+
rootModulePath: string;
|
|
38
|
+
runtimeClientFile: string;
|
|
39
|
+
}): string {
|
|
40
|
+
const { generatedDir, route, rootModulePath, runtimeClientFile } = options;
|
|
41
|
+
|
|
42
|
+
const imports: string[] = [];
|
|
43
|
+
|
|
44
|
+
const runtimeImport = toImportPath(generatedDir, runtimeClientFile);
|
|
45
|
+
const rootImport = toImportPath(generatedDir, rootModulePath);
|
|
46
|
+
const routeImport = toImportPath(generatedDir, route.filePath);
|
|
47
|
+
|
|
48
|
+
imports.push(`import { hydrateRoute } from "${runtimeImport}";`);
|
|
49
|
+
|
|
50
|
+
imports.push(`import RootDefault from "${rootImport}";`);
|
|
51
|
+
imports.push(`import * as RootModule from "${rootImport}";`);
|
|
52
|
+
|
|
53
|
+
imports.push(`import RouteDefault from "${routeImport}";`);
|
|
54
|
+
imports.push(`import * as RouteModule from "${routeImport}";`);
|
|
55
|
+
|
|
56
|
+
const layoutModuleRefs: string[] = [];
|
|
57
|
+
for (let index = 0; index < route.layoutFiles.length; index += 1) {
|
|
58
|
+
const layoutFilePath = route.layoutFiles[index]!;
|
|
59
|
+
const layoutImportPath = toImportPath(generatedDir, layoutFilePath);
|
|
60
|
+
imports.push(`import Layout${index}Default from "${layoutImportPath}";`);
|
|
61
|
+
imports.push(`import * as Layout${index}Module from "${layoutImportPath}";`);
|
|
62
|
+
layoutModuleRefs.push(`{ ...Layout${index}Module, default: Layout${index}Default }`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return `${imports.join("\n")}
|
|
66
|
+
|
|
67
|
+
const modules = {
|
|
68
|
+
root: { ...RootModule, default: RootDefault },
|
|
69
|
+
layouts: [${layoutModuleRefs.join(", ")}],
|
|
70
|
+
route: { ...RouteModule, default: RouteDefault },
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
hydrateRoute(modules);
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function generateClientEntries(options: {
|
|
78
|
+
config: ResolvedConfig;
|
|
79
|
+
manifest: RouteManifest;
|
|
80
|
+
generatedDir: string;
|
|
81
|
+
}): Promise<ClientEntryFile[]> {
|
|
82
|
+
const { config, manifest, generatedDir } = options;
|
|
83
|
+
await ensureDir(generatedDir);
|
|
84
|
+
|
|
85
|
+
const runtimeClientFile = path.resolve(config.cwd, "framework/runtime/client-runtime.tsx");
|
|
86
|
+
|
|
87
|
+
return Promise.all(
|
|
88
|
+
manifest.pages.map(async route => {
|
|
89
|
+
const entryName = `route__${route.id}.tsx`;
|
|
90
|
+
const entryFilePath = path.join(generatedDir, entryName);
|
|
91
|
+
const source = buildClientEntrySource({
|
|
92
|
+
generatedDir,
|
|
93
|
+
route,
|
|
94
|
+
rootModulePath: config.rootModule,
|
|
95
|
+
runtimeClientFile,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
await writeTextIfChanged(entryFilePath, source);
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
routeId: route.id,
|
|
102
|
+
entryFilePath,
|
|
103
|
+
route,
|
|
104
|
+
};
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function mapBuildOutputsByPrefix(options: {
|
|
110
|
+
outDir: string;
|
|
111
|
+
routeIds: string[];
|
|
112
|
+
publicPrefix: string;
|
|
113
|
+
}): Promise<Record<string, BuildRouteAsset>> {
|
|
114
|
+
const { outDir, routeIds, publicPrefix } = options;
|
|
115
|
+
const files = (await walkFiles(outDir)).map(filePath => normalizeSlashes(path.relative(outDir, filePath)));
|
|
116
|
+
|
|
117
|
+
const routeAssets: Record<string, BuildRouteAsset> = {};
|
|
118
|
+
|
|
119
|
+
for (const routeId of routeIds) {
|
|
120
|
+
const base = `route__${routeId}`;
|
|
121
|
+
const script = files.find(file => file.startsWith(base) && file.endsWith(".js"));
|
|
122
|
+
const css = files.filter(file => file.startsWith(base) && file.endsWith(".css"));
|
|
123
|
+
|
|
124
|
+
if (!script) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
routeAssets[routeId] = {
|
|
129
|
+
script: `${publicPrefix}${script}`,
|
|
130
|
+
css: css.map(file => `${publicPrefix}${file}`),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return routeAssets;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function bundleClientEntries(options: {
|
|
138
|
+
entries: ClientEntryFile[];
|
|
139
|
+
outDir: string;
|
|
140
|
+
dev: boolean;
|
|
141
|
+
publicPrefix: string;
|
|
142
|
+
}): Promise<Record<string, BuildRouteAsset>> {
|
|
143
|
+
const { entries, outDir, dev, publicPrefix } = options;
|
|
144
|
+
|
|
145
|
+
await ensureDir(outDir);
|
|
146
|
+
if (entries.length === 0) {
|
|
147
|
+
return {};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = await Bun.build({
|
|
151
|
+
entrypoints: entries.map(entry => entry.entryFilePath),
|
|
152
|
+
outdir: outDir,
|
|
153
|
+
target: "browser",
|
|
154
|
+
format: "esm",
|
|
155
|
+
splitting: false,
|
|
156
|
+
sourcemap: dev ? "inline" : "external",
|
|
157
|
+
minify: !dev,
|
|
158
|
+
naming: dev ? "[name].[ext]" : "[name]-[hash].[ext]",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (!result.success) {
|
|
162
|
+
const messages = result.logs.map(log => log.message).join("\n");
|
|
163
|
+
throw new Error(`Client bundle failed:\n${messages}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return mapBuildOutputsByPrefix({
|
|
167
|
+
outDir,
|
|
168
|
+
routeIds: entries.map(entry => entry.routeId),
|
|
169
|
+
publicPrefix,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function ensureCleanDirectory(dirPath: string): Promise<void> {
|
|
174
|
+
await ensureCleanDir(dirPath);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function copyDirRecursive(sourceDir: string, destinationDir: string): Promise<void> {
|
|
178
|
+
if (!(await existsPath(sourceDir))) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await ensureDir(destinationDir);
|
|
183
|
+
|
|
184
|
+
const entries = await glob("**/*", { cwd: sourceDir });
|
|
185
|
+
await Promise.all(
|
|
186
|
+
entries.map(async entry => {
|
|
187
|
+
const from = path.join(sourceDir, entry);
|
|
188
|
+
const to = path.join(destinationDir, entry);
|
|
189
|
+
const fileStat = await statPath(from);
|
|
190
|
+
if (!fileStat?.isFile()) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await ensureDir(path.dirname(to));
|
|
195
|
+
await Bun.write(to, Bun.file(from));
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function createBuildManifest(routeAssets: Record<string, BuildRouteAsset>): BuildManifest {
|
|
201
|
+
return {
|
|
202
|
+
version: stableHash(JSON.stringify(routeAssets)),
|
|
203
|
+
generatedAt: new Date().toISOString(),
|
|
204
|
+
routes: routeAssets,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function discoverFileSignature(rootDir: string): Promise<string> {
|
|
209
|
+
const files = (await walkFiles(rootDir))
|
|
210
|
+
.filter(file => !normalizeSlashes(file).includes("/node_modules/"))
|
|
211
|
+
.sort();
|
|
212
|
+
|
|
213
|
+
const signatureBits = (await Promise.all(
|
|
214
|
+
files.map(async filePath => {
|
|
215
|
+
const fileStat = await statPath(filePath);
|
|
216
|
+
if (!fileStat?.isFile()) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
const contentHash = stableHash(await Bun.file(filePath).bytes());
|
|
220
|
+
return `${normalizeSlashes(filePath)}:${contentHash}`;
|
|
221
|
+
}),
|
|
222
|
+
)).filter((value): value is string => Boolean(value));
|
|
223
|
+
|
|
224
|
+
return stableHash(signatureBits.join("|"));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function buildRouteManifest(config: ResolvedConfig): Promise<RouteManifest> {
|
|
228
|
+
const adapter = await createBunRouteAdapter({
|
|
229
|
+
routesDir: config.routesDir,
|
|
230
|
+
generatedMarkdownRootDir: path.resolve(config.cwd, ".rbssr/generated/markdown-routes"),
|
|
231
|
+
projectionRootDir: path.resolve(config.cwd, ".rbssr/generated/router-projection/build-manifest"),
|
|
232
|
+
});
|
|
233
|
+
return adapter.manifest;
|
|
234
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ensureCleanDir, ensureDir, writeTextIfChanged } from "./io";
|
|
3
|
+
import { scanRoutes } from "./route-scanner";
|
|
4
|
+
import type {
|
|
5
|
+
ApiRouteDefinition,
|
|
6
|
+
PageRouteDefinition,
|
|
7
|
+
Params,
|
|
8
|
+
RouteManifest,
|
|
9
|
+
RouteMatch,
|
|
10
|
+
} from "./types";
|
|
11
|
+
import { normalizeSlashes, toImportPath } from "./utils";
|
|
12
|
+
|
|
13
|
+
const PAGE_STUB_EXT = ".tsx";
|
|
14
|
+
const API_STUB_EXT = ".ts";
|
|
15
|
+
const ROUTER_FILE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js"];
|
|
16
|
+
|
|
17
|
+
export interface BunRouteAdapter {
|
|
18
|
+
manifest: RouteManifest;
|
|
19
|
+
matchPage(pathname: string): RouteMatch<PageRouteDefinition> | null;
|
|
20
|
+
matchApi(pathname: string): RouteMatch<ApiRouteDefinition> | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toRouterRelativePath(routePath: string, extension: string): string {
|
|
24
|
+
if (routePath === "/") {
|
|
25
|
+
return `index${extension}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const parts = routePath
|
|
29
|
+
.replace(/^\/+/, "")
|
|
30
|
+
.split("/")
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.map((segment) => {
|
|
33
|
+
if (segment.startsWith(":")) {
|
|
34
|
+
return `[${segment.slice(1)}]`;
|
|
35
|
+
}
|
|
36
|
+
if (segment.startsWith("*")) {
|
|
37
|
+
return `[...${segment.slice(1)}]`;
|
|
38
|
+
}
|
|
39
|
+
return segment;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return `${parts.join("/")}${extension}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toPageStubSource(stubFilePath: string, routeFilePath: string): string {
|
|
46
|
+
const fromDir = path.dirname(stubFilePath);
|
|
47
|
+
const routeImportPath = toImportPath(fromDir, routeFilePath);
|
|
48
|
+
return `export { default } from "${routeImportPath}";
|
|
49
|
+
export * from "${routeImportPath}";
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toApiStubSource(stubFilePath: string, routeFilePath: string): string {
|
|
54
|
+
const fromDir = path.dirname(stubFilePath);
|
|
55
|
+
const routeImportPath = toImportPath(fromDir, routeFilePath);
|
|
56
|
+
return `export * from "${routeImportPath}";
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeParams(value: Record<string, string>): Params {
|
|
61
|
+
const params: Params = {};
|
|
62
|
+
for (const [key, paramValue] of Object.entries(value)) {
|
|
63
|
+
params[key] = String(paramValue);
|
|
64
|
+
}
|
|
65
|
+
return params;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeRouteKey(value: string): string {
|
|
69
|
+
return normalizeSlashes(value).replace(/^\/+/, "");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function writeProjectionRoutes<T extends PageRouteDefinition | ApiRouteDefinition>(options: {
|
|
73
|
+
routes: T[];
|
|
74
|
+
outDir: string;
|
|
75
|
+
extension: string;
|
|
76
|
+
toSource: (stubFilePath: string, routeFilePath: string) => string;
|
|
77
|
+
routeTypeLabel: "page" | "api";
|
|
78
|
+
}): Promise<Map<string, T>> {
|
|
79
|
+
const {
|
|
80
|
+
routes,
|
|
81
|
+
outDir,
|
|
82
|
+
extension,
|
|
83
|
+
toSource,
|
|
84
|
+
routeTypeLabel,
|
|
85
|
+
} = options;
|
|
86
|
+
await ensureDir(outDir);
|
|
87
|
+
|
|
88
|
+
const byProjectedFilePath = new Map<string, T>();
|
|
89
|
+
const collisionMap = new Map<string, T>();
|
|
90
|
+
const writes: Array<{ projectedFilePath: string; source: string }> = [];
|
|
91
|
+
|
|
92
|
+
for (const route of routes) {
|
|
93
|
+
const relativeFilePath = normalizeSlashes(toRouterRelativePath(route.routePath, extension));
|
|
94
|
+
const projectedFilePath = path.join(outDir, relativeFilePath);
|
|
95
|
+
const projectedKey = normalizeRouteKey(relativeFilePath);
|
|
96
|
+
const existing = collisionMap.get(projectedKey);
|
|
97
|
+
|
|
98
|
+
if (existing) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Route projection collision for ${routeTypeLabel} routes: "${existing.filePath}" and "${route.filePath}" both map to "${relativeFilePath}"`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
collisionMap.set(projectedKey, route);
|
|
105
|
+
writes.push({
|
|
106
|
+
projectedFilePath,
|
|
107
|
+
source: toSource(projectedFilePath, route.filePath),
|
|
108
|
+
});
|
|
109
|
+
byProjectedFilePath.set(projectedKey, route);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await Promise.all(
|
|
113
|
+
writes.map(async ({ projectedFilePath, source }) => {
|
|
114
|
+
await ensureDir(path.dirname(projectedFilePath));
|
|
115
|
+
await writeTextIfChanged(projectedFilePath, source);
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
return byProjectedFilePath;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function toRouteMatch<T extends PageRouteDefinition | ApiRouteDefinition>(
|
|
123
|
+
routeByProjectedPath: Map<string, T>,
|
|
124
|
+
pathname: string,
|
|
125
|
+
router: Bun.FileSystemRouter,
|
|
126
|
+
): RouteMatch<T> | null {
|
|
127
|
+
const matched = router.match(pathname);
|
|
128
|
+
if (!matched) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const matchedSource = normalizeRouteKey(
|
|
133
|
+
((matched as { src?: string; scriptSrc?: string }).src
|
|
134
|
+
?? (matched as { src?: string; scriptSrc?: string }).scriptSrc
|
|
135
|
+
?? ""),
|
|
136
|
+
);
|
|
137
|
+
const route = routeByProjectedPath.get(matchedSource);
|
|
138
|
+
if (!route) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
route,
|
|
144
|
+
params: normalizeParams(matched.params),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function createBunRouteAdapter(options: {
|
|
149
|
+
routesDir: string;
|
|
150
|
+
generatedMarkdownRootDir: string;
|
|
151
|
+
projectionRootDir: string;
|
|
152
|
+
}): Promise<BunRouteAdapter> {
|
|
153
|
+
const manifest = await scanRoutes(options.routesDir, {
|
|
154
|
+
generatedMarkdownRootDir: options.generatedMarkdownRootDir,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await ensureCleanDir(options.projectionRootDir);
|
|
158
|
+
|
|
159
|
+
const pagesProjectionDir = path.join(options.projectionRootDir, "pages");
|
|
160
|
+
const apiProjectionDir = path.join(options.projectionRootDir, "api");
|
|
161
|
+
|
|
162
|
+
const [pageRouteByProjectedPath, apiRouteByProjectedPath] = await Promise.all([
|
|
163
|
+
writeProjectionRoutes({
|
|
164
|
+
routes: manifest.pages,
|
|
165
|
+
outDir: pagesProjectionDir,
|
|
166
|
+
extension: PAGE_STUB_EXT,
|
|
167
|
+
toSource: toPageStubSource,
|
|
168
|
+
routeTypeLabel: "page",
|
|
169
|
+
}),
|
|
170
|
+
writeProjectionRoutes({
|
|
171
|
+
routes: manifest.api,
|
|
172
|
+
outDir: apiProjectionDir,
|
|
173
|
+
extension: API_STUB_EXT,
|
|
174
|
+
toSource: toApiStubSource,
|
|
175
|
+
routeTypeLabel: "api",
|
|
176
|
+
}),
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const pageRouter = new Bun.FileSystemRouter({
|
|
180
|
+
style: "nextjs",
|
|
181
|
+
dir: pagesProjectionDir,
|
|
182
|
+
fileExtensions: ROUTER_FILE_EXTENSIONS,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const apiRouter = new Bun.FileSystemRouter({
|
|
186
|
+
style: "nextjs",
|
|
187
|
+
dir: apiProjectionDir,
|
|
188
|
+
fileExtensions: ROUTER_FILE_EXTENSIONS,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
manifest,
|
|
193
|
+
matchPage(pathname) {
|
|
194
|
+
return toRouteMatch(pageRouteByProjectedPath, pathname, pageRouter);
|
|
195
|
+
},
|
|
196
|
+
matchApi(pathname) {
|
|
197
|
+
return toRouteMatch(apiRouteByProjectedPath, pathname, apiRouter);
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { hydrateRoot } from "react-dom/client";
|
|
2
|
+
import { isDeferredToken } from "./deferred";
|
|
3
|
+
import type { RouteModuleBundle, RenderPayload } from "./types";
|
|
4
|
+
import { createRouteTree } from "./tree";
|
|
5
|
+
|
|
6
|
+
interface DeferredClientRuntime {
|
|
7
|
+
get(id: string): Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
declare global {
|
|
11
|
+
interface Window {
|
|
12
|
+
__RBSSR_DEFERRED__?: DeferredClientRuntime;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function reviveDeferredPayload(payload: RenderPayload): RenderPayload {
|
|
17
|
+
const sourceData = payload.data;
|
|
18
|
+
if (!sourceData || Array.isArray(sourceData) || typeof sourceData !== "object") {
|
|
19
|
+
return payload;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const runtime = window.__RBSSR_DEFERRED__;
|
|
23
|
+
if (!runtime) {
|
|
24
|
+
return payload;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const revivedData = { ...(sourceData as Record<string, unknown>) };
|
|
28
|
+
for (const [key, value] of Object.entries(revivedData)) {
|
|
29
|
+
if (!isDeferredToken(value)) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
revivedData[key] = runtime.get(value.__rbssrDeferred);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
...payload,
|
|
37
|
+
data: revivedData,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getPayload(): RenderPayload {
|
|
42
|
+
const script = document.getElementById("__RBSSR_PAYLOAD__");
|
|
43
|
+
if (!script) {
|
|
44
|
+
throw new Error("Missing SSR payload script tag");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const raw = script.textContent ?? "{}";
|
|
48
|
+
const parsed = JSON.parse(raw) as RenderPayload;
|
|
49
|
+
return reviveDeferredPayload(parsed);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function hydrateRoute(modules: RouteModuleBundle): void {
|
|
53
|
+
const payload = getPayload();
|
|
54
|
+
const container = document.getElementById("rbssr-root");
|
|
55
|
+
if (!container) {
|
|
56
|
+
throw new Error("Missing #rbssr-root hydration container");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const Leaf = modules.route.default;
|
|
60
|
+
const tree = createRouteTree(modules, <Leaf />, payload);
|
|
61
|
+
hydrateRoot(container, tree);
|
|
62
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsPath } from "./io";
|
|
3
|
+
import type { FrameworkConfig, ResolvedConfig, ResolvedResponseHeaderRule } from "./types";
|
|
4
|
+
import { normalizeSlashes, toFileImportUrl } from "./utils";
|
|
5
|
+
|
|
6
|
+
function escapeRegex(value: string): string {
|
|
7
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function globToRegExp(source: string): RegExp {
|
|
11
|
+
if (source.endsWith("/**")) {
|
|
12
|
+
const prefix = source.slice(0, -3);
|
|
13
|
+
return new RegExp(`^${escapeRegex(prefix)}(?:/.*)?$`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let pattern = "^";
|
|
17
|
+
|
|
18
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
19
|
+
const current = source[index]!;
|
|
20
|
+
const next = source[index + 1];
|
|
21
|
+
|
|
22
|
+
if (current === "*" && next === "*") {
|
|
23
|
+
pattern += ".*";
|
|
24
|
+
index += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (current === "*") {
|
|
29
|
+
pattern += "[^/]*";
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pattern += escapeRegex(current);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pattern += "$";
|
|
37
|
+
return new RegExp(pattern);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function toHeaderRules(config: FrameworkConfig): ResolvedResponseHeaderRule[] {
|
|
41
|
+
if (config.headers === undefined) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (!Array.isArray(config.headers)) {
|
|
46
|
+
throw new Error("[rbssr config] `headers` must be an array.");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return config.headers.map((rule, index) => {
|
|
50
|
+
if (!rule || typeof rule !== "object") {
|
|
51
|
+
throw new Error(`[rbssr config] \`headers[${index}]\` must be an object.`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const rawSource = rule.source;
|
|
55
|
+
if (typeof rawSource !== "string" || rawSource.trim().length === 0) {
|
|
56
|
+
throw new Error(`[rbssr config] \`headers[${index}].source\` must be a non-empty string.`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const source = normalizeSlashes(rawSource.trim());
|
|
60
|
+
if (!source.startsWith("/")) {
|
|
61
|
+
throw new Error(`[rbssr config] \`headers[${index}].source\` must start with '/'.`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rawHeaders = rule.headers;
|
|
65
|
+
if (!rawHeaders || typeof rawHeaders !== "object" || Array.isArray(rawHeaders)) {
|
|
66
|
+
throw new Error(`[rbssr config] \`headers[${index}].headers\` must be an object.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const entries = Object.entries(rawHeaders);
|
|
70
|
+
if (entries.length === 0) {
|
|
71
|
+
throw new Error(`[rbssr config] \`headers[${index}].headers\` must include at least one header.`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const headers: Record<string, string> = {};
|
|
75
|
+
for (const [key, value] of entries) {
|
|
76
|
+
if (typeof key !== "string" || key.trim().length === 0) {
|
|
77
|
+
throw new Error(`[rbssr config] \`headers[${index}].headers\` contains an empty header name.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`[rbssr config] \`headers[${index}].headers.${key}\` must be a non-empty string value.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
headers[key] = value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
source,
|
|
91
|
+
headers,
|
|
92
|
+
matcher: globToRegExp(source),
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveConfig(config: FrameworkConfig = {}, cwd = process.cwd()): ResolvedConfig {
|
|
98
|
+
const appDir = path.resolve(cwd, config.appDir ?? "app");
|
|
99
|
+
const routesDir = path.resolve(appDir, config.routesDir ?? "routes");
|
|
100
|
+
const publicDir = path.resolve(appDir, config.publicDir ?? "public");
|
|
101
|
+
const rootModule = path.resolve(appDir, config.rootModule ?? "root.tsx");
|
|
102
|
+
const middlewareFile = path.resolve(appDir, config.middlewareFile ?? "middleware.ts");
|
|
103
|
+
const distDir = path.resolve(cwd, config.distDir ?? "dist");
|
|
104
|
+
const headerRules = toHeaderRules(config);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
cwd,
|
|
108
|
+
appDir,
|
|
109
|
+
routesDir,
|
|
110
|
+
publicDir,
|
|
111
|
+
rootModule,
|
|
112
|
+
middlewareFile,
|
|
113
|
+
distDir,
|
|
114
|
+
host: config.host ?? "0.0.0.0",
|
|
115
|
+
port: config.port ?? 3000,
|
|
116
|
+
mode: config.mode ?? (process.env.NODE_ENV === "production" ? "production" : "development"),
|
|
117
|
+
headerRules,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function loadUserConfig(cwd = process.cwd()): Promise<FrameworkConfig> {
|
|
122
|
+
const candidates = [
|
|
123
|
+
path.resolve(cwd, "rbssr.config.ts"),
|
|
124
|
+
path.resolve(cwd, "rbssr.config.js"),
|
|
125
|
+
path.resolve(cwd, "rbssr.config.mjs"),
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
let filePath: string | undefined;
|
|
129
|
+
for (const candidate of candidates) {
|
|
130
|
+
if (await existsPath(candidate)) {
|
|
131
|
+
filePath = candidate;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!filePath) {
|
|
137
|
+
return {};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const imported = await import(toFileImportUrl(filePath));
|
|
141
|
+
return (imported.default ?? imported.config ?? {}) as FrameworkConfig;
|
|
142
|
+
}
|