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.
@@ -1,34 +1,66 @@
1
- import path from "node:path";
2
- import { createBunRouteAdapter } from "./bun-route-adapter";
1
+ import path from 'node:path';
2
+ import { createBunRouteAdapter } from './bun-route-adapter';
3
3
  import {
4
4
  ensureCleanDir,
5
5
  ensureDir,
6
6
  existsPath,
7
7
  glob,
8
+ removePath,
8
9
  statPath,
9
10
  writeTextIfChanged,
10
- } from "./io";
11
+ } from './io';
11
12
  import type {
12
13
  BuildManifest,
13
14
  BuildRouteAsset,
14
15
  PageRouteDefinition,
15
16
  ResolvedConfig,
16
17
  RouteManifest,
17
- } from "./types";
18
- import { normalizeSlashes, stableHash, toImportPath } from "./utils";
19
-
20
- interface ClientEntryFile {
18
+ } from './types';
19
+ import { normalizeSlashes, stableHash, toImportPath } from './utils';
20
+
21
+ const BUILD_OPTIMIZE_IMPORTS = [
22
+ 'react-bun-ssr',
23
+ 'react-bun-ssr/route',
24
+ 'react',
25
+ 'react-dom',
26
+ '@datadog/browser-rum-react',
27
+ ];
28
+
29
+ export interface ClientEntryFile {
21
30
  routeId: string;
22
31
  entryFilePath: string;
23
32
  route: PageRouteDefinition;
24
33
  }
25
34
 
35
+ export interface ClientEntrySyncResult {
36
+ entries: ClientEntryFile[];
37
+ addedEntryPaths: string[];
38
+ changedEntryPaths: string[];
39
+ removedEntryPaths: string[];
40
+ entrySetChanged: boolean;
41
+ }
42
+
26
43
  async function walkFiles(rootDir: string): Promise<string[]> {
27
44
  if (!(await existsPath(rootDir))) {
28
45
  return [];
29
46
  }
30
47
 
31
- return glob("**/*", { cwd: rootDir, absolute: true });
48
+ return glob('**/*', { cwd: rootDir, absolute: true });
49
+ }
50
+
51
+ function buildClientModuleProjectionSource(defaultRef: string, moduleRef: string): string {
52
+ return `projectClientModule(${defaultRef}, ${moduleRef})`;
53
+ }
54
+
55
+ function toClientEntryFile(options: {
56
+ route: PageRouteDefinition;
57
+ entryFilePath: string;
58
+ }): ClientEntryFile {
59
+ return {
60
+ routeId: options.route.id,
61
+ entryFilePath: options.entryFilePath,
62
+ route: options.route,
63
+ };
32
64
  }
33
65
 
34
66
  function buildClientEntrySource(options: {
@@ -41,11 +73,13 @@ function buildClientEntrySource(options: {
41
73
 
42
74
  const imports: string[] = [];
43
75
 
44
- const runtimeImport = toImportPath(generatedDir, runtimeClientFile);
76
+ const runtimeImport = normalizeSlashes(path.resolve(runtimeClientFile));
45
77
  const rootImport = toImportPath(generatedDir, rootModulePath);
46
78
  const routeImport = toImportPath(generatedDir, route.filePath);
47
79
 
48
- imports.push(`import { hydrateRoute } from "${runtimeImport}";`);
80
+ imports.push(
81
+ `import { hydrateInitialRoute, projectClientModule, registerRouteModules } from "${runtimeImport}";`,
82
+ );
49
83
 
50
84
  imports.push(`import RootDefault from "${rootImport}";`);
51
85
  imports.push(`import * as RootModule from "${rootImport}";`);
@@ -58,19 +92,24 @@ function buildClientEntrySource(options: {
58
92
  const layoutFilePath = route.layoutFiles[index]!;
59
93
  const layoutImportPath = toImportPath(generatedDir, layoutFilePath);
60
94
  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 }`);
95
+ imports.push(
96
+ `import * as Layout${index}Module from "${layoutImportPath}";`,
97
+ );
98
+ layoutModuleRefs.push(
99
+ buildClientModuleProjectionSource(`Layout${index}Default`, `Layout${index}Module`),
100
+ );
63
101
  }
64
102
 
65
- return `${imports.join("\n")}
103
+ return `${imports.join('\n')}
66
104
 
67
105
  const modules = {
68
- root: { ...RootModule, default: RootDefault },
69
- layouts: [${layoutModuleRefs.join(", ")}],
70
- route: { ...RouteModule, default: RouteDefault },
106
+ root: ${buildClientModuleProjectionSource('RootDefault', 'RootModule')},
107
+ layouts: [${layoutModuleRefs.join(', ')}],
108
+ route: ${buildClientModuleProjectionSource('RouteDefault', 'RouteModule')},
71
109
  };
72
110
 
73
- hydrateRoute(modules);
111
+ registerRouteModules(${JSON.stringify(route.id)}, modules);
112
+ hydrateInitialRoute(${JSON.stringify(route.id)});
74
113
  `;
75
114
  }
76
115
 
@@ -82,10 +121,10 @@ export async function generateClientEntries(options: {
82
121
  const { config, manifest, generatedDir } = options;
83
122
  await ensureDir(generatedDir);
84
123
 
85
- const runtimeClientFile = path.resolve(config.cwd, "framework/runtime/client-runtime.tsx");
124
+ const runtimeClientFile = path.resolve(import.meta.dir, 'client-runtime.tsx');
86
125
 
87
126
  return Promise.all(
88
- manifest.pages.map(async route => {
127
+ manifest.pages.map(async (route) => {
89
128
  const entryName = `route__${route.id}.tsx`;
90
129
  const entryFilePath = path.join(generatedDir, entryName);
91
130
  const source = buildClientEntrySource({
@@ -97,13 +136,84 @@ export async function generateClientEntries(options: {
97
136
 
98
137
  await writeTextIfChanged(entryFilePath, source);
99
138
 
100
- return {
101
- routeId: route.id,
139
+ return toClientEntryFile({
140
+ route,
102
141
  entryFilePath,
142
+ });
143
+ }),
144
+ );
145
+ }
146
+
147
+ export async function syncClientEntries(options: {
148
+ config: ResolvedConfig;
149
+ manifest: RouteManifest;
150
+ generatedDir: string;
151
+ }): Promise<ClientEntrySyncResult> {
152
+ const { config, manifest, generatedDir } = options;
153
+ await ensureDir(generatedDir);
154
+
155
+ const runtimeClientFile = path.resolve(import.meta.dir, 'client-runtime.tsx');
156
+ const desiredEntries = new Map<string, { route: PageRouteDefinition; entryFilePath: string }>();
157
+ for (const route of manifest.pages) {
158
+ const entryFilePath = path.join(generatedDir, `route__${route.id}.tsx`);
159
+ desiredEntries.set(entryFilePath, {
160
+ route,
161
+ entryFilePath,
162
+ });
163
+ }
164
+
165
+ const existingEntryPaths = new Set(
166
+ (await glob('route__*.tsx', {
167
+ cwd: generatedDir,
168
+ absolute: true,
169
+ }))
170
+ .map((filePath) => path.resolve(filePath)),
171
+ );
172
+
173
+ const addedEntryPaths: string[] = [];
174
+ const changedEntryPaths: string[] = [];
175
+ const removedEntryPaths: string[] = [];
176
+
177
+ await Promise.all(
178
+ [...desiredEntries.values()].map(async ({ route, entryFilePath }) => {
179
+ const source = buildClientEntrySource({
180
+ generatedDir,
103
181
  route,
104
- };
182
+ rootModulePath: config.rootModule,
183
+ runtimeClientFile,
184
+ });
185
+ const existed = existingEntryPaths.has(entryFilePath);
186
+ const changed = await writeTextIfChanged(entryFilePath, source);
187
+ if (!existed) {
188
+ addedEntryPaths.push(entryFilePath);
189
+ } else if (changed) {
190
+ changedEntryPaths.push(entryFilePath);
191
+ }
192
+ existingEntryPaths.delete(entryFilePath);
105
193
  }),
106
194
  );
195
+
196
+ await Promise.all(
197
+ [...existingEntryPaths].map(async (entryFilePath) => {
198
+ removedEntryPaths.push(entryFilePath);
199
+ await removePath(entryFilePath);
200
+ }),
201
+ );
202
+
203
+ const entries = manifest.pages.map((route) => {
204
+ return toClientEntryFile({
205
+ route,
206
+ entryFilePath: path.join(generatedDir, `route__${route.id}.tsx`),
207
+ });
208
+ });
209
+
210
+ return {
211
+ entries,
212
+ addedEntryPaths: addedEntryPaths.sort(),
213
+ changedEntryPaths: changedEntryPaths.sort(),
214
+ removedEntryPaths: removedEntryPaths.sort(),
215
+ entrySetChanged: addedEntryPaths.length > 0 || removedEntryPaths.length > 0,
216
+ };
107
217
  }
108
218
 
109
219
  async function mapBuildOutputsByPrefix(options: {
@@ -112,14 +222,20 @@ async function mapBuildOutputsByPrefix(options: {
112
222
  publicPrefix: string;
113
223
  }): Promise<Record<string, BuildRouteAsset>> {
114
224
  const { outDir, routeIds, publicPrefix } = options;
115
- const files = (await walkFiles(outDir)).map(filePath => normalizeSlashes(path.relative(outDir, filePath)));
225
+ const files = (await walkFiles(outDir)).map((filePath) =>
226
+ normalizeSlashes(path.relative(outDir, filePath)),
227
+ );
116
228
 
117
229
  const routeAssets: Record<string, BuildRouteAsset> = {};
118
230
 
119
231
  for (const routeId of routeIds) {
120
232
  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"));
233
+ const script = files.find(
234
+ (file) => file.startsWith(base) && file.endsWith('.js'),
235
+ );
236
+ const css = files.filter(
237
+ (file) => file.startsWith(base) && file.endsWith('.css'),
238
+ );
123
239
 
124
240
  if (!script) {
125
241
  continue;
@@ -127,13 +243,77 @@ async function mapBuildOutputsByPrefix(options: {
127
243
 
128
244
  routeAssets[routeId] = {
129
245
  script: `${publicPrefix}${script}`,
130
- css: css.map(file => `${publicPrefix}${file}`),
246
+ css: css.map((file) => `${publicPrefix}${file}`),
247
+ };
248
+ }
249
+
250
+ return routeAssets;
251
+ }
252
+
253
+ export function normalizeMetafilePath(filePath: string): string {
254
+ return normalizeSlashes(filePath).replace(/^\.\//, "");
255
+ }
256
+
257
+ function toPublicBuildPath(publicPrefix: string, filePath: string): string {
258
+ return `${publicPrefix}${normalizeMetafilePath(filePath)}`;
259
+ }
260
+
261
+ export function mapBuildOutputsFromMetafile(options: {
262
+ metafile: Bun.BuildMetafile;
263
+ entries: ClientEntryFile[];
264
+ publicPrefix: string;
265
+ }): Record<string, BuildRouteAsset> {
266
+ const routeIdByEntrypoint = new Map<string, string>();
267
+ const routeIdByEntryName = new Map<string, string>();
268
+ for (const entry of options.entries) {
269
+ const absoluteEntrypoint = normalizeMetafilePath(path.resolve(entry.entryFilePath));
270
+ const relativeEntrypoint = normalizeMetafilePath(path.relative(process.cwd(), entry.entryFilePath));
271
+ routeIdByEntrypoint.set(absoluteEntrypoint, entry.routeId);
272
+ routeIdByEntrypoint.set(relativeEntrypoint, entry.routeId);
273
+ routeIdByEntryName.set(path.basename(entry.entryFilePath), entry.routeId);
274
+ }
275
+
276
+ const routeAssets: Record<string, BuildRouteAsset> = {};
277
+
278
+ for (const [outputPath, metadata] of Object.entries(options.metafile.outputs)) {
279
+ if (!outputPath.endsWith(".js") || !metadata.entryPoint) {
280
+ continue;
281
+ }
282
+
283
+ const normalizedEntrypoint = normalizeMetafilePath(metadata.entryPoint);
284
+ const absoluteEntrypoint = normalizeMetafilePath(path.resolve(process.cwd(), normalizedEntrypoint));
285
+ const routeId =
286
+ routeIdByEntrypoint.get(normalizedEntrypoint) ??
287
+ routeIdByEntrypoint.get(absoluteEntrypoint) ??
288
+ routeIdByEntryName.get(path.basename(normalizedEntrypoint));
289
+ if (!routeId) {
290
+ continue;
291
+ }
292
+
293
+ routeAssets[routeId] = {
294
+ script: toPublicBuildPath(options.publicPrefix, outputPath),
295
+ css: metadata.cssBundle ? [toPublicBuildPath(options.publicPrefix, metadata.cssBundle)] : [],
131
296
  };
132
297
  }
133
298
 
134
299
  return routeAssets;
135
300
  }
136
301
 
302
+ export function listBuildOutputFiles(metafile: Bun.BuildMetafile): string[] {
303
+ return Object.keys(metafile.outputs)
304
+ .map(normalizeMetafilePath)
305
+ .sort();
306
+ }
307
+
308
+ export function createClientEntrySetSignature(entries: ClientEntryFile[]): string {
309
+ return stableHash(
310
+ entries
311
+ .map((entry) => normalizeSlashes(path.resolve(entry.entryFilePath)))
312
+ .sort()
313
+ .join('|'),
314
+ );
315
+ }
316
+
137
317
  export async function bundleClientEntries(options: {
138
318
  entries: ClientEntryFile[];
139
319
  outDir: string;
@@ -148,42 +328,64 @@ export async function bundleClientEntries(options: {
148
328
  }
149
329
 
150
330
  const result = await Bun.build({
151
- entrypoints: entries.map(entry => entry.entryFilePath),
331
+ entrypoints: entries.map((entry) => entry.entryFilePath),
152
332
  outdir: outDir,
153
- target: "browser",
154
- format: "esm",
155
- splitting: false,
156
- sourcemap: dev ? "inline" : "external",
333
+ target: 'browser',
334
+ format: 'esm',
335
+ metafile: true,
336
+ optimizeImports: BUILD_OPTIMIZE_IMPORTS,
337
+ splitting: true,
338
+ sourcemap: dev ? 'inline' : 'external',
157
339
  minify: !dev,
158
- naming: dev ? "[name].[ext]" : "[name]-[hash].[ext]",
340
+ naming: dev ? '[name].[ext]' : '[name]-[hash].[ext]',
159
341
  });
160
342
 
161
343
  if (!result.success) {
162
- const messages = result.logs.map(log => log.message).join("\n");
344
+ const messages = result.logs.map((log) => log.message).join('\n');
163
345
  throw new Error(`Client bundle failed:\n${messages}`);
164
346
  }
165
347
 
166
- return mapBuildOutputsByPrefix({
348
+ const routeAssetsFromMetafile = result.metafile
349
+ ? mapBuildOutputsFromMetafile({
350
+ metafile: result.metafile,
351
+ entries,
352
+ publicPrefix,
353
+ })
354
+ : {};
355
+
356
+ if (Object.keys(routeAssetsFromMetafile).length === entries.length) {
357
+ return routeAssetsFromMetafile;
358
+ }
359
+
360
+ const routeAssetsFromPrefix = await mapBuildOutputsByPrefix({
167
361
  outDir,
168
- routeIds: entries.map(entry => entry.routeId),
362
+ routeIds: entries.map((entry) => entry.routeId),
169
363
  publicPrefix,
170
364
  });
365
+
366
+ return {
367
+ ...routeAssetsFromPrefix,
368
+ ...routeAssetsFromMetafile,
369
+ };
171
370
  }
172
371
 
173
372
  export async function ensureCleanDirectory(dirPath: string): Promise<void> {
174
373
  await ensureCleanDir(dirPath);
175
374
  }
176
375
 
177
- export async function copyDirRecursive(sourceDir: string, destinationDir: string): Promise<void> {
376
+ export async function copyDirRecursive(
377
+ sourceDir: string,
378
+ destinationDir: string,
379
+ ): Promise<void> {
178
380
  if (!(await existsPath(sourceDir))) {
179
381
  return;
180
382
  }
181
383
 
182
384
  await ensureDir(destinationDir);
183
385
 
184
- const entries = await glob("**/*", { cwd: sourceDir });
386
+ const entries = await glob('**/*', { cwd: sourceDir });
185
387
  await Promise.all(
186
- entries.map(async entry => {
388
+ entries.map(async (entry) => {
187
389
  const from = path.join(sourceDir, entry);
188
390
  const to = path.join(destinationDir, entry);
189
391
  const fileStat = await statPath(from);
@@ -197,7 +399,9 @@ export async function copyDirRecursive(sourceDir: string, destinationDir: string
197
399
  );
198
400
  }
199
401
 
200
- export function createBuildManifest(routeAssets: Record<string, BuildRouteAsset>): BuildManifest {
402
+ export function createBuildManifest(
403
+ routeAssets: Record<string, BuildRouteAsset>,
404
+ ): BuildManifest {
201
405
  return {
202
406
  version: stableHash(JSON.stringify(routeAssets)),
203
407
  generatedAt: new Date().toISOString(),
@@ -205,30 +409,49 @@ export function createBuildManifest(routeAssets: Record<string, BuildRouteAsset>
205
409
  };
206
410
  }
207
411
 
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();
412
+ export async function discoverFileSignature(rootPath: string): Promise<string> {
413
+ const rootStat = await statPath(rootPath);
414
+ if (!rootStat) {
415
+ return stableHash("");
416
+ }
212
417
 
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));
418
+ const files = (
419
+ rootStat.isFile()
420
+ ? [rootPath]
421
+ : await walkFiles(rootPath)
422
+ )
423
+ .filter((file) => !normalizeSlashes(file).includes('/node_modules/'))
424
+ .sort();
223
425
 
224
- return stableHash(signatureBits.join("|"));
426
+ const signatureBits = (
427
+ await Promise.all(
428
+ files.map(async (filePath) => {
429
+ const fileStat = await statPath(filePath);
430
+ if (!fileStat?.isFile()) {
431
+ return null;
432
+ }
433
+ const contentHash = stableHash(await Bun.file(filePath).bytes());
434
+ return `${normalizeSlashes(filePath)}:${contentHash}`;
435
+ }),
436
+ )
437
+ ).filter((value): value is string => Boolean(value));
438
+
439
+ return stableHash(signatureBits.join('|'));
225
440
  }
226
441
 
227
- export async function buildRouteManifest(config: ResolvedConfig): Promise<RouteManifest> {
442
+ export async function buildRouteManifest(
443
+ config: ResolvedConfig,
444
+ ): Promise<RouteManifest> {
228
445
  const adapter = await createBunRouteAdapter({
229
446
  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"),
447
+ generatedMarkdownRootDir: path.resolve(
448
+ config.cwd,
449
+ '.rbssr/generated/markdown-routes',
450
+ ),
451
+ projectionRootDir: path.resolve(
452
+ config.cwd,
453
+ '.rbssr/generated/router-projection/build-manifest',
454
+ ),
232
455
  });
233
456
  return adapter.manifest;
234
457
  }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
- import { ensureCleanDir, ensureDir, writeTextIfChanged } from "./io";
2
+ import { ensureDir, glob, removePath, writeTextIfChanged } from "./io";
3
+ import { matchRouteBySegments } from "./matcher";
3
4
  import { scanRoutes } from "./route-scanner";
4
5
  import type {
5
6
  ApiRouteDefinition,
@@ -116,17 +117,30 @@ async function writeProjectionRoutes<T extends PageRouteDefinition | ApiRouteDef
116
117
  }),
117
118
  );
118
119
 
120
+ const expectedPaths = new Set(writes.map(({ projectedFilePath }) => path.resolve(projectedFilePath)));
121
+ const existingPaths = new Set(await glob(`**/*${extension}`, {
122
+ cwd: outDir,
123
+ absolute: true,
124
+ }));
125
+
126
+ await Promise.all(
127
+ [...existingPaths]
128
+ .filter((projectedFilePath) => !expectedPaths.has(path.resolve(projectedFilePath)))
129
+ .map((projectedFilePath) => removePath(projectedFilePath)),
130
+ );
131
+
119
132
  return byProjectedFilePath;
120
133
  }
121
134
 
122
135
  function toRouteMatch<T extends PageRouteDefinition | ApiRouteDefinition>(
136
+ orderedRoutes: T[],
123
137
  routeByProjectedPath: Map<string, T>,
124
138
  pathname: string,
125
139
  router: Bun.FileSystemRouter,
126
140
  ): RouteMatch<T> | null {
127
141
  const matched = router.match(pathname);
128
142
  if (!matched) {
129
- return null;
143
+ return matchRouteBySegments(orderedRoutes, pathname);
130
144
  }
131
145
 
132
146
  const matchedSource = normalizeRouteKey(
@@ -136,7 +150,7 @@ function toRouteMatch<T extends PageRouteDefinition | ApiRouteDefinition>(
136
150
  );
137
151
  const route = routeByProjectedPath.get(matchedSource);
138
152
  if (!route) {
139
- return null;
153
+ return matchRouteBySegments(orderedRoutes, pathname);
140
154
  }
141
155
 
142
156
  return {
@@ -153,8 +167,7 @@ export async function createBunRouteAdapter(options: {
153
167
  const manifest = await scanRoutes(options.routesDir, {
154
168
  generatedMarkdownRootDir: options.generatedMarkdownRootDir,
155
169
  });
156
-
157
- await ensureCleanDir(options.projectionRootDir);
170
+ await ensureDir(options.projectionRootDir);
158
171
 
159
172
  const pagesProjectionDir = path.join(options.projectionRootDir, "pages");
160
173
  const apiProjectionDir = path.join(options.projectionRootDir, "api");
@@ -191,10 +204,10 @@ export async function createBunRouteAdapter(options: {
191
204
  return {
192
205
  manifest,
193
206
  matchPage(pathname) {
194
- return toRouteMatch(pageRouteByProjectedPath, pathname, pageRouter);
207
+ return toRouteMatch(manifest.pages, pageRouteByProjectedPath, pathname, pageRouter);
195
208
  },
196
209
  matchApi(pathname) {
197
- return toRouteMatch(apiRouteByProjectedPath, pathname, apiRouter);
210
+ return toRouteMatch(manifest.api, apiRouteByProjectedPath, pathname, apiRouter);
198
211
  },
199
212
  };
200
213
  }