Skip to content

Architecture

magicelk235 edited this page Jul 22, 2026 · 2 revisions

Architecture

Contributor-facing overview of how viaduct is structured and how a conversion flows through it. For step-by-step depth on each conversion stage, see Conversion Pipeline. This page is grounded in the current source under src/; where the historical design notes drifted from the code, the code wins and the drift is called out.

What is viaduct

viaduct is a macOS-only command-line tool that turns a Chrome extension into a Safari Web Extension ready for local development or CI. Given a .zip, .crx, .xpi, an unpacked extension directory, or a Chrome Web Store / direct-download URL, it: extracts the bundle, analyzes it for APIs Safari doesn't implement, stages a clean copy of the referenced assets, rewrites the manifest (strips unsupported permissions, retargets keys, converts the MV3 service worker into a non-persistent background page), injects a runtime compatibility shim that backfills or emulates missing chrome.* APIs, and then drives Apple's safari-web-extension-packager + xcodebuild to produce (and optionally install into Safari) a signed host app with an embedded .appex. It leans on macOS system binaries throughout, xcrun, xcodebuild, ditto, pluginkit, plutil, lsregister, osascript, which is why the package is pinned to os: ["darwin"].

Tech stack

Aspect Choice
Language TypeScript, ESM ("type": "module")
Runtime deps None. The only dependencies are dev deps: typescript and @types/node
Node >=18 (uses node:util parseArgs, structuredClone, etc.)
Platform macOS only, os: ["darwin"]
Package @magicelk235/viaduct, version 1.7.0, license PolyForm-Shield-1.0.0
Binary viaductdist/cli.js (published files: dist, README.md)

Near-zero runtime deps is a deliberate stance: everything viaduct needs at runtime is either in the Node stdlib or is a macOS system binary invoked by name. The one third-party asset it ships, webextension-polyfill, is vendored as a static file under src/templates/, not pulled from npm at runtime.

Source layout

Source is grouped by pipeline concern: five folders (input/, manifest/, analyze/, runtime/, build/) plus the orchestrator and shared leaves at the src/ root. This folder grouping was introduced in a07abde; before that everything lived flat under src/.

src/
├── cli.ts            entry point — arg/config parsing, subcommands, calls convert()
├── convert.ts        the orchestrator — runs the whole pipeline in order
├── types.ts          Manifest, Issue, ConvertOptions, ConvertResult, Platforms, Severity
├── util.ts           run() (child_process), logging (info/ok/warn/fail), moveBundle, commandExists
├── paths.ts          single source of truth for bundled-asset dirs (templates/, runtime/)
├── input/            get files onto disk
│   ├── download.ts     fetch a Web-Store / .crx / .zip URL → local file
│   ├── extract.ts      unzip / crx→zip / xpi, sniff archive kind, find the manifest root
│   ├── stage.ts        copy referenced assets into staged_extension/ + source rewrites
│   └── icons.ts        synthesize placeholder PNG icons when the manifest declares none
├── manifest/         parse + analyze + rewrite the manifest
│   ├── manifest.ts     JSONC parse, i18n resolve, analyze, transform, write
│   ├── compat-data.ts  pure data tables: unsupported/shimmed permissions + APIs
│   └── dnr.ts          neutralize unsupported declarativeNetRequest header rules
├── analyze/          scan for incompatibilities + format the report
│   ├── analyze.ts      walk JS/HTML, flag unsupported chrome.* usage as Issues
│   └── report.ts       severity gating, console printout, Markdown/JSON report file
├── runtime/          the runtime compatibility layer
│   ├── shim.ts                    read/emit the shim, SW→bg conversion, HTML/hotkey wiring
│   ├── oauth-bridge.ts            wire the Chrome↔Safari OAuth / externally_connectable bridge
│   └── safari-compat-shim.js      the shim itself — a real .js file (~5.9k lines)
├── build/            Xcode project, build, install, verify
│   ├── packager.ts     run the Apple packager, patch bundle ids/version, native handler, xcodebuild
│   ├── installer.ts    move app → ~/Applications, register with Safari, uninstall, broker agent
│   ├── tempload.ts     write Safari-18 "Add Temporary Extension…" instructions
│   └── verify.ts       check pluginkit registered/enabled the installed extension
└── templates/        copied verbatim into output (browser-polyfill + OAuth-bridge scripts)
    ├── browser-polyfill.min.js     webextension-polyfill (browser.* promises everywhere)
    ├── identity-polyfill.js        chrome.identity replacement over the OAuth bridge
    ├── page-bridge.js              page↔extension OAuth handshake (page side)
    ├── page-bridge-cs.js           the content-script half of the page bridge
    ├── viaduct-sw-lifecycle.js     self.serviceWorker state-machine emulation in the bg page
    └── viaduct-cdp-keepalive.js    content script holding the bg page alive during CDP sessions

Root leaves

File Responsibility
cli.ts Entry point (#!/usr/bin/env node). Parses argv with node:util parseArgs, overlays an optional viaduct.config.json, validates bundle-id / Safari-version / team-id, dispatches the meta subcommands (--analyze, --doctor, --list, --uninstall, --version), handles batch runs, and calls convert() per input.
convert.ts The orchestrator. A single convert(opts) function that runs the full pipeline (see below) inside a scratch dir with SIGINT/SIGTERM cleanup. Owns the blocking-error gate and the input↔output overlap guard.
types.ts Shared types only, Manifest (a permissive structural shape of manifest.json), Issue/Severity, ConvertOptions, ConvertResult, Platforms.
util.ts run(cmd, args), the single spawnSync wrapper every subprocess goes through (never throws; maps ENOENT → exit 127). Colorized stderr logging (info/ok/warn/fail), commandExists, and moveBundle (rename with cross-volume ditto fallback).
paths.ts Exports TEMPLATE_DIR, RUNTIME_DIR, PACKAGE_ROOT, resolved from import.meta.url of paths.ts itself. Single source of truth so any module, at any folder depth, finds the bundled assets.

input/

File Responsibility
download.ts downloadExtension(url), recognizes a Chrome Web Store detail URL (extractStoreIdcrxEndpoint) or a direct .crx/.zip link, follows redirects, and sniffs the payload by magic bytes into a local file.
extract.ts extractExtension(input), unzips a .zip/.xpi, converts a .crx to a zip (crxToZip, stripping the CRX header and reading its public key), sniffs the archive kind, guards against zip path-escape, and locates the real manifest root.
stage.ts stageExtension(...) copies only manifest-referenced files into <output>/staged_extension/. Also hosts the staged-source rewrites applied before shimming: inlineImmutableEnums, rewriteRuntimeIdUrlMatchers, rewriteChromeSchemeLiterals, rewriteSelfPageExtensionUrls, guardAncestorOriginsAccess, and stripDanglingSourcemaps.
icons.ts synthesizePlaceholderIcons(...), hand-writes solid-color PNGs (its own CRC32 + chunk encoder, no image lib) when the manifest ships no icons, seeded from the app name.

manifest/

File Responsibility
manifest.ts The manifest engine: parseJsonc (comment/trailing-comma tolerant), loadManifest, resolveI18nString (__MSG_*__ lookups), analyzeManifest (→ issues + permissionsToRemove + needsCdpShim), collectReferencedPaths, transformManifest (the actual rewrite), and writeManifest.
compat-data.ts Pure data, no logic: UNSUPPORTED_PERMISSIONS, SHIMMED_PERMISSIONS, and UNSUPPORTED_APIS, the tables of what Safari lacks and the per-item remediation notes the analyzer surfaces. Extracted from manifest.ts in 3f919d0; add new unsupported APIs here.
dnr.ts applyDnr(...), parses the extension's declarativeNetRequest rule files and neutralizes header-modification rules Safari doesn't honor, returning human-readable notes.

analyze/

File Responsibility
analyze.ts scanExtension(...), walks the extension's JS/HTML and flags unsupported chrome.* usage. Each finding is an Issue with a severity (error / warning / info) and, when the shim covers it, a shimmed flag so the report can reassure rather than alarm.
report.ts Turns issues into output: countBlocking (the gate convert.ts reads), printIssues (colorized console verdict), summarizeManifestChanges, and buildReportMarkdown / writeReportFile for the on-disk .md/JSON report.

runtime/

File Responsibility
shim.ts The runtime layer's controller (see Runtime Shim). shimSource/writeShim read the .js shim and substitute one config placeholder; writePolyfill, injectShimIntoHtmlPages, injectPopupSizing, convertServiceWorkerToBackgroundPage, wireActionHotkey/wireActionClickBridge, wirePageWorldMainInjection, wireCdpKeepalive, and deriveProxyHosts do the wiring.
oauth-bridge.ts deriveChromeId(manifest) (recovers the real Chrome extension id from the key) and applyOAuthBridge(...), drops in the page-bridge templates and bakes the original id in so pages checking chrome.runtime.id keep working after conversion.
safari-compat-shim.js The shim itself, see the invariants below.

build/

File Responsibility
packager.ts Wraps Apple's toolchain: runPackager (invokes safari-web-extension-packager), patchProjectBundleIds, setBuildVersion, writeNativeHandler / writeAppBroker / unsandboxAppTarget (the native-messaging + CORS-proxy path), buildXcodeProject, verifyBuiltBundleId, plus toolchain probes (pluginkitStatus, detectXcodeTeam, deriveAppName, defaultBundleId).
installer.ts installToSafari(...) moves the built app into ~/Applications and registers it with LaunchServices/Safari; uninstallFromSafari, listSafariExtensions / parsePluginkitList, and the native-messaging broker LaunchAgent (installBrokerAgent).
tempload.ts writeTempLoadInstructions(...), the tiny helper for --temp-load (stage-only, no Xcode).
verify.ts verifyInSafari(bundleId), reads pluginkit to confirm Safari registered and enabled the freshly installed extension (backs --verify).

Repo-layout note: CLAUDE.md (the author's architecture writeup this page is derived from), test/, and reports/ are gitignored: kept local, not pushed. So the design doc quoted here does not appear in the public tree; the authoritative record is the source under src/.

The conversion pipeline

convert() in src/convert.ts is the whole flow. It runs everything against a mkdtempSync scratch dir, but the staged extension is written into the output dir (<output>/staged_extension/), not scratch, so dev-mode symlinks survive cleanup. The high-level order:

extractExtension                    input/extract.ts   → unpack to scratch, find manifest root
loadManifest                        manifest/manifest  → parse manifest.json (JSONC-tolerant)
deriveChromeId                      runtime/oauth-bridge → real Chrome id (BEFORE `key` is stripped)
analyzeManifest + scanExtension     manifest + analyze → collect Issues, permissionsToRemove, needsCdpShim
countBlocking                       analyze/report     → abort here unless --force (or 0 blockers)
── overlap guard: refuse input-inside-output / output-inside-input ──
stageExtension                      input/stage.ts     → copy referenced assets → staged_extension/
inlineImmutableEnums                input/stage.ts     → chrome.scripting.* enums → literals
rewriteRuntimeIdUrlMatchers         input/stage.ts     → strip `runtime.id +` from port matchers
rewriteChromeSchemeLiterals         input/stage.ts     → "chrome-extension:" → "safari-web-extension:"
rewriteSelfPageExtensionUrls        input/stage.ts     → self-page URLs → runtime.getURL
guardAncestorOriginsAccess          input/stage.ts     → guard ancestorOrigins[0] reads
deriveProxyHosts                    runtime/shim.ts    → hosts the shim/native handler may proxy
writePolyfill + writeShim           runtime/shim.ts    → drop the polyfill + compat shim files
injectShimIntoHtmlPages             runtime/shim.ts    → prepend shim(+polyfill) into every HTML page
transformManifest                   manifest/manifest  → strip perms, retarget keys, prepend shim to CS
applyDnr                            manifest/dnr.ts    → neutralize unsupported DNR header rules
applyOAuthBridge                    runtime/oauth-bridge → wire Chrome↔Safari OAuth handshake
wireActionHotkey / wireActionClickBridge   runtime/shim → make the toolbar action reachable
convertServiceWorkerToBackgroundPage runtime/shim.ts   → SW → non-persistent background page, hoist importScripts
wireCdpKeepalive                    runtime/shim.ts    → (if chrome.debugger) keep bg page alive
wirePageWorldMainInjection          runtime/shim.ts    → CSP-blocked page-world scripts → world:"MAIN"
synthesizePlaceholderIcons          input/icons.ts     → placeholder PNGs if none
stripDanglingSourcemaps             input/stage.ts     → drop broken //# sourceMappingURL refs
writeManifest + injectPopupSizing   manifest + runtime → write manifest.json, size the popup/side panel
writeReportFile                     analyze/report.ts  → <output>/…report
── if --temp-load: writeTempLoadInstructions and STOP ──
runPackager                         build/packager.ts  → safari-web-extension-packager → .xcodeproj
patchProjectBundleIds + setBuildVersion   build/packager → unique bundle ids + per-build version
writeNativeHandler / writeAppBroker build/packager.ts  → (if proxy hosts or nativeMessaging)
── if --no-build: STOP with the project ──
buildXcodeProject → verifyBuiltBundleId   build/packager → xcodebuild, then confirm compiled ids
── if --install: installToSafari (+ installBrokerAgent), else moveBundle to a stable path ──

Each stage is documented in depth on the Conversion Pipeline page; per-area detail lives in Manifest Transform, Analyzer, Runtime Shim, Input Handling, and Build and Install. The CLI surface (flags, subcommands, config file) is in CLI Reference.

Drift from the historical doc. The removed CLAUDE.md design notes list a shorter pipeline than the current code. The current code adds several staged-source rewrite passes (the input/stage.ts rewrite*/inline*/guard* group) and the hotkey / main-world wiring steps shown above. convertServiceWorkerToBackgroundPage emits a non-persistent background page ({ page, persistent: false }, src/runtime/shim.ts, the code comment there quotes Safari's rule: "A manifest_version >= 3 must be non-persistent"), which matches the design notes. Trust the code order above.

Key architectural decisions & invariants

These are the load-bearing conventions. Breaking one tends to fail silently (a dead content script, a stale install, a 404 asset), so they're worth internalizing before touching the pipeline.

  • The shim is a real .js file, not TypeScript. src/runtime/safari-compat-shim.js (currently ~5.9k lines, the design doc's "~2200" is stale) is authored as plain JavaScript and read verbatim at conversion time. shimSource() in shim.ts does a single split/join substitution of the __C2S_PROXY_CONFIG_JSON__ placeholder and nothing else. Reading raw bytes is intentional: it keeps every regex backslash and $-reference intact with no template-literal escaping to corrupt them. Edit the .js directly. It was extracted out of shim.ts into its own file in 2927cdf.

    • Cardinal rule: the shim must never throw at top level. It's prepended to every content script and every extension HTML page, and a top-level throw aborts the entire prepended script chain (which reads as "content script not executing"). The config assignment sits above the shim's outer try and is itself defended; every patch below feature-detects before touching chrome.*. There's a __C2S_DEBUG__ flag at the top for gated diagnostics.
  • Bundled asset paths resolve via paths.ts, relative to the dist root. paths.ts derives ROOT from import.meta.url of paths.ts itself, then exports TEMPLATE_DIR = ROOT/templates and RUNTIME_DIR = ROOT/runtime. Because paths.js compiles to the dist root, any importer at any depth (dist/runtime/, dist/build/, …) finds the assets. Don't re-derive asset dirs from import.meta.url in individual modules: go through paths.ts.

  • ESM imports use the .js extension, even from .ts sources. Every relative import is written from "./util.js", from "./manifest/manifest.js", etc., never .ts, never extensionless. This is required for the compiled ESM output to resolve at runtime under Node.

  • Tests import from dist/, so build first. The test suite imports the compiled modules (../dist/analyze/analyze.js), which is why npm test is npm run build && node --test …. Anything run against dist/, the tests and the CLI alike, needs a fresh build. (test/ is gitignored, so it lives locally.)

  • System binaries are invoked by name via util.run(). There is one spawnSync wrapper (util.ts run), and every external command, xcrun, xcodebuild, ditto, pluginkit, plutil, osascript, which, goes through it, found on PATH (or a fixed absolute path for a few like lsregister). run never throws on non-zero exit; it returns { code, stdout, stderr } and maps a missing binary to exit 127. --doctor probes exactly this set.

Build model

The whole build is inline Node one-liners in package.json scripts.build, in order:

  1. rm -rf dist/, fs.rmSync('dist', { recursive: true, force: true })
  2. tsc, compile src/**/*.tsdist/
  3. copy assets: fs.cpSync('src/templates', 'dist/templates') and fs.cpSync('src/runtime', 'dist/runtime'), so both dirs sit beside the compiled paths.js at runtime (this is what makes the paths.ts resolution work)
  4. chmod 0o755 dist/cli.js, make the published binary executable

Related scripts: typecheck = tsc --noEmit; test = build then node --test test/*.test.js; dev = tsc --watch; prepare/prepublishOnly = build. Note the shim .js and the templates/ assets are not compiled by tsc, they're copied as-is in step 3, which is why the shim can be plain JavaScript.

Clone this wiki locally