npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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).
  • Prodvite 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-app

With an explicit version:

npx -p @smwb/srv-solid@latest create-smwb-srv-solid my-app

Options:

  • --no-install - copy files only, skip npm 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 dev

Open 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 SSR

The 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's ssrLoadModule, outside Bun's watch graph.
  • Editing server files (controllers, routes, main.ts, contract) → Bun restarts the process (the framework loads controllers via Node import, 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) and createApiQuery / createApiMutation (take a client + endpoint name explicitly). createApi is 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 start

Tests

npm test        # vitest run (unit + e2e, incl. negative scenarios)
npm run test:watch

The 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).