Skip to content

Commit 5eb51c5

Browse files
committed
parallel build
1 parent f4574ea commit 5eb51c5

File tree

5 files changed

+143
-120
lines changed

5 files changed

+143
-120
lines changed

packages/zudoku/src/vite/build.ts

Lines changed: 93 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
22
import path from "node:path";
3-
import { build as viteBuild } from "vite";
3+
import { createBuilder } from "vite";
44
import { ZuploEnv } from "../app/env.js";
55
import {
66
findOutputPathOfServerConfig,
@@ -17,26 +17,26 @@ import { prerender } from "./prerender/prerender.js";
1717
const DIST_DIR = "dist";
1818

1919
export async function runBuild(options: { dir: string }) {
20-
// Shouldn't run in parallel because it's potentially racy
21-
const viteClientConfig = await getViteConfig(options.dir, {
20+
const viteConfig = await getViteConfig(options.dir, {
2221
mode: "production",
2322
command: "build",
2423
});
25-
const viteServerConfig = await getViteConfig(options.dir, {
26-
mode: "production",
27-
command: "build",
28-
isSsrBuild: true,
29-
});
3024

31-
// Don't run in parallel because it might overwrite itself
32-
const clientResult = await viteBuild(viteClientConfig);
33-
const serverResult = await viteBuild({
34-
...viteServerConfig,
35-
logLevel: "silent",
36-
});
37-
if (Array.isArray(clientResult)) {
38-
throw new Error("Build failed");
39-
}
25+
const builder = await createBuilder(viteConfig);
26+
27+
invariant(builder.environments.client, "Client environment is missing");
28+
invariant(builder.environments.ssr, "SSR environment is missing");
29+
30+
const [clientResult, serverResult] = await Promise.all([
31+
builder.build(builder.environments.client),
32+
builder.build(builder.environments.ssr),
33+
]);
34+
35+
invariant(
36+
clientResult && !Array.isArray(clientResult),
37+
"Client build failed to produce valid output",
38+
);
39+
invariant(serverResult, "SSR build failed to produce valid output");
4040

4141
const { config } = await loadZudokuConfig(
4242
{ mode: "production", command: "build" },
@@ -45,92 +45,94 @@ export async function runBuild(options: { dir: string }) {
4545

4646
const issuer = await getIssuer(config);
4747

48-
if ("output" in clientResult) {
49-
const [jsEntry, cssEntries] = [
50-
clientResult.output.find((o) => "isEntry" in o && o.isEntry)?.fileName,
51-
clientResult.output
52-
.filter((o) => o.fileName.endsWith(".css"))
53-
.map((o) => o.fileName),
54-
];
48+
const base = viteConfig.base ?? "/";
49+
const clientOutDir = viteConfig.environments?.client?.build?.outDir;
50+
const serverOutDir = viteConfig.environments?.ssr?.build?.outDir;
5551

56-
if (!jsEntry || cssEntries.length === 0) {
57-
throw new Error("Build failed. No js or css assets found");
58-
}
52+
invariant(clientOutDir, "Client build outDir is missing");
53+
invariant(serverOutDir, "Server build outDir is missing");
5954

60-
const html = getBuildHtml({
61-
jsEntry: joinUrl(viteClientConfig.base, jsEntry),
62-
cssEntries: cssEntries.map((css) => joinUrl(viteClientConfig.base, css)),
63-
dir: config.site?.dir,
64-
});
55+
if (!("output" in clientResult)) {
56+
throw new Error("Client build output is missing");
57+
}
6558

66-
const serverConfigFilename = findOutputPathOfServerConfig(serverResult);
59+
const [jsEntry, cssEntries] = [
60+
clientResult.output.find((o) => "isEntry" in o && o.isEntry)?.fileName,
61+
clientResult.output
62+
.filter((o) => o.fileName.endsWith(".css"))
63+
.map((o) => o.fileName),
64+
];
6765

68-
invariant(viteClientConfig.build?.outDir, "Client build outDir is missing");
69-
invariant(viteServerConfig.build?.outDir, "Server build outDir is missing");
66+
if (!jsEntry || cssEntries.length === 0) {
67+
throw new Error("Build failed. No js or css assets found");
68+
}
7069

71-
try {
72-
const results = await prerender({
73-
html,
74-
dir: options.dir,
75-
basePath: config.basePath,
76-
serverConfigFilename,
77-
writeRedirects: process.env.VERCEL === undefined,
78-
});
70+
const html = getBuildHtml({
71+
jsEntry: joinUrl(base, jsEntry),
72+
cssEntries: cssEntries.map((css) => joinUrl(base, css)),
73+
dir: config.site?.dir,
74+
});
7975

80-
const indexHtml = path.join(viteClientConfig.build.outDir, "index.html");
76+
const serverConfigFilename = findOutputPathOfServerConfig(serverResult);
8177

82-
if (!results.find((r) => r.outputPath === indexHtml)) {
83-
await writeFile(indexHtml, html, "utf-8");
84-
}
78+
try {
79+
const results = await prerender({
80+
html,
81+
dir: options.dir,
82+
basePath: config.basePath,
83+
serverConfigFilename,
84+
writeRedirects: process.env.VERCEL === undefined,
85+
});
86+
87+
const indexHtml = path.join(clientOutDir, "index.html");
88+
89+
if (!results.find((r) => r.outputPath === indexHtml)) {
90+
await writeFile(indexHtml, html, "utf-8");
91+
}
8592

86-
// find 400.html, 404.html, 500.html
87-
const statusPages = results.flatMap((r) =>
88-
/400|404|500\.html$/.test(r.outputPath) ? r.outputPath : [],
93+
// find 400.html, 404.html, 500.html
94+
const statusPages = results.flatMap((r) =>
95+
/400|404|500\.html$/.test(r.outputPath) ? r.outputPath : [],
96+
);
97+
98+
// move status pages to root path (i.e. without base path)
99+
for (const statusPage of statusPages) {
100+
await rename(
101+
statusPage,
102+
path.join(options.dir, DIST_DIR, path.basename(statusPage)),
89103
);
104+
}
90105

91-
// move status pages to root path (i.e. without base path)
92-
for (const statusPage of statusPages) {
93-
await rename(
94-
statusPage,
95-
path.join(options.dir, DIST_DIR, path.basename(statusPage)),
96-
);
97-
}
98-
99-
// Delete the server build output directory because we don't need it anymore
100-
await rm(viteServerConfig.build.outDir, { recursive: true, force: true });
101-
102-
if (process.env.VERCEL) {
103-
await mkdir(path.join(options.dir, ".vercel/output/static"), {
104-
recursive: true,
105-
});
106-
await rename(
107-
path.join(options.dir, DIST_DIR),
108-
path.join(options.dir, ".vercel/output/static"),
109-
);
110-
}
111-
112-
// Write the build output file
113-
await writeOutput(options.dir, {
114-
config,
115-
redirects: results.flatMap((r) => r.redirect ?? []),
116-
});
106+
// Delete the server build output directory because we don't need it anymore
107+
await rm(serverOutDir, { recursive: true, force: true });
117108

118-
if (ZuploEnv.isZuplo && issuer) {
119-
await writeFile(
120-
path.join(options.dir, DIST_DIR, ".output/zuplo.json"),
121-
JSON.stringify({ issuer }, null, 2),
122-
"utf-8",
123-
);
124-
}
125-
} catch (e) {
126-
// dynamic imports in prerender swallow the stack trace, so we log it here
127-
// biome-ignore lint/suspicious/noConsole: Logging allowed here
128-
console.error(e);
129-
throw e;
109+
if (process.env.VERCEL) {
110+
await mkdir(path.join(options.dir, ".vercel/output/static"), {
111+
recursive: true,
112+
});
113+
await rename(
114+
path.join(options.dir, DIST_DIR),
115+
path.join(options.dir, ".vercel/output/static"),
116+
);
130117
}
131118

132-
return;
133-
}
119+
// Write the build output file
120+
await writeOutput(options.dir, {
121+
config,
122+
redirects: results.flatMap((r) => r.redirect ?? []),
123+
});
134124

135-
throw new Error("Build failed");
125+
if (ZuploEnv.isZuplo && issuer) {
126+
await writeFile(
127+
path.join(options.dir, DIST_DIR, ".output/zuplo.json"),
128+
JSON.stringify({ issuer }, null, 2),
129+
"utf-8",
130+
);
131+
}
132+
} catch (e) {
133+
// dynamic imports in prerender swallow the stack trace, so we log it here
134+
// biome-ignore lint/suspicious/noConsole: Logging allowed here
135+
console.error(e);
136+
throw e;
137+
}
136138
}

packages/zudoku/src/vite/config.ts

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -165,29 +165,40 @@ export async function getViteConfig(
165165
},
166166
},
167167
build: {
168-
ssr: configEnv.isSsrBuild,
169168
sourcemap: true,
170169
target: "es2022",
171-
outDir: path.resolve(
172-
path.join(
173-
dir,
174-
"dist",
175-
config.basePath ?? "",
176-
configEnv.isSsrBuild ? "server" : "",
177-
),
178-
),
179-
emptyOutDir: true,
180-
rollupOptions: {
181-
input:
182-
configEnv.command === "build"
183-
? configEnv.isSsrBuild
184-
? ["zudoku/app/entry.server.tsx", config.__meta.configPath]
185-
: "zudoku/app/entry.client.tsx"
186-
: undefined,
187-
external: [joinUrl(config.basePath, "/pagefind/pagefind.js")],
188-
},
189170
chunkSizeWarningLimit: 1500,
190171
},
172+
environments: {
173+
client: {
174+
build: {
175+
outDir: path.resolve(path.join(dir, "dist", config.basePath ?? "")),
176+
emptyOutDir: true,
177+
rollupOptions: {
178+
input:
179+
configEnv.command === "build"
180+
? "zudoku/app/entry.client.tsx"
181+
: undefined,
182+
external: [joinUrl(config.basePath, "/pagefind/pagefind.js")],
183+
},
184+
},
185+
},
186+
ssr: {
187+
build: {
188+
outDir: path.resolve(
189+
path.join(dir, "dist", config.basePath ?? "", "server"),
190+
),
191+
emptyOutDir: true,
192+
rollupOptions: {
193+
input:
194+
configEnv.command === "build"
195+
? ["zudoku/app/entry.server.tsx", config.__meta.configPath]
196+
: undefined,
197+
external: [joinUrl(config.basePath, "/pagefind/pagefind.js")],
198+
},
199+
},
200+
},
201+
},
191202
experimental: {
192203
renderBuiltUrl(filename) {
193204
if (cdnUrl?.base && [".js", ".css"].includes(path.extname(filename))) {
@@ -202,11 +213,7 @@ export async function getViteConfig(
202213
},
203214
},
204215
optimizeDeps: {
205-
entries: [
206-
configEnv.isSsrBuild
207-
? getAppServerEntryPath()
208-
: getAppClientEntryPath(),
209-
],
216+
entries: [getAppClientEntryPath()],
210217
include: [
211218
"react-dom/client",
212219
...(process.env.SENTRY_DSN ? ["@sentry/react"] : []),
@@ -226,6 +233,8 @@ export async function getViteConfig(
226233
removePluginHookHandleHotUpdate: "warn",
227234
removePluginHookSsrArgument: "warn",
228235
removeServerHot: "warn",
236+
removeServerPluginContainer: "warn",
237+
removeServerReloadModule: "warn",
229238
},
230239
};
231240

packages/zudoku/src/vite/dev-server.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ export class DevServer {
7070
const configEnv: ZudokuConfigEnv = {
7171
mode: "development",
7272
command: "serve",
73-
isSsrBuild: this.#options.ssr,
7473
};
7574
const viteConfig = await getViteConfig(this.#options.dir, configEnv);
7675
const { config } = await loadZudokuConfig(configEnv, this.#options.dir);

packages/zudoku/src/vite/plugin-api.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,25 @@ const viteApiPlugin = async (): Promise<Plugin> => {
5050
processors,
5151
});
5252

53+
// Shared promise to ensure buildStart only processes schemas once across environments
54+
let processingPromise: Promise<void> | null = null;
55+
56+
const processSchemas = async () => {
57+
await fs.rm(tmpStoreDir, { recursive: true, force: true });
58+
await fs.mkdir(tmpStoreDir, { recursive: true });
59+
await schemaManager.processAllSchemas();
60+
};
61+
5362
return {
5463
name: "zudoku-api-plugins",
64+
// Share this plugin instance across all environments during build
65+
sharedDuringBuild: true,
5566
async buildStart() {
56-
await fs.rm(tmpStoreDir, { recursive: true, force: true });
57-
await fs.mkdir(tmpStoreDir, { recursive: true });
58-
59-
await schemaManager.processAllSchemas();
67+
if (!processingPromise) {
68+
processingPromise = processSchemas();
69+
}
6070

71+
await processingPromise;
6172
schemaManager.trackedFiles.forEach((file) => this.addWatchFile(file));
6273
},
6374
configureServer(server) {

packages/zudoku/src/vite/plugin-theme.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ const callPluginLoad = async (plugin: Plugin, id: string) => {
1010
// biome-ignore lint/style/noNonNullAssertion: is guaranteed to be defined
1111
const hook = plugin.load!;
1212
const loadFn = typeof hook === "function" ? hook : hook.handler;
13+
// biome-ignore lint/suspicious/noExplicitAny: Allow any type
1314
return loadFn.call({} as any, id);
1415
};
1516

1617
const callPluginTransform = async (plugin: Plugin, src: string, id: string) => {
1718
// biome-ignore lint/style/noNonNullAssertion: is guaranteed to be defined
1819
const hook = plugin.transform!;
1920
const transformFn = typeof hook === "function" ? hook : hook.handler;
21+
// biome-ignore lint/suspicious/noExplicitAny: Allow any type
2022
return transformFn.call({} as any, src, id);
2123
};
2224

0 commit comments

Comments
 (0)