Development

Write a TypeScript service

A working backend service that connects to Trellis, handles RPCs, and subscribes to events.

This tutorial walks through building a TypeScript service from scratch using published Trellis packages. By the end you will have a working service that connects to NATS, mounts an RPC handler, and subscribes to events.

Before starting, read Trellis Concepts for background on contracts, RPCs, events, and packages.

What you need

1. Create the project

Your service repository is completely separate from the Trellis source tree:

mkdir my-service && cd my-service
deno init

Add the Trellis packages your service needs to your import map in deno.json:

{
  "imports": {
    "@qlever-llc/trellis-contracts": "npm:@qlever-llc/trellis-contracts",
    "@qlever-llc/trellis-server": "npm:@qlever-llc/trellis-server",
    "@qlever-llc/trellis-server/deno": "npm:@qlever-llc/trellis-server/deno",
    "@qlever-llc/trellis-sdk-auth": "npm:@qlever-llc/trellis-sdk-auth",
    "@qlever-llc/trellis-sdk-core": "npm:@qlever-llc/trellis-sdk-core",
    "@qlever-llc/trellis-result": "npm:@qlever-llc/trellis-result",
    "@qlever-llc/trellis-telemetry": "npm:@qlever-llc/trellis-telemetry",
    "@sinclair/typebox": "npm:@sinclair/typebox"
  }
}

If you are using Node instead, install these with npm install.

2. Define your schemas

Create schemas.ts with the TypeBox schemas for your RPCs and events:

import { type Static, Type } from "@sinclair/typebox";

export const MyListRequestSchema = Type.Object({
  limit: Type.Optional(Type.Number({ default: 50 })),
});
export type MyListRequest = Static<typeof MyListRequestSchema>;

export const MyListResponseSchema = Type.Object({
  items: Type.Array(Type.Object({
    id: Type.String(),
    name: Type.String(),
  })),
  limit: Type.Number(),
});
export type MyListResponse = Static<typeof MyListResponseSchema>;

export const MyItemCreatedEventSchema = Type.Object({
  id: Type.String(),
  name: Type.String(),
  createdAt: Type.String(),
});
export type MyItemCreatedEvent = Static<typeof MyItemCreatedEventSchema>;

3. Define the contract

Create contract.ts. The contract declares every RPC, event, dependency, and resource your service uses:

import { defineContract } from "@qlever-llc/trellis-contracts";
import { HealthResponseSchema, HealthRpcSchema } from "@qlever-llc/trellis-server";
import { auth } from "@qlever-llc/trellis-sdk-auth";
import { core } from "@qlever-llc/trellis-sdk-core";
import {
  MyItemCreatedEventSchema,
  MyListRequestSchema,
  MyListResponseSchema,
} from "./schemas.ts";

export const myService = defineContract({
  id: "my-service@v1",
  displayName: "My Service",
  description: "Serve list RPCs and publish item creation events.",
  kind: "service",
  uses: {
    core: core.use({
      rpc: { call: ["Trellis.Bindings.Get", "Trellis.Catalog"] },
    }),
    auth: auth.use({
      events: { subscribe: ["Auth.Connect"] },
    }),
  },
  resources: {
    kv: {
      items: {
        purpose: "Store item records",
        history: 1,
        ttlMs: 0,
      },
    },
  },
  rpc: {
    "MyService.Health": {
      version: "v1",
      inputSchema: HealthRpcSchema,
      outputSchema: HealthResponseSchema,
      capabilities: { call: [] },
      errors: ["UnexpectedError"],
    },
    "MyService.List": {
      version: "v1",
      inputSchema: MyListRequestSchema,
      outputSchema: MyListResponseSchema,
      capabilities: { call: ["admin"] },
      errors: ["ValidationError", "UnexpectedError"],
    },
  },
  events: {
    "MyService.ItemCreated": {
      version: "v1",
      eventSchema: MyItemCreatedEventSchema,
      capabilities: {
        publish: ["service"],
        subscribe: ["service"],
      },
    },
  },
});

export const { CONTRACT_ID, CONTRACT, CONTRACT_DIGEST, API, use } = myService;

Key things to note:

  • uses declares cross-contract dependencies — without these, Trellis will not grant access to those RPCs/events
  • resources.kv.items requests a KV bucket with the logical alias items — Trellis assigns the physical name at install time
  • capabilities controls who can call each operation

4. Write the service entry point

Create main.ts:

import { connectService } from "@qlever-llc/trellis-server/deno";
import { myService } from "./contract.ts";

const service = await connectService(myService, "my-service", {
  sessionKeySeed: Deno.env.get("MY_SERVICE_SESSION_KEY_SEED")!,
  nats: {
    servers: Deno.env.get("NATS_SERVERS") ?? "localhost",
    sentinelCredsPath: Deno.env.get("NATS_SENTINEL_CREDS"),
  },
  server: {},
});

connectService automatically verifies that the contract is active in the Trellis catalog and resolves all resource bindings declared in the contract. If the contract is not installed, it throws with a clear error message.

For Node, change the import to @qlever-llc/trellis-server/node and replace Deno.env.get(...) with process.env.

5. Open KV stores

Resource bindings are resolved during connectService and exposed as handles on the service object. Open a typed KV store directly from the handle:

import { isErr } from "@qlever-llc/trellis-result";
import { MyItemSchema } from "./schemas.ts";

const result = await service.kv.items.open(MyItemSchema);
const itemsKV = result.take();
if (isErr(itemsKV)) throw itemsKV.error;

No need to know bucket names or configure history/TTL — those come from the installed contract binding.

6. Mount RPC handlers

Mount handlers that match the operations declared in your contract:

await service.trellis.mount("MyService.List", async (req) => {
  return Result.ok({
    items: [],
    limit: req.limit ?? 50,
  });
});

7. Subscribe to events

Subscribe to events from other contracts that you declared in uses:

await service.trellis.event("Auth.Connect", {}, async (event) => {
  console.info("user connected", { origin: event.origin, id: event.id });
  return Result.ok(undefined);
});

8. Handle shutdown

Deno.addSignalListener("SIGTERM", async () => {
  await service.stop();
  Deno.exit(0);
});

For Node, use process.on("SIGTERM", ...).

9. Build and verify contract artifacts

Keep the contract source with your service and generate artifacts into build output:

my-service/
  contracts/my_service.ts
  schemas.ts
  main.ts
  dist/contracts/
  generated/sdk/

Build and verify it with the CLI:

trellis contracts build \
  --source ./contracts/my_service.ts \
  --out-manifest ./dist/contracts/trellis.my-service@v1.json

trellis contracts verify --source ./contracts/my_service.ts

10. Generate an SDK for consumers

Other services and apps that want to call your RPCs or subscribe to your events need a typed SDK package. Generate one from the contract source as part of the same build:

trellis contracts build \
  --source ./contracts/my_service.ts \
  --out-manifest ./dist/contracts/trellis.my-service@v1.json \
  --ts-out ./generated/sdk/ts \
  --package-name "@my-org/sdk-my-service"

This produces a Deno package under ./generated/sdk/ts/. The generated SDK version matches the version declared by the workspace that owns the contract source. It includes:

  • mod.ts — the contract module and use(...) export
  • types.ts — typed request, response, and event definitions
  • schemas.ts — JSON schemas extracted from the manifest
  • contract.ts — contract ID and digest constants
  • scripts/build_npm.ts — a build script that uses dnt to produce an npm package

Consumers import the SDK like any other Trellis contract package:

import { myService } from "@my-org/sdk-my-service";

// Declare a dependency on your service
const app = defineContract({
  // ...
  uses: {
    myService: myService.use({
      rpc: { call: ["MyService.List"] },
    }),
  },
});

Regenerate the SDK whenever the contract changes. This is the same pattern that @qlever-llc/trellis-sdk-auth and @qlever-llc/trellis-sdk-core follow.

11. Install and run

Install the contract into your Trellis environment directly from source during development:

trellis service install --source ./contracts/my_service.ts

If you ship the service as an OCI image, embed the generated contract artifact in the image and label its path so operators can install directly from the image:

FROM docker.io/denoland/deno:2.7.7

WORKDIR /app

LABEL io.trellis.contract.path="/trellis/contract.json"

COPY . .
COPY dist/contracts/trellis.my-service@v1.json /trellis/contract.json

CMD ["deno", "run", "--allow-net", "--allow-env", "main.ts"]

Then operators can install or upgrade from the image instead of checking out the repo:

trellis service install --image ghcr.io/my-org/my-service:1.2.3
trellis service upgrade --image ghcr.io/my-org/my-service:1.2.4

Trellis looks for the contract at the labeled path first and falls back to /trellis/contract.json when no label is present.

Then run the service:

MY_SERVICE_SESSION_KEY_SEED=<seed> \
NATS_SERVERS=localhost \
NATS_SENTINEL_CREDS=./path/to/sentinel.creds \
deno run --allow-net --allow-env main.ts

Development loop

  1. Update schemas and the defineContract(...) module
  2. Rebuild contract artifacts and SDKs with trellis contracts build
  3. Upgrade the contract in Trellis if the contract changed
  4. Restart the service