Skip to content
Source
Sign inSign up

vite.config.ts

441 lines16.3 KB

1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Vite config runs before any Effect runtime.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeZlib from "node:zlib";
4​
5import react from "@vitejs/plugin-react";
6import { tanstackRouter } from "@tanstack/router-plugin/vite";
7import compression from "compression";
8import { playwright } from "vite-plus/test/browser-playwright";
9import { defineProject, type TestProjectInlineConfiguration } from "vite-plus/test/config";
10import "vite-plus/test/config";
11import { defineConfig, type Connect, type Plugin } from "vite-plus";
12import * as Hash from "effect/Hash";
13import pkg from "./package.json" with { type: "json" };
14import pdfjsPkg from "pdfjs-dist/package.json" with { type: "json" };
15​
16import {
17 DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES,
18 DEV_PROXIED_PATH_PREFIXES,
19} from "@t3tools/shared/devProxy";
20​
21import { loadRepoEnv } from "../../scripts/lib/public-config";
22import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses";
23import { tailwindPlugins } from "./vite/tailwind";
24import { warmBundledDevPlugin } from "./vite/warmBundledDev";
25import { formatAppDisplayName, resolveWebAppStageLabel } from "./src/branding.logic";
26​
27const repoEnv = loadRepoEnv();
28Object.assign(process.env, repoEnv);
29​
30// Signal single-origin dev explicitly; loadRepoEnv can restore deleted URL keys from .env.
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 configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || "";
43const configuredRelayTracingDataset = repoEnv.VITE_RELAY_OTLP_TRACES_DATASET?.trim() || "";
44const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim() || "";
45const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
46const configuredReleaseChannel =
47 process.env.T3CODE_BUILT_DEV === "1" ? "dev" : process.env.VITE_RELEASE_CHANNEL?.trim() || "";
48const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
49const configuredDesktopUpdateRepository =
50 process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() ||
51 process.env.GITHUB_REPOSITORY?.trim() ||
52 "";
53const configuredFeatureSecrets = process.env.T3CODE_FEATURE_SECRETS?.trim() || "";
54const configuredHostedAppUrl = (() => {
55 const explicitHostedAppUrl = process.env.VITE_HOSTED_APP_URL?.trim();
56 if (explicitHostedAppUrl) {
57 return explicitHostedAppUrl;
58 }
59 if (process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL) {
60 return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
61 }
62 if (process.env.VERCEL_URL) {
63 return `https://${process.env.VERCEL_URL}`;
64 }
65 return undefined;
66})();
67const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
68​
69// `vp run dev --share --built` rebuilds on every change. The license manifest
70// is about 2 s of each build, and nothing in that mode reads it.
71const builtDev = process.env.T3CODE_BUILT_DEV === "1";
72​
73const bundledDevEnv = process.env.T3CODE_BUNDLED_DEV?.trim().toLowerCase();
74const bundledDev = bundledDevEnv === "1" || bundledDevEnv === "true";
75​
76// Bundled dev and `build --watch` pass Rolldown `exclude` but not Vite's default `ignored`, so
77// they watch node_modules. Those files are pnpm-store hardlinks shared by every worktree, so each
78// `vp i` or worktree removal elsewhere rebuilds every long-running server and leaks native memory.
79const rolldownWatchExclude = [/[\\/]node_modules[\\/]/];
80​
81const buildSourcemap: boolean | "hidden" =
82 sourcemapEnv === "0" || sourcemapEnv === "false"
83 ? false
84 : sourcemapEnv === "hidden"
85 ? "hidden"
86 : true;
87​
88const unitTestProject = {
89 extends: true,
90 test: {
91 name: "unit",
92 include: ["src/**/*.test.{ts,tsx}"],
93 exclude: ["src/**/*.browser.test.{ts,tsx}"],
94 hookTimeout: 15_000,
95 testTimeout: 15_000,
96 setupFiles: [
97 "../../packages/shared/src/testing/longTempDir.ts",
98 "../../packages/shared/src/testing/providerEnv.ts",
99 ],
100 },
101} satisfies TestProjectInlineConfiguration;
102​
103// Hash checkout paths to ports 61000-64999; Vitest's fixed port collides under inherited strictPort.
104const browserTestApiPort = 61_000 + ((Hash.string(import.meta.dirname) >>> 0) % 4_000);
105​
106const browserTestProject = {
107 extends: true,
108 // Pre-bundle lazy browser and worker dependencies to avoid mid-run reloads.
109 optimizeDeps: {
110 include: [
111 "vite-plus/test/browser",
112 "@base-ui/react/autocomplete",
113 "saxes",
114 "@betteroffice/docx/editor",
115 "@betteroffice/docx/layout",
116 "@betteroffice/docx/layout/render",
117 "@betteroffice/docx/yrs",
118 "@betteroffice/fonts",
119 "@betteroffice/pptx",
120 "@betteroffice/xlsx",
121 "@betteroffice/xlsx/headless",
122 "fast-xml-parser",
123 "entities",
124 "dompurify",
125 "heic-to/next",
126 "jszip",
127 "pdfjs-dist",
128 "sql.js",
129 "tiff",
130 ],
131 },
132 test: {
133 name: "browser",
134 include: ["src/**/*.browser.test.{ts,tsx}"],
135 hookTimeout: 15_000,
136 testTimeout: 15_000,
137 setupFiles: ["./test/browserSetup.ts"],
138 api: { port: browserTestApiPort, strictPort: false },
139 browser: {
140 enabled: true,
141 headless: true,
142 provider: playwright(),
143 instances: [{ browser: "chromium" }],
144 screenshotFailures: false,
145 commands: {
146 // Playwright's mouse outlives each test, so whatever mounts under its last
147 // position starts hovered. test/browserSetup.ts parks it off the page before each test.
148 resetPointer: async ({ page }) => {
149 await page.mouse.move(-1, -1);
150 },
151 },
152 },
153 },
154} satisfies TestProjectInlineConfiguration;
155​
156// Render scenarios count React commits; they run only through scripts/render-metrics.ts,
157// which reads the per-scenario reports this command writes.
158const renderScenariosProject = {
159 ...browserTestProject,
160 test: {
161 ...browserTestProject.test,
162 name: "renders",
163 include: ["test/*.renders.tsx"],
164 // App scenarios mount the whole app per test, which outlasts 15s on a loaded machine.
165 hookTimeout: 60_000,
166 testTimeout: 60_000,
167 setupFiles: ["./test/renderCounter.ts", ...browserTestProject.test.setupFiles],
168 browser: {
169 ...browserTestProject.test.browser,
170 // Vitest names each instance in place, so a shared array would clash with the browser project.
171 instances: [{ browser: "chromium" }],
172 commands: {
173 ...browserTestProject.test.browser.commands,
174 writeRenderReport: async (_context, scenario: string, json: string) => {
175 const directory = process.env.T3CODE_RENDER_REPORT_DIR;
176 if (!directory) throw new Error("T3CODE_RENDER_REPORT_DIR is not set");
177 await NodeFSP.writeFile(`${directory}/${scenario}.json`, json);
178 },
179 },
180 },
181 },
182} satisfies TestProjectInlineConfiguration;
183​
184function resolveDevProxyTarget(
185 backendPort: string | undefined,
186 wsUrl: string | undefined,
187): string | undefined {
188 const port = Number(backendPort?.trim());
189 if (Number.isInteger(port) && port > 0) {
190 return `http://localhost:${port}/`;
191 }
192​
193 // dev:desktop connects directly to the backend; derive its target from the explicit WebSocket URL.
194 if (!wsUrl) {
195 return undefined;
196 }
197​
198 try {
199 const url = new URL(wsUrl);
200 if (url.protocol === "ws:") {
201 url.protocol = "http:";
202 } else if (url.protocol === "wss:") {
203 url.protocol = "https:";
204 }
205 url.pathname = "";
206 url.search = "";
207 url.hash = "";
208 return url.toString();
209 } catch {
210 return undefined;
211 }
213​
214const devProxyTarget = resolveDevProxyTarget(process.env.T3CODE_PORT, configuredWsUrl);
215​
216// Brotli quality 5 encodes in hundreds of ms; default quality 11 would replace transfer stalls with encode stalls.
217function devCompressionPlugin(): Plugin {
218 return {
219 name: "t3code:dev-compression",
220 apply: "serve",
221 configureServer(server) {
222 server.middlewares.use(
223 compression({
224 brotli: { params: { [NodeZlib.constants.BROTLI_PARAM_QUALITY]: 5 } },
225 }) as unknown as Connect.NextHandleFunction,
226 );
227 },
228 };
230​
231// A static import cycle between chunks can run a chunk before the values it imports exist,
232// which blanks the app at boot. Fail the build instead of shipping it.
233function chunkCycleGuardPlugin(): Plugin {
234 return {
235 name: "t3code:chunk-cycle-guard",
236 apply: "build",
237 generateBundle(_options, bundle) {
238 const done = new Set<string>();
239 const visit = (fileName: string, path: string[]) => {
240 if (done.has(fileName)) return;
241 const start = path.indexOf(fileName);
242 if (start !== -1) {
243 this.error(`Chunk import cycle: ${[...path.slice(start), fileName].join(" -> ")}`);
244 }
245 const chunk = bundle[fileName];
246 if (chunk?.type !== "chunk") return;
247 for (const imported of chunk.imports) visit(imported, [...path, fileName]);
248 done.add(fileName);
249 };
250 for (const fileName of Object.keys(bundle)) visit(fileName, []);
251 },
252 };
254​
255// Bundled dev never evicts emitted files from memory (vitejs/vite#23781), so hashed names keep
256// a copy of every changed chunk from every rebuild. Fixed names overwrite the previous copy.
257function bundledDevStableNamesPlugin(): Plugin {
258 return {
259 name: "t3code:bundled-dev-stable-names",
260 apply: "serve",
261 outputOptions: (options) => ({
262 ...options,
263 chunkFileNames: "assets/[name].js",
264 assetFileNames: "assets/[name][extname]",
265 }),
266 };
268​
269// Stamps index.html's title with the name branding.ts resolves once the app
270// loads, so a tab opens on the right channel instead of flashing another's.
271// Desktop swaps in its injected branding from an inline script in index.html.
272function bootstrapTitlePlugin(): Plugin {
273 let development = false;
274 return {
275 name: "t3code:bootstrap-title",
276 configResolved(config) {
277 // The same flag Vite exposes to the app as import.meta.env.DEV.
278 development = !config.isProduction;
279 },
280 transformIndexHtml(html) {
281 const stageLabel = resolveWebAppStageLabel({
282 hostedAppChannel: configuredHostedAppChannel,
283 development,
284 });
285 const title = formatAppDisplayName({ baseName: "T3 Code", stageLabel });
286 return html.replace(/<title>[^<]*<\/title>/, `<title>${title}</title>`);
287 },
288 };
290​
291// Allow Tailnet hosts for sharing; Tailscale-controlled DNS prevents attacker rebinding.
292const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "")
293 .split(",")
294 .map((entry) => entry.trim())
295 .filter((entry) => entry.length > 0);
296const allowedHosts = [".ts.net", ...configuredAllowedHosts];
297​
298export default defineConfig(() => {
299 return {
300 run: {
301 tasks: {
302 // A local tsbuildinfo is leftover build state, not a typecheck input.
303 typecheck: {
304 command: "effect-tsgo tsrs --noEmit",
305 cache: { input: [{ auto: true }, "!*.tsbuildinfo"] },
306 },
307 },
308 },
309 assetsInclude: ["**/*.wasm"],
310 plugins: [
311 bootstrapTitlePlugin(),
312 devCompressionPlugin(),
313 chunkCycleGuardPlugin(),
314 builtDev
315 ? undefined
316 : thirdPartyLicensesPlugin({
317 bundleName: "web",
318 configFile: new URL("../../third-party-licenses.config.json", import.meta.url),
319 packageManifests: [
320 { bundle: "web", path: new URL("./package.json", import.meta.url) },
321 { bundle: "server", path: new URL("../server/package.json", import.meta.url) },
322 { bundle: "desktop", path: new URL("../desktop/package.json", import.meta.url) },
323 ],
324 }),
325 tanstackRouter({ autoCodeSplitting: true }),
326 react({ compiler: true }),
327 tailwindPlugins(bundledDev),
328 warmBundledDevPlugin(),
329 bundledDev ? bundledDevStableNamesPlugin() : undefined,
330 ],
331 optimizeDeps: {
332 include: [
333 "@clerk/clerk-js",
334 "@clerk/react/internal",
335 "@pierre/diffs",
336 "@pierre/diffs/edit",
337 "@pierre/diffs/react",
338 "@pierre/diffs/worker/worker.js",
339 "effect/Array",
340 "effect/Order",
341 "react-dom/client",
342 ],
343 },
344 define: {
345 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
346 // Override .env URLs with empty values in single-origin dev so clients use window.location.origin.
347 "import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),
348 "import.meta.env.VITE_T3CODE_RELAY_URL": JSON.stringify(configuredRelayUrl),
349 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),
350 "import.meta.env.VITE_CLERK_JWT_TEMPLATE": JSON.stringify(configuredClerkJwtTemplate),
351 "import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID": JSON.stringify(
352 configuredClerkCliOAuthClientId,
353 ),
354 "import.meta.env.VITE_RELAY_OTLP_TRACES_URL": JSON.stringify(configuredRelayTracingUrl),
355 "import.meta.env.VITE_RELAY_OTLP_TRACES_DATASET": JSON.stringify(
356 configuredRelayTracingDataset,
357 ),
358 "import.meta.env.VITE_RELAY_OTLP_TRACES_TOKEN": JSON.stringify(configuredRelayTracingToken),
359 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
360 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
361 "import.meta.env.T3CODE_DESKTOP_UPDATE_REPOSITORY": JSON.stringify(
362 configuredDesktopUpdateRepository,
363 ),
364 "import.meta.env.T3CODE_FEATURE_SECRETS": JSON.stringify(configuredFeatureSecrets),
365 "import.meta.env.VITE_RELEASE_CHANNEL": JSON.stringify(configuredReleaseChannel),
366 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
367 },
368 resolve: {
369 tsconfigPaths: true,
370 dedupe: ["react", "react-dom"],
371 },
372 experimental: {
373 bundledDev,
374 },
375 server: {
376 host,
377 port,
378 strictPort: true,
379 allowedHosts,
380 // Warm the module graph before first request to avoid serial import transforms over tailnet.
381 // Bundled dev ignores this; warmBundledDevPlugin handles it there.
382 warmup: {
383 clientFiles: ["./src/main.tsx"],
384 },
385 watch: { exclude: rolldownWatchExclude },
386 ...(devProxyTarget
387 ? {
388 // Share prefixes with the backend catch-all so the proxy and 404 lists cannot drift.
389 proxy: Object.fromEntries(
390 DEV_PROXIED_PATH_PREFIXES.map((prefix) => [
391 prefix,
392 {
393 target: devProxyTarget,
394 changeOrigin: !DEV_PROXIED_ORIGIN_PRESERVING_PREFIXES.has(prefix),
395 ...(prefix === "/ws" || prefix === "/api" ? { ws: true } : {}),
396 },
397 ]),
398 ),
399 }
400 : {}),
401 // Electron needs an explicit HMR host; elsewhere derive it from page origin for Tailscale/LAN.
402 ...(explicitHost
403 ? {
404 hmr: {
405 protocol: "ws",
406 host: explicitHost,
407 clientPort: port,
408 },
409 }
410 : {}),
411 },
412 // Tailwind needs devSourcemap to avoid Rolldown SOURCEMAP_BROKEN on sourcemapped builds.
413 css: {
414 devSourcemap: buildSourcemap !== false,
415 },
416 build: {
417 outDir: "dist",
418 emptyOutDir: true,
419 manifest: true,
420 sourcemap: buildSourcemap,
421 ...(builtDev ? { watch: { exclude: rolldownWatchExclude } } : {}),
422 rolldownOptions: {
423 output: {
424 // PDF.js resolves decoder modules and ICC data relative to wasmUrl.
425 assetFileNames: (asset) =>
426 asset.originalFileNames.some((path) => /[/\\]pdfjs-dist[/\\]wasm[/\\]/.test(path))
427 ? `assets/pdfjs-${pdfjsPkg.version}/[name][extname]`
428 : "assets/[name]-[hash][extname]",
429 },
430 },
431 },
432 worker: { format: "es" },
433 test: {
434 projects: [
435 defineProject(unitTestProject),
436 defineProject(browserTestProject),
437 defineProject(renderScenariosProject),
438 ],
439 },
440 };
441});