WebAssembly & Web Workers
Running a WASM library — especially one that uses a Web Worker (DuckDB-WASM, sql.js, ffmpeg.wasm, pdf.js, ONNX Runtime Web) — works inside a Magic App, but the sandbox imposes a specific pattern. A naive new Worker(url) or a CDN-hosted worker/wasm will fail. This is the recipe that works, and why.
Why the obvious approaches fail
A Magic App runs in a sandboxed iframe without allow-same-origin, so its origin is opaque (window.location.origin === 'null'):
new Worker('https://…/worker.js')throwsFailed to construct 'Worker': Script at '…' cannot be accessed from origin 'null'. An opaque-origin document can only construct a worker from ablob:(ordata:) URL, not anhttp(s)one.- CDN fetches are blocked.
connect-srcis locked to the app's proxy, sofetch('https://cdn…/x.wasm')is refused. This is the data-exfiltration boundary — don't widen it for convenience. - The fetch shim rewrites
/api/*. Your app's own bundled assets live under/api/apps/{id}/view/-/assets/…; that/view/-/path is carved out of the rewrite so you canfetch()your own assets — but only your own.
The pattern: bundle locally, construct the worker from a blob
1. Bundle the worker + wasm with your app (never a CDN). With Vite, import them as asset URLs and exclude the package from pre-bundling:
// vite.config.js
import { defineConfig } from 'vite';
import informer from '@entrinsik/vite-plugin-informer';
export default defineConfig({
plugins: [informer()],
// Let Vite emit the worker/wasm as real assets (via ?url) instead of
// trying to pre-bundle them.
optimizeDeps: { exclude: ['@duckdb/duckdb-wasm'] }
});
// src/duckdb.js
import * as duckdb from '@duckdb/duckdb-wasm';
import ehWasm from '@duckdb/duckdb-wasm/dist/duckdb-eh.wasm?url';
import ehWorker from '@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js?url';
2. Fetch the worker + wasm bytes in the MAIN frame and wrap them as blob URLs. The main frame's fetch goes through the app's auth shim and the /view/-/ carve-out, so it can read your own assets. The worker itself can't — its fetch is cross-origin from 'null' and unauthenticated.
// Fetch an own /view/-/ asset and hand it back as a blob: URL.
async function blobUrl(assetUrl, type) {
const res = await fetch(assetUrl);
if (!res.ok) throw new Error(`Failed to load ${assetUrl} (${res.status})`);
return URL.createObjectURL(new Blob([await res.arrayBuffer()], { type }));
}
const workerUrl = await blobUrl(ehWorker, 'text/javascript'); // blob: worker script
const wasmUrl = await blobUrl(ehWasm, 'application/wasm'); // blob: wasm module
const worker = new Worker(workerUrl); // allowed: blob: inherits the doc origin
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(), worker);
await db.instantiate(wasmUrl); // worker fetches the blob: wasm (no network)
URL.revokeObjectURL(workerUrl);
URL.revokeObjectURL(wasmUrl);
Why blob URLs on both:
- The worker must be a
blob:— a blob worker inherits the document's origin, so construction is allowed where anhttp(s)URL is not. - The wasm is handed to the worker as a
blob:URL so the worker never does its own cross-origin/unauthenticated fetch — it reads local bytes the main frame already fetched.
This pattern is library-agnostic. For pdf.js, set GlobalWorkerOptions.workerPort = new Worker(blobUrl) (built from the bundled pdf.worker asset) instead of pointing workerSrc at a CDN.
External fetch targets (extension packs, remote data)
Some libraries auto-download from an external host at runtime — e.g. DuckDB pulls json/parquet extensions from extensions.duckdb.org the first time you call a JSON/Parquet function. That request is governed by connect-src and is blocked. Two options, tightest first:
- Avoid the download. Prefer features that don't pull an extension — e.g. ingest with
read_csv_auto(core DuckDB) instead ofread_json_auto(needs thejsonextension). Nothing to approve;connect-srcstays locked. - Approve the origin as a
dataasset. A tenant admin adds the URL to Approved Resources as an Asset, typedata— this opensconnect-src. A Script entry opensscript-srcand will not authorize afetch. Use only for static, trusted hosts.
Loading Informer data into the engine
The worker computes locally, but the data comes from Informer. Load it once via a server route that reads through a context.<slot> dependency (context.<slot>.search/execute/query) and stream it into the engine. Set the dependency's runAs deliberately in informer.yaml:
runAs: owner— every viewer sees the data through the app owner's access (curated dashboard).runAs: user(default) — each viewer's own permissions / RLS apply.
Page large datasets with search_after (a single dataset _search caps at 10k rows), then ingest in one pass — e.g. serialize the rows to CSV in the browser and CREATE TABLE t AS SELECT * FROM read_csv_auto('rows.csv', header = true) (CSV is core; no extension download).
Checklist
- Worker + wasm bundled with the app (Vite
?url), not a CDN -
optimizeDeps.excludeset for the wasm package - Worker constructed from a blob URL fetched in the main frame
- WASM handed to the worker as a blob URL, not an
http(s)URL - No runtime CDN/extension downloads — or the origin approved as an Asset, type
data - Informer data loaded via a server route (
context.<slot>) with an explicitrunAs