Skip to content

Vite Plugin

The poeTile() Vite plugin handles the platform build concerns that every bundled iframe app needs. Import it from poe-tiles-sdk/vite.

typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { poeTile } from "poe-tiles-sdk/vite";

export default defineConfig({
  root: "tile",
  build: {
    outDir: "../dist",
    emptyOutDir: true,
    rollupOptions: {
      output: {
        entryFileNames: "tile-frontend.js",
        assetFileNames: "[name][extname]",
      },
    },
  },
  plugins: [react(), poeTile()],
});

The plugin handles:

  • Externals — Prevents bundling platform-provided modules (poe-tiles-sdk/v1/client.js) since these are provided at runtime via import map.
  • Code splitting — Rewrites dynamic import() calls to load chunks via Poe.getBundleAssetUrl(), which returns blob URLs that work in sandboxed iframes.
  • Vendor extraction — Shared dependencies (from node_modules) are automatically extracted into a vendor chunk so they aren't duplicated across lazy chunks.
  • Web Workers — Fails the build on new Worker(new URL(…, import.meta.url)) and on a ?worker import missing &inline, neither of which can load in a sandboxed iframe. Use ?worker&inline, which works with no help from the plugin. See Web Workers below.
  • Backend building — Optionally builds the synced-store backend config as part of the Vite build.

The plugin does not set root, outDir, entryFileNames, or assetFileNames — consumers control their own directory structure and output naming via standard Vite config.

Backend Config Building

Pass backendEntryPoint to build the synced-store backend config automatically after the Vite build:

typescript
export default defineConfig({
  root: "tile",
  build: { outDir: "../dist" },
  plugins: [
    react(),
    poeTile({ backendEntryPoint: "src/backend.ts" }),
  ],
});

The path is resolved relative to the Vite root directory. The output (synced-store-backend-config.js) is placed in the Vite output directory. This eliminates the need for a separate buildBackend() call in your build script.

Code Splitting

Dynamic import() calls are rewritten so that chunks are loaded via Poe.getBundleAssetUrl(). This is more reliable than normal browser-cached imports — the browser's HTTP cache may evict entries at any time, so lazy-loaded chunks fetched over the network can fail offline. Assets loaded via Poe.getBundleAssetUrl() are stored in IndexedDB by the top document, so they remain available even without a network connection.

Web Workers

Import your worker with Vite's ?worker&inline suffix:

typescript
// worker.ts
self.onmessage = (event) => {
  self.postMessage(expensiveWork(event.data));
};

// entry.tsx
import MyWorker from "./worker.ts?worker&inline";

const worker = new MyWorker();
worker.onmessage = (event) => setResult(event.data);
worker.postMessage(input);

&inline is required — it is the whole reason this works. It bundles the worker into your code and starts it from a blob URL, the only form the sandboxed iframe allows. Two near-misses fail instead:

  • new Worker(new URL("./worker.ts", import.meta.url)) resolves against import.meta.url, which is a blob: URL in an activity, so the call throws TypeError: Invalid URL.
  • A plain ?worker (or ?sharedworker) import compiles to new Worker("/assets/worker-hash.js") — a path nothing serves here, since the iframe makes no network requests.

poeTile() fails the build on both, with a message pointing at the fix, so forgetting &inline is caught before you ship rather than breaking at runtime.

Constraints that come from the iframe sandbox:

  • Keep worker code statically imported. A dynamic import() inside a worker will not resolve. Regular top-level import statements are fine — they get bundled into the worker.
  • Don't set worker.format: "es" in vite.config.ts. It makes Vite's inline wrapper request a module worker, which a blob URL cannot serve. The default (iife) is what you want, and poeTile() pins it.
  • SharedWorker does not work at all — shared workers are keyed by origin and the iframe's origin is opaque, so the browser denies it (Access to shared workers is denied to origin 'null'). &inline does not help. Use one regular worker per frame.
  • No localStorage, IndexedDB, or direct network access in the worker — it inherits the activity's sandbox. Keep workers pure-compute (physics, pathfinding, parsing, image processing) and do I/O on the main thread via Poe.
  • Big workers ride in your bundle. The source is inlined into your code. If it is large enough to matter for first paint, load it on demand instead: new Worker(await Poe.getBundleAssetUrl("workers/heavy.js")) — classic scripts only, and the file must end in .js.

Testing a worker

?worker&inline is a Vite-only module specifier. bun test cannot resolve it, so any module that names it — directly or transitively — fails to import in a test. That single fact drives the whole approach: keep the specifier out of everything you want to test, and you can test with no mocks and a real worker.

This works because bun test provides a real Worker that runs your .ts worker file directly, with no build step, and it survives the happy-dom setup (that setup assigns an explicit list of DOM globals and does not touch Worker — don't add it).

1. Put the algorithm in a plain module and test it directly. This is where almost all your logic lives, and it needs no worker at all:

typescript
// ui/prime-search.ts — pure, no Worker, no DOM
export function countPrimesBelow(limit: number, onProgress?: (p: number) => void) {
  /* … */
}

// ui/prime-search.worker.ts — transport only, nothing worth testing
import { countPrimesBelow } from "./prime-search";
self.onmessage = (event) => {
  const count = countPrimesBelow(event.data, (percent) =>
    self.postMessage({ type: "progress", percent }),
  );
  self.postMessage({ type: "done", count });
};
typescript
// ui/prime-search.test.ts — real algorithm, microseconds, zero setup
test("countPrimesBelow returns pi(100000) = 9592", () => {
  expect(countPrimesBelow(100_000)).toBe(9592);
});

2. Inject the worker factory so the component never names the specifier. Let your entry supply the real one and let tests supply a real Bun Worker:

typescript
// ui/App.tsx — takes the factory; never imports ?worker&inline
export type CreateSearchWorker = () => Worker;
export function App({ store, createSearchWorker }: AppProps) { /* … */ }

// tile/src/entry.tsx — the ONLY place the Vite specifier appears
// @ts-expect-error — Vite worker imports have no built-in TS types.
import SearchWorker from "../../ui/prime-search.worker.ts?worker&inline";
render(<App store={store} createSearchWorker={() => new SearchWorker()} />);
typescript
// ui/App.test.happydom.tsx — a REAL worker, no mocks
render(
  <App
    store={store}
    createSearchWorker={() =>
      new Worker(new URL("./prime-search.worker.ts", import.meta.url).href, {
        type: "module",
      })
    }
  />,
);

Your test now exercises the real worker, the real message protocol, and real progress events. Worth knowing: worker messages arrive asynchronously, so assert with waitFor, and terminate() the worker when the test ends.

If you cannot move the specifier — e.g. an existing component imports it — replace the module before importing the component, and mind the ordering, because a static import would otherwise run first:

typescript
mock.module("./prime-search.worker.ts?worker&inline", () => ({ default: FakeWorker }));
const { App } = await import("./App"); // after the mock, not a top-level import

Prefer option 2. The mock tests your wiring against a fake; option 2 tests it against the real thing.

Without a bundler

No-build apps can't use ?worker&inline, but the underlying primitive — a classic worker started from a blob URL minted inside the iframe — is available directly. Two recipes.

Worker source as a string:

js
const workerSource = `
  self.onmessage = (event) => {
    self.postMessage(event.data.a + event.data.b);
  };
`;

// The MIME type is required: the browser refuses to run a worker whose blob is
// text/plain or application/octet-stream.
const url = URL.createObjectURL(
  new Blob([workerSource], { type: "text/javascript" }),
);

const worker = new Worker(url); // classic — do NOT pass { type: "module" }
worker.onmessage = (event) => console.log("sum:", event.data);
worker.postMessage({ a: 1, b: 2 });

Or keep the worker in its own file and ask the host for it:

js
// worker.js must ship in your app directory and end in .js — the host derives
// the blob's MIME type from the extension.
const url = await Poe.getBundleAssetUrl("worker.js");
const worker = new Worker(url); // classic only

The second form costs one round-trip on first use but keeps the worker out of your initial HTML. Both require classic worker syntax (importScripts, no import/export); a module script cannot be fetched from a blob URL here. A data: URL is the one exception that does allow { type: "module" }.

No-Build vs Bundled

No-BuildBundled
Importpoe-tiles-sdk/v1/client.jspoe-tiles-sdk/v1/client.js
SchemaInline mutatorsdefineSchema() + defineClientConfig()
Config{ mutators, schemaVersion }Pre-built client config object
TypesNone (plain JS)Full type inference from Zod schema