Skip to main content
Alpha

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/*).

A browser request flows into the service router, through middleware to the matched contract handler, optionally to the database, and back as a typed response.
Request lifecycle: browser → service router → middleware → contract handler → database → typed response.

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.

DefineServiceOptions (extends ServiceConfig)
NameTypeDescription
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.

Users service surface (port 3001)
NameTypeDescription
/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).

OpenAPI / Scalar primitives (@netscript/service)
NameTypeDescription
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/*.
OpenAPIConfig (createOpenAPISpec)
NameTypeDescription
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).
ScalarDocsOptions (createScalarDocs)
NameTypeDescription
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 (statushealthy | 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.

Health primitives & checks (@netscript/service)
NameTypeDescription
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>; reports not-ready until every check resolves true. Mount at /health/ready.
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 as a named check.
// 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').

Shutdown types (@netscript/service)
NameTypeDescription
ShutdownHook (context: ShutdownContext) => Promise | void 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.
ServeOptions (serve()) — shutdown-relevant keys
NameTypeDescription
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 } }).

@netscript/service/auth surface
NameTypeDescription
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.

service