authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-24 23:43:48-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 23:43:48-07:00
logd10bd1360ace3ab1dbccc1ee706c9169982d98ae
tree43b5a0a4fe7ab4c1fbda63b75ba3ea7f7d55d522
parentc13f7d93fff0f578886985a3f673b4b21b7f6ac8
signature Signed by PGP key B5690EEEBB952194

perf(desktop): cache compiled JavaScript between launches (#13501)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

15 files changed, 200 insertions(+), 4 deletions(-)

apps/desktop/package.json+1-1
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
3 "version": "0.0.42",3 "version": "0.0.42",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "main": "dist-electron/main.cjs",6 "main": "dist-electron/boot.cjs",
7 "scripts": {7 "scripts": {
8 "ensure:electron": "node scripts/ensure-electron-runtime.mjs",8 "ensure:electron": "node scripts/ensure-electron-runtime.mjs",
9 "start": "node scripts/start-electron.mjs",9 "start": "node scripts/start-electron.mjs",
apps/desktop/scripts/main-process-bundle.test.mjs+63
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as NodeChildProcess from "node:child_process";
1import * as NodeFSP from "node:fs/promises";2import * as NodeFSP from "node:fs/promises";
2import * as NodeOS from "node:os";3import * as NodeOS from "node:os";
3import * as NodePath from "node:path";4import * as NodePath from "node:path";
...@@ -108,3 +109,65 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`,...@@ -108,3 +109,65 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`,
108 await NodeFSP.rm(directory, { recursive: true, force: true });109 await NodeFSP.rm(directory, { recursive: true, force: true });
109 }110 }
110});111});
112
113it("loads the emitted packaged boot entry and backend cache preload", async () => {
114 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-"));
115 try {
116 const entries = ["src/boot.ts", "src/compileCache.ts"];
117 await NodeFSP.mkdir(NodePath.join(directory, "src"));
118 await Promise.all(
119 entries.map((entry) =>
120 NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)),
121 ),
122 );
123 assert.ok(Array.isArray(desktopConfig.pack));
124 for (const packConfig of desktopConfig.pack) {
125 if (!Array.isArray(packConfig.entry)) continue;
126 if (!packConfig.entry.some((entry) => entries.includes(entry))) continue;
127 await build({
128 ...packConfig,
129 config: false,
130 cwd: directory,
131 tsconfig: false,
132 sourcemap: false,
133 onSuccess: undefined,
134 logLevel: "silent",
135 });
136 }
137 const outputDirectory = NodePath.join(directory, "dist-electron");
138 const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`;
139 await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture);
140 await NodeFSP.writeFile(
141 NodePath.join(outputDirectory, "backend.mjs"),
142 `import { getCompileCacheDir } from 'node:module'; console.log(getCompileCacheDir() ? 'cached' : 'uncached');`,
143 );
144 for (const disabled of [false, true]) {
145 for (const args of [
146 [NodePath.join(outputDirectory, "boot.cjs")],
147 [
148 "--require",
149 NodePath.join(outputDirectory, "compileCache.cjs"),
150 NodePath.join(outputDirectory, "backend.mjs"),
151 ],
152 ]) {
153 const child = NodeChildProcess.spawnSync(process.execPath, args, {
154 encoding: "utf8",
155 env: {
156 ...process.env,
157 APPIMAGE: "",
158 NODE_COMPILE_CACHE: undefined,
159 NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined,
160 XDG_CACHE_HOME: directory,
161 TMPDIR: directory,
162 TEMP: directory,
163 TMP: directory,
164 },
165 });
166 assert.equal(child.status, 0, child.stderr);
167 assert.equal(child.stdout.trim(), disabled ? "uncached" : "cached");
168 }
169 }
170 } finally {
171 await NodeFSP.rm(directory, { recursive: true, force: true });
172 }
173});
apps/desktop/scripts/smoke-test.mjs+1-1
...@@ -5,7 +5,7 @@ import { resolveElectronLaunchCommand } from "./electron-launcher.mjs";...@@ -5,7 +5,7 @@ import { resolveElectronLaunchCommand } from "./electron-launcher.mjs";
55
6const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));6const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
7const desktopDir = NodePath.resolve(__dirname, "..");7const desktopDir = NodePath.resolve(__dirname, "..");
8const mainJs = NodePath.resolve(desktopDir, "dist-electron/main.cjs");8const mainJs = NodePath.resolve(desktopDir, "dist-electron/boot.cjs");
99
10console.log("\nLaunching Electron smoke test...");10console.log("\nLaunching Electron smoke test...");
1111
apps/desktop/src/app/DesktopEnvironment.ts+3
...@@ -66,6 +66,8 @@ export class DesktopEnvironment extends Context.Service<...@@ -66,6 +66,8 @@ export class DesktopEnvironment extends Context.Service<
66 readonly clientAssetsDir: string;66 readonly clientAssetsDir: string;
67 readonly backendCwd: string;67 readonly backendCwd: string;
68 readonly preloadPath: string;68 readonly preloadPath: string;
69 // Preload that turns on the V8 compile cache for the local backend.
70 readonly compileCachePath: string;
69 readonly appUpdateYmlPath: string;71 readonly appUpdateYmlPath: string;
70 readonly devServerUrl: Option.Option<URL>;72 readonly devServerUrl: Option.Option<URL>;
71 readonly devRemoteT3ServerEntryPath: Option.Option<string>;73 readonly devRemoteT3ServerEntryPath: Option.Option<string>;
...@@ -221,6 +223,7 @@ const make = Effect.fn("desktop.environment.make")(function* (...@@ -221,6 +223,7 @@ const make = Effect.fn("desktop.environment.make")(function* (
221 clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"),223 clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"),
222 backendCwd: input.isPackaged ? homeDirectory : appRoot,224 backendCwd: input.isPackaged ? homeDirectory : appRoot,
223 preloadPath: path.join(input.dirname, "preload.cjs"),225 preloadPath: path.join(input.dirname, "preload.cjs"),
226 compileCachePath: path.join(input.dirname, "compileCache.cjs"),
224 appUpdateYmlPath: input.isPackaged227 appUpdateYmlPath: input.isPackaged
225 ? path.join(resourcesPath, "app-update.yml")228 ? path.join(resourcesPath, "app-update.yml")
226 : path.join(input.appPath, "dev-app-update.yml"),229 : path.join(input.appPath, "dev-app-update.yml"),
apps/desktop/src/backend/DesktopBackendConfiguration.test.ts+7
...@@ -235,6 +235,11 @@ describe("DesktopBackendConfiguration", () => {...@@ -235,6 +235,11 @@ describe("DesktopBackendConfiguration", () => {
235 const second = yield* configuration.resolvePrimary;235 const second = yield* configuration.resolvePrimary;
236236
237 assert.equal(first.executablePath, process.execPath);237 assert.equal(first.executablePath, process.execPath);
238 assert.deepEqual(first.args.slice(0, 3), [
239 "--require",
240 environment.compileCachePath,
241 environment.backendEntryPath,
242 ]);
238 assert.equal(first.entryPath, environment.backendEntryPath);243 assert.equal(first.entryPath, environment.backendEntryPath);
239 assert.equal(first.cwd, environment.backendCwd);244 assert.equal(first.cwd, environment.backendCwd);
240 assert.equal(first.captureOutput, true);245 assert.equal(first.captureOutput, true);
...@@ -927,6 +932,8 @@ describe("DesktopBackendConfiguration", () => {...@@ -927,6 +932,8 @@ describe("DesktopBackendConfiguration", () => {
927 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;932 const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration;
928 const config = yield* configuration.resolvePrimary;933 const config = yield* configuration.resolvePrimary;
929 assert.equal(config.captureOutput, true);934 assert.equal(config.captureOutput, true);
935 // Dev never shares the prod compile cache.
936 assert.notInclude(config.args, "--require");
930 }).pipe(937 }).pipe(
931 Effect.provide(938 Effect.provide(
932 DesktopBackendConfiguration.layer.pipe(939 DesktopBackendConfiguration.layer.pipe(
apps/desktop/src/backend/DesktopBackendConfiguration.ts+10-1
...@@ -550,7 +550,16 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv...@@ -550,7 +550,16 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv
550550
551 return {551 return {
552 executablePath: process.execPath,552 executablePath: process.execPath,
553 args: [environment.backendEntryPath, "--bootstrap-fd", "3"],553 // Packaged builds only, so a dev instance never shares the cache with the
554 // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE,
555 // so the setting does not leak into the provider and terminal processes
556 // the backend starts.
557 args: [
558 ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []),
559 environment.backendEntryPath,
560 "--bootstrap-fd",
561 "3",
562 ],
554 entryPath: environment.backendEntryPath,563 entryPath: environment.backendEntryPath,
555 cwd: environment.backendCwd,564 cwd: environment.backendCwd,
556 env: {565 env: {
apps/desktop/src/boot.ts created+4
...@@ -0,0 +1,4 @@
1// Packaged app entry. Enables the compile cache before the main bundle loads,
2// so the cache also covers main.cjs itself.
3require("./compileCache.cjs");
4require("./main.cjs");
apps/desktop/src/compileCache.ts created+28
...@@ -0,0 +1,28 @@
1// @effect-diagnostics nodeBuiltinImport:off
2// Runs before any Effect runtime exists, so it stays on Node built-ins.
3import * as NodeModule from "node:module";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7// Turns on Node's on-disk V8 code cache for every module loaded after this one,
8// so later launches skip recompiling the large main and server bundles.
9// Packaged builds only: boot.ts loads it for the main process, and the local
10// backend gets it with `--require`. Dev launches main.cjs directly and skips it.
11// Linux uses the user's cache dir because /tmp is shared between users; the
12// macOS and Windows temp dirs are already per user.
13//
14// Skipped under AppImage: it mounts the app at a new /tmp/.mount_* path each
15// launch, and Node keys entries by path, so every launch would miss and leave
16// another copy behind. The backend inherits APPIMAGE, so this covers it too.
17try {
18 if (!process.env.APPIMAGE) {
19 const cacheRoot =
20 // oxlint-disable-next-line t3code/no-global-process-runtime -- Loads before any Effect runtime.
21 process.platform === "linux"
22 ? process.env.XDG_CACHE_HOME || NodePath.join(NodeOS.homedir(), ".cache")
23 : NodeOS.tmpdir();
24 NodeModule.enableCompileCache(NodePath.join(cacheRoot, "t3code", "compile-cache"));
25 }
26} catch {
27 // The cache is only a speedup. Never let it stop the app from starting.
28}
apps/desktop/src/electron/ElectronProtocol.ts+3
...@@ -126,6 +126,9 @@ function registerDesktopSchemePrivilegesSync(): void {...@@ -126,6 +126,9 @@ function registerDesktopSchemePrivilegesSync(): void {
126 supportFetchAPI: true,126 supportFetchAPI: true,
127 corsEnabled: true,127 corsEnabled: true,
128 stream: true,128 stream: true,
129 // Custom schemes skip Chromium's V8 code cache unless they opt in.
130 // Dev stays off: Vite serves changing code at stable URLs.
131 codeCache: true,
129 },132 },
130 },133 },
131 {134 {
apps/desktop/vite.config.ts+13
...@@ -85,6 +85,19 @@ export default defineConfig({...@@ -85,6 +85,19 @@ export default defineConfig({
85 onlyBundle: false,85 onlyBundle: false,
86 },86 },
87 },87 },
88 {
89 // boot.cjs requires the other two at runtime, so all three stay separate files.
90 format: "cjs",
91 outDir: "dist-electron",
92 dts: false,
93 sourcemap: true,
94 outExtensions: () => ({ js: ".cjs" }),
95 entry: ["src/boot.ts", "src/compileCache.ts"],
96 clean: false,
97 deps: {
98 neverBundle: (id) => id === "./main.cjs" || id === "./compileCache.cjs",
99 },
100 },
88 {101 {
89 format: "cjs",102 format: "cjs",
90 outDir: "dist-electron",103 outDir: "dist-electron",
apps/server/src/compileCache.test.ts created+53
...@@ -0,0 +1,53 @@
1// @effect-diagnostics nodeBuiltinImport:off - the test kills a real Node process to prove the cache is on disk.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFSP from "node:fs/promises";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import { assert, it } from "@effect/vitest";
7
8it.each([false, true])(
9 "persists an enabled cache before forced exit (disabled: %s)",
10 async (disabled) => {
11 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-compile-cache-"));
12 try {
13 const cacheDirectory = NodePath.join(directory, "cache");
14 const child = NodeChildProcess.spawnSync(
15 process.execPath,
16 [
17 "--input-type=module",
18 "--eval",
19 `import * as Effect from ${JSON.stringify(import.meta.resolve("effect/Effect"))};
20const { flushCompileCache } = await import(${JSON.stringify(new URL("./compileCache.ts", import.meta.url).href)});
21await Effect.runPromise(flushCompileCache);
22process.kill(process.pid, "SIGKILL");`,
23 ],
24 {
25 encoding: "utf8",
26 env: {
27 ...process.env,
28 NODE_COMPILE_CACHE: cacheDirectory,
29 NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined,
30 },
31 },
32 );
33 assert.equal(child.error, undefined);
34 assert.equal(child.stderr, "");
35 assert.notEqual(child.status, 0);
36 const entries = await NodeFSP.readdir(cacheDirectory, { recursive: true }).catch(
37 (error: NodeJS.ErrnoException) => {
38 if (error.code === "ENOENT") return [];
39 throw error;
40 },
41 );
42 const files = await Promise.all(
43 entries.map((entry) => NodeFSP.stat(NodePath.join(cacheDirectory, entry))),
44 );
45 assert.equal(
46 files.some((entry) => entry.isFile()),
47 !disabled,
48 );
49 } finally {
50 await NodeFSP.rm(directory, { recursive: true, force: true });
51 }
52 },
53);
apps/server/src/compileCache.ts created+9
...@@ -0,0 +1,9 @@
1import * as NodeModule from "node:module";
2import * as Effect from "effect/Effect";
3
4// Desktop enables this cache before loading the backend. Windows force-kills
5// the backend on quit, so persist it after startup instead of waiting for exit.
6// This is a no-op when caching is disabled, including normal dev launches.
7export const flushCompileCache = Effect.try(() => NodeModule.flushCompileCache()).pipe(
8 Effect.ignore,
9);
apps/server/src/serverRuntimeStartup.ts+2
...@@ -33,6 +33,7 @@ import * as Schema from "effect/Schema";...@@ -33,6 +33,7 @@ import * as Schema from "effect/Schema";
33import * as Scope from "effect/Scope";33import * as Scope from "effect/Scope";
3434
35import * as ServerConfig from "./config.ts";35import * as ServerConfig from "./config.ts";
36import { flushCompileCache } from "./compileCache.ts";
36import * as Keybindings from "./keybindings.ts";37import * as Keybindings from "./keybindings.ts";
37import * as ExternalLauncher from "./process/externalLauncher.ts";38import * as ExternalLauncher from "./process/externalLauncher.ts";
38import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";39import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
...@@ -1074,6 +1075,7 @@ export const make = (options?: StartupOptions) =>...@@ -1074,6 +1075,7 @@ export const make = (options?: StartupOptions) =>
1074 }),1075 }),
1075 );1076 );
1076 yield* Effect.logDebug("startup phase: complete");1077 yield* Effect.logDebug("startup phase: complete");
1078 yield* flushCompileCache;
1077 }).pipe(1079 }).pipe(
1078 Effect.annotateSpans({1080 Effect.annotateSpans({
1079 "server.mode": serverConfig.mode,1081 "server.mode": serverConfig.mode,
knip.jsonc+2
...@@ -38,6 +38,8 @@...@@ -38,6 +38,8 @@
38 "src/snapShot/GlobalShiftShortcutWorker.ts!",38 "src/snapShot/GlobalShiftShortcutWorker.ts!",
39 "src/snapShot/RegionSnapShotWorker.ts!",39 "src/snapShot/RegionSnapShotWorker.ts!",
40 "src/snapShot/SnapShotAccessibilityWorker.ts!",40 "src/snapShot/SnapShotAccessibilityWorker.ts!",
41 "src/boot.ts!",
42 "src/compileCache.ts!",
41 "src/main.ts!",43 "src/main.ts!",
42 "src/preload.ts!",44 "src/preload.ts!",
43 "src/preview-pick-preload.ts!",45 "src/preview-pick-preload.ts!",
scripts/build-desktop-artifact.ts+1-1
...@@ -3670,7 +3670,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3670,7 +3670,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3670 // Required by the .deb control file.3670 // Required by the .deb control file.
3671 homepage: "https://t3.codes",3671 homepage: "https://t3.codes",
3672 author: "T3 Tools",3672 author: "T3 Tools",
3673 main: "apps/desktop/dist-electron/main.cjs",3673 main: "apps/desktop/dist-electron/boot.cjs",
3674 build: yield* createBuildConfig(3674 build: yield* createBuildConfig(
3675 options.platform,3675 options.platform,
3676 options.target,3676 options.target,