@tschk/moonshine-compiler 0.4.1 → 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.
- package/README.md +43 -0
- package/package.json +10 -6
- package/src/build.ts +535 -75
- package/src/compile-graph.ts +215 -0
- package/src/compile-output.ts +91 -0
- package/src/crepus-source.ts +116 -0
- package/src/discover.ts +29 -2
- package/src/inherit.ts +7 -4
- package/src/manifest.ts +156 -12
- package/src/path.ts +16 -0
- package/src/source.ts +8 -0
- package/test/analyze.test.ts +0 -146
- package/test/classify.test.ts +0 -99
- package/test/client-process-env.test.ts +0 -73
- package/test/discover.test.ts +0 -76
- package/test/fixtures/modes/api.ts +0 -3
- package/test/fixtures/modes/client.client.tsx +0 -3
- package/test/fixtures/modes/interactive.tsx +0 -3
- package/test/fixtures/modes/loader.ts +0 -5
- package/test/fixtures/modes/near-miss.tsx +0 -5
- package/test/fixtures/modes/renamed-source.tsx +0 -5
- package/test/fixtures/modes/request-utils.ts +0 -3
- package/test/fixtures/modes/request.ts +0 -1
- package/test/fixtures/modes/ssr-helpers.ts +0 -5
- package/test/fixtures/modes/static.tsx +0 -3
- package/test/fixtures/modes/unresolved.ts +0 -5
- package/test/fixtures/project/src/routes/+error.tsx +0 -3
- package/test/fixtures/project/src/routes/+layout.tsx +0 -3
- package/test/fixtures/project/src/routes/+middleware.ts +0 -3
- package/test/fixtures/project/src/routes/about.data.ts +0 -7
- package/test/fixtures/project/src/routes/about.tsx +0 -7
- package/test/fixtures/project/src/routes/api/health.ts +0 -3
- package/test/fixtures/project/src/routes/client.client.tsx +0 -8
- package/test/fixtures/project/src/routes/counter.tsx +0 -3
- package/test/fixtures/project/src/routes/docs/[...path].tsx +0 -3
- package/test/fixtures/project/src/routes/index.tsx +0 -3
- package/test/fixtures/routes/+error.tsx +0 -3
- package/test/fixtures/routes/+layout.tsx +0 -3
- package/test/fixtures/routes/+middleware.ts +0 -3
- package/test/fixtures/routes/about.tsx +0 -3
- package/test/fixtures/routes/account/[[tab]].tsx +0 -3
- package/test/fixtures/routes/api/health.server.ts +0 -7
- package/test/fixtures/routes/blog/[slug].data.ts +0 -7
- package/test/fixtures/routes/blog/[slug].tsx +0 -3
- package/test/fixtures/routes/docs/[...path].tsx +0 -3
- package/test/fixtures/routes/index.tsx +0 -3
- package/test/inherit.test.ts +0 -50
- package/test/manifest.test.ts +0 -96
- package/test/path.test.ts +0 -30
- package/test/server-bundle-support-modules.test.ts +0 -68
- package/tsconfig.json +0 -4
package/src/build.ts
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
?
|
|
62
|
-
|
|
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
|
|
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(
|
|
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
|
|
109
|
-
body.push(`
|
|
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
|
-
|
|
290
|
+
entries: string[],
|
|
124
291
|
outdir: string,
|
|
125
292
|
root: string,
|
|
126
|
-
target: "bun" | "browser",
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
156
|
-
.
|
|
157
|
-
|
|
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
|
|
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(
|
|
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
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
208
|
-
serverEntry,
|
|
513
|
+
const serverBuild = await runBuild(
|
|
514
|
+
[serverEntry],
|
|
209
515
|
distDir,
|
|
210
516
|
options.root,
|
|
211
|
-
|
|
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
|
|
214
|
-
?
|
|
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(
|
|
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:
|
|
229
|
-
|
|
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
|
}
|