@sigx/vite
v0.15.6
Published
Vite plugin for SignalX Framework with HMR, library builds, and automatic type generation
Readme
@sigx/vite
Vite plugin for SignalX — wires up dev-mode source
aliasing, HMR for component(), and ships a small sigx-types CLI that
generates TypeScript definitions for tag-named components.
📚 Full guides, API reference and live examples → https://sigx.dev/vite/
Install
npm install -D @sigx/vite@sigx/vite peer-depends on vite >= 8 and sigx.
Usage
Add the plugin to your vite.config.ts:
import { defineConfig } from 'vite';
import sigx from '@sigx/vite';
export default defineConfig({
plugins: [sigx()],
});That's it — the plugin handles the rest. Its job is keeping @sigx/reactivity
a single module instance in every environment (two instances mean signals
written through one never trigger effects tracked by the other — silently
dead UI):
Dev: generates a
resolve.aliasentry for every installed@sigx/*package and every one of itsexportssubpaths, each pinned to that package's built entry, so the whole family resolves to one physical copy. Subpath entries are emitted before bare ones — Vite matches aliases by prefix, so a bare@sigx/resumeahead of@sigx/resume/clientwould rewrite the subpath into a nonexistent path. It also excludes all@sigx/*packages fromoptimizeDepspre-bundling — the core packages plus every@sigx/*dependency found in yourpackage.json(store, router, daisyui, …), so prebundled chunks can't carry a second reactivity copy.If your config already aliases a package, the plugin leaves that package entirely alone (all of its entries or none — a partially overridden set is worse than none, since a bare key ahead of its own subpaths breaks them). You should not need a hand-written map; if you do, that's a bug worth reporting.
SSR: sets
ssr.noExternal: ['sigx', /^@sigx\//]so the whole family stays in the SSR module graph instead of splitting between Vite's module runner and Node's resolver.Build: dedupes the core packages and pins the runtime into one shared
sigxchunk.
Your own optimizeDeps.exclude / ssr.noExternal entries are merged with the
plugin's, never replaced.
Options
sigx({
// Enable HMR for component() (default: true). Also drives the dev
// full-reload for server-only pages: a zero-JS / resumable route never
// loads its components in the browser, so an edit has no client HMR
// boundary — the plugin reloads the page instead so the change shows.
hmr: true,
// Port for Vite's HMR websocket. Only relevant in middleware mode (the
// standard SSR setup), where Vite's fixed default (24678) collides when
// two dev servers run on one machine. Unset: the plugin picks a free port
// automatically. Explicit `server.hmr` settings in your Vite config always
// take precedence.
hmrPort: undefined,
// SSR mode: ONE `vite build --app` produces the client bundle (with its
// asset manifest) into dist/client AND the server entry into dist/server —
// shaped by `adapter` (default nodeAdapter(): dependencies external, one
// module graph with the production request handler; see "Deployment
// artifacts" below).
ssr: { entry: 'src/entry-server.tsx' },
})SSR mode
The dev server is createServer plus one handler; production is static
assets plus one handler (@sigx/server-renderer/node). The entry contract:
export createApp(url) returning a fresh per-request app
(docs/router-ssr-contract.md).
// dev
import { createDevRequestHandler } from '@sigx/vite/ssr';
app.use(vite.middlewares);
app.use(await createDevRequestHandler(vite, { entry: '/src/entry-server.tsx' }));
// prod: resolve manifest entries into DocumentOptions.assets
import { collectAssets } from '@sigx/vite/assets';
const assets = collectAssets(manifest, ['index.html']);@sigx/vite/assets imports nothing — no node: builtins — and its one
process.env read is typeof-guarded, so a workerd/Deno/Bun entry (where
process may not exist at all) can use it directly. Import it from /assets, not
/ssr: the latter also carries the dev request handler, which does import
node:fs/promises and node:path, and pulling that into an edge graph is not
possible. @sigx/vite/ssr still re-exports it, so existing imports keep
working.
Styles in dev
There is no manifest in dev, and Vite serves JS-imported CSS
(import './styles.css') as a module that injects a <style> at runtime —
so a server-rendered document would carry no styles in its head and paint
unstyled until the client entry executes.
createDevRequestHandler closes that gap: it walks the SSR module graph and
inlines the reachable CSS into <head> as <style data-vite-dev-id="…">,
the shape Vite's client adopts on boot and rewrites in place on HMR — so
there is no flash, no duplicated rules, and CSS HMR is unaffected. Nothing to
configure; production is untouched (the built template carries real <link>
tags).
Pass devStyles: false to opt out if your template already ships its own
stylesheet link.
Server functions — sigxServer()
sigxServer() (from @sigx/vite/server) is the build half of
@sigx/server. A *.server.ts module is server-only
wholesale: the SSR build keeps its body, the client build replaces the
entire module with generated RPC stubs, so a server-only import can never
reach the browser.
// vite.config.ts
import sigx from '@sigx/vite';
import { sigxServer } from '@sigx/vite/server';
export default defineConfig({
plugins: [sigx({ ssr: { entry: 'src/entry-server.tsx' } }), sigxServer()]
});// src/api.server.ts — never shipped to the browser
export const getProduct = serverFn({
allowAnonymous: true,
handler: async (rq, id: string) => db.get(id)
});// Product.tsx — a normal import; the client gets a stub that POSTs
import { getProduct } from './api.server';
const product = await getProduct('sku-1');Dev needs no wiring: the plugin serves the endpoint from
vite.middlewares, which every example mounts already. Production reads the
build's registry — virtual:sigx-server-fns, emitted as
dist/server/sigx-server-fns.js (or inlined in a bundled build) — and your
entry passes it explicitly, never ambiently:
import { handleServerFnRequest, matchesServerFn } from '@sigx/server/server';
import { serverFns, serverFnBase } from 'virtual:sigx-server-fns';
if (matchesServerFn(request, serverFnBase)) {
return handleServerFnRequest(request, {
base: serverFnBase,
resolve: (symbol) => serverFns[symbol]?.() ?? null
});
}Options
| Option | Type | Default | What it does |
|---|---|---|---|
| include | string \| string[] | ['**/*.server.ts', '**/*.server.tsx'] | Which modules are server modules. |
| exclude | string \| string[] | ['**/node_modules/**', '**/dist/**'] | Excluded from matching. |
| base | string | '/_sigx/fn' | The server mount path — the dev middleware's and createServerFnHandler's prefix. Exported back to your entry as serverFnBase; pass it to matchesServerFn and the handler so all three agree. |
| endpoint | string | base | The fetch target baked into stubs; an absolute URL for a build that calls a remote server. Call-time precedence: configureServerFn > this > base. |
| role | 'auto' \| 'client' | 'auto' | 'auto' swaps stubs in the Vite client environment only. 'client' declares the whole build a remote-server client (lynx, terminal): every environment gets stubs, baked with stable symbols, and no registry is emitted — there is no server in this build. |
| scan | string[] | [] | Extra directories scanned for server modules — shared workspace packages outside the Vite root. |
| serverApp | string | — | The app's server-app module ('/src/server-app.ts') — it calls createServerApp(...) at module scope (rfc-server-v4 §3.4). Dev loads it eagerly through the SSR module runner and re-evaluates it after edits, so middleware/authentication/authorization/posture apply to the dev endpoint AND in-process SSR calls without a restart; a production build injects one side-effect import of it at the top of virtual:sigx-server-fns. Without it the fail-closed runtime denies rather than opens. |
| requireAuthorization | boolean \| 'warn' | true | The access gate (rfc-server-v4 §5): every extracted serverFn/serverStream must have a decided access policy — declare authorize: [...], declare the literal allowAnonymous: true, or inherit the app default via a configured serverApp. A bare one with no app is a build error naming its file, line and the remedies. 'warn' lists them without failing; false opts out deliberately. |
| renderBoundaries | string | — | A Vite-root-relative module exporting renderBoundaries — the value createBoundaryRefresh (@sigx/resume/server) builds for production entries — forwarded to the dev endpoint so single-flight boundary refresh behaves identically in dev. |
| origin | 'same-origin' \| 'verify-when-present' \| string[] \| false | 'same-origin' | Origin policy forwarded to the dev endpoint. |
| maxBodyBytes | number | 1_048_576 | Body cap forwarded to the dev endpoint. |
| maxUrlBytes | number | 8_192 | A GET read's query-string cap, forwarded to the dev endpoint. |
| timeoutMs | number | — | Forwarded to the dev endpoint. |
| onError | (error, info, ctx) => void | — | Forwarded to the dev endpoint. |
The last five come from ServerFnRequestOptions by inheritance rather than by
being copied, so an option added to the endpoint is reachable in dev the day it
ships. serverApp and renderBoundaries differ from their production twins by
necessity: they are module specifiers here, loaded through the SSR module
runner so edits apply without a restart, where a production entry imports the
modules itself. Everything about what those values mean lives in
@sigx/server.
Inline server functions
A serverFn declared at module scope of any client-reachable file is extracted
in place: the client build replaces the initializer with a stub and strips
imports used only inside extracted bodies, so a server-only dependency never
loads in the browser (dev included — there is no tree-shaking there). Captured
free variables are a hard build error: the rule is imports-only, and the message
tells you to pass the value as an argument.
Extraction outside Vite — @sigx/vite/server-extract
Non-Vite bundlers (a lynx app on Rspack, say) reach the same analysis directly:
@sigx/vite/server-extract exports extractServerFns,
extractInlineServerFns, mintSymbols and computeStableId with no Vite
plugin around them, so every client of one solution mints identical stable
symbols.
Resumability — sigxResume()
sigxResume() (from @sigx/vite/resume) completes the @sigx/resume story:
event handlers in resume modules (*.resume.tsx, or anything under resume/)
are extracted at build time into lazily-imported QRL chunks, signal state is
keyed from its declaration (const hits = ctx.signal(0) is keyed "hits" —
named = transferred), and the client build emits
.vite/sigx-resume-manifest.json for resumePlugin({ manifest }) on the
server. With sigxServer() present it also stamps real action/method
attributes onto a <form> whose submit handler calls a form: true server
function — the zero-JS transport (rfc-server §6.4).
Deployment artifacts — ssr.adapter and virtual:sigx-app
The build seam of the deployment RFC (docs/rfc-deploy.md §3). ssr.adapter
is a plain SigxAdapter object — default nodeAdapter(), which keeps
today's externalized Node output byte-identical:
sigx({ ssr: { entry: 'src/entry-server.tsx', adapter: nodeAdapter() } })serverBuild: 'external' resolves deps from node_modules at runtime (Node /
Bun hosts); serverBuild: 'bundled' produces a fully self-contained server
build (resolve.noExternal: true, platform conditions — a REPLACEMENT
array, so node is present only if the adapter lists it — target esnext,
runtimeExternal for platform-scheme imports). Binary on purpose:
partially-external is the dangerous middle ground for DI-token identity.
Build ordering is explicit: client → ssr → remaining environments →
adapter.generate(ctx) (which sees both finished output trees). Adapters
may also hook the dev server via dev(server) — dev stays
createDevRequestHandler on every platform.
The document-side artifacts become code: virtual:sigx-app exports
template, assets (precomputed collectAssets), manifest,
islandsManifest, and resumeManifest as inlined literals — no filesystem
in the output. External builds also materialize it as
dist/server/sigx-app.js (imports of the virtual resolve to that emitted
sibling), so a Node server.mjs collapses from four readFiles to one
import:
const { template, assets, islandsManifest, resumeManifest } = await import(
new URL('./dist/server/sigx-app.js', import.meta.url).href
);Bundled builds inline the module instead — one self-contained file is the
deliverable. In dev the virtual throws (dev has no manifests;
createDevRequestHandler resolves template/assets live). Combining
ssr.adapter with sigxServer({ role: 'client' }) is a config-time error —
a client-role build has no server for an adapter to shape.
The narrow sibling virtual:sigx-manifests exports just islandsManifest
and resumeManifest, and — unlike virtual:sigx-app — resolves in EVERY
mode: real inlined literals in the SSR build, undefined under dev (the
packs run manifest-less there). It exists for the entry-server's app
factory, the pack install site (#413):
// src/entry-server.tsx
import { islandsManifest } from 'virtual:sigx-manifests';
export const createApp = (url) =>
defineApp(<App />).use(islandsPlugin({ manifest: islandsManifest }));Typing the virtual modules — @sigx/vite/client
One reference line, next to vite/client, types every virtual:* module the
sigx plugins generate — virtual:sigx-app, virtual:sigx-manifests,
virtual:sigx-server-fns, virtual:sigx-islands, virtual:sigx-resume/entry:
// src/env.d.ts
/// <reference types="vite/client" />
/// <reference types="@sigx/vite/client" />The two pack manifests type themselves from the packs you actually installed:
importing @sigx/ssr-islands gives islandsManifest its IslandsManifestV2,
importing @sigx/resume gives resumeManifest its ResumeManifest, and a
manifest whose pack is absent stays unknown — which is what the value is
anyway, since a pack that is not installed contributes no manifest. That is why
this file never imports the packs: both are optional peers, and an app with only
one of them installed still has to type-check. Registration rides the pack's own
import, exactly like the client:* and use: attribute types.
Islands
sigxIslands() (from @sigx/vite/islands) completes the
@sigx/ssr-islands story: island modules (*.island.tsx or anything under
islands/) get stable __islandId identities and automatic signal state
keys (const state = ctx.signal(…) is keyed "state" from the declaration —
named = transferred, per island instance), virtual:sigx-islands
registers a lazy code-split loader per island in the client entry, and the
client build emits .vite/sigx-islands-manifest.json for
islandsPlugin({ manifest }) on the server.
📚 Documentation
Plugin options, HMR, the sigx-types CLI, TSX setup and subpath exports —
full guides, the complete reference and live examples → https://sigx.dev/vite/
