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-beta.11 — 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. service add creates the initial versioned contract and aggregate. Add each procedure through the CLI so it updates the existing contract without hand-editing the aggregate:

netscript contract add-route users findByEmail \
  --method POST \
  --path /users/by-email \
  --input "z.object({ email: z.string().email() })" \
  --output "UsersListItemSchemaV1.optional()"

netscript contract inspect users
netscript contract inspect users --json

The command appends an @orpc/contract + Zod route to contracts/versions/v1/users.contract.ts. The generated module calls implement() so the result is ready for .handler() binding. The equivalent source shape is shown below so you know what to customize:

// 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);

The CLI maintains this aggregate 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 };

For a breaking schema change, promote the contract instead of editing v1 in place. This creates the v2 aggregate and updates the root contract exports:

netscript contract version add users --from v1 --to v2
netscript contract list

Step 3 — Implement the handlers

Create the binding stub with the paired service verb, then replace its intentional Not implemented error with your business logic:

netscript service add-handler users findByEmail

The command verifies that findByEmail exists in the users contract, then appends a compiling .handler() binding to services/users/src/routers/v1.ts. Import the contract through the project alias, not a relative path. At this scaffold stage handlers can 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

netscript service add already creates this entry point and registers it in appsettings and the Deno workspace; netscript service generate can regenerate Aspire helpers after later config edits. 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,
});

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 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.

To reverse this lifecycle, netscript service remove users removes the service workspace, appsettings/workspace registrations, paired contracts, and regenerated helpers. Pass --keep-contract when the API definition must remain published after the runtime is retired.

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.