43458142142a

load sync and pairing through static imports: runtime import() throws in an MV3 service worker, and Vite's preload helper reported it as "window is not defined", so the whole sync surface failed silently (v4.1.10)

time
2026-07-26 12:21 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
pairing room hang
parent
06089c871dd2
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+4 −3scripts/check-sw-static-imports.mjs+92 −0src/background/messages.ts+31 −21src/background/vault.ts+9 −9
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.1.9",4+  "version": "4.1.10",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.1.9",4+  "version": "4.1.10",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+4 −3
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.1.9",3+  "version": "4.1.10",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",@@ -9,7 +9,7 @@99     "dev": "vite",1010     "build:noise-core": "node scripts/build-noise-core.mjs",1111     "build": "npm run build:chrome && npm run build:firefox && npm run build:mobile",12-    "build:chrome": "node scripts/clean-chrome-dist.mjs && BUILD_TARGET=chrome vite build && node scripts/sync-chrome-dist.mjs",12+    "build:chrome": "node scripts/clean-chrome-dist.mjs && BUILD_TARGET=chrome vite build && node scripts/sync-chrome-dist.mjs && npm run check:sw",1313     "build:firefox": "node scripts/clean-firefox-artifacts.mjs && BUILD_TARGET=firefox vite build",1414     "build:mobile": "vite build --config vite.config.mobile.ts",1515     "build:e2e": "PEACH_E2E_PASSKEYS=1 BUILD_TARGET=chrome vite build && node scripts/sync-chrome-dist.mjs",@@ -23,7 +23,8 @@2323     "release": "bun run scripts/release.ts",2424     "release:chrome": "bun run scripts/release.ts --chrome-only",2525     "release:firefox": "bun run scripts/release-firefox.ts",26-    "submit:firefox": "web-ext sign --source-dir dist-firefox --artifacts-dir artifacts --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET --channel=listed"26+    "submit:firefox": "web-ext sign --source-dir dist-firefox --artifacts-dir artifacts --api-key=$AMO_JWT_ISSUER --api-secret=$AMO_JWT_SECRET --channel=listed",27+    "check:sw": "node scripts/check-sw-static-imports.mjs"2728   },2829   "engines": {2930     "node": ">=26.0.0",

view file

scripts/check-sw-static-imports.mjs+92 −0
@@ -0,0 +1,92 @@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.14+15+import { readFileSync, existsSync, readdirSync } from "fs";16+import { join, dirname } from "path";17+18+// Chrome only. The Firefox build declares `background.scripts`, an event page19+// with a real `window` and `document`, where dynamic import() is legal.20+const DISTS = ["dist-chrome"];21+22+const STATIC_IMPORT = /(?:^|[;\s})])(?:import|export)\s*(?:[^`"'()]*?from\s*)?[`"']([^`"']+)[`"']/g;23+const DYNAMIC_IMPORT = /import\(\s*[`"']([^`"']+)[`"']\s*\)/g;24+25+function matches(source, re) {26+  return [...source.matchAll(re)].map((m) => m[1]);27+}28+29+function resolveFrom(fromFile, spec) {30+  if (!spec.startsWith(".")) return null;31+  return join(dirname(fromFile), spec);32+}33+34+/** The worker entry, via the loader crxjs emits. */35+function 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+}45+46+function 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+}65+66+let failed = false;67+for (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+}91+92+process.exit(failed ? 1 : 0);

view file

src/background/messages.ts+31 −21
@@ -77,6 +77,37 @@7777   revokeDeviceById as revokeDeviceByIdV3,7878   upsertPairedDevice as upsertPairedDeviceV3,7979 } from "./paired-devices";80+// Sync and pairing are imported statically, never with `await import()`.81+// `import()` throws `TypeError: import() is disallowed on82+// ServiceWorkerGlobalScope` at runtime in MV3, and Vite's preload helper83+// reports the failure through `window.dispatchEvent`, which does not exist in84+// a worker either — so the real reason was replaced by "window is not defined"85+// and the pairing UI just stopped. scripts/check-sw-static-imports.mjs fails86+// the build if a runtime import() reappears anywhere in the worker's graph.87+import { parsePairingCode } from "../shared/pairing-code";88+import {89+  isCodePairingInFlight,90+  resolveCodePairingConfirm,91+  startCodePairing,92+} from "./code-pairing-bg";93+import {94+  cancelRelayAdoptionSource,95+  confirmRelayAdoptionSource,96+  isRelayAdoptionSourceInFlight,97+  startRelayAdoptionSource,98+} from "./relay-adoption-source-bg";99+import {100+  getRelaySettings,101+  setLanOnly,102+  setRelayUrl,103+} from "./relay-settings";104+import {105+  getSyncState,106+  hydratePersistedSyncState,107+  setSyncRunner,108+  triggerSyncIfReachable,109+} from "./sync-trigger";110+import { productionSyncRunner } from "./sync-runner";80111 81112 // Compile-time E2E flag, folded to a literal by Vite's `define` (false in82113 // every production build — verified in the bundled output). Mirrors the@@ -205,8 +236,6 @@205236   if (nativeSyncRunnerBootstrapped) return;206237   nativeSyncRunnerBootstrapped = true;207238   try {208-    const { setSyncRunner, hydratePersistedSyncState } = await import("./sync-trigger");209-    const { productionSyncRunner } = await import("./sync-runner");210239     setSyncRunner(productionSyncRunner);211240     await hydratePersistedSyncState();212241   } catch (err) {@@ -1442,13 +1471,9 @@14421471       case "PAIR_WITH_CODE": {14431472         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };14441473         const { code } = (msg.payload ?? {}) as { code?: string };1445-        const { parsePairingCode } = await import("../shared/pairing-code");14461474         if (typeof code !== "string" || parsePairingCode(code) === null) {14471475           return { ok: false, error: "invalid_payload" };14481476         }1449-        const { startCodePairing, isCodePairingInFlight } = await import(1450-          "./code-pairing-bg"1451-        );14521477         if (isCodePairingInFlight()) return { ok: false, error: "pairing_in_progress" };14531478         startCodePairing(code);14541479         return { ok: true };@@ -1456,17 +1481,12 @@14561481 14571482       case "CODE_PAIR_CONFIRM": {14581483         const { confirmed } = (msg.payload ?? {}) as { confirmed?: boolean };1459-        const { resolveCodePairingConfirm } = await import("./code-pairing-bg");14601484         resolveCodePairingConfirm(confirmed === true);14611485         return { ok: true };14621486       }14631487 14641488       case "START_ADOPTION_SOURCE": {14651489         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1466-        const {1467-          isRelayAdoptionSourceInFlight,1468-          startRelayAdoptionSource,1469-        } = await import("./relay-adoption-source-bg");14701490         if (isRelayAdoptionSourceInFlight()) {14711491           return { ok: false, error: "adoption_source_in_progress" };14721492         }@@ -1485,9 +1505,6 @@14851505           password?: unknown;14861506           cancel?: unknown;14871507         };1488-        const { confirmRelayAdoptionSource } = await import(1489-          "./relay-adoption-source-bg"1490-        );14911508         if (cancel === true) {14921509           return {14931510             ok: true,@@ -1504,9 +1521,6 @@15041521       }15051522 15061523       case "CANCEL_ADOPTION_SOURCE": {1507-        const { cancelRelayAdoptionSource } = await import(1508-          "./relay-adoption-source-bg"1509-        );15101524         cancelRelayAdoptionSource();15111525         return { ok: true };15121526       }@@ -1518,18 +1532,15 @@15181532         };15191533         const reason = payload.reason ?? "foreground";15201534 1521-        const { triggerSyncIfReachable } = await import("./sync-trigger");15221535         void triggerSyncIfReachable({ reason, force: payload.force === true });15231536         return { ok: true };15241537       }15251538 15261539       case "GET_SYNC_STATE": {1527-        const { getSyncState } = await import("./sync-trigger");15281540         return { ok: true, data: getSyncState() };15291541       }15301542 15311543       case "GET_RELAY_SETTINGS": {1532-        const { getRelaySettings } = await import("./relay-settings");15331544         return { ok: true, data: await getRelaySettings() };15341545       }15351546 @@ -1538,7 +1549,6 @@15381549           relayUrl?: string;15391550           lanOnly?: boolean;15401551         };1541-        const { getRelaySettings, setLanOnly, setRelayUrl } = await import("./relay-settings");15421552         try {15431553           if (payload.relayUrl !== undefined) {15441554             if (typeof payload.relayUrl !== "string") {

view file

src/background/vault.ts+9 −9
@@ -14,6 +14,10 @@1414 import { clampSyncTimestamp } from "../shared/sync-limits";1515 import { computeVaultId } from "../shared/sync-identity";1616 import { recordSyncAlerts, type SyncAlert } from "./sync-alerts";17+// Static, not `await import()`: runtime import() throws in an MV3 service18+// worker. Neither module imports vault.ts back, so there is no cycle to break.19+import { triggerSyncIfReachable } from "./sync-trigger";20+import { clearPairedDevices } from "./paired-devices";1721 import {1822   rootKeyToWords,1923   wordsToRootKeyMaterial,@@ -197,16 +201,13 @@197201   settings = (result as { peach_settings?: VaultSettings }).peach_settings ?? {};198202 }199203 200-// Fire-and-forget sync trigger after a committed vault write. Lazy201-// dynamic import avoids a circular dependency between vault.ts and202-// sync-trigger.ts (the trigger imports paired-devices, which only reads203-// storage, but the lazy load also gives us a clean opt-out path in204-// tests where sync-trigger has not been initialised).204+// Fire-and-forget sync trigger after a committed vault write. A failure here205+// must never fail the write that caused it.205206 function fireSyncTrigger(reason: "vault_change"): void {206-  void import("./sync-trigger")207-    .then((mod) => mod.triggerSyncIfReachable({ reason }))207+  void Promise.resolve()208+    .then(() => triggerSyncIfReachable({ reason }))208209     .catch(() => {209-      /* sync trigger module not available — non-fatal */210+      /* no sync runner registered, or nothing reachable — non-fatal */210211     });211212 }212213 @@ -2950,7 +2951,6 @@29502951   rootKey = newRootKey;29512952 29522953   // 5. Old peers' identities derive from the dead root key — drop them all.2953-  const { clearPairedDevices } = await import("./paired-devices");29542954   await clearPairedDevices();29552955 29562956   // 6. Force re-presentation of the NEW recovery words on next popup view.

view file