check-sw-static-imports.mjs

3.3 KB · raw

1// Fails the build if any chunk reachable from the MV3 service worker still2// contains a runtime `import()`.3//4// Chrome throws `TypeError: import() is disallowed on ServiceWorkerGlobalScope`5// for dynamic import inside a service worker, and Vite's module-preload helper6// reports that failure via `window.dispatchEvent` — which also does not exist7// in a worker. The original error is therefore replaced by "window is not8// defined" and surfaces nowhere useful. The whole sync and pairing surface was9// dead this way, silently, because Rollup happened to split those modules into10// their own chunks while inlining everything else.11//12// Whether a given `await import()` survives to runtime depends on Rollup's13// chunking, so source review cannot catch this — only the built output can.1415import { readFileSync, existsSync, readdirSync } from "fs";16import { join, dirname } from "path";1718// Chrome only. The Firefox build declares `background.scripts`, an event page19// with a real `window` and `document`, where dynamic import() is legal.20const DISTS = ["dist-chrome"];2122const STATIC_IMPORT = /(?:^|[;\s})])(?:import|export)\s*(?:[^`"'()]*?from\s*)?[`"']([^`"']+)[`"']/g;23const DYNAMIC_IMPORT = /import\(\s*[`"']([^`"']+)[`"']\s*\)/g;2425function matches(source, re) {26  return [...source.matchAll(re)].map((m) => m[1]);27}2829function resolveFrom(fromFile, spec) {30  if (!spec.startsWith(".")) return null;31  return join(dirname(fromFile), spec);32}3334/** The worker entry, via the loader crxjs emits. */35function serviceWorkerEntry(dist) {36  const loader = join(dist, "service-worker-loader.js");37  if (existsSync(loader)) {38    const [spec] = matches(readFileSync(loader, "utf8"), STATIC_IMPORT);39    if (spec) return resolveFrom(loader, spec);40  }41  const manifest = JSON.parse(readFileSync(join(dist, "manifest.json"), "utf8"));42  const direct = manifest.background?.service_worker;43  return direct ? join(dist, direct) : null;44}4546function walk(entry) {47  const seen = new Set();48  const violations = [];49  const stack = [entry];50  while (stack.length) {51    const file = stack.pop();52    if (seen.has(file) || !existsSync(file)) continue;53    seen.add(file);54    const source = readFileSync(file, "utf8");55    for (const spec of matches(source, STATIC_IMPORT)) {56      const next = resolveFrom(file, spec);57      if (next) stack.push(next);58    }59    for (const spec of matches(source, DYNAMIC_IMPORT)) {60      violations.push({ file, spec });61    }62  }63  return { chunks: seen.size, violations };64}6566let failed = false;67for (const dist of DISTS) {68  if (!existsSync(dist)) {69    console.error(`FAIL: ${dist} not built — nothing to check`);70    failed = true;71    continue;72  }73  const entry = serviceWorkerEntry(dist);74  if (!entry || !existsSync(entry)) {75    console.error(`FAIL ${dist}: could not locate the service worker entry`);76    failed = true;77    continue;78  }79  const { chunks, violations } = walk(entry);80  if (violations.length) {81    failed = true;82    console.error(`FAIL ${dist}: runtime import() in the service worker graph`);83    for (const v of violations) console.error(`  ${v.file} -> import("${v.spec}")`);84    console.error(85      "  Fix: make these static top-level imports. Dynamic import() cannot run in a service worker.",86    );87  } else {88    console.log(`ok ${dist}: ${chunks} worker chunks, no runtime import()`);89  }90}9192process.exit(failed ? 1 : 0);