Skip to main content
Alpha

Add a service

Goal: add a new typed oRPC service to an existing NetScript workspace — define its contract, implement the handlers, serve it with defineService, and confirm it answers on its own port over both its OpenAPI surface and the /api/rpc/* RPC endpoint that typed clients call.

This is a task-oriented recipe. It assumes you already have a NetScript workspace (created with netscript init) and that the netscript command is on your path. If you want the guided, build-up-from-scratch version that explains why each piece exists — contract to typed client to a Fresh island — follow the Build a service tutorial instead. For the full generated API of the service runtime, see the @netscript/service reference; for the concept behind contract-first wiring, read Contracts, explained.

A NetScript service is contract-first: a service is the runtime that implements an @orpc/contract definition. You author the contract once (route + zod input/output), implement() it, bind .handler()s, then hand the resulting router to defineService(...). The same contract object is what a typed client imports, so the service and its callers cannot drift.

Before you start

Prerequisites
NameTypeDescription
A NetScript workspace netscript init An existing project on disk. If you do not have one, scaffold it first — see the tutorials. Run commands from the workspace root.
The netscript CLI on your PATH Install globally with: deno install --global --allow-all --name netscript jsr:@netscript/cli@0.0.1-alpha.12 — then confirm with netscript --help.
A contracts workspace contracts/ The init scaffold ships a shared contracts/ workspace exposed as the @/contracts import alias. New services add their contract here so clients can import it.
A free port :3001 by default The example users service listens on :3001. Pick an unused port per service; it is read from the PORT env var with a literal fallback. Plugin API ports are already claimed (workers :8091, sagas :8092, triggers :8093, auth :8094).

This recipe adds a service named users on port 3001, mirroring the example the scaffold ships, so every path and code shape below matches a real generated workspace. Substitute your own name and port where you see them.

Step 1 — Scaffold the service (or add one at init time)

The fastest path is to let the CLI scaffold a service workspace for you. If you are creating a brand-new project, pass the service flags straight to netscript init:

netscript init my-app --db postgres --service --service-name users --service-port 3001 --yes

--db postgres is the recommended default; swap it for mysql, mssql, or sqlite to scaffold a different Prisma-backed engine (sqlite is file-backed and runs without an Aspire container).

To add a service to a workspace that already exists, use the netscript service add subcommand with the --name and --port flags (the service group also has list and generate subcommands; service generate only regenerates Aspire helper files):

# from the workspace root
netscript service add --name users --port 3001

Either path lays down a services/users/ workspace member with this shape:

services/users/
├── deno.json              # workspace member; exports ./src/main.ts
└── src/
    ├── main.ts            # defineService(router, { name, version, port, openapi })
    ├── router.ts          # version-namespaced router aggregation
    └── routers/
        ├── v1.ts          # binds the contract: v1.users.list.handler(...)
        └── health.ts      # health.check handler

Step 2 — Define the contract

A service implements a contract; define it first. Add a versioned contract module under contracts/versions/v1/ using @orpc/contract + zod, then implement() it so the result is ready for .handler() binding.

// contracts/versions/v1/users.contract.ts
import { z } from 'zod';
import { oc } from '@orpc/contract';
import { implement } from '@orpc/server';

export const UsersListItemSchemaV1 = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  summary: z.string().min(1),
  status: z.enum(['active', 'suspended']),
  createdAt: z.string().datetime(),
});

export const UsersContractV1 = {
  health: {
    check: oc.route({ method: 'GET' })
      .input(z.object({}).optional())
      .output(z.object({ status: z.literal('healthy'), service: z.string() })),
  },
  list: oc.route({ method: 'POST' })
    .input(z.object({ limit: z.number().int().positive().optional() }))
    .output(z.object({ items: z.array(UsersListItemSchemaV1) })),
};

// implement() turns the contract object into a .handler()-bindable surface.
export const UsersV1 = implement(UsersContractV1);

Re-export it from the contracts barrel so callers and the service share one type source:

// contracts/versions/v1/mod.ts
export { UsersContractV1, UsersV1 } from './users.contract.ts';
export const v1 = { users: UsersV1 };

Step 3 — Implement the handlers

Bind handlers to the implemented contract. Import the contract through the project alias, not a relative path. At this scaffold stage handlers return seeded in-memory records — no database is wired yet, which keeps the contract↔client proof isolated.

// services/users/src/routers/v1.ts
import { v1 } from '@my-app/contracts';

const seeded = [
  { id: 1, name: 'Ada Lovelace', summary: 'first programmer', status: 'active' as const, createdAt: new Date().toISOString() },
];

export const UsersV1 = {
  list: v1.users.list.handler(async ({ input }) => ({
    items: seeded.slice(0, input.limit ?? seeded.length),
  })),
};
// services/users/src/routers/health.ts
import { v1 } from '@my-app/contracts';

export const health = {
  check: v1.users.health.check.handler(async () => ({
    status: 'healthy' as const,
    service: 'users',
  })),
};

Aggregate the handlers into a version-namespaced router:

// services/users/src/router.ts
import { UsersV1 } from './routers/v1.ts';
import { health } from './routers/health.ts';

export const v1 = { users: { ...UsersV1, health } };
export const router = { v1 };

Step 4 — Serve it with defineService

The service entry point passes the router to defineService(...). The port reads from the PORT env var with a literal fallback so the same code runs locally and under Aspire.

// services/users/src/main.ts
import { defineService } from '@netscript/service';
import { router } from './router.ts';

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,
});

defineService stands up the Hono + oRPC runtime, mounting your router under both an OpenAPI surface (/api/v1/users/*) and the RPC surface (/api/rpc/v1/...). The default RPC mount point is /api/rpc and the OpenAPI mount point is /api; both are overridable via the builder's rpcPath / apiPath options if you reach for createService.

Step 5 — Run and verify

Start just this service workspace directly, or let aspire start orchestrate it alongside the rest of your resources:

# run only the users service
deno task --cwd services/users dev

You should see it bind on :3001. Confirm the runtime answers — the health route over HTTP, and the RPC surface that typed clients call:

# OpenAPI / HTTP surface
curl http://localhost:3001/api/v1/users/health

# RPC surface (what the generated typed client uses)
curl -X POST http://localhost:3001/api/rpc/v1/users/list \
  -H 'content-type: application/json' -d '{"limit":10}'

A healthy service returns {"status":"healthy","service":"users"} from the health route and the seeded items array from list. A typed client imports UsersContractV1 from @my-app/contracts and calls .list(...) with full input/output inference — no codegen, no drift.

See also

Manage the service over its lifetime by editing its contract under contracts/versions/ and re-running your workspace gates (deno task check). For the concepts behind contract-first services, read the contracts explanation; for the capability overview, see Services.