@statewalker/webrun-site-host
v0.2.0
Published
Host a webrun-site-builder site behind a same-origin ServiceWorker in one call: files + endpoints + dynamic server-module runners, no manual SW plumbing
Readme
@statewalker/webrun-site-host
Browser-side host for a SiteHandler. Registers a same-origin ServiceWorker,
mounts the handler under a virtual path, and rewrites incoming requests to
site-relative form before dispatching.
This package owns where a site runs (browser + SW). It does NOT own what
the site does — endpoints, files, auth, and routing live in
@statewalker/webrun-site-builder (or anywhere
else that produces a SiteHandler = (Request) => Promise<Response>).
Getting started
import { SiteBuilder } from "@statewalker/webrun-site-builder";
import { HostedSiteBuilder } from "@statewalker/webrun-site-host";
const handler = new SiteBuilder()
.setEndpoint("/api/time", () => new Response(new Date().toISOString()))
.setFiles("/", clientFiles)
.build();
const site = await new HostedSiteBuilder()
.setSiteKey("demo")
.setHandler(handler)
.build();
iframe.src = site.baseUrl;The split is intentional: the same SiteHandler works in every host —
browser + SW via HostedSiteBuilder, any webrun-streams-* transport via
DuplexSiteBuilder, and Node / Deno / Bun /
Cloudflare Workers directly, since a SiteHandler already is their handler
shape. Configuration lives in one place.
Install
npm install @statewalker/webrun-site-host @statewalker/webrun-files@statewalker/webrun-files is a
peer dependency (^0.7.0). Browser-only — it registers a ServiceWorker, so
a secure context (https:// or localhost) is required.
Cross-application HTTP (no domains, no certificates)
Because the handler is just a function, you can point it at a remote peer over
any webrun-streams-* transport (WebSocket, WebRTC, libp2p, LiveKit,
MessagePort, …). The browser-side host doesn't care:
import { fetchOverDuplex } from "@statewalker/webrun-http-streams";
import { connect } from "@statewalker/webrun-streams-ws";
const { call } = await connect({ url: "wss://peer.example" }); // any adapter
const site = await new HostedSiteBuilder()
.setHandler((request) => fetchOverDuplex(call, request))
.build();
iframe.src = site.baseUrl;
// Every fetch inside the iframe is now proxied across the peer connection.apps/livekit-demo/client-page/main.ts and apps/p2p-demo/client-page/main.ts
are both this pattern against a real transport.
API
class HostedSiteBuilder {
constructor(options?: HostedSiteBuilderOptions);
setSiteKey(key: string): this;
setServiceWorkerUrl(url: string): this;
setHandler(handler: SiteHandler): this;
build(): Promise<HostedSite>;
}
interface HostedSite {
readonly siteKey: string;
readonly baseUrl: string;
stop(): Promise<void>;
}
interface HostedSiteBuilderOptions {
adapterFactory?: AdapterFactory;
}build() throws if setHandler was not called. siteKey defaults to a
generated UUID and serviceWorkerUrl to /sw-worker.js. adapterFactory is
the seam the tests use to inject a fake instead of a real ServiceWorker; it
also takes SiteAdapter, SiteAdapterRegistration and AdapterFactory, all
exported.
Also exported, for callers that accept "a FilesApi or a plain path → content
map" in their own APIs:
type FilesSource = FilesApi | Record<string, string | Uint8Array>;
function resolveFilesSource(source: FilesSource): Promise<FilesApi>;HostedSiteBuilder itself never calls it — it hosts a SiteHandler and owns
no file configuration.
Plus a standalone utility for the "endpoint is a JS module dynamically imported from the site itself" pattern:
export function newServerRunner(
modulePath: string,
getBaseUrl: () => string,
env?: Record<string, unknown>,
): EndpointHandler;Use it with SiteBuilder.setEndpoint:
let getBaseUrl = () => "";
const handler = new SiteBuilder()
.setFiles("/server", serverFiles)
.setEndpoint("/api", newServerRunner("/server/api/index.js", () => getBaseUrl()))
.build();
const site = await new HostedSiteBuilder().setHandler(handler).build();
getBaseUrl = () => site.baseUrl;What build() does
- Resolve
siteKey(generated UUID if not set) andswUrl(/sw-worker.jsif not set). - Construct and start the adapter (
SwHttpAdapterby default — registers the ServiceWorker and awaits activation). - Register a fetch interceptor under
<origin>/<siteKey>/that:- Strips the SW prefix from the incoming
Request.url. - Dispatches to your handler.
- Strips the SW prefix from the incoming
- Return a
HostedSitewith the resolvedbaseUrland astop()for teardown.
See also
@statewalker/webrun-site-builder— produces aSiteHandlerfrom endpoints + files + auth + routing.@statewalker/webrun-http-streams—DuplexSiteBuilder, the sibling host for anywebrun-streams-*transport, plusfetchOverDuplex/serveFetchOverDuplex.apps/site-builder-demoandapps/site-builder-tsx-spike— runnable examples.
Dependencies
| Dependency | Kind | Why |
| --- | --- | --- |
| @statewalker/webrun-site-builder | runtime | The SiteHandler shape and file/endpoint composition. |
| @statewalker/webrun-http-browser | runtime | SwHttpAdapter — the ServiceWorker registration and dispatch. |
| @statewalker/webrun-files-mem | runtime | In-memory FilesApi used when resolving inline file maps. |
| @statewalker/webrun-files | peer (^0.7.0) | The FilesApi interface itself. |
Browser-only: requires navigator.serviceWorker and therefore a secure
context. ESM only ("type": "module").
Development
pnpm test # vitest run
pnpm run build # rolldown + tsc --emitDeclarationOnly
pnpm lint # biome check src testsLicense
MIT © statewalker — see LICENSE.
