Nodepod routes preview iframes (nodepod.port(3000)) and virtual HTTP
servers through a service worker. The SW intercepts same-origin fetches
and hands them off to Nodepod's in-browser Node runtime.
Unlike the rest of the package, the SW cannot be bundled into your app JS. Browsers impose two hard requirements:
- A service worker must be served from the same origin as the page that registers it.
- It must be served with a JavaScript
Content-Type(e.g.application/javascript). Serving it astext/html, which is what SPA dev servers fall back to for unknown routes, silently breaks registration.
So: a file has to sit on your host at /__sw__.js, answering with a JS
Content-Type. This doc shows how to make that happen in every common
framework, and what error you'll see if you don't.
// vite.config.ts
import { defineConfig } from 'vite';
import nodepod from '@scelar/nodepod/vite';
export default defineConfig({
plugins: [nodepod()],
});- Dev: the plugin adds middleware that serves
/__sw__.jsfrom the in-memory source. - Build: the plugin emits
__sw__.jsas an asset next to your other build output, so your production host serves it automatically.
Optional: nodepod({ path: '/foo/__sw__.js' }) to mount under a custom
path. Pair with Nodepod.boot({ swUrl: '/foo/__sw__.js' }).
Works the same across Next 13 through 16. Route handlers weren't touched by the Next 16 rename.
// app/__sw__.js/route.ts
export { GET } from '@scelar/nodepod/next';Next matches this file at GET /__sw__.js because the folder name is
literally __sw__.js. No other config required.
Next 16 renamed middleware.ts to proxy.ts.
If you already have a proxy.ts, compose nodepodProxy:
// proxy.ts
import { nodepodProxy, nodepodMatcher } from '@scelar/nodepod/next';
export async function proxy(req) {
const sw = await nodepodProxy(req);
if (sw) return sw;
// ...your own proxy logic
}
export const config = { matcher: [nodepodMatcher /*, your paths */] };nodepodMiddleware is a back-compat alias of nodepodProxy, for projects
still on middleware.ts:
// middleware.ts
import { nodepodMiddleware, nodepodMatcher } from '@scelar/nodepod/next';
export async function middleware(req) {
const sw = await nodepodMiddleware(req);
if (sw) return sw;
// ...your own middleware
}
export const config = { matcher: [nodepodMatcher /*, your paths */] };Upgrading to Next 16? Run
npx @next/codemod@canary middleware-to-proxy .to rename your file + function, then swapnodepodMiddlewarefornodepodProxy. They're the same function re-exported under both names.
import { serveSW } from '@scelar/nodepod/server';
app.get('/__sw__.js', () => serveSW());import { serveSWNode } from '@scelar/nodepod/server';
app.get('/__sw__.js', async (_req, res) => {
const { body, headers } = await serveSWNode();
for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);
res.status(200).send(body);
});No server to edit? Copy the file into your public/static directory and let the host serve it:
cp node_modules/@scelar/nodepod/dist/__sw__.js public/__sw__.jsRe-run the copy when you upgrade @scelar/nodepod.
Starting in 1.2, Nodepod.boot() registers the service worker by
default. If the SW can't be reached or is served as HTML, boot throws:
NodepodSWSetupError: service worker at /__sw__.js returned HTTP 404
Requested: /__sw__.js
HTTP status: 404
Detected Vite. Add the nodepod plugin to serve __sw__.js automatically:
// vite.config.ts
import nodepod from '@scelar/nodepod/vite';
export default defineConfig({ plugins: [nodepod()] });
The framework hint is picked automatically by sniffing the runtime
(import.meta.hot, window.__NEXT_DATA__, etc.).
Two knobs:
await Nodepod.boot({
// Skip SW registration entirely (SSR, Node tests, hosts without a SW).
serviceWorker: false,
// Or: keep SW on, but skip the HEAD preflight (use if your host
// disallows HEAD, requires auth, or trips the check some other way).
skipSWPreflight: true,
});With serviceWorker: false you keep the rest of Nodepod (filesystem,
spawn, packages) but preview iframes and nodepod.request() to virtual
ports won't work.
If you can't use /__sw__.js (maybe it's already taken), serve the SW
somewhere else and tell the SDK:
await Nodepod.boot({ swUrl: '/assets/nodepod-sw.js' });Framework integrations support this too:
// Vite
nodepod({ path: '/assets/nodepod-sw.js' });
// Fetch-style
app.get('/assets/nodepod-sw.js', () => serveSW());| Symptom | Likely cause | Fix |
|---|---|---|
NodepodSWSetupError: HTTP 404 |
No handler mounted | Add the plugin / route for your framework above |
NodepodSWSetupError: wrong Content-Type (text/html) |
SPA fallback catching /__sw__.js |
Ensure your router serves the SW before the fallback |
NodepodSWSetupError: could not be reached |
CORS, network error, or timeout | Check devtools → Network; make sure the URL is same-origin |
| SW registers but preview iframes are blank | Nodepod's Cross-Origin-* headers missing |
Set Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: credentialless on HTML responses |