@smwb/srv-solid
v0.4.0
Published
Fullstack Solid.js SSR + static serving on top of @smwb/srv. One monolithic app: /api from controllers, SSR and assets from the same Node.js 24+ server.
Readme
@smwb/srv-solid
Fullstack Solid.js on top of @smwb/srv.
One monolithic Node.js 24+ app: the API is served from @smwb/srv controllers under
/api, and the same server renders your Solid app (SSR) and serves its static
assets for everything else.
- Backend —
@smwb/srv(DI, controllers, Zod, guards, …). - Frontend — Solid.js, server-rendered through the backend and hydrated on the client.
- Dev — Bun + Vite middleware mode (TS without a build, HMR).
- Prod —
vite build+ plain Node.js.
npm install @smwb/srv-solid @smwb/srv solid-js
npm install -D vite vite-plugin-solid@smwb/srv, solid-js, vite and vite-plugin-solid are peer dependencies.
Create a new app
Scaffold a starter project with one command:
npx -p @smwb/srv-solid create-smwb-srv-solid my-appWith an explicit version:
npx -p @smwb/srv-solid@latest create-smwb-srv-solid my-appOptions:
--no-install- copy files only, skipnpm install-h,--help- show usage
The generator creates a fullstack app with SSR + hydration, a Zod API contract
(GET /api/health, GET /api/hello), @solidjs/router routing and scripts for
dev (bun --watch) and prod (vite build + node).
cd my-app
npm run devOpen http://localhost:3000.
How it works
@smwb/srv's router has no wildcard routes, so SSR and static files cannot be a
controller. Instead this library adds one middleware that runs before the
router:
request → CORS → WebMiddleware → @smwb/srv Router (→ /api/* controllers)
│
├─ path under the API prefix, or non-GET/HEAD → next() (router)
└─ otherwise → static asset (dist/client) or Solid SSRThe API prefix is not hardcoded: the middleware reads it from DI
(routePrefixBindingKey) on every request, so apiPrefix, the ROUTE_PREFIX env
var, or rebinding the key all move the API and the SSR boundary together.
Quick start
A consuming app needs five files plus your controllers.
vite.config.ts
import { defineConfig } from 'vite';
import { solidFullstack } from '@smwb/srv-solid/vite';
export default defineConfig({ plugins: solidFullstack() });index.html — <!--app-html--> is where SSR output is injected.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>my app</title>
<!--app-head-->
</head>
<body>
<div id="root"><!--app-html--></div>
<script type="module" src="/src/entry-client.tsx"></script>
</body>
</html>src/app/App.tsx — your root component. It receives { url } (used for SSR routing).
src/entry-client.tsx
import { hydrateApp } from '@smwb/srv-solid/client';
import App from './app/App';
hydrateApp(App);src/entry-server.tsx
import { createSsrRender } from '@smwb/srv-solid/ssr';
import App from './app/App';
export const render = createSsrRender(App);src/server/main.ts
import { startFullstack } from '@smwb/srv-solid';
import { helloRoutes } from './controllers/hello.routes.js';
await startFullstack({
controllers: [helloRoutes], // result of @smwb/srv's defineController
// apiPrefix: '/api', // default; or set ROUTE_PREFIX
});Scripts
{
"dev": "bun --watch src/server/main.ts",
"build": "vite build --outDir dist/client && vite build --ssr src/entry-server.tsx --outDir dist/server && tsc -p tsconfig.server.json",
"start": "cross-env NODE_ENV=production node dist/srv/server/main.js"
}startFullstack picks dev vs prod from NODE_ENV (override with mode).
Dev reloading. bun --watch only watches Bun's own module graph — main.ts and
what it statically imports (controllers, routes, contract). So:
- Editing UI files (
src/app/**, components) → Vite HMR: the browser updates in place (no full reload, no server restart). They reach the server only via Vite'sssrLoadModule, outside Bun's watch graph. - Editing server files (controllers, routes,
main.ts, contract) → Bun restarts the process (the framework loads controllers via Nodeimport, so a restart is needed to pick them up). The browser then reconnects and reloads.
This is the intended split — UI is hot, backend restarts — so HMR is preserved for day-to-day component work.
The HMR WebSocket binds to a fresh OS-assigned free port on every boot (instead of Vite's fixed default), so a restart-on-change runner doesn't hit "port already in use" when the old process's socket is still bound — hot-reload works without disabling HMR.
Type-safe data hooks from a contract
The API is described once as a contract (zod schemas keep the inferred types). The same contract drives the server routes and generates fully-typed Solid hooks — no codegen, no duplicated types.
src/shared/contract.ts
import { z } from 'zod';
import { defineContract } from '@smwb/srv-solid/contract';
export const contract = defineContract({
health: { method: 'GET', path: '/health', response: z.object({ status: z.string() }) },
hello: {
method: 'GET',
path: '/hello',
query: z.object({ name: z.string().optional() }),
response: z.object({ message: z.string(), at: z.string() }),
},
createItem: {
method: 'POST',
path: '/items',
body: z.object({ title: z.string() }),
response: z.object({ id: z.string() }),
},
});Server — generate @smwb/srv routes from the contract (the controller class still
implements the actions). The controller type is inferred from load; no generics:
import { defineContractController } from '@smwb/srv-solid';
import { contract } from '../../shared/contract.js';
// `['hello']` = method `hello` serves the `hello` endpoint (name === key).
export const helloRoutes = defineContractController({
name: 'HelloController',
load: () => import('./hello.controller.js'),
contract,
actions: ['hello'],
});
// Use the { method: endpoint } map form when the names differ:
// actions: { check: 'health' }Type the action with ContractActionInput to drop the input.body as X casts, and
store thumbnails/uploads with defineAssetStore instead of hand-rolling base64 + disk:
import { Controller } from '@smwb/srv';
import { defineAssetStore, type ContractActionInput, type ContractActionResult } from '@smwb/srv-solid';
import { contract } from '../../shared/contract.js';
// Saves under save/thumbs/<id> and serves them at GET /thumbs/<id>.
export const thumbs = defineAssetStore({ root: 'save/thumbs', route: 'thumbs' });
// → register thumbs.routes in startFullstack({ controllers: [...] })
export default class HairController extends Controller {
async setPreview(
input: ContractActionInput<typeof contract, 'setHairPreview'>,
): ContractActionResult<typeof contract, 'setHairPreview'> {
const { id, dataUrl } = input.body; // fully typed — no cast
const url = await thumbs.save(`${id}.png`, dataUrl); // → '/thumbs/<id>.png'
return { url }; // checked against the response schema
}
}For a true multipart upload, declare the endpoint body as uploadBody(...); the call
then takes body: FormData and the action reads the parsed input.files.
Client — one createApi builds an RPC-style object, one entry per endpoint
(each a callable fetch with .query / .mutation attached):
import { createApi } from '@smwb/srv-solid/query';
import { contract } from './shared/contract';
export const api = createApi(contract, { baseUrl: '/api' });
// direct call — params/query/body and the result are all typed:
const data = await api.hello({ query: { name: 'Ann' } });
// ^? { message: string; at: string }// reactive GET (Solid resource); awaited during SSR, hydrated on the client:
const [hello] = api.hello.query(() => ({ query: { name: name() } }));
// mutation with data/error/pending signals:
const create = api.createItem.mutation();
await create.mutate({ body: { title: 'x' } }); // create.data() is typed
// fail-soft: resolve to a fallback instead of erroring when the API is unreachable
// (drops the ad-hoc try/catch), and seed a value before the first fetch resolves:
const [items] = api.listItems.query(() => ({ query: {} }), undefined, {
initialValue: [],
fallback: [],
});Wrong endpoints, params, query, body, or response usage are compile errors.
Non-2xx responses throw ApiError (with status and parsed body).
Lower-level building blocks are also exported:
createApiClient(solid-free, from@smwb/srv-solid/contract) andcreateApiQuery/createApiMutation(take a client + endpoint name explicitly).createApiis just these wired together per endpoint.
Headers & request options. Set defaults on the client and override per call:
export const api = createApi(contract, {
baseUrl: '/api',
headers: () => ({ authorization: `Bearer ${getToken()}` }), // static, or sync/async fn
requestInit: { credentials: 'include' }, // default fetch init for all calls
});
// Change global defaults at runtime (e.g. after login) — no client recreation:
api.$client.setHeader('authorization', `Bearer ${token}`);
api.$client.setHeader('authorization', null); // remove
api.$client.setHeaders({ 'x-app': 'demo' }); // merge several
api.$client.setRequestInit({ credentials: 'include' });
// Per-call options as the trailing argument — headers merge over (and override)
// the defaults; signal / credentials / cache / mode / … are forwarded to fetch:
await api.hello({ query: { name } }, { headers: { 'x-trace': id }, signal: ac.signal });
// Also available on the primitives:
const create = api.createItem.mutation();
await create.mutate({ body }, { signal: ac.signal });
const [list] = api.items.query(() => ({ query }), { headers: { 'x-trace': id } });Three levels, each overriding the previous: global at creation (headers /
requestInit) → global at runtime (setHeader / setHeaders / setRequestInit)
→ per-call (the trailing argument). method and body are always controlled by
the client/contract and can't be overridden by request options.
Error handling. A non-2xx response throws ApiError (with status and the parsed
body); network and response-validation errors propagate too. Catch them per call, or
intercept globally:
import { ApiError, isApiError } from '@smwb/srv-solid/contract';
// Global interceptor — runs on every failed call (401 redirect, logging, toasts):
const api = createApi(contract, {
baseUrl: '/api',
onError: (error, { endpoint, method, url }) => {
if (isApiError(error) && error.status === 401) redirectToLogin();
console.warn(`${method} ${url} failed`, error);
// throw a different error here to replace the one the caller sees
},
});
// Per-call interceptor (runs before the global one):
await api.hello({ query }, { onError: (e) => track(e) });
// Or handle locally:
try {
await api.createItem({ body });
} catch (e) {
if (isApiError(e) && e.status === 409) showConflict(e.body);
else throw e;
}
// Queries expose the error reactively; mutations via .error():
const [items] = api.items.query(); // items.error
const create = api.createItem.mutation(); // create.error()Interceptors observe by default (the original error still propagates, so local
try/catch and resource.error keep working); throw from a hook to replace it.
WebSockets
The same contract idea covers @smwb/srv WebSocket gateways. The server side needs
the ws package (@smwb/srv loads it lazily): npm i ws.
Server — a gateway implements the actions; defineContractGateway generates the
@smwb/srv WS routes from the same socket contract (so path/params/query/message
stay in one place). The socket is a typed TypedWebSocket<TIncoming, TOutgoing>. WS
routes share the HTTP apiPrefix; an optional root (a base path, like a controller
root) is overridable per gateway.
import { WebSocketGateway, expandWebSocketRoutes, type TypedWebSocket } from '@smwb/srv';
import { defineContractGateway } from '@smwb/srv-solid';
import { sockets } from '../../shared/sockets.js';
export default class ChatGateway extends WebSocketGateway {
chat(input: { params: { room: string } }, socket: TypedWebSocket<{ text: string }, { from: string; text: string }>) {
socket.send({ from: 'system', text: `joined ${input.params.room}` });
socket.onMessage((msg) => socket.send({ from: 'me', text: msg.text })); // msg is typed
}
}
// method name → socket contract key; the contract's `send` schema validates inbound.
const chat = defineContractGateway({
name: 'ChatGateway',
load: () => import('./chat.gateway.js'),
root: 'ws', // optional base path override (→ /api/ws/chat/:room); keep client baseUrl in sync
contract: sockets,
actions: { chat: 'chat' },
});
await startFullstack({ controllers: [...], websocketRoutes: expandWebSocketRoutes(chat) });Client — a socket contract drives a typed client and a Solid primitive
(@smwb/srv-solid/socket). send/receive schemas type outgoing/incoming messages;
the base URL carries the ws origin and the same prefix:
import { defineSocketContract, createSocketClient, createSocket } from '@smwb/srv-solid/socket';
import { z } from 'zod';
export const sockets = defineSocketContract({
chat: {
path: '/ws/chat/:room',
params: z.object({ room: z.string() }),
send: z.object({ text: z.string() }),
receive: z.object({ from: z.string(), text: z.string() }),
},
});
const client = createSocketClient(sockets, {
baseUrl: isServer ? `ws://localhost:${port}/api` : `${location.origin.replace('http', 'ws')}/api`,
validateIncoming: true, // parse + validate each message against `receive`
reconnect: { delayMs: 1000 }, // auto-reconnect on unexpected close (off by default)
});
// Imperative connection: typed send + parsed messages + lifecycle events.
const conn = client.connect('chat', { params: { room: 'general' } });
conn.onMessage((m) => console.log(m.from, m.text)); // m is typed
conn.onOpen(() => conn.send({ text: 'hi' })); // send is typed
conn.close();
// Solid primitive — reactive status/latest/error; closes on cleanup:
const socket = createSocket(client, 'chat', { params: { room: 'general' } });
socket.status(); // 'connecting' | 'open' | 'closing' | 'closed'
socket.latest(); // last received message (typed) | undefined
socket.send({ text: 'hello' });Outgoing messages sent before the socket opens are queued and flushed on open.
Invalid incoming messages (with validateIncoming) go to onError instead of
onMessage / latest.
API
| Export | From | Purpose |
| --- | --- | --- |
| startFullstack(options) | @smwb/srv-solid | Boot the monolith (web middleware + @smwb/srv + listen). Returns the App. |
| createWebMiddleware(options) | @smwb/srv-solid | The static + SSR middleware on its own (advanced/manual wiring). |
| webMiddlewareKey | @smwb/srv-solid | DI key the middleware is bound under. |
| createSsrRender(App) | @smwb/srv-solid/ssr | Wrap your root component into render(url) for entry-server. |
| hydrateApp(App, { rootId? }) | @smwb/srv-solid/client | Hydrate on the client from entry-client. |
| solidFullstack(options) | @smwb/srv-solid/vite | Vite plugin preset (vite-plugin-solid with SSR on). |
| defineContract(contract) | @smwb/srv-solid/contract | Declare the API contract (zod schemas). Single source of types. |
| createApi(contract, opts) | @smwb/srv-solid/query | RPC-style object: api.x(input), api.x.query(), api.x.mutation(). |
| createApiClient(contract, opts) | @smwb/srv-solid/contract | Lower-level type-safe fetch client; throws ApiError on non-2xx. |
| createApiQuery(client, name, source?) | @smwb/srv-solid/query | Reactive GET as a Solid resource (building block). |
| createApiMutation(client, name) | @smwb/srv-solid/query | Mutation with data/error/pending signals (building block). |
| defineContractController(def) | @smwb/srv-solid | Generate @smwb/srv routes from a contract (type inferred from load). |
| ContractActionInput<C, K> / ContractActionResult<C, K> | @smwb/srv-solid | Typed action input/return for endpoint K — drops the input.body as X casts. |
| defineAssetStore({ root, route }) | @smwb/srv-solid | Save binary assets under a sanitized id, serve them as /<route>/<id> URLs. Returns { routes, save, read, remove, list, urlFor, pathFor }. |
| saveDataUrl / dataUrlToBuffer / sanitizeAssetId / serveAssetById | @smwb/srv-solid | Standalone asset helpers (decode a data-URL, write it, sanitize an id, stream by id). |
| defineSocketContract(contract) | @smwb/srv-solid/socket | Declare WebSocket endpoints (path + params/query + send/receive schemas). |
| createSocketClient(contract, opts) | @smwb/srv-solid/socket | Typed WS client: connect() → typed send / parsed messages / events / reconnect. |
| createSocket(client, name, input?) | @smwb/srv-solid/socket | Solid primitive: reactive status / latest / error, closes on cleanup. |
| defineContractGateway(def) | @smwb/srv-solid | Generate @smwb/srv WS gateway routes from a socket contract; overridable root. |
StartFullstackOptions
| Field | Default | Notes |
| --- | --- | --- |
| controllers | [] | defineController results; expanded for you. |
| apiPrefix | '/api' | Overridden by ROUTE_PREFIX. Held in DI — never hardcoded. |
| cors | framework CORS | CorsOptions, or false to disable. |
| middlewares / interceptors | — | Extra @smwb/srv middlewares/interceptors. |
| websocketRoutes | — | @smwb/srv WebSocket routes. |
| mode | from NODE_ENV | 'development' | 'production'. |
| web.root | process.cwd() | App root. |
| web.template | 'index.html' | HTML template with placeholders. |
| web.clientOutDir | 'dist/client' | Built client (prod static + template). |
| web.serverOutDir | 'dist/server' | Built SSR entry dir. |
| web.serverEntry | dev src/entry-server.tsx, prod <serverOutDir>/entry-server.js | SSR module. |
Example
A complete app lives in example/ — a shared contract
(src/shared/contract.ts) driving both the
server routes (defineContractController) and a typed createApiQuery hook,
@solidjs/router routing, and the dev/prod scripts.
npm install # from the repo root (npm workspaces)
npm run build # build this library
cd example
npm run dev # Bun + Vite, http://localhost:3000
# or production:
npm run build && npm startTests
npm test # vitest run (unit + e2e, incl. negative scenarios)
npm run test:watchThe suite covers URL building, the typed client (success + ApiError + validation),
the Solid query/mutation primitives, prefix/template/static helpers, the web
middleware routing decisions, the contract→routes bridge, and an end-to-end
@smwb/srv server driven by a contract and hit through the typed client.
Requirements
- Node.js >= 24
- A single copy of
@smwb/srv(use npm workspaces / dedupe so the DI container is shared).
