0.1.0-alpha.3

Direttore

Direttore is a TypeScript API orchestration library for defining typed service endpoints, request construction, error normalization, cache keys, and React Query/Node adapters in one clean package family.

Packages

@direttore/core

Functional API orchestration primitives for TypeScript applications.

Direttore turns third-party service contracts into an executable API tree. It is the layer between your project and backends you do not own: a place to encapsulate service auth, metadata, request construction, wire response parsing, domain normalization, error mapping, cache keys, and settled-call telemetry. It does not own cache storage, retries, rendering state, or application state.

Install

npm install @direttore/core

Direttore targets Node >=18.17 and runtimes with the standard Fetch API.

Quick Start

import {
  byStatus,
  byTransportKind,
  createApi,
  input,
  schema,
} from "@direttore/core";
 
type RequestMeta = {
  feature: "profile" | "settings";
};
 
type AuthPolicy = {
  scope: "users:read" | "users:write";
};
 
type UserWire = {
  data: {
    id: string;
    full_name: string;
  };
};
 
type User = {
  id: string;
  name: string;
};
 
const d = createApi<RequestMeta, AuthPolicy>();
 
export const identity = d.service({
  baseUrl: "https://identity.example.com",
  auth: {
    scope: "users:read",
  },
  getAuthHeaders: async (ctx) => ({
    authorization: `Bearer ${await session.getToken(ctx.auth?.scope)}`,
  }),
  mapHttpError: byStatus({
    401: {
      message: "Sign in again.",
      code: "SESSION_EXPIRED",
      status: 401,
    },
    404: {
      message: "User was not found.",
      code: "USER_NOT_FOUND",
      status: 404,
    },
    "5xx": ({ body, response }) => ({
      message: "Identity is unavailable.",
      code: "IDENTITY_UNAVAILABLE",
      status: response.status,
      raw: body,
    }),
    default: ({ body, response }) => ({
      message: "Identity request failed.",
      status: response.status,
      raw: body,
    }),
  }),
  mapTransportError: byTransportKind({
    clientOffline: {
      message: "You appear to be offline.",
      code: "CLIENT_OFFLINE",
    },
    serviceUnreachable: ({ error }) => ({
      message: "Identity is unreachable.",
      code: "IDENTITY_UNREACHABLE",
      raw: error,
    }),
  }),
  endpoints: {
    getUser: d.endpoint({
      kind: "query",
      input: input(userInputSchema),
      path: "/users/:id",
      params: ({ id }) => ({ id }),
      key: ({ id }) => [id],
      response: schema(userWireSchema),
      mapResponse: (wire: UserWire): User => ({
        id: wire.data.id,
        name: wire.data.full_name,
      }),
    }),
  },
});
 
export const api = d.client().use({
  identity,
});
 
await api.identity.getUser.fetch({ id: "u1" });
api.identity.getUser.cacheKey({ id: "u1" });

API Model

Use createApi<TMeta, TAuth>() when an application has typed metadata and auth policy:

const d = createApi<RequestMeta, AuthPolicy>();
 
d.endpoint(config);
d.service(config);
d.client(config);

The unbound endpoint(), service(), and client() functions are also exported for simple or library-level use.

Every endpoint must explicitly declare input:

import {
  input,
  none,
  passthrough,
} from "@direttore/core";
 
input(userInputSchema);          // validated caller input
passthrough<{ id: string }>();   // typed schema-less caller input
none();                          // no caller input

No runtime input guessing is performed. Inputful endpoints are always called as fetch(input, options). No-input endpoints are called as fetch(options?).

Services

A service is the integration boundary. Put vendor-wide concerns here and let endpoints describe only the route-specific parts:

const identity = d.service({
  baseUrl: "https://identity.example.com",
  auth: {
    scope: "users:read",
  },
  meta: {
    vendor: "identity",
  },
  getAuthHeaders: async (ctx) => ({
    authorization: `Bearer ${await session.getToken(ctx.auth?.scope)}`,
  }),
  mapHttpError: byStatus({
    401: { message: "Sign in again.", code: "SESSION_EXPIRED", status: 401 },
    "5xx": ({ response, body }) => ({
      message: "Identity is unavailable.",
      code: "IDENTITY_UNAVAILABLE",
      status: response.status,
      raw: body,
    }),
  }),
  observe: (event) => {
    console.debug(event.endpointName, event.outcome);
  },
  endpoints: {},
});

Important service fields:

FieldPurpose
baseUrlbase URL shared by fetch-backed endpoints
fetchoptional service-specific Fetch implementation
headersheaders shared by every endpoint in the service
requestInitshared Fetch init defaults
authdefault auth policy for endpoints in the service
metadefault metadata for calls in the service
getAuthHeadersconverts resolved auth/meta/request context into headers
parseErrorResponseshared non-2xx response body parser
mapHttpErrorshared non-2xx error mapper
mapTransportErrorshared Fetch rejection mapper
observeshared settled-call observer

Client config supports the same cross-service fields. Runtime resolution is endpoint first, then service, then client. Per-call meta overrides endpoint, service, and client metadata.

Endpoint Config

d.endpoint({
  kind: "query",
  input: input(userInputSchema),
  path: "/users/:id",
  params: ({ id }) => ({ id }),
  query: ({ expand }) => ({ expand }),
  key: ({ id }) => [id],
  response: schema(userWireSchema),
  mapResponse: toUser,
});

Important fields:

FieldPurpose
kind"query" or "mutation"; queries default to GET, mutations to POST
inputinput(schema), passthrough<T>(), or none()
pathfetch-backed endpoint path or path factory
resolvecustom resolver; mutually exclusive with path
paramsexplicit path placeholder values for :id and {id}
queryexplicit query string mapper
bodyexplicit request body mapper
authendpoint-specific auth policy override
metaendpoint-specific call metadata default
keyrequired for inputful queries; returns cache key parts
responseschema/parser for decoded transport response
mapResponsemaps decoded response to app-facing output
outputoptional final output schema/parser
mapHttpErrormaps non-2xx responses to app errors
mapTransportErrormaps Fetch rejections to app errors
observereceives a settled success/error event

Inputful query endpoints require key(input). Runtime cache keys are:

[serviceName, endpointKey, ...key(input)]

No-input endpoints use:

[serviceName, endpointKey]

Request Construction

Fetch-backed endpoints build requests in this order:

  1. Parse endpoint input.
  2. Resolve method.
  3. Resolve path and explicit path params.
  4. Select baseUrl.
  5. Merge requestInit.
  6. Merge client and service headers.
  7. Resolve endpoint headers.
  8. Resolve service auth headers, or client auth headers when the service has none.
  9. Merge per-call headers.
  10. Append explicit query parameters.
  11. Serialize explicit body for methods that allow bodies.
  12. Execute Fetch, parse response, validate response, map response, validate output.

GET and HEAD never receive bodies. Plain objects and arrays are JSON serialized and receive content-type: application/json when no content type is already set. BodyInit values pass through unchanged.

Observability

Middleware has been removed. Use explicit request mappers for request construction and observe for logging, metrics, tracing, and analytics.

Observers can be registered on client, service, or endpoint:

const api = d.client({
  observe: (event) => {
    event.input;
    event.output;
    event.error;
    event.serviceName;
    event.endpointName;
    event.auth;
    event.meta;
    event.request;
    event.response;
    event.timings.totalMs;
    event.timings.fetchMs;
    event.timings.serverTiming;
  },
});

timings.fetchMs is client-observed Fetch duration. True backend timing can be exposed by the server through the Server-Timing header, available as event.timings.serverTiming.

Observer failures are ignored; telemetry never changes endpoint results.

Errors

Default error behavior:

FailureRejection
non-2xx Fetch responseApiError with HTTP_ERROR by default
Fetch rejection while offlineApiError with CLIENT_OFFLINE
other Fetch rejectionApiError with SERVICE_UNREACHABLE
Fetch abortoriginal abort error
input/response/output validationValidationError
invalid JSON success responseResponseParseError
custom resolver rejectionoriginal error

Use parseErrorResponse, mapHttpError, and mapTransportError to normalize service errors into ApiError. payload is the app-facing place for parsed backend-specific error bodies, while raw can keep the original value for diagnostics. Direttore also exports table helpers for the common cases:

const identity = d.service({
  mapHttpError: byStatus({
    400: ({ body }) => ({
      message: "Invalid profile data.",
      code: "PROFILE_INVALID",
      payload: body,
      raw: body,
    }),
    401: {
      message: "Sign in again.",
      code: "SESSION_EXPIRED",
      status: 401,
    },
    404: {
      message: "Profile was not found.",
      code: "PROFILE_NOT_FOUND",
      status: 404,
    },
    "5xx": ({ response, body }) => ({
      message: "Identity is unavailable.",
      code: "IDENTITY_UNAVAILABLE",
      status: response.status,
      raw: body,
    }),
    default: ({ response, body }) => ({
      message: "Identity request failed.",
      status: response.status,
      raw: body,
    }),
  }),
 
  mapTransportError: byTransportKind({
    clientOffline: {
      message: "You appear to be offline.",
      code: "CLIENT_OFFLINE",
    },
    serviceUnreachable: ({ error }) => ({
      message: "Identity is unreachable.",
      code: "IDENTITY_UNREACHABLE",
      raw: error,
    }),
  }),
 
  endpoints: {},
});

byStatus() checks exact status codes first, then status classes like "4xx" or "5xx", then default. byTransportKind() checks clientOffline, serviceUnreachable, then default. Handler values may be functions or static normalized error objects.

Any object with a string message is normalized to ApiError. code, errorCode, status, fields, errors, payload, raw, and response are copied when present. Use apiError(code, message, init?) when you want literal error codes and payloads to flow into EndpointError<typeof endpoint>.

Custom Resolvers

Use resolve when the endpoint is not Fetch-backed:

const health = d.endpoint({
  kind: "query",
  input: none(),
  resolve: async () => "ok",
  response: schema(stringSchema),
});

resolve is mutually exclusive with path. It still participates in input parsing, response validation, response mapping, output validation, and observation.

Exports

Core exports the main functions:

And the primary error classes:

that’s all. not much, but it’s honest work.