@tschk/moonshine-compiler 0.4.0 → 0.4.4

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.
Files changed (51) hide show
  1. package/README.md +43 -0
  2. package/package.json +10 -6
  3. package/src/build.ts +535 -75
  4. package/src/compile-graph.ts +215 -0
  5. package/src/compile-output.ts +91 -0
  6. package/src/crepus-source.ts +116 -0
  7. package/src/discover.ts +29 -2
  8. package/src/inherit.ts +7 -4
  9. package/src/manifest.ts +156 -12
  10. package/src/path.ts +16 -0
  11. package/src/source.ts +8 -0
  12. package/test/analyze.test.ts +0 -146
  13. package/test/classify.test.ts +0 -99
  14. package/test/client-process-env.test.ts +0 -73
  15. package/test/discover.test.ts +0 -76
  16. package/test/fixtures/modes/api.ts +0 -3
  17. package/test/fixtures/modes/client.client.tsx +0 -3
  18. package/test/fixtures/modes/interactive.tsx +0 -3
  19. package/test/fixtures/modes/loader.ts +0 -5
  20. package/test/fixtures/modes/near-miss.tsx +0 -5
  21. package/test/fixtures/modes/renamed-source.tsx +0 -5
  22. package/test/fixtures/modes/request-utils.ts +0 -3
  23. package/test/fixtures/modes/request.ts +0 -1
  24. package/test/fixtures/modes/ssr-helpers.ts +0 -5
  25. package/test/fixtures/modes/static.tsx +0 -3
  26. package/test/fixtures/modes/unresolved.ts +0 -5
  27. package/test/fixtures/project/src/routes/+error.tsx +0 -3
  28. package/test/fixtures/project/src/routes/+layout.tsx +0 -3
  29. package/test/fixtures/project/src/routes/+middleware.ts +0 -3
  30. package/test/fixtures/project/src/routes/about.data.ts +0 -7
  31. package/test/fixtures/project/src/routes/about.tsx +0 -7
  32. package/test/fixtures/project/src/routes/api/health.ts +0 -3
  33. package/test/fixtures/project/src/routes/client.client.tsx +0 -8
  34. package/test/fixtures/project/src/routes/counter.tsx +0 -3
  35. package/test/fixtures/project/src/routes/docs/[...path].tsx +0 -3
  36. package/test/fixtures/project/src/routes/index.tsx +0 -3
  37. package/test/fixtures/routes/+error.tsx +0 -3
  38. package/test/fixtures/routes/+layout.tsx +0 -3
  39. package/test/fixtures/routes/+middleware.ts +0 -3
  40. package/test/fixtures/routes/about.tsx +0 -3
  41. package/test/fixtures/routes/account/[[tab]].tsx +0 -3
  42. package/test/fixtures/routes/api/health.server.ts +0 -7
  43. package/test/fixtures/routes/blog/[slug].data.ts +0 -7
  44. package/test/fixtures/routes/blog/[slug].tsx +0 -3
  45. package/test/fixtures/routes/docs/[...path].tsx +0 -3
  46. package/test/fixtures/routes/index.tsx +0 -3
  47. package/test/inherit.test.ts +0 -50
  48. package/test/manifest.test.ts +0 -96
  49. package/test/path.test.ts +0 -30
  50. package/test/server-bundle-support-modules.test.ts +0 -68
  51. package/tsconfig.json +0 -4
package/src/build.ts CHANGED
@@ -1,7 +1,27 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { join, relative, resolve } from "node:path";
3
- import { PUBLIC_DIR, type RouteArtifact } from "@tschk/moonshine-framework";
4
- import { toPosix } from "./path.js";
1
+ import {
2
+ copyFile,
3
+ lstat,
4
+ mkdir,
5
+ readdir,
6
+ realpath,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import {
10
+ basename,
11
+ dirname,
12
+ extname,
13
+ isAbsolute,
14
+ join,
15
+ relative,
16
+ resolve,
17
+ sep,
18
+ } from "node:path";
19
+ import {
20
+ PUBLIC_DIR,
21
+ type RouteArtifact,
22
+ type RendererName,
23
+ } from "@tschk/moonshine-framework";
24
+ import { canonicalPath, toPosix } from "./path.js";
5
25
 
6
26
  export type BundleAsset = {
7
27
  file: string;
@@ -9,59 +29,162 @@ export type BundleAsset = {
9
29
  integrity: string;
10
30
  };
11
31
 
32
+ export type PublicAsset = { source: string; relative: string };
33
+
34
+ function overlaps(left: string, right: string): boolean {
35
+ return [relative(left, right), relative(right, left)].some(
36
+ (path) =>
37
+ path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path),
38
+ );
39
+ }
40
+
41
+ /** Run before clearing output: public inputs must remain separate and never follow symlinks. */
42
+ export async function preparePublicAssets(
43
+ root: string,
44
+ outDir: string,
45
+ ): Promise<PublicAsset[]> {
46
+ const source = resolve(root, "public");
47
+ const [canonicalSource, canonicalOutput] = await Promise.all([
48
+ canonicalPath(source),
49
+ canonicalPath(resolve(outDir)),
50
+ ]);
51
+ if (overlaps(canonicalSource, canonicalOutput))
52
+ throw new Error("Public asset source and build output must not overlap");
53
+ let entry;
54
+ try {
55
+ entry = await lstat(source);
56
+ } catch (error) {
57
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
58
+ throw error;
59
+ }
60
+ if (entry.isSymbolicLink() || !entry.isDirectory())
61
+ throw new Error(
62
+ "Public asset root must be a real directory, not a symlink",
63
+ );
64
+ const assets: PublicAsset[] = [];
65
+ const visit = async (directory: string): Promise<void> => {
66
+ const children = (
67
+ await readdir(directory, { withFileTypes: true })
68
+ ).toSorted((a, b) => a.name.localeCompare(b.name));
69
+ for (const child of children) {
70
+ const path = join(directory, child.name);
71
+ if (child.isSymbolicLink())
72
+ throw new Error(`Public asset symlinks are unsupported: ${path}`);
73
+ if (child.isDirectory()) await visit(path);
74
+ else if (child.isFile())
75
+ assets.push({
76
+ source: path,
77
+ relative: sourcePath(relative(source, path)),
78
+ });
79
+ else throw new Error(`Public assets must be regular files: ${path}`);
80
+ }
81
+ };
82
+ await visit(source);
83
+ return assets;
84
+ }
85
+
86
+ type BuiltFile = {
87
+ path: string;
88
+ kind: string;
89
+ cssBundle?: string;
90
+ entryPoint?: string;
91
+ };
92
+ type InputGraph = Map<
93
+ string,
94
+ {
95
+ format?: string;
96
+ imports: Array<{ path: string; external?: boolean }>;
97
+ }
98
+ >;
99
+ type BuiltResult = { files: BuiltFile[]; inputs: InputGraph };
100
+
12
101
  export type BundleOutput = {
13
102
  server: BundleAsset[];
14
103
  client: BundleAsset[];
104
+ routeClientEntries: Record<string, string>;
105
+ routeClientStyles: Record<string, string[]>;
15
106
  entryFiles: {
16
107
  server: string;
17
108
  client?: string;
18
109
  };
19
110
  };
20
111
 
112
+ function sourcePath(path: string): string {
113
+ return sep === "\\" ? toPosix(path) : path;
114
+ }
115
+
21
116
  function importPath(from: string, to: string): string {
22
- const rel = toPosix(relative(from, to));
117
+ const rel = sourcePath(relative(from, to));
23
118
  return rel.startsWith(".") ? rel : `./${rel}`;
24
119
  }
25
120
 
26
121
  function generateServerEntry(
27
122
  buildDir: string,
28
123
  routes: RouteArtifact[],
124
+ native: boolean,
125
+ root: string,
126
+ requestContext: boolean,
29
127
  ): string {
30
- const imports: string[] = [];
128
+ const keysForFile = (file: string) => {
129
+ const rel = sourcePath(relative(root, file));
130
+ return rel.startsWith("..") ? [sourcePath(file)] : [sourcePath(file), rel];
131
+ };
132
+ const imports: string[] = native
133
+ ? [
134
+ `import { createNativeRenderer } from "@tschk/moonshine-dom/framework-native";`,
135
+ ]
136
+ : [];
137
+ if (requestContext) {
138
+ imports.push(
139
+ `export { runWithRequest as requestScope } from "@tschk/moonshine-framework/request";`,
140
+ );
141
+ }
31
142
  const bodies: string[] = [];
32
143
  const moduleHelpers: string[] = [];
33
144
  const moduleBodys: string[] = [];
34
145
  for (let i = 0; i < routes.length; i++) {
35
146
  const route = routes[i]!;
36
147
  const modulePath = importPath(buildDir, route.file);
37
- imports.push(`import * as route_${i} from "${modulePath}";`);
148
+ imports.push(`import * as route_${i} from ${JSON.stringify(modulePath)};`);
38
149
  if (route.dataFile) {
39
150
  const dataPath = importPath(buildDir, route.dataFile);
40
- imports.push(`import * as data_${i} from "${dataPath}";`);
151
+ imports.push(`import * as data_${i} from ${JSON.stringify(dataPath)};`);
41
152
  }
42
153
  const dataRef = route.dataFile ? `data_${i}` : "undefined";
43
- bodies.push(` "${route.path}": { module: route_${i}, data: ${dataRef} }`);
154
+ bodies.push(
155
+ ` [${JSON.stringify(route.path)}]: { module: route_${i}, data: ${dataRef} }`,
156
+ );
44
157
  moduleHelpers.push(`function makeModule_${i}(mod, data) {
45
158
  const m = { ...mod, ...data };
46
- if (!m.loader && typeof mod.GET === "function") {
47
- m.loader = (ctx) => mod.GET(ctx.request);
48
- }
49
- if (!m.action && typeof mod.POST === "function") {
50
- m.action = (ctx) => mod.POST(ctx.request);
51
- }
52
- if (!m.loader && !m.action && typeof mod.handler === "function") {
53
- m.loader = (ctx) => mod.handler(ctx.request);
159
+ const methods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
160
+ const handlers = Object.fromEntries(methods.filter(method => typeof m[method] === "function").map(method => [method, m[method]]));
161
+ const fallback = typeof m.handler === "function" ? m.handler : undefined;
162
+ if (Object.keys(handlers).length || fallback) {
163
+ const allowed = new Set(Object.keys(handlers));
164
+ if (m.loader || handlers.GET) { allowed.add("GET"); allowed.add("HEAD"); }
165
+ if (m.action) for (const method of ["POST", "PUT", "PATCH", "DELETE"]) allowed.add(method);
166
+ const supported = methods.filter(method => allowed.has(method));
167
+ if (!fallback) m.methods = supported;
168
+ const allow = supported.join(", ");
169
+ const dispatch = (ctx) => {
170
+ const method = ctx.request.method;
171
+ const handler = (Object.hasOwn(handlers, method) ? handlers[method] : undefined) || (method === "HEAD" ? handlers.GET : undefined) || fallback;
172
+ return handler ? handler(ctx.request) : new Response(null, { status: 405, headers: { allow } });
173
+ };
174
+ if (!m.loader && (handlers.GET || handlers.HEAD || fallback)) m.loader = (ctx) => ctx.request.method === "GET" || ctx.request.method === "HEAD" ? dispatch(ctx) : undefined;
175
+ if (!m.action && (fallback || methods.some(method => method !== "GET" && method !== "HEAD" && handlers[method]))) m.action = dispatch;
54
176
  }
55
177
  return m;
56
178
  }`);
57
179
  const moduleVar = `module_${i}`;
58
- moduleBodys.push(` "${route.id}": ${moduleVar},
59
- "${toPosix(route.file)}": ${moduleVar}${
60
- route.dataFile
61
- ? `,
62
- "${toPosix(route.dataFile)}": ${moduleVar}`
63
- : ""
64
- }`);
180
+ const keys = new Set([
181
+ route.id,
182
+ ...keysForFile(route.file),
183
+ ...(route.dataFile ? keysForFile(route.dataFile) : []),
184
+ ]);
185
+ moduleBodys.push(
186
+ ...Array.from(keys, (key) => ` [${JSON.stringify(key)}]: ${moduleVar}`),
187
+ );
65
188
  }
66
189
  // Layouts, middleware, and error boundaries are route dependencies the server
67
190
  // renders through, so they belong in the bundle alongside the pages. Without
@@ -82,31 +205,61 @@ function generateServerEntry(
82
205
  for (let i = 0; i < supportFiles.length; i++) {
83
206
  const file = supportFiles[i]!;
84
207
  imports.push(
85
- `import * as support_${i} from "${importPath(buildDir, file)}";`,
208
+ `import * as support_${i} from ${JSON.stringify(importPath(buildDir, file))};`,
86
209
  );
87
210
  // Registered unwrapped: these export a component or middleware, not a route
88
211
  // module with loader/action semantics.
89
- moduleBodys.push(` "${toPosix(file)}": support_${i}`);
212
+ moduleBodys.push(
213
+ ...keysForFile(file).map(
214
+ (key) => ` [${JSON.stringify(key)}]: support_${i}`,
215
+ ),
216
+ );
90
217
  }
91
218
 
92
- return `${imports.join("\n")}\n\n${moduleHelpers.join("\n")}\n\nconst ${routes.map((_, i) => `module_${i} = makeModule_${i}(route_${i}, ${routes[i]!.dataFile ? `data_${i}` : "undefined"})`).join(";\nconst ")};\n\nexport const routes = {\n${bodies.join(",\n")}\n};\n\nexport const modules = {\n${moduleBodys.join(",\n")}\n};\n`;
219
+ return `${imports.join("\n")}\n\n${moduleHelpers.join("\n")}\n\nconst ${routes.map((_, i) => `module_${i} = makeModule_${i}(route_${i}, ${routes[i]!.dataFile ? `data_${i}` : "undefined"})`).join(";\nconst ")};\n\nexport const routes = {\n${bodies.join(",\n")}\n};\n\nexport const modules = {\n${moduleBodys.join(",\n")}\n};\n${native ? `export const renderer = createNativeRenderer(modules, Object.fromEntries(${JSON.stringify(routes.map((route) => [route.id, route.layouts ?? []]))}));\n` : ""}`;
93
220
  }
94
221
 
95
222
  function generateClientEntry(
96
223
  buildDir: string,
97
224
  routes: RouteArtifact[],
225
+ native: boolean,
98
226
  ): string | undefined {
99
227
  const clientRoutes = routes.filter(
100
228
  (r) => r.mode === "island" || r.mode === "spa",
101
229
  );
102
230
  if (clientRoutes.length === 0) return undefined;
231
+ if (native) {
232
+ const imports = [`import { h, render } from "@tschk/moonshine-dom/dom";`];
233
+ const pages: string[] = [];
234
+ for (const [index, route] of clientRoutes.entries()) {
235
+ if (!route.clientFile)
236
+ throw new Error(`Native SPA route "${route.path}" requires clientFile`);
237
+ imports.push(
238
+ `import page_${index} from ${JSON.stringify(importPath(buildDir, route.clientFile))};`,
239
+ );
240
+ pages.push(`[${JSON.stringify(route.id)}]: page_${index}`);
241
+ }
242
+ return `${imports.join("\n")}
243
+ const pages = {${pages.join(",")}};
244
+ const root = document.querySelector("[data-moonshine-app]");
245
+ if (root) {
246
+ const id = root.getAttribute("data-moonshine-app");
247
+ const page = Object.hasOwn(pages, id) ? pages[id] : undefined;
248
+ if (!page) throw new Error("Native page entry was not bundled");
249
+ const payload = document.querySelector('script[type="application/json"][data-moonshine-props]');
250
+ const props = payload ? JSON.parse(payload.textContent || "{}") : {};
251
+ const dispose = render(() => h(page, props), root);
252
+ window.addEventListener("pagehide", (event) => { if (!event.persisted) dispose(); });
253
+ }
254
+ `;
255
+ }
103
256
  const imports: string[] = [];
104
257
  const body: string[] = [];
105
258
  let index = 0;
106
259
  for (const route of clientRoutes) {
107
260
  const modulePath = importPath(buildDir, route.file);
108
- imports.push(`import route_${index} from "${modulePath}";`);
109
- body.push(` "${route.path}": route_${index}`);
261
+ imports.push(`import route_${index} from ${JSON.stringify(modulePath)};`);
262
+ body.push(` [${JSON.stringify(route.path)}]: route_${index}`);
110
263
  index++;
111
264
  }
112
265
  return `${imports.join("\n")}\n\nexport const islands = {\n${body.join(",\n")}\n};\n`;
@@ -119,46 +272,112 @@ async function hashFile(path: string): Promise<string> {
119
272
  return `sha256-${hasher.digest("base64")}`;
120
273
  }
121
274
 
275
+ // Bun.build is not safe to overlap in one process: concurrent calls race
276
+ // module resolution and fail with "Unexpected reading file" on shared
277
+ // dependencies (seen on macOS CI when compiler tests run in parallel).
278
+ let bunBuildQueue: Promise<unknown> = Promise.resolve();
279
+
280
+ function enqueueBunBuild<T>(work: () => Promise<T>): Promise<T> {
281
+ const run = bunBuildQueue.then(work, work);
282
+ bunBuildQueue = run.then(
283
+ () => undefined,
284
+ () => undefined,
285
+ );
286
+ return run;
287
+ }
288
+
122
289
  async function runBuild(
123
- entry: string,
290
+ entries: string[],
124
291
  outdir: string,
125
292
  root: string,
126
- target: "bun" | "browser",
127
- ): Promise<string[]> {
128
- const result = await Bun.build({
129
- entrypoints: [entry],
130
- outdir,
131
- root,
132
- target,
133
- format: "esm",
134
- naming: "[name].js",
135
- splitting: false,
136
- sourcemap: "none",
137
- // Browsers have no `process`. Any dependency that reads `process.env.X` —
138
- // and most of them do, behind a `typeof` guard or not — otherwise throws
139
- // `ReferenceError: process is not defined` while the entry module is being
140
- // evaluated. That happens before any render, so the app silently mounts
141
- // nothing: no error boundary runs and the page is simply blank.
142
- //
143
- // `??=` so an app that injects real values ahead of this bundle keeps them;
144
- // this only guarantees the property exists. Values are deliberately not
145
- // inlined here — inlining the process environment into a browser bundle
146
- // would ship every secret the build host happens to hold.
147
- ...(target === "browser"
148
- ? { banner: "globalThis.process ??= { env: {} };" }
149
- : {}),
150
- });
293
+ target: "bun" | "node" | "browser",
294
+ options: {
295
+ serverFiles?: Set<string>;
296
+ splitting?: boolean;
297
+ metadata?: boolean;
298
+ minify?: boolean;
299
+ } = {},
300
+ ): Promise<BuiltResult> {
301
+ const result = await enqueueBunBuild(() =>
302
+ Bun.build({
303
+ entrypoints: entries,
304
+ outdir,
305
+ root,
306
+ target,
307
+ format: "esm",
308
+ naming: "[name].[ext]",
309
+ splitting: options.splitting ?? false,
310
+ sourcemap: "none",
311
+ metafile: options.metadata || options.splitting,
312
+ minify: options.minify ?? false,
313
+ ...(options.serverFiles && {
314
+ plugins: [
315
+ {
316
+ name: "native-client-boundary",
317
+ setup(build) {
318
+ build.onLoad({ filter: /.*/ }, (args) => {
319
+ if (options.serverFiles!.has(resolve(args.path))) {
320
+ throw new Error(
321
+ `Native client imports server-only route module: ${args.path}`,
322
+ );
323
+ }
324
+ });
325
+ },
326
+ },
327
+ ],
328
+ }),
329
+ // Browsers have no `process`. Any dependency that reads `process.env.X` —
330
+ // and most of them do, behind a `typeof` guard or not — otherwise throws
331
+ // `ReferenceError: process is not defined` while the entry module is being
332
+ // evaluated. That happens before any render, so the app silently mounts
333
+ // nothing: no error boundary runs and the page is simply blank.
334
+ //
335
+ // `??=` so an app that injects real values ahead of this bundle keeps them;
336
+ // this only guarantees the property exists. Values are deliberately not
337
+ // inlined here — inlining the process environment into a browser bundle
338
+ // would ship every secret the build host happens to hold.
339
+ ...(target === "browser"
340
+ ? { banner: "globalThis.process ??= { env: {} };" }
341
+ : {}),
342
+ }),
343
+ );
151
344
  if (!result.success) {
152
345
  const messages = result.logs.map((log) => log.message).join("\n");
153
346
  throw new Error(`Bun build failed for ${target}: ${messages}`);
154
347
  }
155
- return result.outputs
156
- .filter((output) => output.kind !== "sourcemap")
157
- .map((output) => output.path);
348
+ const metadata = new Map(
349
+ Object.entries(result.metafile?.outputs ?? {}).map(([name, details]) => [
350
+ resolve(outdir, name),
351
+ details,
352
+ ]),
353
+ );
354
+ return {
355
+ inputs: new Map(
356
+ Object.entries(result.metafile?.inputs ?? {}).map(([file, input]) => [
357
+ resolve(file),
358
+ input,
359
+ ]),
360
+ ),
361
+ files: result.outputs
362
+ .filter((output) => output.kind !== "sourcemap")
363
+ .map(({ path, kind }) => {
364
+ const details = metadata.get(resolve(path));
365
+ return {
366
+ path,
367
+ kind,
368
+ ...(details?.cssBundle && {
369
+ cssBundle: resolve(outdir, details.cssBundle),
370
+ }),
371
+ ...(details?.entryPoint && {
372
+ entryPoint: resolve(details.entryPoint),
373
+ }),
374
+ };
375
+ }),
376
+ };
158
377
  }
159
378
 
160
379
  function toOutRelative(outDir: string, outputPath: string): string {
161
- return toPosix(relative(outDir, outputPath));
380
+ return sourcePath(relative(outDir, outputPath));
162
381
  }
163
382
 
164
383
  async function buildBundleAssets(
@@ -178,22 +397,109 @@ async function buildBundleAssets(
178
397
  return assets;
179
398
  }
180
399
 
400
+ function findEntry(
401
+ entry: string,
402
+ directory: string,
403
+ outputs: BuiltFile[],
404
+ ): BuiltFile {
405
+ const expected = resolve(directory, `${basename(entry, extname(entry))}.js`);
406
+ const output = outputs.find(
407
+ (artifact) =>
408
+ artifact.kind === "entry-point" && resolve(artifact.path) === expected,
409
+ );
410
+ if (!output) throw new Error(`Missing bundle entry output for ${entry}`);
411
+ return output;
412
+ }
413
+
414
+ async function cssRoots(graph: InputGraph, files: string[]): Promise<string[]> {
415
+ const visited = new Set<string>();
416
+ const css: string[] = [];
417
+ const visit = async (file: string) => {
418
+ file = resolve(file);
419
+ if (visited.has(file)) return;
420
+ visited.add(file);
421
+ const input = graph.get(file);
422
+ if (!input) return;
423
+ if (input.format === "css") {
424
+ css.push(file);
425
+ return;
426
+ }
427
+ // Dynamic imports contribute CSS eagerly until the native loader supports lazy styles.
428
+ for (const edge of input.imports) {
429
+ if (edge.external) continue;
430
+ let target = edge.path;
431
+ // Bun may leave duplicate import edges as their original relative specifier.
432
+ if (
433
+ !isAbsolute(target) &&
434
+ !graph.has(resolve(target)) &&
435
+ (target.startsWith("./") || target.startsWith("../"))
436
+ ) {
437
+ target = await realpath(Bun.resolveSync(target, dirname(file)));
438
+ if (!graph.has(target)) {
439
+ throw new Error(
440
+ `Missing CSS dependency graph input for ${edge.path} imported by ${file}`,
441
+ );
442
+ }
443
+ }
444
+ await visit(target);
445
+ }
446
+ };
447
+ for (const file of files) await visit(await realpath(file));
448
+ return css;
449
+ }
450
+
451
+ function cssImport(file: string): string {
452
+ const escaped = file.replace(
453
+ /["\\\n\r\f]/g,
454
+ (character) => `\\${character.charCodeAt(0).toString(16)} `,
455
+ );
456
+ return `@import "${escaped}";`;
457
+ }
458
+
181
459
  export async function buildBundles(options: {
182
460
  root: string;
183
461
  outDir: string;
184
462
  routes: RouteArtifact[];
463
+ renderer?: RendererName;
464
+ requestContext?: boolean;
465
+ publicAssets?: readonly PublicAsset[];
185
466
  }): Promise<BundleOutput> {
467
+ const publicAssets =
468
+ options.publicAssets ??
469
+ (await preparePublicAssets(options.root, options.outDir));
186
470
  const buildDir = resolve(options.outDir, ".build");
187
471
  await mkdir(buildDir, { recursive: true });
188
472
 
189
473
  const serverEntry = join(buildDir, "server.ts");
190
- await writeFile(serverEntry, generateServerEntry(buildDir, options.routes));
474
+ await writeFile(
475
+ serverEntry,
476
+ generateServerEntry(
477
+ buildDir,
478
+ options.routes,
479
+ options.renderer === "native",
480
+ options.root,
481
+ options.requestContext === true,
482
+ ),
483
+ );
191
484
 
192
- const clientCode = generateClientEntry(buildDir, options.routes);
193
- let clientEntry: string | undefined;
194
- if (clientCode) {
195
- clientEntry = join(buildDir, "client.ts");
196
- await writeFile(clientEntry, clientCode);
485
+ const clientEntries: string[] = [];
486
+ const routeEntries = new Map<string, string>();
487
+ const native = options.renderer === "native";
488
+ const clientGroups = native
489
+ ? options.routes
490
+ .filter((route) => route.mode === "spa")
491
+ .map((route) => [route])
492
+ : [options.routes];
493
+ for (const [index, routes] of clientGroups.entries()) {
494
+ const code = generateClientEntry(buildDir, routes, native);
495
+ if (!code) continue;
496
+ const entry = join(
497
+ buildDir,
498
+ index === 0 ? "client.ts" : `client-${index}.ts`,
499
+ );
500
+ await writeFile(entry, code);
501
+ clientEntries.push(entry);
502
+ if (native) routeEntries.set(routes[0]!.id, entry);
197
503
  }
198
504
 
199
505
  const distDir = resolve(options.outDir, "dist");
@@ -204,29 +510,183 @@ export async function buildBundles(options: {
204
510
  const publicDir = resolve(options.outDir, PUBLIC_DIR);
205
511
  await mkdir(publicDir, { recursive: true });
206
512
 
207
- const serverOutputs = await runBuild(
208
- serverEntry,
513
+ const serverBuild = await runBuild(
514
+ [serverEntry],
209
515
  distDir,
210
516
  options.root,
211
- "bun",
517
+ native &&
518
+ options.routes.some((route) =>
519
+ ["node", "cloudflare"].includes(route.runtime),
520
+ )
521
+ ? "node"
522
+ : "bun",
523
+ { metadata: native },
212
524
  );
213
- const clientOutputs = clientEntry
214
- ? await runBuild(clientEntry, publicDir, options.root, "browser")
215
- : [];
525
+ const serverFiles = native
526
+ ? new Set(
527
+ await Promise.all(
528
+ options.routes
529
+ .flatMap((route) => [
530
+ route.file,
531
+ route.dataFile,
532
+ route.errorBoundary,
533
+ ...(route.layouts ?? []),
534
+ ...(route.middleware ?? []),
535
+ ])
536
+ .filter((file): file is string => Boolean(file))
537
+ .map((file) => realpath(file)),
538
+ ),
539
+ )
540
+ : undefined;
541
+ const clientBuild = clientEntries.length
542
+ ? await runBuild(clientEntries, publicDir, options.root, "browser", {
543
+ serverFiles,
544
+ splitting: native,
545
+ })
546
+ : { files: [], inputs: new Map() };
547
+ const serverOutputs = serverBuild.files;
548
+ const clientOutputs = clientBuild.files;
549
+ const serverStyleEntries = new Map<string, string>();
550
+ if (native) {
551
+ for (const [index, route] of options.routes.entries()) {
552
+ if (route.mode === "api") continue;
553
+ const serverCss = await cssRoots(
554
+ serverBuild.inputs,
555
+ [
556
+ ...(route.layouts ?? []),
557
+ ...(route.middleware ?? []),
558
+ route.dataFile,
559
+ route.file,
560
+ route.errorBoundary,
561
+ ].filter((file): file is string => Boolean(file)),
562
+ );
563
+ if (!serverCss.length) continue;
564
+ const browserCss = route.clientFile
565
+ ? await cssRoots(clientBuild.inputs, [route.clientFile])
566
+ : [];
567
+ const entry = join(buildDir, `server-style-${index}.css`);
568
+ await writeFile(
569
+ entry,
570
+ [...new Set([...serverCss, ...browserCss])].map(cssImport).join("\n"),
571
+ );
572
+ serverStyleEntries.set(route.id, entry);
573
+ }
574
+ }
575
+ const cssBuild = serverStyleEntries.size
576
+ ? await runBuild(
577
+ [...serverStyleEntries.values()],
578
+ publicDir,
579
+ options.root,
580
+ "browser",
581
+ { metadata: true, minify: true },
582
+ )
583
+ : { files: [], inputs: new Map() };
216
584
 
217
- const serverAssets = await buildBundleAssets(options.outDir, serverOutputs);
585
+ const serverAssets = await buildBundleAssets(
586
+ options.outDir,
587
+ serverOutputs.map((output) => output.path),
588
+ );
218
589
  const clientAssets = await buildBundleAssets(
219
590
  options.outDir,
220
- clientOutputs,
591
+ [...clientOutputs, ...cssBuild.files].map((output) => output.path),
221
592
  publicDir,
222
593
  );
223
594
 
595
+ const publicPaths = publicAssets.map((asset) => ({
596
+ ...asset,
597
+ destination: resolve(publicDir, asset.relative),
598
+ url: "/" + asset.relative.split("/").map(encodeURIComponent).join("/"),
599
+ }));
600
+ const generatedPaths = [
601
+ ...serverOutputs,
602
+ ...clientOutputs,
603
+ ...cssBuild.files,
604
+ ].map((output) => resolve(output.path));
605
+ const urls = new Set(
606
+ [...serverAssets, ...clientAssets].map((asset) => asset.path),
607
+ );
608
+ // Check every file before copying any public input, including file/directory conflicts.
609
+ for (const asset of publicPaths) {
610
+ if (
611
+ !asset.destination.startsWith(publicDir + sep) ||
612
+ generatedPaths.some((path) => overlaps(path, asset.destination))
613
+ )
614
+ throw new Error(
615
+ `Public asset collides with generated output: ${asset.relative}`,
616
+ );
617
+ if (urls.has(asset.url))
618
+ throw new Error(
619
+ `Public asset URL collides with generated output: ${asset.url}`,
620
+ );
621
+ urls.add(asset.url);
622
+ let ancestor = asset.destination;
623
+ while (ancestor !== publicDir) {
624
+ try {
625
+ const entry = await lstat(ancestor);
626
+ if (
627
+ ancestor === asset.destination ||
628
+ !entry.isDirectory() ||
629
+ entry.isSymbolicLink()
630
+ )
631
+ throw new Error(
632
+ `Public asset destination already exists: ${asset.relative}`,
633
+ );
634
+ } catch (error) {
635
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
636
+ }
637
+ ancestor = dirname(ancestor);
638
+ }
639
+ }
640
+ for (const asset of publicPaths) {
641
+ await mkdir(dirname(asset.destination), { recursive: true });
642
+ await copyFile(asset.source, asset.destination);
643
+ clientAssets.push({
644
+ file: toOutRelative(options.outDir, asset.destination),
645
+ path: asset.url,
646
+ integrity: await hashFile(asset.destination),
647
+ });
648
+ }
649
+
650
+ const routeClientEntries = Object.fromEntries(
651
+ Array.from(routeEntries, ([id, entry]) => [
652
+ id,
653
+ `/${toOutRelative(publicDir, findEntry(entry, publicDir, clientOutputs).path)}`,
654
+ ]),
655
+ );
656
+
657
+ const routeClientStyles: Record<string, string[]> = Object.create(null);
658
+ for (const [id, entry] of routeEntries) {
659
+ const { cssBundle } = findEntry(entry, publicDir, clientOutputs);
660
+ routeClientStyles[id] = cssBundle
661
+ ? [`/${toOutRelative(publicDir, cssBundle)}`]
662
+ : [];
663
+ }
664
+
665
+ for (const [id, entry] of serverStyleEntries) {
666
+ const source = await realpath(entry);
667
+ const output = cssBuild.files.find(
668
+ (file) => file.entryPoint === source && file.path.endsWith(".css"),
669
+ );
670
+ if (!output)
671
+ throw new Error(
672
+ `Missing CSS bundle output for ${entry} (resolved ${source}); returned outputs: ${JSON.stringify(cssBuild.files.map(({ path, entryPoint }) => ({ path, entryPoint })))}`,
673
+ );
674
+ routeClientStyles[id] = [`/${toOutRelative(publicDir, output.path)}`];
675
+ }
676
+
224
677
  return {
225
678
  server: serverAssets,
226
679
  client: clientAssets,
680
+ routeClientEntries,
681
+ routeClientStyles,
227
682
  entryFiles: {
228
- server: serverAssets[0]?.file ?? "",
229
- client: clientAssets[0]?.path,
683
+ server: toOutRelative(
684
+ options.outDir,
685
+ findEntry(serverEntry, distDir, serverOutputs).path,
686
+ ),
687
+ client: clientEntries.length
688
+ ? `/${toOutRelative(publicDir, findEntry(clientEntries[0]!, publicDir, clientOutputs).path)}`
689
+ : undefined,
230
690
  },
231
691
  };
232
692
  }