authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-07 05:28:00-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-07 02:28:00-07:00
log388b43a27cc37ae067087073825633d4fc5de3b7
treeb2a230e5172d87e38440c10d0485c4d7906c0e8a
parent23f0a1ae38cb1fc510a499ef2b3602f5ae98d0c9
signature Signed by PGP key B5690EEEBB952194

perf(dev): faster cold-start for dev web serving (#5584)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

9 files changed, 267 insertions(+), 3 deletions(-)

apps/web/package.json+2
......@@ -52,11 +52,13 @@
5252 "@tailwindcss/vite": "^4.0.0",
5353 "@tanstack/router-plugin": "^1.161.0",
5454 "@types/babel__core": "^7.20.5",
55 "@types/compression": "^1.8.1",
5556 "@types/react": "~19.2.14",
5657 "@types/react-dom": "~19.2.3",
5758 "@vercel/config": "^0.3.0",
5859 "@vitejs/plugin-react": "^6.0.0",
5960 "babel-plugin-react-compiler": "1.0.0",
61 "compression": "^1.8.1",
6062 "msw": "2.12.11",
6163 "tailwindcss": "^4.0.0",
6264 "vite": "catalog:",
apps/web/scripts/warm-dep-cache.ts created+22
......@@ -0,0 +1,22 @@
1// @effect-diagnostics nodeBuiltinImport:off - setup-script bootstrap, runs before any Effect runtime exists.
2/**
3 * Pre-warms Vite's dependency-optimizer cache (`node_modules/.vite/deps`) so
4 * the first `vp run dev` in a fresh worktree doesn't stall the initial page
5 * load on a full optimize pass. Run by the t3.json worktree setup script;
6 * safe to re-run — a valid cache makes this a fast no-op.
7 *
8 * The cache cannot be shared between worktrees: Vite's config hash includes
9 * the absolute project root, so each worktree must warm its own.
10 */
11import * as NodePath from "node:path";
12import * as NodeURL from "node:url";
13
14import { optimizeDeps, resolveConfig } from "vite";
15
16const webRoot = NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)));
17
18// logLevel "error" silences the "manually calling optimizeDeps is deprecated"
19// warning — deliberate here: warming ahead of the server is the whole point.
20const config = await resolveConfig({ root: webRoot, logLevel: "error" }, "serve");
21await optimizeDeps(config);
22console.log("[warm-dep-cache] web dependency cache is warm");
apps/web/tsconfig.json+8-1
......@@ -25,5 +25,12 @@
2525 }
2626 ]
2727 },
28 "include": ["src", "vite.config.ts", "vercel.ts", "test", "../../scripts/lib/public-config.ts"]
28 "include": [
29 "src",
30 "vite.config.ts",
31 "vercel.ts",
32 "test",
33 "scripts/warm-dep-cache.ts",
34 "../../scripts/lib/public-config.ts"
35 ]
2936}
apps/web/vite.config.ts+36-1
......@@ -1,10 +1,13 @@
1import * as NodeZlib from "node:zlib";
2
13import tailwindcss from "@tailwindcss/vite";
24import react, { reactCompilerPreset } from "@vitejs/plugin-react";
35import babel from "@rolldown/plugin-babel";
46import { tanstackRouter } from "@tanstack/router-plugin/vite";
7import compression from "compression";
58import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config";
69import "vite-plus/test/config";
7import { defineConfig } from "vite-plus";
10import { defineConfig, type Connect, type Plugin } from "vite-plus";
811import pkg from "./package.json" with { type: "json" };
912
1013import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy";
......@@ -55,6 +58,8 @@ const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
5558// Vite 8.1's experimental bundled dev mode: serves rolldown-bundled chunks in
5659// dev for much faster startup/reload on large module graphs, with HMR served
5760// as hot patches. Opt-in while experimental: T3CODE_BUNDLED_DEV=1 pnpm dev:web
61// The dev runner defaults this on for --share runs (remote browsers pay a
62// round trip per import level in unbundled dev); T3CODE_BUNDLED_DEV=0 opts out.
5863const bundledDevEnv = process.env.T3CODE_BUNDLED_DEV?.trim().toLowerCase();
5964const bundledDev = bundledDevEnv === "1" || bundledDevEnv === "true";
6065
......@@ -114,6 +119,28 @@ function resolveDevProxyTarget(
114119
115120const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl);
116121
122// Vite's dev server sends JS uncompressed. On localhost that is free; over a
123// shared origin (tailnet, LAN) it is the whole cold-start: bundled dev serves
124// one ~25 MB chunk, and a typical uplink moves that in about a minute while
125// both machines sit idle. Compressing turns it into a few seconds of CPU.
126// Brotli quality 5 keeps encode time in the hundreds of ms; the default
127// (quality 11) would trade the transfer stall for an equally long encode stall.
128function devCompressionPlugin(): Plugin {
129 return {
130 name: "t3code:dev-compression",
131 apply: "serve",
132 configureServer(server) {
133 // compression() is typed against Express's req/res, which extend the
134 // node http objects Connect actually passes — safe to narrow.
135 server.middlewares.use(
136 compression({
137 brotli: { params: { [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 5 } },
138 }) as unknown as Connect.NextHandleFunction,
139 );
140 },
141 };
142}
143
117144// Vite rejects requests whose Host header isn't localhost, which blocks sharing
118145// a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale:
119146// the DNS is controlled by tailscale, so they can't be rebound by an attacker.
......@@ -128,6 +155,7 @@ export default defineConfig(() => {
128155 return {
129156 assetsInclude: ["**/*.wasm"],
130157 plugins: [
158 devCompressionPlugin(),
131159 tanstackRouter(),
132160 react(),
133161 babel({
......@@ -187,6 +215,13 @@ export default defineConfig(() => {
187215 port,
188216 strictPort: true,
189217 allowedHosts,
218 // Transform the whole module graph at server start instead of on the
219 // first request. Without this, a cold worktree discovers and transforms
220 // modules one import-level at a time while the browser waits — which
221 // over a tailnet origin turns into minutes of waterfall.
222 warmup: {
223 clientFiles: ["./src/main.tsx"],
224 },
190225 ...(devProxyTarget
191226 ? {
192227 // One entry per shared prefix; the server's dev catch-all 404s the
docs/internals/scripts.md+3
......@@ -24,6 +24,9 @@ authenticated.
2424- `vp run dev`: Starts contracts, server, and web in watch mode.
2525- `vp run dev --share`: Also publishes the web port over HTTPS on this machine's tailnet. The
2626 startup pairing URL is built against the shared origin, and the mapping is removed on exit.
27 Shared runs default to Vite's bundled dev mode (`T3CODE_BUNDLED_DEV=1`): a remote browser pays a
28 network round trip per import level in unbundled dev, which turns a cold module graph into
29 minutes of waterfall. Set `T3CODE_BUNDLED_DEV=0` to opt a shared run back out.
2730- `vp run dev --browser`: Auto-opens a browser. Off by default. The dev runner writes
2831 `T3CODE_NO_BROWSER` itself from this flag, so setting `T3CODE_NO_BROWSER=0` in your environment has
2932 no effect; use `--browser`.
pnpm-lock.yaml+78
......@@ -623,6 +623,9 @@ importers:
623623 '@types/babel__core':
624624 specifier: ^7.20.5
625625 version: 7.20.5
626 '@types/compression':
627 specifier: ^1.8.1
628 version: 1.8.1
626629 '@types/react':
627630 specifier: ~19.2.14
628631 version: 19.2.16
......@@ -638,6 +641,9 @@ importers:
638641 babel-plugin-react-compiler:
639642 specifier: 1.0.0
640643 version: 1.0.0
644 compression:
645 specifier: ^1.8.1
646 version: 1.8.1
641647 msw:
642648 specifier: 2.12.11
643649 version: 2.12.11(@types/node@24.12.4)(typescript@6.0.3)
......@@ -4777,6 +4783,9 @@ packages:
47774783 '@types/babel__traverse@7.28.0':
47784784 resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
47794785
4786 '@types/body-parser@1.19.6':
4787 resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
4788
47804789 '@types/bun@1.3.14':
47814790 resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==}
47824791
......@@ -4786,6 +4795,12 @@ packages:
47864795 '@types/chai@5.2.3':
47874796 resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
47884797
4798 '@types/compression@1.8.1':
4799 resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==}
4800
4801 '@types/connect@3.4.38':
4802 resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
4803
47894804 '@types/debug@4.1.13':
47904805 resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
47914806
......@@ -4801,6 +4816,12 @@ packages:
48014816 '@types/estree@1.0.9':
48024817 resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
48034818
4819 '@types/express-serve-static-core@5.1.3':
4820 resolution: {integrity: sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==}
4821
4822 '@types/express@5.0.6':
4823 resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
4824
48044825 '@types/fs-extra@9.0.13':
48054826 resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
48064827
......@@ -4813,6 +4834,9 @@ packages:
48134834 '@types/http-cache-semantics@4.2.0':
48144835 resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==}
48154836
4837 '@types/http-errors@2.0.5':
4838 resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
4839
48164840 '@types/istanbul-lib-coverage@2.0.6':
48174841 resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==}
48184842
......@@ -4843,6 +4867,12 @@ packages:
48434867 '@types/pngjs@6.0.5':
48444868 resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
48454869
4870 '@types/qs@6.15.1':
4871 resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
4872
4873 '@types/range-parser@1.2.7':
4874 resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
4875
48464876 '@types/react-dom@19.2.3':
48474877 resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
48484878 peerDependencies:
......@@ -4857,6 +4887,12 @@ packages:
48574887 '@types/responselike@1.0.3':
48584888 resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
48594889
4890 '@types/send@1.2.1':
4891 resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
4892
4893 '@types/serve-static@2.2.0':
4894 resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
4895
48604896 '@types/statuses@2.0.6':
48614897 resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
48624898
......@@ -14913,6 +14949,11 @@ snapshots:
1491314949 dependencies:
1491414950 '@babel/types': 7.29.7
1491514951
14952 '@types/body-parser@1.19.6':
14953 dependencies:
14954 '@types/connect': 3.4.38
14955 '@types/node': 24.12.4
14956
1491614957 '@types/bun@1.3.14':
1491714958 dependencies:
1491814959 bun-types: 1.3.14
......@@ -14929,6 +14970,15 @@ snapshots:
1492914970 '@types/deep-eql': 4.0.2
1493014971 assertion-error: 2.0.1
1493114972
14973 '@types/compression@1.8.1':
14974 dependencies:
14975 '@types/express': 5.0.6
14976 '@types/node': 24.12.4
14977
14978 '@types/connect@3.4.38':
14979 dependencies:
14980 '@types/node': 24.12.4
14981
1493214982 '@types/debug@4.1.13':
1493314983 dependencies:
1493414984 '@types/ms': 2.1.0
......@@ -14943,6 +14993,19 @@ snapshots:
1494314993
1494414994 '@types/estree@1.0.9': {}
1494514995
14996 '@types/express-serve-static-core@5.1.3':
14997 dependencies:
14998 '@types/node': 24.12.4
14999 '@types/qs': 6.15.1
15000 '@types/range-parser': 1.2.7
15001 '@types/send': 1.2.1
15002
15003 '@types/express@5.0.6':
15004 dependencies:
15005 '@types/body-parser': 1.19.6
15006 '@types/express-serve-static-core': 5.1.3
15007 '@types/serve-static': 2.2.0
15008
1494615009 '@types/fs-extra@9.0.13':
1494715010 dependencies:
1494815011 '@types/node': 24.12.4
......@@ -14955,6 +15018,8 @@ snapshots:
1495515018
1495615019 '@types/http-cache-semantics@4.2.0': {}
1495715020
15021 '@types/http-errors@2.0.5': {}
15022
1495815023 '@types/istanbul-lib-coverage@2.0.6': {}
1495915024
1496015025 '@types/istanbul-lib-report@3.0.3':
......@@ -14989,6 +15054,10 @@ snapshots:
1498915054 dependencies:
1499015055 '@types/node': 24.12.4
1499115056
15057 '@types/qs@6.15.1': {}
15058
15059 '@types/range-parser@1.2.7': {}
15060
1499215061 '@types/react-dom@19.2.3(@types/react@19.2.16)':
1499315062 dependencies:
1499415063 '@types/react': 19.2.16
......@@ -15005,6 +15074,15 @@ snapshots:
1500515074 dependencies:
1500615075 '@types/node': 24.12.4
1500715076
15077 '@types/send@1.2.1':
15078 dependencies:
15079 '@types/node': 24.12.4
15080
15081 '@types/serve-static@2.2.0':
15082 dependencies:
15083 '@types/http-errors': 2.0.5
15084 '@types/node': 24.12.4
15085
1500815086 '@types/statuses@2.0.6': {}
1500915087
1501015088 '@types/unist@2.0.11': {}
scripts/dev-runner.test.ts+109
......@@ -986,6 +986,115 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
986986 });
987987 });
988988
989 // A shared origin means a remote browser, where unbundled dev's
990 // per-module waterfall pays a tailnet round trip per import level. The
991 // runner defaults bundled dev on for the spawned stack, but only
992 // defaults: an explicit T3CODE_BUNDLED_DEV (even "0") must pass through.
993 describe("--share bundled dev default", () => {
994 const shareSpawnedEnv = (input: { readonly ambientBundledDev: string | undefined }) =>
995 Effect.gen(function* () {
996 let captured: Record<string, string | undefined> | undefined;
997 const spawnerLayer = Layer.succeed(
998 ChildProcessSpawner.ChildProcessSpawner,
999 ChildProcessSpawner.make((command) => {
1000 const spawned = command as unknown as {
1001 readonly command: string;
1002 readonly args: ReadonlyArray<string>;
1003 readonly options?: { readonly env?: Record<string, string | undefined> };
1004 };
1005 if (spawned.command === "vp") {
1006 captured = spawned.options?.env;
1007 return Effect.succeed(mockProcess(0));
1008 }
1009 // tailscale: answer `status --json` with a valid tailnet name,
1010 // succeed the `serve`/`off` calls.
1011 return Effect.succeed(
1012 ChildProcessSpawner.makeHandle({
1013 pid: ChildProcessSpawner.ProcessId(2),
1014 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
1015 isRunning: Effect.succeed(false),
1016 kill: () => Effect.void,
1017 unref: Effect.succeed(Effect.void),
1018 stdin: Sink.drain,
1019 stdout: spawned.args.includes("status")
1020 ? Stream.make(
1021 new TextEncoder().encode(
1022 JSON.stringify({ Self: { DNSName: "host.example.ts.net." } }),
1023 ),
1024 )
1025 : Stream.empty,
1026 stderr: Stream.empty,
1027 all: Stream.empty,
1028 getInputFd: () => Sink.drain,
1029 getOutputFd: () => Stream.empty,
1030 }),
1031 );
1032 }),
1033 );
1034
1035 yield* runDevRunnerWithInput({
1036 ...devServerInput,
1037 mode: "dev",
1038 port: undefined,
1039 share: true,
1040 }).pipe(
1041 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
1042 Effect.provideService(HostProcessPlatform, "linux"),
1043 Effect.provideService(
1044 HostProcessEnvironment,
1045 input.ambientBundledDev === undefined
1046 ? {}
1047 : { T3CODE_BUNDLED_DEV: input.ambientBundledDev },
1048 ),
1049 );
1050
1051 return captured;
1052 });
1053
1054 it.effect("defaults T3CODE_BUNDLED_DEV=1 for a shared run", () =>
1055 Effect.gen(function* () {
1056 const env = yield* shareSpawnedEnv({ ambientBundledDev: undefined });
1057 assert.equal(env?.T3CODE_BUNDLED_DEV, "1");
1058 }),
1059 );
1060
1061 it.effect("keeps an explicit T3CODE_BUNDLED_DEV=0 opt-out", () =>
1062 Effect.gen(function* () {
1063 const env = yield* shareSpawnedEnv({ ambientBundledDev: "0" });
1064 assert.equal(env?.T3CODE_BUNDLED_DEV, "0");
1065 }),
1066 );
1067
1068 it.effect("leaves T3CODE_BUNDLED_DEV unset without --share", () =>
1069 Effect.gen(function* () {
1070 let captured: Record<string, string | undefined> | undefined;
1071 const spawnerLayer = Layer.succeed(
1072 ChildProcessSpawner.ChildProcessSpawner,
1073 ChildProcessSpawner.make((command) => {
1074 captured = (
1075 command as {
1076 readonly options?: { readonly env?: Record<string, string | undefined> };
1077 }
1078 ).options?.env;
1079 return Effect.succeed(mockProcess(0));
1080 }),
1081 );
1082
1083 yield* runDevRunnerWithInput({
1084 ...devServerInput,
1085 mode: "dev",
1086 port: undefined,
1087 }).pipe(
1088 Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)),
1089 Effect.provideService(HostProcessPlatform, "linux"),
1090 Effect.provideService(HostProcessEnvironment, {}),
1091 );
1092
1093 assert.equal(captured?.T3CODE_BUNDLED_DEV, undefined);
1094 }),
1095 );
1096 });
1097
9891098 it.effect("spawns nothing when --dry-run is combined with --share", () => {
9901099 let spawnCount = 0;
9911100 const spawnerLayer = Layer.succeed(
scripts/dev-runner.ts+8
......@@ -780,6 +780,14 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
780780 if (input.devUrl === undefined) {
781781 env.VITE_DEV_SERVER_URL = shared.url;
782782 }
783 // A shared origin serves a remote browser, where unbundled dev's
784 // per-module requests each pay a tailnet round trip — a cold module
785 // graph takes minutes to first paint. Bundled dev collapses that to
786 // a few chunk requests. Only defaulted, so T3CODE_BUNDLED_DEV=0
787 // still opts a --share run back out.
788 if (env.T3CODE_BUNDLED_DEV === undefined) {
789 env.T3CODE_BUNDLED_DEV = "1";
790 }
783791 yield* Effect.logInfo(`[dev-runner] shared on tailnet: ${shared.url}`);
784792 }
785793 }
t3.json+1-1
......@@ -4,7 +4,7 @@
44 "scripts": [
55 {
66 "name": "Setup Worktree",
7 "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env",
7 "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env && node apps/web/scripts/warm-dep-cache.ts",
88 "icon": "configure",
99 "runOnWorktreeCreate": true
1010 }