Typed SDK & client
The @netscript/sdk is the typed client and data layer for NetScript: it turns an
@orpc/contract into a discovered, type-safe service client,
wraps each contract action in a cache-first query factory (KV-backed SWR), and bridges
those into TanStack Query for islands. The contract object is
the single source of truth — the same object the service
implements is the one the client imports, so caller and server cannot drift.
alpha
What it is
The SDK is layered. L1 is the typed client: createServiceClient<Contract>() resolves a
service URL from Aspire service discovery and returns a callable
object whose method signatures are inferred from the contract. L2 wraps each contract
action in a query factory (createQueryFactories) that runs through a shared KV-backed cache
provider and exposes queryOptions/mutationOptions/clientKey/key/getCachedEntry per
action. L3 is the defineServices() preset that builds clients, server query factories,
and frontend query utils from one contract map. You consume the layer you need and can drop
down without rewiring. See Contracts for the type-flow theory.
Learn → / Do →
Minimal example
A single lib/api-clients.ts is the spine of the data layer: build the typed client from a
contract, then derive a query factory from { contract, client }. Every consumer — server
loader or island — imports from here.
// apps/playground/lib/api-clients.ts
import { createServiceClient } from '@netscript/sdk/client';
import { createQueryFactories } from '@netscript/sdk/query';
import { ordersContract } from '@contracts';
// L1 — typed client. `serviceName` resolves a URL via Aspire discovery.
export const ordersClient = createServiceClient<typeof ordersContract>({
contract: ordersContract,
serviceName: 'orders',
});
// L2 — cache-first query factory bound to that client (KV-backed SWR).
export const api = createQueryFactories({
orders: { contract: ordersContract, client: ordersClient },
});
// Direct typed call: `.list()` is fully inferred from the contract.
const recent = await ordersClient.list({ limit: 10 });
Key types first
The query factory is the heart of the data layer. Each contract action becomes an
ActionMethod carrying the full cache-first surface — keys, prefetch, cached reads, and
TanStack option helpers. These are the methods you call from a definePage loader and an
island; reading them first explains the rest of the page.
| Name | Type | Description |
|---|---|---|
queryOptions(props, options?) |
(input, ActionQueryOptions?) => QueryOptions |
TanStack queryOptions with a typed queryKey and queryFn derived from the contract. The primary helper a loader/island passes to useQuery. |
mutationOptions(options?) |
(ActionMutationOptions?) => MutationOptions |
TanStack mutationOptions with a typed mutationKey and mutationFn for writes (e.g. updateStatus). |
key(props) |
(input) => readonly [string, action, string] |
Canonical SERVER cache key [resource, action, serializedInput] used by the KV cache. |
clientKey(props?) |
(input?) => readonly unknown[] |
CLIENT-side TanStack query key, prefix-matchable for invalidateQueries. clientKey() vs key() is the server↔client split. |
getCachedEntry(props) |
(input) => Promise |
Read cached data WITH its cache timestamp (cachedAt) — the SWR primitive a layer loader uses to decide stale-reload. |
getCachedData(props) |
(input) => Promise |
Read cached data only, without fetching. |
prefetch(props, options?) |
(input, QueryParams?) => void |
Warm the cache in the background (fire-and-forget). |
invalidate() |
() => Promise |
Invalidate all cached queries for this action. |
Constructing the client & factory
The two L1/L2 constructors and their options. createServiceClient is the only call that
touches discovery; createQueryFactories is pure wiring over { contract, client }.
| Name | Type | Description |
|---|---|---|
contract |
TContract — required |
The @orpc/contract object. Drives both client typing AND HTTP method inference. |
serviceName |
string — required |
Service name registered in Aspire / NetScript config. Resolved to a URL via discovery (services__{name}__{protocol}__{index}; browser uses the VITE_ mirror). |
routerName |
string? |
Router-name segment for URL path construction. Required for plugin API services, omitted for plain services. |
protocol |
'http' | 'https'? |
Resolved protocol for service discovery. |
apiPath / apiVersion |
string? |
Base RPC path and API version segment overrides. |
propagateTraceContext |
boolean? |
Auto-propagate W3C traceparent/tracestate headers on each call. |
The L3 alternative builds all three layers from one map:
defineServices({ orders: { contract, serviceName: 'orders' } }) returns
{ clients, queryFactories, queryUtils } — the same L2 values, just composed in one call.
Service discovery & query client
serviceName is resolved at call time from Aspire-injected environment variables. The
@netscript/sdk/discovery subpath exposes the resolvers directly when you need a raw URL, and
@netscript/sdk/query-client provides the browser-side TanStack glue.
| Name | Type | Description |
|---|---|---|
getServiceUrl(name, protocol, index) |
@netscript/sdk/discovery |
Resolve a service URL from Aspire browser-VITE or server services__* env. This is how serviceName resolves. |
getServiceInfo(name) / isServiceAvailable(name) / getAllServices() |
@netscript/sdk/discovery |
Inspect a service's endpoints, check availability, or list all server-side Aspire service names (topology). |
createNetScriptQueryClient(options) |
@netscript/sdk/query-client |
A TanStack QueryClient with server-first defaults: staleTime 30s, gcTime 300s, refetchOnWindowFocus false, retry 1. |
createServiceQueryUtils() |
@netscript/sdk/query-client |
Bridge a typed SDK client into oRPC/TanStack frontend query utilities. |
bridgeInvalidation(resource, action?) |
@netscript/sdk/query-client |
Build a client-side invalidation filter ({ queryKey }) for queryClient.invalidateQueries(). |
toClientKeyPrefix(resource, action?) |
@netscript/sdk/query-client |
Map a server resource/action to a prefix-matchable client query key, e.g. ['orders','list']. |
cacheQuery.setCachedData(key, data, ttl) |
@netscript/sdk/cache |
Server-only: fire-and-forget pre-warm of an entity into the KV cache. Importing /cache auto-registers the shared provider. |
// routes/(dashboard)/orders/(_loaders)/orders-list.ts
import { api } from '@/lib/api-clients.ts';
// Cache-first: read the KV entry (with cachedAt) for SWR; fall back to a fetch.
export const loadOrders = async () => {
const entry = await api.orders.list.getCachedEntry({ limit: 20 });
if (entry) return entry; // serve cached; SDK reloads stale in the background
return { data: await api.orders.list.queryOptions({ limit: 20 }).queryFn(), cachedAt: Date.now() };
};
// orders/(_islands)/OrdersQueryIsland.tsx
import { useQuery, useQueryClient } from '@netscript/fresh/query';
import { api } from '@/lib/api-clients.ts';
// Same contract action → same query key as the server loader, so the island
// hydrates from server state instead of refetching on mount.
const OrdersList = () => {
const qc = useQueryClient();
const orders = useQuery(api.orders.list.queryOptions({ limit: 20 }));
// invalidate by prefix after a write:
const refresh = () => qc.invalidateQueries({ queryKey: api.orders.list.clientKey() });
return null; // render orders.data
};
// services/orders/src/routers/v1.ts — service-to-service call
import { safe, isDefinedError } from '@netscript/sdk/client';
import { usersClient } from '@/lib/api-clients.ts';
const [error, user, isDefined] = await safe(usersClient.getById({ id }));
if (error) {
// narrow to a typed, contract-declared error
if (isDefinedError(error)) return { code: error.code, status: error.status };
throw error;
}
Production notes
Reference →
This hub is intentionally thin — the full generated API (client, query, query-client, cache, collections, discovery, streams, telemetry, ports) lives in the reference.