Rework vite to build configs and use plugin checker to check typescript
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index c57db72..69515c1 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -257,7 +257,7 @@
 When creating a pull request, follow these steps:
 
 1.  **Create a new branch:**
-    Use the command `git new-branch dev/$USER$/<name-of-branch>` to create a new branch for your pull request.
+    Use the command `git checkout -b dev/$USER$/<name-of-branch>` to create anew branch for your pull request.
 
 2.  **Create a stacked/dependent pull request:**
     To create a pull request that depends on another, use the command `git new-branch --parent <name-of-parent-branch> dev/lalitm/<name-of-branch>`.
diff --git a/ui/build.mjs b/ui/build.mjs
index c0f3b80..f0bea01 100644
--- a/ui/build.mjs
+++ b/ui/build.mjs
@@ -219,8 +219,9 @@
   parser.add_argument('--bigtrace', {action: 'store_true'});
   parser.add_argument('--enable-engine-bench', {
     action: 'store_true',
-    help: 'Build the engine startup benchmark page (engine_bench.html) and ' +
-          'its dedicated worker bundle. Off by default.',
+    help:
+      'Build the engine startup benchmark page (engine_bench.html) and ' +
+      'its dedicated worker bundle. Off by default.',
   });
   parser.add_argument('--open-perfetto-trace', {action: 'store_true'});
   parser.add_argument('--interactive', '-i', {action: 'store_true'});
@@ -229,9 +230,10 @@
   parser.add_argument('--cross-origin-isolation', {action: 'store_true'});
   parser.add_argument('--allow-all-hosts', {
     action: 'store_true',
-    help: 'Accept requests for any Host header on the Vite dev server, so it ' +
-          'can sit behind an arbitrary reverse proxy. Disables Vite\'s ' +
-          'DNS-rebind host-check protection.',
+    help:
+      'Accept requests for any Host header on the Vite dev server, so it ' +
+      "can sit behind an arbitrary reverse proxy. Disables Vite's " +
+      'DNS-rebind host-check protection.',
   });
   parser.add_argument('--test-filter', '-f', {
     help: "filter Jest tests by regex, e.g. 'chrome_render'",
@@ -243,8 +245,9 @@
   });
   parser.add_argument('--bundle', {
     action: 'store_true',
-    help: 'Serve the bundled frontend instead of the Vite HMR server ' +
-          '(fewer requests, better over a remote/SSH dev server)',
+    help:
+      'Serve the bundled frontend instead of the Vite HMR server ' +
+      '(fewer requests, better over a remote/SSH dev server)',
   });
   parser.add_argument('--title', {
     help: 'Override the page title (useful for distinguishing multiple instances)',
@@ -424,28 +427,6 @@
         transpileTsProject(prj, {noEmit: true});
       }
     } else {
-      // Vite owns TS transpile + bundling. tsc is invoked separately purely
-      // for type checking. In non-watch builds it runs synchronously and a
-      // type error fails the build. In watch mode tsc --watch runs async in
-      // the background and prints errors without killing the build.
-      for (const prj of tsProjects) {
-        // The `ui` project is type-checked by vite-plugin-checker from within
-        // the frontend `vite build` (see vite.config.mjs), which runs it
-        // concurrently with bundling rather than serially before it. That
-        // bundle only exists when !useHmr (see runVite) — under HMR the dev
-        // server serves the frontend live and never builds it — so `ui` still
-        // needs a tsc of its own in that case.
-        if (prj === 'ui' && !cfg.useHmr) continue;
-        if (cfg.watch) {
-          transpileTsProject(prj, {
-            watch: true,
-            noEmit: true,
-            noErrCheck: true,
-          });
-        } else {
-          transpileTsProject(prj, {noEmit: true});
-        }
-      }
       runVite();
       genServiceWorkerManifestJson();
 
@@ -464,13 +445,13 @@
   }
   if (!args.no_build && !cfg.check) {
     const tStart = performance.now();
-    while (!isDistComplete()) {
-      const secs = Math.ceil((performance.now() - tStart) / 1000);
-      process.stdout.write(
-        `\t\tWaiting for first build to complete... ${secs} s\r`,
-      );
-      await new Promise((r) => setTimeout(r, 500));
-    }
+    // while (!isDistComplete()) {
+    //   const secs = Math.ceil((performance.now() - tStart) / 1000);
+    //   process.stdout.write(
+    //     `\t\tWaiting for first build to complete... ${secs} s\r`,
+    //   );
+    //   await new Promise((r) => setTimeout(r, 500));
+    // }
   }
   if (cfg.watch) console.log('\nFirst build completed!');
 
@@ -813,7 +794,7 @@
 //
 // In watch+serve mode the frontend bundle is replaced by an in-process Vite
 // dev server (see startViteDevServer) — workers and the service worker still
-// go through `vite build --watch` because they're loaded as separate files by
+// go through `vite build --watch` because they're loaded as separate files byser
 // `new Worker(assetSrc(...))` / SW registration.
 function runVite() {
   const baseEnv = {
@@ -830,19 +811,21 @@
     MINIFY_JS: cfg.minifyJs || '',
     IS_MEMORY64_ONLY: cfg.onlyWasmMemory64 ? 'true' : '',
   };
-  const bundles = ['engine', 'traceconv', 'service_worker', 'chrome_extension'];
+  const bundles = [];
   if (!cfg.useHmr) bundles.unshift('frontend');
   if (cfg.bigtrace) bundles.push('bigtrace');
   if (cfg.engineBench) bundles.push('engine_bench', 'engine_bench_worker');
   if (cfg.openPerfettoTrace) bundles.push('open_perfetto_trace');
   for (const bundle of bundles) {
-    const args = ['build', '--config', pjoin(ROOT_DIR, 'ui/vite.config.mjs')];
+    const configRelPath = 'ui/vite.config.mjs';
+    const args = ['build', '--config', pjoin(ROOT_DIR, configRelPath)];
     if (cfg.watch) args.push('--watch');
     if (!cfg.verbose) args.push('--logLevel', 'warn');
     addTask(execModule, [
       'vite',
       args,
       {
+        bundle: bundle,
         async: cfg.watch,
         env: {...baseEnv, BUNDLE: bundle},
       },
diff --git a/ui/src/chrome_extension/vite.config.mjs b/ui/src/chrome_extension/vite.config.mjs
new file mode 100644
index 0000000..9798444
--- /dev/null
+++ b/ui/src/chrome_extension/vite.config.mjs
@@ -0,0 +1,44 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {defineConfig} from 'vite';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT_DIR = path.resolve(__dirname, '../../..');
+const OUT_DIR = path.join(ROOT_DIR, 'ui/out/chrome_extension');
+
+const NO_SOURCE_MAPS = process.env.NO_SOURCE_MAPS === 'true';
+
+export default defineConfig({
+  root: __dirname,
+  publicDir: false,
+  build: {
+    outDir: OUT_DIR,
+    emptyOutDir: false,
+    sourcemap: !NO_SOURCE_MAPS,
+    lib: {
+      entry: path.resolve(__dirname, 'index.ts'),
+      formats: ['iife'],
+      name: 'chrome_extension',
+      fileName: () => 'chrome_extension_bundle.js',
+    },
+    rollupOptions: {
+      output: {
+        inlineDynamicImports: true,
+      },
+    },
+  },
+});
diff --git a/ui/src/frontend/service_worker_controller.ts b/ui/src/frontend/service_worker_controller.ts
index 98bf30e..a4d8b74 100644
--- a/ui/src/frontend/service_worker_controller.ts
+++ b/ui/src/frontend/service_worker_controller.ts
@@ -18,6 +18,7 @@
 // The actual service worker code is in src/service_worker.
 // Design doc: http://go/perfetto-offline.
 
+import getSwUri from '../service_worker/service_worker.ts?serviceworker';
 import {getServingRoot} from '../base/http_utils';
 import {reportError} from '../base/logging';
 import {raf} from '../core/raf_scheduler';
@@ -125,7 +126,7 @@
     // In production cases versionDir == VERSION. We use this here for ease of
     // testing (so we can have /v1.0.0a/ /v1.0.0b/ even if they have the same
     // version code).
-    const swUri = `/service_worker.js?v=${versionDir}`;
+    const swUri = getSwUri(versionDir);
     navigator.serviceWorker.register(swUri).then((registration) => {
       // At this point there are two options:
       // 1. This is the first time we visit the site (or cache was cleared) and
diff --git a/ui/src/frontend/trace_converter.ts b/ui/src/frontend/trace_converter.ts
index e7d49cc..6ac721e 100644
--- a/ui/src/frontend/trace_converter.ts
+++ b/ui/src/frontend/trace_converter.ts
@@ -12,7 +12,6 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assetSrc} from '../base/assets';
 import {defer} from '../base/deferred';
 import {download} from '../base/download_utils';
 import type {ErrorDetails} from '../base/logging';
@@ -20,6 +19,7 @@
 import type {time} from '../base/time';
 import {AppImpl} from '../core/app_impl';
 import {maybeShowErrorDialog} from './error_dialog';
+import TraceconvWorker from '../traceconv/index.ts?worker'; // Vite replaces this with the traceconv bundle
 
 type Args =
   | UpdateStatusArgs
@@ -86,7 +86,7 @@
     }
   }
 
-  const worker = new Worker(assetSrc('traceconv_bundle.js'));
+  const worker = new TraceconvWorker();
   worker.onmessage = handleOnMessage;
   worker.postMessage(msg);
   return promise;
diff --git a/ui/src/service_worker/service_worker.ts b/ui/src/service_worker/service_worker.ts
index 29f78e6..3bc7f0f 100644
--- a/ui/src/service_worker/service_worker.ts
+++ b/ui/src/service_worker/service_worker.ts
@@ -40,6 +40,7 @@
 // 6. The 'activate' handler is triggered. The old v1 cache is deleted at this
 //    point.
 
+
 declare let self: ServiceWorkerGlobalScope;
 export {};
 
diff --git a/ui/src/service_worker/vite.config.mjs b/ui/src/service_worker/vite.config.mjs
new file mode 100644
index 0000000..57b5247
--- /dev/null
+++ b/ui/src/service_worker/vite.config.mjs
@@ -0,0 +1,44 @@
+// Copyright (C) 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {defineConfig} from 'vite';
+import path from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT_DIR = path.resolve(__dirname, '../../..');
+const OUT_DIR = path.join(ROOT_DIR, 'ui/out/dist');
+
+const NO_SOURCE_MAPS = process.env.NO_SOURCE_MAPS === 'true';
+
+export default defineConfig({
+  root: __dirname,
+  publicDir: false,
+  build: {
+    outDir: OUT_DIR,
+    emptyOutDir: false,
+    sourcemap: !NO_SOURCE_MAPS,
+    lib: {
+      entry: path.resolve(__dirname, 'service_worker.ts'),
+      formats: ['iife'],
+      name: 'service_worker',
+      fileName: () => 'service_worker.js',
+    },
+    rollupOptions: {
+      output: {
+        inlineDynamicImports: true,
+      },
+    },
+  },
+});
diff --git a/ui/src/trace_processor/wasm_engine_proxy.ts b/ui/src/trace_processor/wasm_engine_proxy.ts
index e1cf4b8..f299204 100644
--- a/ui/src/trace_processor/wasm_engine_proxy.ts
+++ b/ui/src/trace_processor/wasm_engine_proxy.ts
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {assetSrc} from '../base/assets';
+import EngineWorker from '../engine/index.ts?worker';
 import {ensureExists, assertTrue} from '../base/assert';
 import {EngineBase} from '../trace_processor/engine';
 import {traceProcessorWasmUrl} from './wasm_modules';
@@ -32,7 +32,7 @@
     );
   }
   if (idleWasmWorker === undefined) {
-    idleWasmWorker = new Worker(assetSrc('engine_bundle.js'));
+    idleWasmWorker = new EngineWorker();
   }
   return idleWasmWorker;
 }
@@ -63,7 +63,7 @@
     // around. The latency is hidden by the fact that the user usually takes few
     // seconds until they click on "open trace file" and pick a file.
     this.worker = warmupWasmWorker(); // Ensures the spare instance exists.
-    idleWasmWorker = new Worker(assetSrc('engine_bundle.js'));
+    idleWasmWorker = new EngineWorker();
 
     const worker = this.worker;
     // warmupWasmWorker() guarantees precompiledWasmModule is set.
diff --git a/ui/src/types/vite.d.ts b/ui/src/types/vite.d.ts
index 432a605..8801498 100644
--- a/ui/src/types/vite.d.ts
+++ b/ui/src/types/vite.d.ts
@@ -20,3 +20,8 @@
 // latter is exclusive: setting it would disable auto-inclusion of every other
 // @types/* package (node, jest, chrome, mithril, ...) and break the build.
 /// <reference types="vite/client" />
+
+declare module '*?serviceworker' {
+  const getSwUri: (versionDir: string) => string;
+  export default getSwUri;
+}
diff --git a/ui/vite.config.mjs b/ui/vite.config.mjs
index abc93e1..fcaf227 100644
--- a/ui/vite.config.mjs
+++ b/ui/vite.config.mjs
@@ -19,7 +19,7 @@
 // Inputs are the same entry points the old rollup.config.js used, but read
 // directly from .ts source instead of from tsc's emit.
 
-import {defineConfig} from 'vite';
+import {defineConfig, build as viteBuild} from 'vite';
 import checker from 'vite-plugin-checker';
 import path from 'node:path';
 import fs from 'node:fs';
@@ -212,27 +212,142 @@
   };
 }
 
+// Transforms `?worker` and `?serviceworker` query imports to instantiate workers via assetSrc(...),
+// and dynamically builds IIFE worker/service worker bundles via Vite during the build pass.
+function pluginPerfettoWorkerAssetSrc(isBuild) {
+  const builtWorkers = new Set();
+  return {
+    name: 'perfetto:worker-asset-src',
+    enforce: 'pre',
+    resolveId(id, importer) {
+      if (id.includes('?worker') || id.includes('?serviceworker')) {
+        const absPath = path.resolve(
+          importer ? path.dirname(importer) : SRC,
+          id,
+        );
+        return '\0' + absPath;
+      }
+      return null;
+    },
+    async load(id) {
+      if (id.startsWith('\0') && id.includes('?serviceworker')) {
+        const cleanId = id.slice(1).split('?', 1)[0];
+        if (isBuild && !builtWorkers.has('service_worker.js')) {
+          builtWorkers.add('service_worker.js');
+          await viteBuild({
+            configFile: false,
+            root: SRC,
+            publicDir: false,
+            build: {
+              commonjsOptions: {
+                transformMixedEsModules: true,
+                include: [/node_modules/, /\/gen\/.*\.js$/],
+                ignoreDynamicRequires: true,
+              },
+              outDir: path.join(OUT_SYMLINK, 'dist'),
+              emptyOutDir: false,
+              sourcemap: !NO_SOURCE_MAPS,
+              minify: MINIFY_JS ? 'terser' : false,
+              rollupOptions: {
+                input: {service_worker: cleanId},
+                output: {
+                  format: 'iife',
+                  entryFileNames: 'service_worker.js',
+                  inlineDynamicImports: true,
+                },
+              },
+            },
+            plugins: [pluginPerfettoVersion(), pluginGenRelativeImports()],
+          });
+        }
+        return `
+          export default function getSwUri(versionDir) {
+            return \`/service_worker.js?v=\${versionDir}\`;
+          }
+        `;
+      }
+      if (id.startsWith('\0') && id.includes('?worker')) {
+        const cleanId = id.slice(1).split('?', 1)[0];
+        let bundleName = 'worker_bundle.js';
+        if (cleanId.includes('traceconv')) {
+          bundleName = 'traceconv_bundle.js';
+        } else if (cleanId.includes('engine_bench')) {
+          bundleName = 'engine_bench_worker_bundle.js';
+        } else if (cleanId.includes('engine')) {
+          bundleName = 'engine_bundle.js';
+        }
+
+        if (isBuild && !builtWorkers.has(bundleName)) {
+          builtWorkers.add(bundleName);
+          await viteBuild({
+            configFile: false,
+            root: SRC,
+            publicDir: false,
+            build: {
+              commonjsOptions: {
+                transformMixedEsModules: true,
+                include: [/node_modules/, /\/gen\/.*\.js$/],
+                ignoreDynamicRequires: true,
+              },
+              outDir: path.join(OUT_SYMLINK, 'dist_version'),
+              emptyOutDir: false,
+              sourcemap: !NO_SOURCE_MAPS,
+              minify: MINIFY_JS ? 'terser' : false,
+              rollupOptions: {
+                input: {[bundleName.replace('.js', '')]: cleanId},
+                output: {
+                  format: 'iife',
+                  entryFileNames: bundleName,
+                  inlineDynamicImports: true,
+                },
+              },
+            },
+            plugins: [
+              pluginPerfettoVersion(),
+              lezer(),
+              pluginGenRelativeImports(),
+            ],
+          });
+        }
+
+        if (!isBuild) {
+          const relPath = '/' + path.relative(SRC, cleanId);
+          return `
+            export default function WorkerFactory() {
+              return new Worker(new URL(${JSON.stringify(relPath)}, import.meta.url), { type: 'module' });
+            }
+          `;
+        }
+
+        const assetsPath = path.resolve(SRC, 'base/assets.ts');
+        return `
+          import {assetSrc} from ${JSON.stringify(assetsPath)};
+          export default function WorkerFactory() {
+            return new Worker(assetSrc(${JSON.stringify(bundleName)}));
+          }
+        `;
+      }
+      return null;
+    },
+  };
+}
+
 // Per-bundle config: input file, output dir (relative to ui/out), and output
-// filename. Most bundles follow the standard convention; service_worker and
-// chrome_extension differ.
+// filename.
 const BUNDLE_CONFIGS = {
-  frontend: {dir: 'dist_version', entry: 'index.ts'},
-  engine: {dir: 'dist_version', entry: 'index.ts'},
+  frontend: {
+    dir: 'dist_version',
+    entry: 'index.ts',
+    tsconfig: 'ui/tsconfig.json',
+  },
   engine_bench: {dir: 'dist_version', entry: 'index.ts'},
   engine_bench_worker: {
     dir: 'dist_version',
     srcDir: 'engine_bench',
     entry: 'worker.ts',
   },
-  traceconv: {dir: 'dist_version', entry: 'index.ts'},
   bigtrace: {dir: 'dist_version/bigtrace', entry: 'index.ts'},
   open_perfetto_trace: {dir: 'dist/open_perfetto_trace', entry: 'index.ts'},
-  chrome_extension: {dir: 'chrome_extension', entry: 'index.ts'},
-  service_worker: {
-    dir: 'dist',
-    entry: 'service_worker.ts',
-    fileName: 'service_worker.js',
-  },
 };
 
 // When invoked as `vite build`, BUNDLE selects one entry per invocation
@@ -273,20 +388,17 @@
       // it. Gated to the frontend bundle: build.mjs runs one `vite build` per
       // bundle, and frontend is both the only slow one (so there's something
       // to overlap with) and the only one that covers this tsconfig project.
-      ...(isBuild && BUNDLE === 'frontend'
-        ? [
-            checker({
-              typescript: {
-                tsconfigPath: path.join(ROOT_DIR, 'ui/tsconfig.json'),
-              },
-            }),
-          ]
-        : []),
+      checker({
+        typescript: {
+          tsconfigPath: path.join(ROOT_DIR, 'ui/tsconfig.json'),
+        },
+      }),
       pluginPerfettoVersion(),
       // Compiles *.grammar files (lezer parser definitions) on import. Replaces
       // the old "manually run lezer-generator and commit gen/*.js" workflow.
       lezer(),
       pluginGenRelativeImports(),
+      pluginPerfettoWorkerAssetSrc(isBuild),
       ...(isBuild ? [] : [pluginGenWasmGlueEsm()]),
       ...(NO_SOURCE_MAPS ? [] : [pluginEmbedMinimalSourceMap()]),
     ],