import { sveltekit } from '@sveltejs/kit/vite' import { existsSync, readFileSync } from 'fs' import { fileURLToPath } from 'url' import { searchForWorkspaceRoot } from 'vite' import mkcert from 'vite-plugin-mkcert' const file = fileURLToPath(new URL('package.json', import.meta.url)) const json = readFileSync(file, 'utf8') const version = JSON.parse(json) // The postinstall downloads the pinned UI Builder artifact into static/ui_builder, // which SvelteKit serves at /ui_builder. Serve that directly; only proxy to a // live UI Builder dev server on :4000 when the bundle is absent (mirrors the // backend's static-vs-:4000 fallback). Delete static/ui_builder to develop the // builder against :4000. const uiBuilderStaticPresent = existsSync( fileURLToPath(new URL('static/ui_builder/app-preview.html', import.meta.url)) ) /** * Fail the build if the emitted chunks import each other in a cycle. * * A chunk's imports all evaluate before its own body, so in a cycle one member runs * while another has initialized nothing: its `const`/`class` exports read as * `undefined` and the app dies on load with "X is not a constructor" behind * SvelteKit's default 500 page. The module graph being acyclic is not enough — * chunk *grouping* alone can create one. See docs/frontend-import-cycles.md. */ function assertAcyclicChunks() { return { name: 'wm-assert-acyclic-chunks', generateBundle(_options, bundle) { const imports = new Map() for (const [file, chunk] of Object.entries(bundle)) { if (chunk.type === 'chunk') imports.set(file, chunk.imports ?? []) } const state = new Map() // file -> 'visiting' | 'done' const describe = (file) => { const chunk = bundle[file] const ids = chunk.moduleIds ?? Object.keys(chunk.modules ?? {}) const own = ids.filter((id) => id.includes('/src/')).map((id) => id.split('/src/')[1]) const shown = own.slice(0, 6).join(', ') return ` ${file}\n ${shown}${own.length > 6 ? `, +${own.length - 6} more` : ''}` } for (const root of imports.keys()) { if (state.get(root)) continue const stack = [[root, 0]] const path = [root] state.set(root, 'visiting') while (stack.length) { const frame = stack[stack.length - 1] const deps = imports.get(frame[0]) ?? [] if (frame[1] >= deps.length) { state.set(path.pop(), 'done') stack.pop() continue } const dep = deps[frame[1]++] if (state.get(dep) === 'visiting') { const cycle = path.slice(path.indexOf(dep)).concat(dep) this.error( `Cyclic chunk imports — this ships a runtime crash, see docs/frontend-import-cycles.md:\n` + cycle.map(describe).join('\n ->\n') ) } if (!state.has(dep) && imports.has(dep)) { state.set(dep, 'visiting') path.push(dep) stack.push([dep, 0]) } } } } } } /** * Fail the build if a public app URL statically loads the low-code runtime or monaco. * * These pages also serve raw apps, which only need a small shell around their bundle's * iframe. One static import of AppPreview (or of anything reaching monaco) makes every * public app page preload ~650 chunks instead of ~55. See loadAppPreview.ts. */ function assertLeanPublicAppRoutes() { // Route directories, so +page.js counts as well as +page.svelte. const routes = ['/src/routes/public/[workspace]/[...secret]/', '/src/routes/a/[...path]/'] const forbidden = [ '/src/lib/components/apps/editor/AppPreview.svelte', '/node_modules/monaco-editor/' ] return { name: 'wm-assert-lean-public-app-routes', generateBundle(_options, bundle) { const chunks = Object.entries(bundle).filter(([, c]) => c.type === 'chunk') if (!chunks.some(([file]) => file.startsWith('_app/immutable/'))) return const idsOf = (c) => c.moduleIds ?? Object.keys(c.modules ?? {}) for (const route of routes) { const starts = chunks .filter(([, c]) => idsOf(c).some((id) => id.includes(route))) .map(([file]) => file) if (!starts.length) this.error(`No chunk contains ${route}; update assertLeanPublicAppRoutes`) const seen = new Set(starts) const queue = [...starts] while (queue.length) { const chunk = bundle[queue.shift()] if (!chunk) continue const hit = idsOf(chunk).find((id) => forbidden.some((f) => id.includes(f))) if (hit) { this.error(`${route} statically loads ${hit}; import it lazily (see loadAppPreview.ts)`) } for (const dep of chunk.imports ?? []) { if (seen.has(dep)) continue seen.add(dep) queue.push(dep) } } } } } } const remoteUrl = process.env.REMOTE ?? (process.env.BACKEND_PORT ? `http://localhost:${process.env.BACKEND_PORT}` : 'https://app.windmill.dev/') // Browsers scope cookies by host, not port, so dev servers sharing a host (one per // worktree) would share one `token` and each login would sign the others out. The session // is stored under a name tied to its backend and forwarded as `token`, the name the backend // reads. ISOLATE_DEV_AUTH=0 keeps the shared `token` for tools reading it across instances. const isolateDevAuth = process.env.ISOLATE_DEV_AUTH !== '0' const authCookie = `token_${new URL(remoteUrl).host.replace(/\W/g, '_')}` // Isolated cookies are host-only: `Domain=localhost` is rejected when the dev server is // reached as 127.0.0.1 or over the network. const cookieDomain = isolateDevAuth ? '' : 'localhost' function isolateAuthCookie(proxy) { if (!isolateDevAuth) return proxy.on('proxyReq', (proxyReq, req) => { const kept = [] let session for (const cookie of (req.headers.cookie ?? '').split(/;\s*/)) { if (cookie.startsWith(`${authCookie}=`)) session = cookie.slice(authCookie.length + 1) else if (cookie && !cookie.startsWith('token=')) kept.push(cookie) } if (session !== undefined) kept.push(`token=${session}`) if (kept.length) proxyReq.setHeader('cookie', kept.join('; ')) else proxyReq.removeHeader('cookie') }) proxy.on('proxyRes', (proxyRes) => { const setCookie = proxyRes.headers['set-cookie'] if (setCookie) { proxyRes.headers['set-cookie'] = setCookie.map((c) => c.replace(/^token=/, `${authCookie}=`)) } }) } // Cross-origin isolation headers, scoped to mirror the production predicate — // see `needs_cross_origin_isolation` in backend/windmill-api/src/static_assets.rs // for which paths need them and why the raw app viewer must be excluded. // `enforce: 'pre'` so these headers are set before SvelteKit's sirv static // handler serves `static/` files and ends the response without calling next(). function needsCrossOriginIsolation(url) { const [path, query = ''] = url.split('?') return ( path.startsWith('/apps_raw/edit') || path.startsWith('/apps_raw/add') || path.startsWith('/ui_builder/') || ((path.startsWith('/public/') || path.startsWith('/a/')) && new URLSearchParams(query).has('wm_coep')) ) } let plugin = { name: 'configure-response-headers', enforce: 'pre', configureServer: (server) => { server.middlewares.use((req, res, next) => { if (needsCrossOriginIsolation(req.url ?? '')) { res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp') } // CORP on everything so dev assets stay loadable as subresources of // isolated documents on other dev origins (e.g. 127.0.0.1 vs localhost). res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin') next() }) } } /** @type {import('vite').UserConfig} */ const config = { server: { https: process.env.HTTPS === 'true', allowedHosts: [ 'localhost', '127.0.0.1', '0.0.0.0', 'rubendev.wimill.xyz', 'windmill.xyz', 'app.windmill.xyz', 'public.windmill.xyz' ], port: parseInt(process.env.FRONTEND_PORT) || 3000, cors: { origin: '*' }, // `windmill-chat` (svelte.config.js alias) lives outside the frontend root. fs: { allow: [ searchForWorkspaceRoot(process.cwd()), fileURLToPath(new URL('../chat-sdk', import.meta.url)) ] }, proxy: { '^/\\.well-known/.*': { target: remoteUrl, changeOrigin: true, cookieDomainRewrite: cookieDomain, configure: isolateAuthCookie }, '^/api/w/[^/]+/s3_proxy/.*': { target: remoteUrl, changeOrigin: false, // Important for signature to be correct cookieDomainRewrite: cookieDomain, configure: (proxy, options) => { isolateAuthCookie(proxy) proxy.on('proxyReq', (proxyReq, req, res) => { // Prevent collapsing slashes during URL normalization const originalPath = req.url proxyReq.path = originalPath }) } }, '^/api/.*': { target: remoteUrl, changeOrigin: true, cookieDomainRewrite: cookieDomain, configure: isolateAuthCookie }, '^/ws/.*': { target: process.env.REMOTE_LSP ?? process.env.REMOTE_EXTRA ?? 'https://app.windmill.dev', changeOrigin: true, ws: true }, '^/ws_mp/.*': { target: process.env.REMOTE_MP ?? process.env.REMOTE_EXTRA ?? 'https://app.windmill.dev', changeOrigin: true, ws: true }, '^/ws_debug/.*': { target: process.env.REMOTE_DEBUG ?? process.env.REMOTE_EXTRA ?? 'https://app.windmill.dev', changeOrigin: true, ws: true }, ...(uiBuilderStaticPresent ? {} : { '^/ui_builder/.*': { target: 'http://localhost:4000', changeOrigin: true, headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Resource-Policy': 'cross-origin' } } }) } }, preview: { port: 3001 }, plugins: [ sveltekit(), ...(process.env.HTTPS === 'true' ? [mkcert()] : []), plugin, assertAcyclicChunks(), assertLeanPublicAppRoutes() ], define: { __pkg__: version }, optimizeDeps: { include: ['highlight.js', 'highlight.js/lib/core', 'monaco-vim'], exclude: [ '@codingame/monaco-vscode-standalone-typescript-language-features', '@codingame/monaco-vscode-standalone-languages', 'windmill-client' ] }, worker: { format: 'es' }, resolve: { alias: { path: 'path-browserify', 'monaco-editor/esm/vs/editor/contrib/hover/browser/hover': 'monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution' }, dedupe: ['vscode', 'monaco-editor'] }, assetsInclude: ['**/*.wasm'], build: { rollupOptions: { output: { // src/lib/gen is a self-contained generated client and a leaf of the module // graph. Left alone the bundler splits it — index.ts in one chunk, the // 12k-line schemas.gen.ts in another beside app code that imports back — // which makes the *chunk* graph cyclic where the module graph is not, and // modules then evaluate against uninitialized bindings. See // docs/frontend-import-cycles.md. advancedChunks: { groups: [{ name: 'gen', test: /[\\/]src[\\/]lib[\\/]gen[\\/]/ }] } } } }, test: { expect: { requireAssertions: true }, projects: [ { extends: './vite.config.js', test: { name: 'server', environment: 'node', include: ['src/**/*.{test,spec}.{js,ts}'], exclude: ['src/**/*.svelte.{test,spec}.{js,ts}', 'src/**/*.dom.{test,spec}.{js,ts}'], setupFiles: ['src/lib/test-setup.ts'] } }, { // `*.dom.test.ts` — for the pure DOM utilities (snapshot serialization, // replay sanitization) whose contracts can only be asserted against a // real document. extends: './vite.config.js', test: { name: 'dom', environment: 'jsdom', include: ['src/**/*.dom.{test,spec}.{js,ts}'] } } ] } } export default config