Silgi

Server

Start your API with serve(), deploy anywhere with handler(), enable HTTP/2, and auto-generate API docs.

Silgi gives you two ways to run your API: serve() for a universal server (Node.js, Deno, Bun — powered by srvx), and handler() for a raw Fetch API function you can plug into anything.

serve()

The simplest way to get a server running. Uses srvx under the hood — works on Node.js, Deno, and Bun with zero config:

import {  } from 'silgi'
import {  } from 'zod'

const  = ({
  : () => ({ : getDB() }),
})

const  = .({
  : .(() => ({ : 'ok' })),
})

.(, {
  : 3000,
})

Run it with npx tsx src/server.ts and you'll see:

Silgi server running at http://127.0.0.1:3000

serve() returns a SilgiServer handle you can use to shut down the server programmatically:

const  = await s.serve(appRouter, { : 3000 })

// Later — gracefully shut down
await .close()

All options

s.serve(appRouter, {
  : 3000, // auto-finds next available if busy
  : '0.0.0.0', // bind to all interfaces
  : '/api', // mount under /api prefix
  : true, // API docs at /api/reference
  // ws is auto-enabled when the router contains a subscription.
  // Pass ws: false to disable, or ws: { compress, keepalive, ... } to configure crossws.
  : {
    // HTTP/2 with TLS
    : './certs/cert.pem',
    : './certs/key.pem',
  },
  : true, // default — shuts down cleanly on SIGINT/SIGTERM
})
OptionTypeDefaultDescription
portnumber3000Port to listen on. If it's taken, Silgi automatically picks the next free port in 3000-3100.
hostnamestring"127.0.0.1"Network interface. Use "0.0.0.0" to accept connections from other machines.
basePathstringundefinedURL prefix (e.g. "/api"). Only requests matching this prefix are handled; others return 404.
scalarboolean | ScalarOptionsfalseEnable auto-generated API docs at /api/reference.
wsboolean | WSAdapterOptionsfalseEnable WebSocket RPC on the same port. Pass an object for WS options.
http2{ cert, key }undefinedEnable HTTP/2 with TLS certificates. Falls back to HTTP/1.1 for older clients.
gracefulShutdownboolean | { timeout?, forceTimeout? }trueHandle SIGINT/SIGTERM for clean shutdown. See graceful shutdown.

Runtime support

serve() uses srvx — the same server runs on Node.js, Deno, and Bun without code changes. TLS and HTTP/2 work on all runtimes.

handler()

Returns a standard Fetch API function: (Request) => Promise<Response>. Use it with Nitro, Cloudflare Workers, or any platform that supports the Fetch API.

const  = s.handler(appRouter)
// handle is: (request: Request) => Promise<Response>

With Nitro / Nuxt

server.ts
export default { fetch: s.handler(appRouter) }

With Cloudflare Workers

export default {
  : s.handler(appRouter),
}

handler() options

You can pass options to mount under a prefix and enable API docs:

const  = s.handler(appRouter, {
  : '/api', // only handle /api/* requests, strip prefix before routing
  : true, // serves /api/openapi.json and /api/reference
})
OptionTypeDefaultDescription
basePathstringundefinedURL prefix (e.g. "/api"). Requests not matching this prefix return 404.
scalarboolean | ScalarOptionsfalseEnable Scalar API docs at {basePath}/reference and {basePath}/openapi.json

Both serve() and handler() support content negotiation automatically. If the client sends an Accept: application/x-msgpack header, the response is encoded as MessagePack. Same for devalue. JSON is the default fallback.

Content negotiation

Both serve() and handler() inspect the Accept header and respond in the format the client prefers:

Accept headerResponse formatWhen to use
application/json (or none)JSONDefault, works everywhere
application/x-msgpackMessagePackSmaller payloads, binary
application/x-devalue+jsondevalueRich types (Date, Map, Set, BigInt)

The server also checks Content-Type on incoming POST requests to decode the body correctly. No configuration needed on the server side — it all happens automatically.

Scalar API docs

Silgi can generate an OpenAPI 3.1.0 spec from your router and serve an interactive API reference powered by Scalar:

s.serve(appRouter, {
  : 3000,
  : true,
})

This gives you two extra routes:

  • /api/reference — interactive API documentation UI
  • /api/openapi.json — the raw OpenAPI specification

The spec is generated once at startup, so there is no per-request cost.

Customizing the docs

Pass an options object instead of true:

s.serve(appRouter, {
  : {
    : 'My API',
    : 'Built with Silgi',
    : '2.0.0',
    : [{ : 'https://api.example.com' }],
    : { : '[email protected]' },
    : { : 'http', : 'bearer', : 'JWT' },
  },
})

Scalar UI source

By default, the Scalar UI JavaScript is loaded from a CDN. You can change this with the cdn option:

s.serve(appRouter, {
  : {
    : 'My API',
    : 'local', // serve from node_modules (offline)
  },
})
ValueDescription
'cdn' (default)Load from cdn.jsdelivr.net
'unpkg'Load from unpkg.com
'local'Serve from node_modules — no external requests, fully offline
Custom URLAny URL string, e.g. '/assets/scalar.js' for self-hosting

The 'local' option requires @scalar/api-reference as a dependency. If the package is not found, Silgi falls back to CDN with a warning.

bash pnpm add @scalar/api-reference
bash npm install @scalar/api-reference
bash bun add @scalar/api-reference

Adding metadata to procedures

The generated docs pull information from your procedure definitions. Add route metadata to make the docs more useful:

const  = s
  .$input(z.object({ : z.number().optional() }))
  .$route({
    : 'List all users',
    : 'Returns a paginated list of users, ordered by creation date.',
    : ['users'],
  })
  .$resolve(({ ,  }) => .db.users.findMany({ : .limit }))

The route field accepts:

PropertyDescription
summaryShort description shown in the endpoint list
descriptionLonger description shown in the detail view
tagsGroup endpoints by tag
deprecatedMark an endpoint as deprecated
successStatusOverride the default 200 status
successDescriptionDescribe what a successful response looks like
cacheCache-Control header for GET procedure responses

Caching

Add a cache option to route to set Cache-Control headers on query responses. This enables HTTP-level caching by browsers, CDNs, and reverse proxies.

// Shorthand: cache for 60 seconds
const  = s.$route({ : 60 }).$resolve(({  }) => .db.users.findMany())

// Full control: custom Cache-Control value
const  = s.$route({ : 'public, max-age=300, stale-while-revalidate=60' }).$resolve(() => loadConfig())
ValueBehavior
numberSets Cache-Control: public, max-age=N (seconds)
stringSets the exact Cache-Control header value

The cache header is compiled once at startup and added to every response for that procedure — zero per-request overhead.

Caching only applies to GET procedures (.$route({ method: 'GET' })). POST procedures and subscriptions never get Cache-Control headers, even if you set cache on them.

HTTP/2

For production deployments that benefit from multiplexing and header compression, pass TLS certificates:

s.serve(appRouter, {
  : 443,
  : {
    : './certs/cert.pem',
    : './certs/key.pem',
  },
})

Output:

Silgi server running at https://127.0.0.1:443
  HTTP/2 enabled (with HTTP/1.1 fallback)

HTTP/2 requires TLS. Older clients that don't support HTTP/2 automatically fall back to HTTP/1.1 on the same port.

For local development, generate self-signed certificates with mkcert. For production, use certificates from your hosting provider or Let's Encrypt.

WebSocket support

WebSocket RPC is auto-enabled whenever your router contains a subscription. The same code works on Node, Bun, and Deno — silgi detects the runtime and wires crossws accordingly.

s.serve(appRouter, { : 3000 })
Silgi server running at http://127.0.0.1:3000
  WebSocket RPC at ws://127.0.0.1:3000/_ws (node)

HTTP and WebSocket share the same port. Every procedure — queries, mutations, subscriptions — is reachable over both transports. Pass ws: false if you want to disable WebSocket even when subscriptions exist.

See the WebSocket protocol page for the message format and client usage.

WebSocket options

Pass an options object instead of true to configure compression, keepalive, and payload limits:

s.serve(appRouter, {
  : 3000,
  : {
    : true, // enable per-message-deflate compression
    : 1_048_576, // 1 MB max message size (default)
    : 30_000, // ping every 30s, terminate unresponsive peers
  },
})
OptionTypeDefaultDescription
compressboolean | objectfalseEnable per-message-deflate. Pass an object to fine-tune zlib options.
maxPayloadnumber1_048_576Maximum message size in bytes. Connections sending larger messages are terminated.
keepalivenumber | false30_000Ping interval in ms. If the client doesn't respond with pong before the next ping, the connection is terminated. Set to false to disable.
binarybooleanfalseUse MessagePack binary protocol instead of JSON.
context(peer) => ctxContext factory — receives the crossws peer on each message.

These options also work with attachWebSocket() for standalone setups:

import {  } from 'silgi/ws'

(httpServer, appRouter, {
  : true,
  : 60_000,
  : 5 * 1024 * 1024, // 5 MB
})

Graceful shutdown

By default, Silgi handles SIGINT and SIGTERM signals to shut down cleanly — waiting for in-flight requests to finish before closing the server.

const  = await s.serve(appRouter, {
  : 3000,
  : true, // default
})

Programmatic shutdown

serve() returns a SilgiServer handle with a close() method:

const  = await s.serve(appRouter, { : 3000 })

// Graceful — waits for in-flight requests
await .close()

// Force — immediately terminates all connections
await .close(true)

The SilgiServer object exposes:

PropertyTypeDescription
urlstringServer URL (e.g. "http://127.0.0.1:3000")
portnumberActual listening port (resolved for port: 0)
hostnamestringConfigured hostname
close()(force?: boolean) => Promise<void>Shut down the server

Shutdown timeouts

For fine-grained control over shutdown behavior:

s.serve(appRouter, {
  : {
    : 10_000, // wait up to 10s for in-flight requests
    : 15_000, // force-kill after 15s
  },
})

Shutdown hook

The serve:stop hook fires when close() is called — useful for cleanup like closing database connections:

const  = silgi({
  : () => ({ : getDB() }),
  : {
    'serve:stop': async ({  }) => {
      await db.end()
      .(`Server at ${} stopped`)
    },
  },
})

Lifecycle hooks

Silgi fires hooks at key points during request processing. Register them in the hooks option when creating the instance:

const  = silgi({
  : () => ({ : getDB() }),
  : {
    : ({ ,  }) => {
      .(`--> ${}`)
    },
    : ({ , ,  }) => {
      .(`<-- ${} (${.toFixed(1)}ms)`)
    },
    : ({ ,  }) => {
      .(`ERR ${}:`, )
    },
    'serve:start': ({ , ,  }) => {
      .(`Server ready at ${}`)
    },
  },
})

You can also add or remove hooks after the instance is created:

// Add a hook
s.hook('error', ({  }) => reportToSentry())

// Remove a hook
s.removeHook('error', myHookFn)
HookWhen it firesPayload
requestBefore a request is processed{ path, input }
responseAfter a successful response{ path, output, durationMs }
errorWhen any error occurs{ path, error }
serve:startWhen the server starts listening{ url, port, hostname }
serve:stopWhen the server is shutting down{ url, port, hostname }

Hooks are for side effects like logging and metrics. They don't modify the request or response. For that, use guards and wraps.

Raw Response and binary streaming

Procedures can return a Response or ReadableStream for full control over the HTTP response — useful for file downloads, PDF generation, image serving, and binary data.

Returning a Response

Return a standard Response object for complete control over status, headers, and body:

const  = s.$input(z.object({ : z.string() })).$resolve(async ({ ,  }) => {
  const  = await .storage.getFile(.id)
  return new (, {
    : {
      'content-type': 'application/pdf',
      'content-disposition': `attachment; filename="${.id}.pdf"`,
    },
  })
})

Returning a ReadableStream

Return a ReadableStream for binary streaming. Silgi sends it with application/octet-stream:

const  = s.$resolve(async ({  }) => {
  return new ({
    async () {
      .(new ().('id,name\n'))
      for await (const  of .db.users.cursor()) {
        .(new ().(`${.id},${.name}\n`))
      }
      .()
    },
  })
})

When a procedure returns a Response or ReadableStream, content negotiation and JSON serialization are skipped entirely. The response is passed through as-is.

callable()

For server-side code where you need to call a procedure directly — without HTTP, serialization, or the client proxy. Useful in scripts, cron jobs, and seed files.

import {  } from 'silgi'

const  = s
  .$input(z.object({ : z.number().optional() }))
  .$resolve(({ ,  }) => .db.users.findMany({ : .limit }))

const  = (, {
  : () => ({ : getDB() }),
  : 5000, // optional — default 30s, null to disable
})

// Direct call — compiled pipeline, no HTTP
const  = await ({ : 10 })

Each call gets a fresh AbortSignal with the configured timeout. The compiled pipeline (guards, wraps, validation) runs exactly as it would through serve() or handler(). The only difference is there's no network involved.

What's next?

  • Testing — call procedures directly with createCaller() — no HTTP server needed
  • Client — connect to your server from the browser or another service
  • Middleware — guards and wraps that run inside your request pipeline
  • Plugins — add CORS, logging, rate limiting, and tracing
  • Protocols — learn about JSON, MessagePack, devalue, and WebSocket

On this page