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.
Before you start
| Name | Type | Description |
|---|---|---|
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 |
@ |
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__ |
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.
| Name | Type | Description |
|---|---|---|
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__ |
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 .