react-bun-ssr 0.1.0 → 0.2.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 +116 -132
- package/framework/cli/commands.ts +59 -223
- package/framework/cli/dev-client-watch.ts +281 -0
- package/framework/cli/dev-route-table.ts +71 -0
- package/framework/cli/dev-runtime.ts +382 -0
- package/framework/cli/internal.ts +138 -0
- package/framework/cli/main.ts +27 -31
- package/framework/runtime/build-tools.ts +280 -57
- package/framework/runtime/bun-route-adapter.ts +20 -7
- package/framework/runtime/client-runtime.tsx +1218 -15
- package/framework/runtime/client-transition-core.ts +159 -0
- package/framework/runtime/config.ts +4 -1
- package/framework/runtime/index.ts +6 -0
- package/framework/runtime/io.ts +1 -1
- package/framework/runtime/link.tsx +205 -0
- package/framework/runtime/markdown-headings.ts +54 -0
- package/framework/runtime/markdown-routes.ts +9 -40
- package/framework/runtime/matcher.ts +11 -11
- package/framework/runtime/module-loader.ts +215 -52
- package/framework/runtime/navigation-api.ts +223 -0
- package/framework/runtime/render.tsx +105 -105
- package/framework/runtime/route-api.ts +6 -0
- package/framework/runtime/route-errors.ts +166 -0
- package/framework/runtime/router.ts +80 -0
- package/framework/runtime/runtime-constants.ts +4 -0
- package/framework/runtime/server.ts +713 -145
- package/framework/runtime/tree.tsx +171 -3
- package/framework/runtime/types.ts +81 -3
- package/framework/runtime/utils.ts +9 -5
- package/package.json +19 -5
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
createClientEntrySetSignature,
|
|
4
|
+
listBuildOutputFiles,
|
|
5
|
+
mapBuildOutputsFromMetafile,
|
|
6
|
+
type ClientEntryFile,
|
|
7
|
+
} from "../runtime/build-tools";
|
|
8
|
+
import { ensureDir, readText, removePath, statPath } from "../runtime/io";
|
|
9
|
+
import type { BuildRouteAsset } from "../runtime/types";
|
|
10
|
+
|
|
11
|
+
export interface DevClientBuildSnapshot {
|
|
12
|
+
entrySetSignature: string;
|
|
13
|
+
routeAssets: Record<string, BuildRouteAsset>;
|
|
14
|
+
outputFiles: string[];
|
|
15
|
+
buildCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DevClientWatchHandle {
|
|
19
|
+
syncEntries(entries: ClientEntryFile[]): Promise<{ restarted: boolean; entrySetSignature: string }>;
|
|
20
|
+
getBuildCount(): number;
|
|
21
|
+
waitForBuildAfter(buildCount: number): Promise<void>;
|
|
22
|
+
waitUntilReady(): Promise<void>;
|
|
23
|
+
stop(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface DevClientWatchState {
|
|
27
|
+
buildCount: number;
|
|
28
|
+
entrySetSignature: string;
|
|
29
|
+
entries: ClientEntryFile[];
|
|
30
|
+
readyPromise: Promise<void>;
|
|
31
|
+
resolveReady: () => void;
|
|
32
|
+
rejectReady: (error: unknown) => void;
|
|
33
|
+
outputFiles: Set<string>;
|
|
34
|
+
process: Bun.Subprocess<"ignore", "inherit", "inherit"> | null;
|
|
35
|
+
stopped: boolean;
|
|
36
|
+
waiters: Array<{
|
|
37
|
+
minBuildCount: number;
|
|
38
|
+
resolve: () => void;
|
|
39
|
+
reject: (error: unknown) => void;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createDeferred(): {
|
|
44
|
+
promise: Promise<void>;
|
|
45
|
+
resolve: () => void;
|
|
46
|
+
reject: (error: unknown) => void;
|
|
47
|
+
} {
|
|
48
|
+
let resolve!: () => void;
|
|
49
|
+
let reject!: (error: unknown) => void;
|
|
50
|
+
const promise = new Promise<void>((res, rej) => {
|
|
51
|
+
resolve = res;
|
|
52
|
+
reject = rej;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
promise,
|
|
57
|
+
resolve,
|
|
58
|
+
reject,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createDevClientWatch(options: {
|
|
63
|
+
cwd: string;
|
|
64
|
+
outDir: string;
|
|
65
|
+
metafilePath: string;
|
|
66
|
+
entries: ClientEntryFile[];
|
|
67
|
+
publicPrefix: string;
|
|
68
|
+
onBuild: (snapshot: DevClientBuildSnapshot) => void | Promise<void>;
|
|
69
|
+
onLog?: (message: string) => void;
|
|
70
|
+
}): DevClientWatchHandle {
|
|
71
|
+
const state: DevClientWatchState = {
|
|
72
|
+
buildCount: 0,
|
|
73
|
+
entrySetSignature: createClientEntrySetSignature(options.entries),
|
|
74
|
+
entries: [...options.entries],
|
|
75
|
+
...(() => {
|
|
76
|
+
const deferred = createDeferred();
|
|
77
|
+
return {
|
|
78
|
+
readyPromise: deferred.promise,
|
|
79
|
+
resolveReady: deferred.resolve,
|
|
80
|
+
rejectReady: deferred.reject,
|
|
81
|
+
};
|
|
82
|
+
})(),
|
|
83
|
+
outputFiles: new Set<string>(),
|
|
84
|
+
process: null,
|
|
85
|
+
stopped: false,
|
|
86
|
+
waiters: [],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
let metafilePoller: ReturnType<typeof setInterval> | undefined;
|
|
90
|
+
let lastMetafileMtime = "";
|
|
91
|
+
|
|
92
|
+
const stopPolling = (): void => {
|
|
93
|
+
if (metafilePoller) {
|
|
94
|
+
clearInterval(metafilePoller);
|
|
95
|
+
metafilePoller = undefined;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const parseMetafile = async (): Promise<void> => {
|
|
100
|
+
const stat = await statPath(options.metafilePath);
|
|
101
|
+
if (!stat?.isFile()) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const nextMtime = stat.mtime.toISOString();
|
|
106
|
+
if (nextMtime === lastMetafileMtime) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let metafile: Bun.BuildMetafile;
|
|
111
|
+
try {
|
|
112
|
+
metafile = JSON.parse(await readText(options.metafilePath)) as Bun.BuildMetafile;
|
|
113
|
+
} catch {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
lastMetafileMtime = nextMtime;
|
|
118
|
+
|
|
119
|
+
const nextOutputFiles = new Set(listBuildOutputFiles(metafile));
|
|
120
|
+
const staleOutputFiles = [...state.outputFiles].filter((filePath) => !nextOutputFiles.has(filePath));
|
|
121
|
+
|
|
122
|
+
await Promise.all(
|
|
123
|
+
staleOutputFiles.map((filePath) => removePath(path.resolve(options.cwd, filePath))),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
state.outputFiles = nextOutputFiles;
|
|
127
|
+
state.buildCount += 1;
|
|
128
|
+
|
|
129
|
+
const readyWaiters = state.waiters.filter((waiter) => state.buildCount > waiter.minBuildCount);
|
|
130
|
+
state.waiters = state.waiters.filter((waiter) => state.buildCount <= waiter.minBuildCount);
|
|
131
|
+
for (const waiter of readyWaiters) {
|
|
132
|
+
waiter.resolve();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
await options.onBuild({
|
|
136
|
+
entrySetSignature: state.entrySetSignature,
|
|
137
|
+
routeAssets: mapBuildOutputsFromMetafile({
|
|
138
|
+
metafile,
|
|
139
|
+
entries: state.entries,
|
|
140
|
+
publicPrefix: options.publicPrefix,
|
|
141
|
+
}),
|
|
142
|
+
outputFiles: [...nextOutputFiles].sort(),
|
|
143
|
+
buildCount: state.buildCount,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
state.resolveReady();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const startProcess = async (): Promise<void> => {
|
|
150
|
+
await ensureDir(path.dirname(options.metafilePath));
|
|
151
|
+
await ensureDir(options.outDir);
|
|
152
|
+
await removePath(options.metafilePath);
|
|
153
|
+
|
|
154
|
+
const previousOutputFiles = [...state.outputFiles];
|
|
155
|
+
const deferred = createDeferred();
|
|
156
|
+
state.readyPromise = deferred.promise;
|
|
157
|
+
state.resolveReady = deferred.resolve;
|
|
158
|
+
state.rejectReady = deferred.reject;
|
|
159
|
+
state.buildCount = 0;
|
|
160
|
+
state.outputFiles = new Set<string>();
|
|
161
|
+
lastMetafileMtime = "";
|
|
162
|
+
|
|
163
|
+
if (state.entries.length === 0) {
|
|
164
|
+
await Promise.all(
|
|
165
|
+
previousOutputFiles.map((filePath) => removePath(path.resolve(options.cwd, filePath))),
|
|
166
|
+
);
|
|
167
|
+
state.resolveReady();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const cmd = [
|
|
172
|
+
"bun",
|
|
173
|
+
"build",
|
|
174
|
+
"--watch",
|
|
175
|
+
"--no-clear-screen",
|
|
176
|
+
"--target=browser",
|
|
177
|
+
"--format=esm",
|
|
178
|
+
"--splitting",
|
|
179
|
+
"--sourcemap=inline",
|
|
180
|
+
"--outdir",
|
|
181
|
+
options.outDir,
|
|
182
|
+
`--metafile=${options.metafilePath}`,
|
|
183
|
+
"--entry-naming",
|
|
184
|
+
"[name].[ext]",
|
|
185
|
+
"--chunk-naming",
|
|
186
|
+
"[name]-[hash].[ext]",
|
|
187
|
+
"--asset-naming",
|
|
188
|
+
"[name]-[hash].[ext]",
|
|
189
|
+
...state.entries.map((entry) => entry.entryFilePath),
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
state.process = Bun.spawn({
|
|
193
|
+
cmd,
|
|
194
|
+
cwd: options.cwd,
|
|
195
|
+
stdin: "ignore",
|
|
196
|
+
stdout: "inherit",
|
|
197
|
+
stderr: "inherit",
|
|
198
|
+
env: process.env,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
void state.process.exited.then((exitCode) => {
|
|
202
|
+
if (state.stopped || exitCode === 0) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
for (const waiter of state.waiters) {
|
|
206
|
+
waiter.reject(new Error(`bun build --watch exited with code ${exitCode}`));
|
|
207
|
+
}
|
|
208
|
+
state.waiters = [];
|
|
209
|
+
state.rejectReady(new Error(`bun build --watch exited with code ${exitCode}`));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
metafilePoller = setInterval(() => {
|
|
213
|
+
void parseMetafile();
|
|
214
|
+
}, 75);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const stopProcess = async (): Promise<void> => {
|
|
218
|
+
stopPolling();
|
|
219
|
+
|
|
220
|
+
if (!state.process) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const active = state.process;
|
|
225
|
+
state.process = null;
|
|
226
|
+
active.kill();
|
|
227
|
+
await active.exited;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
void startProcess();
|
|
231
|
+
|
|
232
|
+
return {
|
|
233
|
+
async syncEntries(entries) {
|
|
234
|
+
const nextEntrySetSignature = createClientEntrySetSignature(entries);
|
|
235
|
+
const restarted = nextEntrySetSignature !== state.entrySetSignature;
|
|
236
|
+
state.entries = [...entries];
|
|
237
|
+
|
|
238
|
+
if (!restarted) {
|
|
239
|
+
options.onLog?.("kept Bun client watch hot");
|
|
240
|
+
return {
|
|
241
|
+
restarted: false,
|
|
242
|
+
entrySetSignature: state.entrySetSignature,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
state.entrySetSignature = nextEntrySetSignature;
|
|
247
|
+
await stopProcess();
|
|
248
|
+
await startProcess();
|
|
249
|
+
options.onLog?.("restarted Bun client watch after entry set change");
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
restarted: true,
|
|
253
|
+
entrySetSignature: state.entrySetSignature,
|
|
254
|
+
};
|
|
255
|
+
},
|
|
256
|
+
getBuildCount() {
|
|
257
|
+
return state.buildCount;
|
|
258
|
+
},
|
|
259
|
+
waitForBuildAfter(buildCount) {
|
|
260
|
+
if (state.buildCount > buildCount || state.entries.length === 0) {
|
|
261
|
+
return Promise.resolve();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return new Promise<void>((resolve, reject) => {
|
|
265
|
+
state.waiters.push({
|
|
266
|
+
minBuildCount: buildCount,
|
|
267
|
+
resolve,
|
|
268
|
+
reject,
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
waitUntilReady() {
|
|
273
|
+
return state.readyPromise;
|
|
274
|
+
},
|
|
275
|
+
async stop() {
|
|
276
|
+
state.stopped = true;
|
|
277
|
+
stopPolling();
|
|
278
|
+
await stopProcess();
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { statPath } from "../runtime/io";
|
|
2
|
+
import type { RouteManifest } from "../runtime/types";
|
|
3
|
+
import { ensureWithin } from "../runtime/utils";
|
|
4
|
+
|
|
5
|
+
export const RBSSR_DEV_RELOAD_TOPIC = "rbssr:reload";
|
|
6
|
+
export const RBSSR_DEV_WS_PATH = "/__rbssr/ws";
|
|
7
|
+
|
|
8
|
+
export type DevReloadReason =
|
|
9
|
+
| "client-build"
|
|
10
|
+
| "route-structure"
|
|
11
|
+
| "server-runtime"
|
|
12
|
+
| "markdown-route"
|
|
13
|
+
| "config-restart";
|
|
14
|
+
|
|
15
|
+
export interface DevReloadMessage {
|
|
16
|
+
token: number;
|
|
17
|
+
reason: DevReloadReason;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeApiRoutePath(routePath: string): string {
|
|
21
|
+
return routePath.replace(/\*[A-Za-z0-9_]+/g, "*");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function serveDevClientAsset(devClientDir: string, request: Request): Promise<Response> {
|
|
25
|
+
const url = new URL(request.url);
|
|
26
|
+
const relativePath = url.pathname.replace(/^\/__rbssr\/client\//, "");
|
|
27
|
+
const resolvedPath = ensureWithin(devClientDir, relativePath);
|
|
28
|
+
if (!resolvedPath) {
|
|
29
|
+
return new Response("Not found", { status: 404 });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const stat = await statPath(resolvedPath);
|
|
33
|
+
if (!stat?.isFile()) {
|
|
34
|
+
return new Response("Not found", { status: 404 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return new Response(Bun.file(resolvedPath), {
|
|
38
|
+
headers: {
|
|
39
|
+
"cache-control": "no-store",
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createDevRouteTable(options: {
|
|
45
|
+
devClientDir: string;
|
|
46
|
+
manifest: RouteManifest;
|
|
47
|
+
handleFrameworkFetch: (request: Request) => Promise<Response>;
|
|
48
|
+
}): Record<string, Response | ((request: Request, server: Bun.Server<undefined>) => Response | Promise<Response> | void)> {
|
|
49
|
+
const routes: Record<
|
|
50
|
+
string,
|
|
51
|
+
Response | ((request: Request, server: Bun.Server<undefined>) => Response | Promise<Response> | void)
|
|
52
|
+
> = {
|
|
53
|
+
[RBSSR_DEV_WS_PATH]: (request, server) => {
|
|
54
|
+
const upgraded = server.upgrade(request);
|
|
55
|
+
if (!upgraded) {
|
|
56
|
+
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"/__rbssr/client/*": (request) => {
|
|
60
|
+
return serveDevClientAsset(options.devClientDir, request);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (const apiRoute of options.manifest.api) {
|
|
65
|
+
routes[normalizeApiRoutePath(apiRoute.routePath)] = (request) => {
|
|
66
|
+
return options.handleFrameworkFetch(request);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return routes;
|
|
71
|
+
}
|