Deploying SignalX
Every platform runs the same app: a WinterCG createFetchHandler behind an
entry file you own. The Node server is built in; Cloudflare, Vercel and Netlify each have a
build adapter; Deno and Bun deploy from a few lines of config.
The deployment model
A SignalX SSR app deploys the same way everywhere:
- One runtime —
createFetchHandlerfrom@sigx/server-rendererturns aRequestinto aResponseon any WinterCG runtime. Bots get a complete blocking document; everyone else streams shell-first;useResponsestatus/redirects resolve before the first byte. - One build seam — an adapter passed to
sigx({ ssr: { adapter } })in your Vite config decides how the server bundle is built (externalized for Node and Bun, fully bundled for the edge) and what platform output is generated. - One entry you own —
src/entry.<platform>.tsis scaffolded on first build and never overwritten. It composes static assets → server functions → document render, and always exports the portable{ fetch }shape.
The platform matrix
| Platform | Adapter | serverBuild | Statics | Deploy with |
|---|---|---|---|---|
| Node | built-in nodeAdapter() (default) | external | express.static / node:http | node --conditions production server.mjs |
| Cloudflare Workers | @sigx/cloudflare | bundled, ['workerd','worker'] | wrangler assets — served before the worker runs | wrangler deploy |
| Deno / Deno Deploy | inline adapter object — no package | bundled, ['deno'] | serveDir (@std/http) fallthrough | deno deploy |
| Bun | reuses the Node build — no package | external | Bun.serve routes / Bun.file | bun --conditions=production server.bun.ts |
| Vercel — Node runtime | @sigx/vercel | bundled | Build Output static/ + filesystem route | vercel deploy --prebuilt |
| Vercel — Edge runtime | @sigx/vercel with { runtime: 'edge' } | bundled, ['edge-light','worker'] | Build Output static/ + filesystem route | vercel deploy --prebuilt |
| Netlify | @sigx/netlify | bundled | publish dir CDN, preferStatic | netlify deploy --prod |
The entry contract
Whatever the platform, the entry rhymes (Cloudflare shown):
// src/entry.cloudflare.ts — user-owned, ~20 lines
import { createFetchHandler } from '@sigx/server-renderer/server';
import { handleServerFnRequest, matchesServerFn } from '@sigx/server/server';
import { createBoundaryRefresh } from '@sigx/resume/server';
import { template, assets } from 'virtual:sigx-app';
import { serverFns, serverFnBase } from 'virtual:sigx-server-fns';
import { createApp } from './entry-server';
const handler = createFetchHandler({
template,
app: (url) => createApp(url),
document: { assets }
});
const renderBoundaries = createBoundaryRefresh({ components: { /* … */ } });
export default {
async fetch(request: Request) {
if (matchesServerFn(request, serverFnBase)) {
return handleServerFnRequest(request, {
base: serverFnBase,
resolve: (s) => serverFns[s]?.() ?? null,
renderBoundaries
});
}
return handler(request);
}
};
The composition order is your routing policy: static assets first (served by the platform wherever possible), then the server-function endpoint, then the document render for everything else.
Two details in that entry are easy to leave out, and both fail silently:
serverFnBase—virtual:sigx-server-fnsexports the mount path the build actually used, so the predicate and the handler cannot disagree with each other or withsigxServer({ base }). Everything after the base is the symbol, so a base that is wrong only in part slices the symbol at the wrong offset — worse than a clean miss. Passing it to both is the fix; the plugin warns at build time when a non-default base is configured and an entry still callsmatchesServerFn(request)bare.renderBoundaries— without it, single-flight boundary refresh never happens. Nothing errors; mutations simply stop updating server-rendered boundaries, and the client converges a round trip later through$cache. You do not need to import your server app here. Production builds inject a side-effect import of it at the top ofvirtual:sigx-server-fns, so an entry that imports the registry — as this one does — already has the pipeline stamped. Add an explicitimport './server-app'only in an entry that does not touch the registry at all.
The static-tier rules
Two rules hold on every platform — the adapters encode them, and the hand-written entries (Deno, Bun) must keep them:
- GET/HEAD-only. POSTs belong to the server-function endpoint; a static tier
that answers other methods (e.g.
serveDir's 405) would swallow them. - Never serve the raw outlet template. The client build's
index.htmlis the unrendered document shell — every adapter keeps it off/(wranglerhtml_handling: "none", Vercel'sstatic/omits it, Netlify strips it from the publish dir), because the platform's static tier would otherwise shadow the document render.
Security defaults are unchanged by deployment — the server-function endpoint keeps its POST-only JSON, CSRF and origin gates on every platform; see Deployment & security.
Where to go next
- Node — the default, no adapter needed
- Deno & Deno Deploy / Bun — the package-less platforms
- Cloudflare, Vercel, Netlify — the adapter packages
- Deploy adapters in @sigx/vite — the
SigxAdaptercontract behind all of them
Running actors as well? A host is stateful and long-lived, so it has requirements this page does not cover — readiness that distinguishes draining from dead, a shutdown order that drains connections, and a backend for state. See Kubernetes and Cloudflare Workers.
