-
Notifications
You must be signed in to change notification settings - Fork 3
Architecture
Contributor-facing overview of how
viaductis 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 undersrc/; where the historical design notes drifted from the code, the code wins and the drift is called out.
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"].
| 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 |
viaduct → dist/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 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
| 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. |
| File | Responsibility |
|---|---|
download.ts |
downloadExtension(url), recognizes a Chrome Web Store detail URL (extractStoreId → crxEndpoint) 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. |
| 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. |
| 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. |
| 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. |
| 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/, andreports/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 undersrc/.
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.mddesign notes list a shorter pipeline than the current code. The current code adds several staged-source rewrite passes (theinput/stage.tsrewrite*/inline*/guard*group) and the hotkey / main-world wiring steps shown above.convertServiceWorkerToBackgroundPageemits 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.
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
.jsfile, 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()inshim.tsdoes a singlesplit/joinsubstitution 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.jsdirectly. It was extracted out ofshim.tsinto its own file in2927cdf.-
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
tryand is itself defended; every patch below feature-detects before touchingchrome.*. There's a__C2S_DEBUG__flag at the top for gated diagnostics.
-
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
-
Bundled asset paths resolve via
paths.ts, relative to the dist root.paths.tsderivesROOTfromimport.meta.urlofpaths.tsitself, then exportsTEMPLATE_DIR = ROOT/templatesandRUNTIME_DIR = ROOT/runtime. Becausepaths.jscompiles to the dist root, any importer at any depth (dist/runtime/,dist/build/, …) finds the assets. Don't re-derive asset dirs fromimport.meta.urlin individual modules: go throughpaths.ts. -
ESM imports use the
.jsextension, even from.tssources. Every relative import is writtenfrom "./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 whynpm testisnpm run build && node --test …. Anything run againstdist/, 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 onespawnSyncwrapper (util.tsrun), and every external command,xcrun,xcodebuild,ditto,pluginkit,plutil,osascript,which, goes through it, found onPATH(or a fixed absolute path for a few likelsregister).runnever throws on non-zero exit; it returns{ code, stdout, stderr }and maps a missing binary to exit 127.--doctorprobes exactly this set.
The whole build is inline Node one-liners in package.json scripts.build, in order:
-
rm -rf dist/,fs.rmSync('dist', { recursive: true, force: true }) -
tsc, compilesrc/**/*.ts→dist/ -
copy assets:
fs.cpSync('src/templates', 'dist/templates')andfs.cpSync('src/runtime', 'dist/runtime'), so both dirs sit beside the compiledpaths.jsat runtime (this is what makes thepaths.tsresolution work) -
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.
Viaduct CLI · @magicelk235/viaduct · PolyForm Shield 1.0.0 · Verified against src/ and grounded in git history.