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  });