go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/vite.config.ts (about) 1 // Copyright 2023 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import * as fs from 'fs'; 16 import * as path from 'path'; 17 18 import replace from '@rollup/plugin-replace'; 19 import react from '@vitejs/plugin-react'; 20 import { Plugin, defineConfig, loadEnv } from 'vite'; 21 import { VitePWA as vitePWA } from 'vite-plugin-pwa'; 22 import tsconfigPaths from 'vite-tsconfig-paths'; 23 24 import { AuthState, msToExpire } from './src/common/api/auth_state'; 25 import { regexpsForRoutes } from './src/generic_libs/tools/react_router_utils'; 26 import { routes } from './src/routes'; 27 28 /** 29 * Get a boolean from the envDir. 30 * 31 * Return true/false if the value matches 'true'/'false' (case sensitive). 32 * Otherwise, return null. 33 */ 34 function getBoolEnv( 35 envDir: Record<string, string>, 36 key: string, 37 ): boolean | null { 38 const value = envDir[key]; 39 if (value === 'true') { 40 return true; 41 } 42 if (value === 'false') { 43 return false; 44 } 45 return null; 46 } 47 48 /** 49 * Construct a `configs.js` file from environment variables. 50 */ 51 function getLocalDevConfigsJs(env: Record<string, string>) { 52 const localVersion = env['VITE_MILO_VERSION']; 53 const localSettings: typeof SETTINGS = { 54 buildbucket: { 55 host: env['VITE_BUILDBUCKET_HOST'], 56 }, 57 swarming: { 58 defaultHost: env['VITE_SWARMING_DEFAULT_HOST'], 59 }, 60 resultdb: { 61 host: env['VITE_RESULT_DB_HOST'], 62 }, 63 luciAnalysis: { 64 host: env['VITE_LUCI_ANALYSIS_HOST'], 65 }, 66 luciBisection: { 67 host: env['VITE_LUCI_BISECTION_HOST'], 68 }, 69 luciNotify: { 70 host: env['VITE_LUCI_NOTIFY_HOST'], 71 }, 72 sheriffOMatic: { 73 host: env['VITE_SHERIFF_O_MATIC_HOST'], 74 }, 75 luciTreeStatus: { 76 host: env['VITE_TREE_STATUS_HOST'], 77 }, 78 }; 79 80 const localDevConfigsJs = 81 `self.VERSION = '${localVersion}';\n` + 82 `self.SETTINGS = Object.freeze(${JSON.stringify( 83 localSettings, 84 undefined, 85 2, 86 )});\n`; 87 88 return localDevConfigsJs; 89 } 90 91 /** 92 * Get a virtual-configs-js plugin so we can import configs.js in the service 93 * workers with the correct syntax required by different environments. 94 */ 95 function getVirtualConfigsJsPlugin( 96 mode: string, 97 configsContent: string, 98 ): Plugin { 99 return { 100 name: 'virtual-configs-js', 101 resolveId: (id, importer) => { 102 if (id !== 'virtual:configs.js') { 103 return null; 104 } 105 106 // `importScripts` is only available in workers. 107 // Ensure this module is only used by service workers. 108 if ( 109 !['src/sw/root_sw.ts', 'src/sw/ui_sw.ts'] 110 .map((p) => path.join(__dirname, p)) 111 .includes(importer || '') 112 ) { 113 throw new Error( 114 'virtual:configs.js should only be imported by a service worker script.', 115 ); 116 } 117 return '\0virtual:config.js'; 118 }, 119 load: (id) => { 120 if (id !== '\0virtual:config.js') { 121 return null; 122 } 123 // During development, the service worker script can only be a JS module, 124 // because it runs through the same pipeline as the rest of the scripts. 125 // It cannot use the `importScripts`. So we inject the configs directly. 126 // In production, the service worker script cannot be a JS module. Because 127 // that has limited browser support. So we need to use `importScripts` 128 // instead of `import` to load `/configs.js`. 129 return mode === 'development' 130 ? configsContent 131 : "importScripts('/configs.js');"; 132 }, 133 }; 134 } 135 136 export default defineConfig(({ mode }) => { 137 const env = loadEnv(mode, process.cwd()); 138 139 const localDevConfigsJs = getLocalDevConfigsJs(env); 140 const virtualConfigJs = getVirtualConfigsJsPlugin(mode, localDevConfigsJs); 141 142 return { 143 base: '/ui', 144 build: { 145 outDir: 'out', 146 assetsDir: 'immutable', 147 sourcemap: true, 148 rollupOptions: { 149 input: { 150 index: 'index.html', 151 root_sw: './src/sw/root_sw.ts', 152 }, 153 output: { 154 // 'root_sw' needs to be referenced by URL therefore cannot have a 155 // hash in its filename. 156 entryFileNames: (chunkInfo) => 157 chunkInfo.name === 'root_sw' 158 ? '[name].js' 159 : 'immutable/[name]-[hash:8].js', 160 }, 161 }, 162 // Set to 8 MB to silence warnings. The files are cached by service worker 163 // anyway. Large chunks won't hurt much. 164 chunkSizeWarningLimit: Math.pow(2, 13), 165 }, 166 assetsInclude: ['RELEASE_NOTES.md'], 167 plugins: [ 168 replace({ 169 preventAssignment: true, 170 // We have different building pipeline for dev/test/production builds. 171 // Limits the impact of the pipeline-specific features to only the 172 // entry files for better consistently across different builds. 173 include: ['./src/main.tsx'], 174 values: { 175 ENABLE_UI_SW: JSON.stringify( 176 getBoolEnv(env, 'VITE_ENABLE_UI_SW') ?? true, 177 ), 178 }, 179 }), 180 virtualConfigJs, 181 { 182 name: 'inject-configs-js-in-html', 183 // Vite resolves external resources with relative URLs (URLs without a 184 // domain name) inconsistently. 185 // Inject `<script src="/configs.js" ><script>` via a plugin to prevent 186 // Vite from conditionally prepending "/ui" prefix onto the URL. 187 transformIndexHtml: (html) => ({ 188 html, 189 tags: [ 190 { 191 tag: 'script', 192 attrs: { 193 src: '/configs.js', 194 }, 195 injectTo: 'head-prepend', 196 }, 197 ], 198 }), 199 }, 200 { 201 // We cannot implement this as a virtual module or a replace variable 202 // plugin because we need all the modules to be generated. 203 name: 'preload-modules-handle', 204 transformIndexHtml: (html, ctx) => { 205 const jsChunks = Object.keys(ctx.bundle || {}) 206 // Prefetch all JS files so users are less likely to run into errors 207 // when navigating between views after a new LUCI UI version is 208 // deployed. 209 .filter((name) => name.match(/^immutable\/.+\.js$/)) 210 // Don't need to prefetch the entry file since it's loaded as a 211 // script tag already. 212 .filter((name) => name !== ctx.chunk?.fileName) 213 .map((name) => `/ui/${name}`); 214 215 // Sort the chunks to ensure the generated prefetch tags are 216 // deterministic. 217 jsChunks.sort(); 218 219 const preloadScript = ` 220 function preloadModules() { 221 ${jsChunks.map((c) => `import('${c}');\n`).join('')} 222 } 223 `; 224 225 return { 226 html, 227 tags: [ 228 { 229 tag: 'script', 230 children: preloadScript, 231 injectTo: 'head', 232 }, 233 ], 234 }; 235 }, 236 }, 237 { 238 name: 'dev-server', 239 configureServer: (server) => { 240 // Serve `/root_sw.js` 241 server.middlewares.use((req, _res, next) => { 242 if (req.url === '/root_sw.js') { 243 req.url = '/ui/src/sw/root_sw.ts'; 244 } 245 return next(); 246 }); 247 248 // Serve `/configs.js` during development. 249 // We don't want to define `SETTINGS` directly because that would 250 // prevent us from testing the service worker's `GET '/configs.js'` 251 // handler. 252 server.middlewares.use((req, res, next) => { 253 if (req.url !== '/configs.js') { 254 return next(); 255 } 256 res.setHeader('content-type', 'application/javascript'); 257 res.end(localDevConfigsJs); 258 }); 259 260 // Return a predefined auth state object if a valid 261 // `auth_state.local.json` exists. 262 server.middlewares.use((req, res, next) => { 263 if (req.url !== '/auth/openid/state') { 264 return next(); 265 } 266 267 let authState: AuthState; 268 try { 269 authState = JSON.parse( 270 fs.readFileSync('auth_state.local.json', 'utf8'), 271 ); 272 if (msToExpire(authState) < 10000) { 273 return next(); 274 } 275 } catch (_e) { 276 return next(); 277 } 278 279 res.setHeader('content-type', 'application/json'); 280 res.end(JSON.stringify(authState)); 281 }); 282 283 // When VitePWA is enabled in -dev mode, the entry files are somehow 284 // resolved to the wrong paths. We need to remap them to the correct 285 // paths. 286 server.middlewares.use((req, _res, next) => { 287 if (req.url === '/ui/src/index.tsx') { 288 req.url = '/ui/src/main.tsx'; 289 } 290 if (req.url === '/ui/src/styles/style.css') { 291 req.url = '/ui/src/common/styles/style.css'; 292 } 293 return next(); 294 }); 295 }, 296 }, 297 react({ 298 babel: { 299 configFile: true, 300 }, 301 }), 302 // Support custom path mapping declared in tsconfig.json. 303 tsconfigPaths(), 304 // Needed to add workbox powered service workers, which enables us to 305 // implement some cache strategy that's not possible with regular HTTP 306 // cache due to dynamic URLs. 307 vitePWA({ 308 injectRegister: null, 309 // We cannot use the simpler 'generateSW' mode because 310 // 1. we need to inject custom scripts that import other files, and 311 // 2. in 'generateSW' mode, custom scripts can only be injected via 312 // `workbox.importScripts`, which doesn't support ES modules, and 313 // 3. in 'generateSW' mode, we need to build the custom script to be 314 // imported by the service worker as an entry file, which means the 315 // resulting bundle may contain ES import statements due to 316 // [vite/rollup not supporting disabling code splitting][1]. 317 // 318 // [1]: https://github.com/rollup/rollup/issues/2756 319 strategies: 'injectManifest', 320 srcDir: 'src/sw', 321 filename: 'ui_sw.ts', 322 outDir: 'out', 323 devOptions: { 324 enabled: true, 325 // During development, the service worker script can only be a JS 326 // module, because it runs through the same pipeline as the rest of 327 // the scripts, which produces ES modules. 328 type: 'module', 329 navigateFallback: 'index.html', 330 }, 331 injectManifest: { 332 globPatterns: ['**/*.{js,css,html,svg,png}'], 333 plugins: [ 334 replace({ 335 preventAssignment: true, 336 include: ['./src/sw/ui_sw.ts'], 337 values: { 338 // The build pipeline gets confused when the service worker 339 // depends on a lazy-loaded assets (which are used in `routes`). 340 // Computes the defined routes at build time to avoid polluting 341 // the service worker with unwanted dependencies. 342 DEFINED_ROUTES_REGEXP: JSON.stringify( 343 regexpsForRoutes([{ path: 'ui', children: routes }]).source, 344 ), 345 }, 346 }), 347 virtualConfigJs, 348 tsconfigPaths(), 349 ], 350 // Set to 8 MB. Some files might be larger than the default. 351 maximumFileSizeToCacheInBytes: Math.pow(2, 23), 352 }, 353 }), 354 ], 355 server: { 356 port: 8080, 357 strictPort: true, 358 // Proxy the queries to `self.location.host` to the configured milo server 359 // (typically https://luci-milo-dev.appspot.com) since we don't run the 360 // milo go server on the same host. 361 proxy: { 362 '^(?!/ui(/.*)?$)': { 363 target: env['VITE_MILO_URL'], 364 changeOrigin: true, 365 secure: false, 366 }, 367 }, 368 }, 369 }; 370 });