Skip to main content
0.0.x

Services & contracts

Establish the public contract once; the handler, typed client, and OpenAPI surface are all derived from it — so an agent (or a teammate) never spends a turn keeping them in sync.

A NetScript service is a typed HTTP runtime that implements an @orpc/contract definition. You establish the versioned public contract (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 its assigned port with both an OpenAPI surface (/api/v1/users/*) and a typed oRPC endpoint (/api/rpc/*).

For a DB-backed service, the public Zod shape normally has a generated predecessor: run netscript db generate, import from @database/zod, and narrow or extend the model before binding it to the route. See the generated-schema step and the two contract origins. DB-less services author the same public shape directly.

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.

Why contract-first: the drift you never debug

Every hand-synced API dies the same way. A field gets renamed on the server; the client wrapper, the API docs, and the front-end types each get updated in separate steps — and one of them gets missed. The bug ships silently and surfaces later as a runtime undefined, and the fix costs a round of archaeology to find which of the three copies drifted.

A NetScript service removes the wire-shape copies. The contract — plain @orpc/contract routes with Zod input/output schemas, versioned in its own package — is the only declaration of the wire shape. The handler binds to it via implement(), the client imports it, and the OpenAPI spec is generated from it. Rename a field and the build fails in the handler and the caller before anything ships. The in-repo Storefront tutorial uses this same path: its catalog contract derives from generated Product schemas, the service implements that contract, and callers consume the same type without a separate "keep the API docs in sync" step.

The same property is why contract-first services suit AI-agent codebases. Encore's NestJS-alternatives article names the failure mode: TypeScript's backend ecosystem has many valid ways to structure validation, data access, and project layout, so coding agents pick a different combination on every prompt. Encore's answer is to fix the conventions in application code; NetScript locks the convention at the contract layer — one way to declare a route (contract + zod schemas + implement()), and everything downstream derived from it. An agent working in the codebase reads one declared shape and follows it, instead of inventing a new client wrapper per session.

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. When a generated database schema exists, it precedes this public declaration rather than replacing it.

// 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'), // note: your scaffold's port will differ
  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 }) // note: your scaffold's port will differ
  .withCors()
  .withLogger()
  .withOpenAPI({ title: 'Workers API' })
  .withDocs()
  .withDatabase(dbClient)
  .withContext(() => ({ workers: runtime }))
  .withRPC({ traceContext: true })
  .withHealth()
  .withServiceInfo()
  .onStartup(async () => {/* seed, warm caches */})
  .serve();

Aspire injects PORT at runtime, so the entrypoint reads it from the environment; the typed source of truth is your netscript.config.ts services.<name>.port field, which the scaffold wires as the fallback default — set the port there rather than editing this line.

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') (allocated dynamically per project at scaffold time).
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().
tls ServiceTlsOptions? Opt-in TLS: { cert, key } as PEM strings. When set, the listener serves HTTPS and negotiates HTTP/2 via ALPN automatically. Forwarded to serve() as .serve({ tls }). See TLS & HTTP/2 below.

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
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).
Assigned port The service port allocated at scaffold time (read from Deno.env.get('PORT')).

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, // note: your scaffold's port will differ
  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.
tls ServiceTlsOptions? Opt-in TLS ({ cert, key } PEM strings). Present → HTTPS + HTTP/2 via ALPN; absent → plain HTTP/1.1. See TLS & HTTP/2 below.
// 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, // note: your scaffold's port will differ
    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();

TLS & HTTP/2 (opt-in)

By default a service listens over plain HTTP. Pass a tls option and the same listener serves HTTPS and negotiates HTTP/2 automatically via ALPN — HTTP/1.1 stays available as a fallback, and there is no separate http2 flag. TLS is purely additive: a service that omits tls is unchanged.

tls is a ServiceTlsOptions object whose cert and key are PEM-encoded strings — the certificate chain and the private key as contents, not file paths. It is accepted by both defineService(router, { …, tls }) and the fluent builder's .serve({ tls }).

// services/users/src/main.ts — serve HTTPS + HTTP/2 with an inline cert/key
import { defineService } from '@netscript/service';
import { router } from './router.ts';

await defineService(router, {
  name: 'users',
  port: 3000, // note: your scaffold's port will differ
  tls: {
    cert: await Deno.readTextFile('cert.pem'), // PEM contents
    key: await Deno.readTextFile('key.pem'),   // PEM contents
  },
});
// Fluent equivalent: createService(router, { name }).withHealth().serve({ tls: { cert, key } })

If you omit tls, the listener falls back to environment configuration: set both NETSCRIPT_TLS_CERT_FILE and NETSCRIPT_TLS_KEY_FILE and the service serves HTTPS from those files. Unlike the inline cert/key (PEM contents), these env vars are file paths — and both are required; one alone is ignored.

ServiceTlsOptions (@netscript/service) + env fallback
NameTypeDescription
cert string (required) PEM-encoded certificate chain — the contents, not a path.
key string (required) PEM-encoded private key — the contents, not a path.
NETSCRIPT_TLS_CERT_FILE env (path) Fallback when tls is omitted: file path to the PEM certificate chain. Both env vars must be set together.
NETSCRIPT_TLS_KEY_FILE env (path) Fallback when tls is omitted: file path to the PEM private key. Both env vars must be set together.

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 }) // note: your scaffold's port will differ
  .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