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
| Name | Type | Description |
|---|---|---|
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 @ |
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.