Services & contracts
A NetScript service is a typed HTTP runtime that implements an
@orpc/contract definition. You author the contract once
(route + zod input/output), implement() it, bind .handler()s, and serve the resulting
router on a Hono + oRPC runtime. The contract object is the single source of truth: the
same object a typed client imports is the one the server implements, so caller and server
cannot drift. The example users service answers on port 3001 with both an
OpenAPI surface (/api/v1/users/*) and a typed oRPC endpoint (/api/rpc/*).
What it is
A service is the Layer 3 preset (defineService) over a three-layer package. Layer 1 is
small primitives — health, error, RPC, OpenAPI, and Scalar docs handlers you can mount in any
Hono app. Layer 2 is createService(), a fluent builder that materializes a mountable
ServiceApp (via build()) or starts a Deno listener (via serve()). Layer 3 is
defineService(), the curated one-call preset used by generated service entrypoints. Every
layer takes the oRPC router as its input, and the same contract the router implements is the
one a typed client imports — so a renamed field is a compile error on both sides. The full
type story is in Contracts.
Learn → / Do →
The contract is the source of truth
Before there is a service there is a contract. A contract is plain @orpc/contract
routes whose inputs and outputs are zod schemas, collected into an object and passed to
implement() from @orpc/server. implement() returns a .handler()-bindable object
the service router consumes. The example workspace versions its contracts under
contracts/versions/v1/ and re-exports them as @<project>/contracts — so a contract
bump is an explicit, reviewable version directory, never an accidental break.
// contracts/versions/v1/users.contract.ts
import { z } from 'zod';
import { oc } from '@orpc/contract';
import { implement } from '@orpc/server';
export const UsersContractV1 = {
health: oc.route({ method: 'GET' }).input(z.object({}).optional()).output(UsersHealthSchemaV1),
list: oc.route({ method: 'POST' }).input(UsersListInputSchemaV1).output(UsersListResponseSchemaV1),
updateStatus: oc.route({ method: 'POST' }).input(UsersUpdateStatusInputSchemaV1).output(UsersUpdateStatusResponseSchemaV1),
};
// `implement()` produces the .handler()-bindable object the service router consumes.
export const UsersV1 = implement(UsersContractV1);
The chain end-to-end — contract → implement() → .handler() → typed client → query →
island — is laid out in Contracts. The key property: the
client imports the same contract object, so a renamed field or a changed output shape is
a compile error in both the handler and the caller before it can ship.
Headline API: two ways to construct a service
NetScript ships two service-construction APIs, and the example project uses both.
Workspace services use the one-call defineService(router, options) — the right default
for the 80% case. Plugin API services (workers, sagas, auth) use the fluent
createService(router, options).with*().serve() builder when they need to add CORS,
OpenAPI, a database client, auth middleware, or custom context step by step. Both stand up
the same Hono + oRPC runtime; defineService is a curated preset over the same builder.
// services/users/src/main.ts
import { defineService } from '@netscript/service';
import { router } from './router.ts';
// One call wires CORS, request logging, OpenAPI, RPC, and health endpoints.
await defineService(router, {
name: 'users',
version: '1.0.0',
port: parseInt(Deno.env.get('PORT') || '3001'),
openapi: { title: 'Users API', description: 'users service' },
debug: true,
});
// plugins/workers/services/src/main.ts — step-by-step composition
import { createService } from '@netscript/service';
import { router } from './router.ts';
await createService(router, { name: 'workers', version: '1.0.0', port: 8091 })
.withCors()
.withLogger()
.withOpenAPI({ title: 'Workers API' })
.withDocs()
.withDatabase(dbClient)
.withContext(() => ({ workers: runtime }))
.withRPC({ traceContext: true })
.withHealth()
.withServiceInfo()
.onStartup(async () => {/* seed, warm caches */})
.serve();
defineService(router, options) — the preset options
DefineServiceOptions extends the base ServiceConfig (name, version, port) and adds
the preset-only keys below. These are the complete option keys confirmed against the
package surface — nothing is omitted.
| Name | Type | Description |
|---|---|---|
name |
string (required) |
Service name used for logging, telemetry, and health-check labels. |
version |
string? |
Service version (e.g. '1.0.0'); surfaced on /health and the OpenAPI spec. |
port |
number? |
Default listener port if serve() is not passed an explicit port. The generated entrypoint reads Deno.env.get('PORT') || '3001'. |
db |
DbContext? |
Database context injected as context.db. Accepts a single Prisma client (with $queryRaw) or a multi-db record like { netscript, mdb, prosco, prev }; the first value exposing $queryRaw is auto-wired as the /health and /health/ready probe client. |
openapi |
{ title?; description? }? |
Turns on the generated OpenAPI spec endpoint and the Scalar docs UI with this title/description. |
debug |
boolean? |
Enables verbose oRPC logging. Defaults to the NETSCRIPT_DEBUG env var. |
auth |
{ authn: AuthnOptions; authz?: AuthzOptions }? |
Installs the authentication (and optional authorization) gate on guarded paths — the preset form of .withAuthn()/.withAuthz(). |
Endpoints & ports
A defineService runtime exposes its OpenAPI routes, a typed oRPC endpoint mounted under
/api/rpc/*, and a health check. The example users service is reachable once
aspire start is up (Aspire provisions Postgres/Redis first) or when you run it directly
with deno task --cwd services/users dev.
| Name | Type | Description |
|---|---|---|
/api/v1/users/* |
HTTP/OpenAPI |
REST surface generated from the contract (list, updateStatus, health). |
/api/rpc/* |
oRPC |
Typed RPC endpoint a generated client calls — same contract object, no drift. This is the served default path (not /rpc). |
/health |
HTTP |
Liveness/readiness check; anonymous by default (excluded from authn). |
:3001 |
port |
Default service port; read from Deno.env.get('PORT') || '3001'. |
OpenAPI spec & Scalar docs
Passing openapi to defineService (or calling .withOpenAPI().withDocs() on the builder)
turns on a REST surface generated from the same contract, a machine-readable OpenAPI JSON
spec, and the Scalar interactive docs UI. Under the hood three Layer-1 primitives do the
work, and you can mount them directly in any host Hono app when you need finer control:
createOpenAPISpec serves the spec JSON, createScalarDocs serves the Scalar HTML page, and
createOpenAPIHandler serves the REST routes themselves (it adds the ZodSmartCoercionPlugin
so query-string values coerce to their schema types automatically).
| Name | Type | Description |
|---|---|---|
createOpenAPISpec(router, config) |
→ ServiceHandler |
Serves the OpenAPI JSON spec. config is OpenAPIConfig (see below). Mount at e.g. /api/openapi.json. |
createScalarDocs(options) |
→ ServiceHandler |
Serves the Scalar docs UI HTML that loads the spec. options is ScalarDocsOptions (specUrl, title?, theme?). |
createScalarJs() |
→ ServiceHandler |
Serves the bundled Scalar JS so the docs UI works offline without CDN access. |
createOpenAPIHandler(router, config?) |
→ FetchHandler |
The OpenAPI REST request handler (with ZodSmartCoercionPlugin). config is RPCHandlerConfig; mount under /api/*. |
| Name | Type | Description |
|---|---|---|
title |
string (required) |
API title shown in the spec and docs. |
version |
string (required) |
API version, e.g. '1.0.0'. |
description |
string? |
Longer API description. |
servers |
Array<{ url; description? }>? |
Server URLs advertised in the spec (e.g. staging vs. production base URLs). |
| Name | Type | Description |
|---|---|---|
specUrl |
string (required) |
URL the docs UI fetches the OpenAPI spec from, e.g. '/api/openapi.json'. |
title |
string? |
Docs page title. |
theme |
'default' | 'kepler' | 'moon' | 'purple' | 'saturn' |
Scalar UI theme. |
// services/users/src/main.ts
import { defineService } from '@netscript/service';
import { router } from './router.ts';
// Turns on the OpenAPI spec + Scalar docs UI in one option.
await defineService(router, {
name: 'users',
version: '1.0.0',
port: 3001,
openapi: {
title: 'Users API',
description: 'User management service',
},
});
// host/openapi-routes.ts — wire the primitives onto an existing Hono app
import {
createOpenAPISpec,
createScalarDocs,
createScalarJs,
} from '@netscript/service';
import { router } from './router.ts';
app.get('/api/openapi.json', createOpenAPISpec(router, {
title: 'Users API',
version: '1.0.0',
description: 'User management service',
}));
app.get('/api/docs', createScalarDocs({
specUrl: '/api/openapi.json',
title: 'Users API',
theme: 'kepler',
}));
// Serve Scalar offline (no CDN dependency)
app.get('/api/docs/scalar.js', createScalarJs());
Health, readiness & liveness
The preset wires /health automatically; the underlying primitives let you compose probes
by hand or mount them in a host app. createHealthHandler runs every registered
HealthCheck in parallel and returns an aggregate HealthResponse
(status ∈ healthy | degraded | unhealthy). createReadinessHandler takes an array of
async boolean checks and is the one to point an orchestrator's readiness probe at — it
fails until dependencies (DB, caches) are reachable. createLivenessHandler is a bare
"is the process up" probe that returns 200 with no dependency checks, for an orchestrator's
liveness probe. The healthChecks namespace ships pre-built checks for the common
dependencies so you don't hand-roll them.
| Name | Type | Description |
|---|---|---|
createHealthHandler(options?) |
→ ServiceHandler |
Runs all checks in parallel, returns an aggregate HealthResponse. options: HealthHandlerOptions { checks?, version?, includeDetails? (default true) }. |
createReadinessHandler(checks) |
→ ServiceHandler |
Readiness probe: checks is Array<() => Promise |
createLivenessHandler() |
→ ServiceHandler |
Liveness probe: 200 OK while the process runs, no dependency checks. Mount at /health/live. |
healthChecks.database(db) |
→ HealthCheck |
Pre-built DB probe; runs a $queryRaw SELECT 1-style ping against the Prisma client. |
healthChecks.kv() |
→ HealthCheck |
Pre-built check for the KV store. |
healthChecks.service(name, baseUrl) |
→ HealthCheck |
Probes another service's health endpoint by name + base URL. |
healthChecks.custom(name, fn) |
→ HealthCheck |
Wraps your own async () => Promise |
// host/health-routes.ts — readiness + liveness + aggregate health
import {
createHealthHandler,
createLivenessHandler,
createReadinessHandler,
healthChecks,
} from '@netscript/service';
import { db } from '@database';
// Aggregate health (parallel checks + details)
app.get('/health', createHealthHandler({
version: '1.0.0',
checks: [healthChecks.database(db)],
}));
// Liveness: is the process up?
app.get('/health/live', createLivenessHandler());
// Readiness: are dependencies reachable?
app.get('/health/ready', createReadinessHandler([
async () => { await db.$queryRaw`SELECT 1`; return true; },
]));
Graceful shutdown
serve() installs SIGINT/SIGTERM (or SIGBREAK on Windows) handlers and drains in-flight
requests before exiting. Register teardown work with .onShutdown(hook) on the builder — a
ShutdownHook receives a ShutdownContext (reason, optional signal) and runs during the
drain. The drain is bounded by drainTimeoutMs (default 30_000); when it elapses the
service stops anyway and the resulting ShutdownReport records timedOut: true plus a
per-hook ShutdownHookOutcome for each registered hook. Calling running.stop() triggers
the same drain manually (reason 'manual').
| Name | Type | Description |
|---|---|---|
ShutdownHook |
(context: ShutdownContext) => Promise |
Async teardown callback registered via .onShutdown(). Run in registration order during the drain. |
ShutdownContext |
{ reason: ShutdownReason; signal?: Deno.Signal } |
Passed to each hook. signal is set only when reason is 'signal'. |
ShutdownReason |
'signal' | 'manual' | 'startup-failure' |
Why the drain started: an OS signal, a manual stop() call, or a failed startup hook. |
ShutdownHookOutcome |
{ ok: boolean; error?: string } |
Per-hook result; error holds a normalized message when a hook throws or rejects. |
ShutdownReport |
{ reason; timedOut: boolean; hooks: readonly ShutdownHookOutcome[] } |
Final result of a completed shutdown — the reason, whether the timeout elapsed, and every hook outcome in execution order. |
| Name | Type | Description |
|---|---|---|
port |
number? |
Preferred listener port; use 0 for an ephemeral port. |
signal |
AbortSignal? |
External signal that stops the listener when aborted (drives the drain). |
drainTimeoutMs |
number? |
Max time to wait for in-flight requests and shutdown hooks before forcing exit. Defaults to 30_000. |
handleSignals |
boolean? |
Install SIGINT/SIGTERM (or SIGBREAK) handlers. Defaults to true. |
// services/users/src/main.ts — drain + teardown on signal or manual stop
import { createService } from '@netscript/service';
import { router } from './router.ts';
import { db } from '@database';
const running = await createService(router, { name: 'users', version: '1.0.0' })
.withRPC()
.withHealth()
.onShutdown(async ({ reason, signal }) => {
// reason: 'signal' | 'manual' | 'startup-failure'
audit.record({ event: 'shutdown', reason, signal });
await db.$disconnect();
})
.serve({
port: 3001,
drainTimeoutMs: 10_000, // wait up to 10s for in-flight work
handleSignals: true, // SIGINT/SIGTERM/SIGBREAK
});
// Later, in tests or a supervisor: trigger the same drain manually.
await running.stop();
Service-layer authn / authz middleware
The service builder ships a provider-agnostic authentication and authorization seam in
@netscript/service/auth. It is a thin Hono-middleware layer over the request pipeline —
deliberately distinct from the auth plugin, which composes a
sign-in/session backend (kv-oauth, WorkOS, better-auth). Use this seam when a service needs
to gate its own routes — verify a credential, trust an upstream identity header, or check a
scope — without taking on an interactive identity provider.
Two stages wrap the request: .withAuthn() resolves a Principal (authentication) via an
AuthenticatorPort, and .withAuthz() makes an AuthzDecision from that principal
(authorization) via an AuthorizerPort. By default the /api surface is protected and
/health is anonymous — both configurable through AuthnOptions.protect /
AuthnOptions.allowAnonymous. The preset form is defineService(router, { auth: { authn, authz } }).
| Name | Type | Description |
|---|---|---|
createStaticCredentialAuthenticator |
authenticator |
Matches a configured static credential (e.g. API key / shared secret) → Principal. |
createTrustedHeaderAuthenticator |
authenticator |
Trusts an identity asserted by an upstream proxy header (e.g. behind a gateway). |
createScopeAuthorizer |
authorizer |
Allows/denies a request by checking the Principal's scopes against required scopes. |
.withAuthn({ authenticator, protect?, allowAnonymous? }) |
builder method |
Installs the authentication gate (AuthnOptions). protect defaults to ['/api']; allowAnonymous defaults to ['/health']. |
.withAuthz({ authorizer, denyByDefault? }) |
builder method |
Installs the authorization gate (AuthzOptions) from the Principal. denyByDefault fails closed and defaults to true. |
// Gate a service with a static credential + scope check
import { createService } from '@netscript/service';
import {
createStaticCredentialAuthenticator,
createScopeAuthorizer,
} from '@netscript/service/auth';
import { router } from './router.ts';
const authenticator = createStaticCredentialAuthenticator({
credentials: {
[Deno.env.get('SERVICE_API_KEY') ?? '']: {
subject: 'service:ci',
scopes: ['users:write'],
},
},
});
const authorizer = createScopeAuthorizer({
rules: [{ match: () => true, requireScopes: ['users:write'] }],
denyByDefault: true,
});
await createService(router, { name: 'users', version: '1.0.0', port: 3001 })
.withRPC()
.withAuthn({ authenticator })
.withAuthz({ authorizer })
.withHealth() // /health stays anonymous
.serve();
// Trust identity asserted by an upstream proxy/gateway
import { createTrustedHeaderAuthenticator } from '@netscript/service/auth';
// The Principal is assembled internally from these header NAMES — point at the
// headers your gateway sets; you do not map values yourself.
const authenticator = createTrustedHeaderAuthenticator({
subjectHeader: 'x-forwarded-user',
scopesHeader: 'x-forwarded-scopes', // optional; space/comma-separated
});
// then: .withAuthn({ authenticator })
Production notes
Reference
This hub is intentionally thin — the full generated API lives in the reference.