Skip to main content
Alpha

Discover services

Goal: call another plugin's (or another workspace member's) oRPC service from your app, end to end — declare the dependency so Aspire injects the callee's URL, then obtain a fully typed client from @netscript/sdk that resolves that URL at request time. No registry, no hardcoded localhost:<port>, no codegen.

alpha

There is no service-registry API in NetScript. "Discovery" is two cooperating mechanics: Aspire injects each referenced service's resolved endpoint as an environment variable (services__<name>__http__0), and the SDK's typed client reads that variable lazily via @netscript/sdk/discovery when it builds the request URL. Declare the reference, import the contract, construct the client — that is the whole recipe.

The shared contract object feeds the generated typed client; the client resolves the callee's URL from an Aspire-injected services__ env var and issues a cache-first oRPC query that the same contract's service implements.
Discovery flow: shared contract → typed client → URL resolved from the Aspire services__ env var → oRPC call to the service that implements the same contract.

Before you start

Prerequisites
NameTypeDescription
A NetScript workspace netscript init An existing project with at least the caller (an app, plugin, or service) and the callee service already present. Run commands from the workspace root.
The callee service services// A target service that answers on its own port over /api/rpc/* — e.g. the example users service on :3001. See the Add a service recipe to create one.
@netscript/sdk import alias The SDK provides the typed client (createServiceClient / defineServices) and the discovery readers (@netscript/sdk/discovery). It is a workspace dependency of apps and consuming services.
The shared contract @/contracts Both the service and the caller import the SAME oRPC contract object through the project alias, so the client's input/output types are inferred — never duplicated.
Aspire (for resolved URLs) aspire start Aspire injects the services____http__ env vars that discovery reads. Without it you must set those vars yourself (see pitfalls).

This recipe assumes the callee is the example users service (port 3001) and the caller is an app or another service. Substitute your own service name throughout.

Step 1 — Declare the service reference

The caller declares which services it depends on by name in its resource entry under ServiceReferences. The init scaffold already writes this into the Aspire appsettings.json when you scaffold an app against a service; to add a dependency to an existing resource, add the callee's name to that resource's ServiceReferences array.

// aspire/appsettings.json — the caller (an app here) depends on the users service
{
  "NetScript": {
    "Apps": {
      "web": {
        "Runtime": "deno",
        "Type": "app",
        "Port": 3000,
        "ServiceReferences": ["users"]
      }
    }
  }
}

ServiceReferences is the live field. The names you list here must match the service resource keys under NetScript.Services.

Step 2 — Regenerate the Aspire helpers

The register-services helper that performs the two-pass wiring is generated from appsettings.json. After editing ServiceReferences, regenerate so the new dependency is emitted into the AppHost helpers:

# from the workspace root — regenerates the Aspire helper files only
netscript service generate

service generate only rewrites the Aspire helper files (it does not touch your service code). The next aspire start will inject the services__users__http__0 variable into the caller's environment.

Step 3 — Construct the typed client

In the caller, import the same contract object the service implements and hand it to createServiceClient. The serviceName you pass is the discovery key — it must match the name you referenced in Step 1. The client is fully typed from the contract; there is no generated client file to import.

// web/src/clients/users.ts — a typed client for the discovered users service
import { createServiceClient } from '@netscript/sdk/client';
import { UsersContractV1 } from '@my-app/contracts';

// serviceName is the discovery key — it resolves services__users__http__0 at call time.
export const usersClient = createServiceClient({
  contract: UsersContractV1,
  serviceName: 'users',
});

// Fully inferred input/output — a renamed contract field is a compile error here.
const { items } = await usersClient.list({ limit: 20 });

The URL is resolved lazily, on the first call, by the internal HTTP link — so the client can be constructed at import time (even in a browser bundle) without touching Deno APIs. By default it targets <resolved-url>/api/rpc/v1/<service> over HTTP.

CreateServiceClientOptions (createServiceClient) — confirmed surface
NameTypeDescription
contract TContract (required) The shared oRPC contract object. Drives both client typing and HTTP method inference — import the same object the service implements.
serviceName string (required) The discovery key. Resolved as services____http__ on the server (and the VITE_ form in the browser). Must match the ServiceReferences entry.
routerName string? Overrides the URL path segment for the router; defaults to serviceName. Use when the mounted router path differs from the service name.
protocol 'http' | 'https'? Discovery protocol. Defaults to 'http' (resolves the __http__ endpoint).
apiPath string? Base RPC path the runtime mounts. Defaults to '/api/rpc' — match what the service serves.
apiVersion string? API version segment in the URL. Defaults to 'v1'.
propagateTraceContext boolean? Attach W3C traceparent/tracestate headers automatically from the active trace context. Defaults to true.

Step 4 — Call it (and handle typed errors)

The client methods mirror the contract's procedure tree. Wrap calls in the SDK's safe() helper to get a tuple instead of a throw, and narrow defined errors with isDefinedError:

// web/src/routes/users.ts — typed call with safe-style error handling
import { isDefinedError, safe } from '@netscript/sdk/client';
import { usersClient } from '../clients/users.ts';

const [error, result] = await safe(usersClient.list({ limit: 20 }));

if (error && isDefinedError(error)) {
  // error.code and error.data are typed from the contract
  console.error('users.list failed:', error.code, error.data);
} else {
  // result.items is the contract's output type
  return result.items;
}

Many services at once — defineServices

When the caller talks to several services, defineServices builds the clients (plus cache-aware query factories and TanStack Query utils) from one map. The map key is the discovery key unless you override serviceName.

// web/src/clients/users.ts — one service, direct calls only
import { createServiceClient } from '@netscript/sdk/client';
import { UsersContractV1 } from '@my-app/contracts';

export const usersClient = createServiceClient({
  contract: UsersContractV1,
  serviceName: 'users',
});

const page = await usersClient.list({ limit: 20 });
void page;
// web/src/sdk.ts — clients + query factories + query utils for several services
import { defineServices } from '@netscript/sdk';
import { OrdersContractV1, UsersContractV1 } from '@my-app/contracts';

export const sdk = defineServices({
  // map key is the discovery key (services__users__http__0, services__orders__http__0)
  users: { contract: UsersContractV1 },
  orders: { contract: OrdersContractV1, options: { staleTime: 60_000 } },
});

const people = await sdk.clients.users.list({ limit: 20 });
const orderPage = await sdk.queries.orders.list({ limit: 20, offset: 0 });
void people;
void orderPage;

defineServices returns { clients, queries, queryUtils }. The serviceName per entry defaults to the map key, so users resolves services__users__http__0 automatically — override it only when the registered service name differs from your local map key.

Manual / non-Aspire resolution

The discovery readers are usable on their own when you are not running under Aspire — for a smoke test, a one-off script, or a custom transport. getServiceUrl(name) throws a clear error naming the missing env var; isServiceAvailable(name) is the non-throwing probe; getAllServices() lists every services__* name in the environment.

// scripts/check-users.ts — resolve a discovered URL by hand
import { getAllServices, getServiceUrl, isServiceAvailable } from '@netscript/sdk/discovery';

if (isServiceAvailable('users')) {
  const baseUrl = getServiceUrl('users', 'http'); // reads services__users__http__0
  console.log('users at', baseUrl, '| all:', getAllServices());
}

The server-side lookup key is services__<name>__http__<index>; the browser path checks the VITE_-prefixed forms first (Aspire injects those for Vite apps). Set the env var yourself to point a client at a fixed URL with no orchestrator:

# point the users client at a locally-running service, no Aspire
services__users__http__0=http://localhost:3001 deno task --cwd web dev

Production pitfalls

See also

For the concepts behind the typed-client surface see Typed SDK & client ; the sibling recipe that builds the callee is Add a service ; and the orchestration that injects the URLs is explained in Orchestration with Aspire .