1import * as NodeZlib from "node:zlib";
2
3import react, { reactCompilerPreset } from "@vitejs/plugin-react";
4import babel from "@rolldown/plugin-babel";
5import { tanstackRouter } from "@tanstack/router-plugin/vite";
6import compression from "compression";
7import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config";
8import "vite-plus/test/config";
9import { defineConfig, type Connect, type Plugin } from "vite-plus";
10import pkg from "./package.json" with { type: "json" };
11
12import {
13 DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES,
14 DEV_PROXIED_PATH_PREFIXES,
15} from "@t3tools/shared/devProxy";
16
17import { loadRepoEnv } from "../../scripts/lib/public-config";
18import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses";
19import { tailwindPlugins } from "./vite/tailwind";
20
21const repoEnv = loadRepoEnv();
22Object.assign(process.env, repoEnv);
23
24// Single-origin dev is signalled positively, because it cannot be inferred
25// from the absence of VITE_HTTP_URL/VITE_WS_URL: the runner deletes those keys
26// but `loadRepoEnv` merges `.env`/`.env.local` *underneath* the process env, so
27// a developer with either URL in their `.env` gets it back here. Baking it then
28// pins the client to localhost and breaks every non-localhost origin — the
29// exact failure single-origin mode exists to prevent, and an invisible one
30// since the page still loads.
31const isSingleOriginDev = process.env.T3CODE_SINGLE_ORIGIN_DEV === "1";
32
33const port = Number(process.env.PORT ?? 5733);
34const explicitHost = process.env.HOST?.trim();
35const host = explicitHost || "localhost";
36const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim();
37const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim();
38const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || "";
39const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
40const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || "";
41const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || "";
42const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
43const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
44const configuredHostedAppUrl = (() => {
45 const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim();
46 if (explicitHostedAppUrl) {
47 return explicitHostedAppUrl;
48 }
49 if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) {
50 return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
51 }
52 if (process.env.VERCEL_URL) {
53 return `https://${process.env.VERCEL_URL}`;
54 }
55 return undefined;
56})();
57const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
58
59// Vite 8.1's experimental bundled dev mode: serves rolldown-bundled chunks in
60// dev for much faster startup/reload on large module graphs, with HMR served
61// as hot patches. Opt-in while experimental: T3CODE_BUNDLED_DEV=1 pnpm dev:web
62// The dev runner defaults this on for --share runs (remote browsers pay a
63// round trip per import level in unbundled dev); T3CODE_BUNDLED_DEV=0 opts out.
64const bundledDevEnv = process.env.T3CODE_BUNDLED_DEV?.trim().toLowerCase();
65const bundledDev = bundledDevEnv === "1" || bundledDevEnv === "true";
66
67const buildSourcemap: boolean | "hidden" =
68 sourcemapEnv === "0" || sourcemapEnv === "false"
69 ? false
70 : sourcemapEnv === "hidden"
71 ? "hidden"
72 : true;
73
74const unitTestProject = {
75 extends: true,
76 test: {
77 name: "unit",
78 include: ["src/**/*.test.{ts,tsx}"],
79 // The web runtime suite exercises auth bootstrap, saved environments,
80 // and websocket subscription lifecycles. Under the full monorepo test
81 // run, those async tests can exceed Vitest's default 5s budget.
82 hookTimeout: 15_000,
83 testTimeout: 15_000,
84 setupFiles: ["../../packages/shared/src/testing/longTempDir.ts"],
85 },
86} satisfies TestProjectInlineConfiguration;
87
88function resolveDevProxyTarget(
89 backendPort: string | undefined,
90 wsUrl: string | undefined,
91): string | undefined {
92 // Browser dev is single-origin: the backend port is proxied through this
93 // server so the app works from any origin (localhost, tailnet, LAN, phone).
94 // T3CODE_PORT is set by scripts/dev-runner.ts for every non-desktop mode.
95 const port = Number(backendPort?.trim());
96 if (Number.isInteger(port) && port > 0) {
97 return `http://localhost:${port}/`;
98 }
99
100 // dev:desktop still points the renderer straight at the backend, so fall
101 // back to deriving the target from the explicit websocket URL.
102 if (!wsUrl) {
103 return undefined;
104 }
105
106 try {
107 const url = new URL(wsUrl);
108 if (url.protocol === "ws:") {
109 url.protocol = "http:";
110 } else if (url.protocol === "wss:") {
111 url.protocol = "https:";
112 }
113 url.pathname = "";
114 url.search = "";
115 url.hash = "";
116 return url.toString();
117 } catch {
118 return undefined;
119 }
120}
121
122const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl);
123
124// Vite's dev server sends JS uncompressed. On localhost that is free; over a
125// shared origin (tailnet, LAN) it is the whole cold-start: bundled dev serves
126// one ~25 MB chunk, and a typical uplink moves that in about a minute while
127// both machines sit idle. Compressing turns it into a few seconds of CPU.
128// Brotli quality 5 keeps encode time in the hundreds of ms; the default
129// (quality 11) would trade the transfer stall for an equally long encode stall.
130function devCompressionPlugin(): Plugin {
131 return {
132 name: "t3code:dev-compression",
133 apply: "serve",
134 configureServer(server) {
135 // compression() is typed against Express's req/res, which extend the
136 // node http objects Connect actually passes — safe to narrow.
137 server.middlewares.use(
138 compression({
139 brotli: { params: { [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 5 } },
140 }) as unknown as Connect.NextHandleFunction,
141 );
142 },
143 };
144}
145
146// Vite rejects requests whose Host header isn't localhost, which blocks sharing
147// a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale:
148// the DNS is controlled by tailscale, so they can't be rebound by an attacker.
149// Anything else (ngrok, a LAN IP alias) goes through the env var.
150const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "")
151 .split(",")
152 .map((entry) => entry.trim())
153 .filter((entry) => entry.length > 0);
154const allowedHosts = [".ts.net", ...configuredAllowedHosts];
155
156export default defineConfig(() => {
157 return {
158 assetsInclude: ["**/*.wasm"],
159 plugins: [
160 devCompressionPlugin(),
161 thirdPartyLicensesPlugin({
162 bundleName: "web",
163 configFile: new URL("../../third-party-licenses.config.json", import.meta.url),
164 packageManifests: [
165 { bundle: "web", path: new URL("./package.json", import.meta.url) },
166 { bundle: "server", path: new URL("../server/package.json", import.meta.url) },
167 { bundle: "desktop", path: new URL("../desktop/package.json", import.meta.url) },
168 ],
169 }),
170 // Route components load as split chunks so settings, pull-request, and
171 // usage code stay out of the cold-start payload; the router prefetches
172 // them on navigation intent (see getRouter's defaultPreload).
173 tanstackRouter({ autoCodeSplitting: true }),
174 react(),
175 babel({
176 // We need to be explicit about the parser options after moving to @vitejs/plugin-react v6.0.0
177 // This is because the babel plugin only automatically parses typescript and jsx based on relative paths (e.g. "**/*.ts")
178 // whereas the previous version of the plugin parsed all files with a .ts extension.
179 // This is causing our packages/ directory to fail to parse, as they are not relative to the CWD.
180 parserOpts: { plugins: ["typescript", "jsx"] },
181 presets: [reactCompilerPreset()],
182 }),
183 tailwindPlugins(bundledDev),
184 ],
185 optimizeDeps: {
186 include: [
187 "@clerk/clerk-js",
188 "@clerk/react/internal",
189 "@pierre/diffs",
190 "@pierre/diffs/edit",
191 "@pierre/diffs/react",
192 "@pierre/diffs/worker/worker.js",
193 "effect/Array",
194 "effect/Order",
195 "react-dom/client",
196 ],
197 },
198 define: {
199 // In dev mode, tell the web app where the WebSocket server lives
200 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
201 // Pinned explicitly rather than left to Vite's automatic VITE_ exposure:
202 // under single-origin dev this must stay empty even when a `.env`
203 // supplies it, so the client falls back to window.location.origin.
204 "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),
205 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),
206 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),
207 "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate),
208 "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify(
209 configuredClerkCliOAuthClientId,
210 ),
211 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
212 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
213 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
214 },
215 resolve: {
216 tsconfigPaths: true,
217 dedupe: ["react", "react-dom"],
218 },
219 experimental: {
220 bundledDev,
221 },
222 server: {
223 host,
224 port,
225 strictPort: true,
226 allowedHosts,
227 // Transform the whole module graph at server start instead of on the
228 // first request. Without this, a cold worktree discovers and transforms
229 // modules one import-level at a time while the browser waits — which
230 // over a tailnet origin turns into minutes of waterfall.
231 warmup: {
232 clientFiles: ["./src/main.tsx"],
233 },
234 ...(devProxyTarget
235 ? {
236 // One entry per shared prefix; the server's dev catch-all 404s the
237 // same list, so the two sides cannot drift. `/ws` is the app's own
238 // socket and `/api` carries the device hub's stream sockets —
239 // Vite's HMR socket is matched separately and exactly (path "/"
240 // plus a vite-hmr subprotocol), so the upgrade handlers don't
241 // collide.
242 proxy: Object.fromEntries(
243 DEV_PROXIED_PATH_PREFIXES.map((prefix) => [
244 prefix,
245 {
246 target: devProxyTarget,
247 changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix),
248 ...(prefix === "/ws" || prefix === "/api" || prefix === "/environments"
249 ? { ws: true }
250 : {}),
251 },
252 ]),
253 ),
254 }
255 : {}),
256 // Electron's BrowserWindow needs the HMR socket pinned to an explicit
257 // host to connect reliably; dev:desktop is the only mode that sets HOST.
258 // Everywhere else, leaving this unset lets the client derive it from the
259 // page origin, which is what makes HMR work over Tailscale/LAN instead of
260 // failing an attempt against the wrong machine's localhost first.
261 // (Vite 8 logs connection state via console.debug — enable "Verbose".)
262 ...(explicitHost
263 ? {
264 hmr: {
265 protocol: "ws",
266 host: explicitHost,
267 clientPort: port,
268 },
269 }
270 : {}),
271 },
272 // @tailwindcss/vite only emits a CSS sourcemap when devSourcemap is on; without it
273 // rolldown flags the transform as SOURCEMAP_BROKEN on every sourcemapped build.
274 css: {
275 devSourcemap: buildSourcemap !== false,
276 },
277 build: {
278 outDir: "dist",
279 emptyOutDir: true,
280 manifest: true,
281 sourcemap: buildSourcemap,
282 },
283 test: {
284 projects: [defineProject(unitTestProject)],
285 },
286 };
287});