@tetrascience-npm/request
v0.3.0
Published
Request tracking middleware for TetraScience services and data apps (client + server).
Readme
@tetrascience-npm/request
Request middleware and typed OpenAPI client generation for TetraScience services and data apps. Runs a composable fetch pipeline (tracing, auth, validation, safe-response) and ships an orval mutator plus a CLI that turns an OpenAPI spec into a publishable client package.
Contents
- Installation
- Generating a client
- Using a generated client
- Auth
- Middleware pipeline
- Server setup (Express)
- Browser setup
- Error handling
- Entrypoints
- API reference
Quick start
yarn add -D @tetrascience-npm/request orval
npm pkg set scripts.generate:client="generate-service-client"
npm pkg set scripts.postinstall="yarn generate:client"
npm pkg set serviceClient.packageName="@tetrascience/my-service-client"
npm pkg set serviceClient.spec="openapi/spec.yaml"
npm pkg set serviceClient.version="1.0.0"
yarn installThis installs the package, configures serviceClient, and generates a typed client in client/ automatically. The postinstall hook ensures the client is regenerated after every yarn install — developers never need to run it manually.
Installation
yarn add -D @tetrascience-npm/request orval@tetrascience-npm/request provides the runtime middleware + the generate-service-client CLI. orval is the codegen engine. zod is an optional peer dependency for request body validation.
Generating a client
Declare the package metadata and spec in your service's package.json, run the CLI, and commit the scaffolding.
1. Configure serviceClient in package.json:
{
"serviceClient": {
"packageName": "@tetrascience/data-apps-client",
"spec": "openapi/dataapps.yaml",
"version": "1.0.0"
}
}Or via CLI:
npm pkg set serviceClient.packageName="@tetrascience/data-apps-client"
npm pkg set serviceClient.spec="openapi/dataapps.yaml"
npm pkg set serviceClient.version="1.0.0"2. Add generate + postinstall scripts:
npm pkg set scripts.generate:client="generate-service-client"
npm pkg set scripts.postinstall="yarn generate:client"
yarn installThe postinstall hook runs after every yarn install, so the client is always up to date. Developers never need to run generation manually — yarn install handles it.
This produces client/ with:
generated/api.ts— orval fetch client with per-operation Zod validation wired through@tetrascience-npm/request/mutator/fetchgenerated/zod/— Zod schemas derived from the specindex.ts—create<Service>Client(options)factorypackage.json,tsconfig.build.json,.gitignore,.yarnrc.yml
3. Commit client/ (the .gitignore excludes generated/, dist/, node_modules/, and mutator-shim.ts — only index.ts and metadata are checked in; generated/ is re-created on each build).
Multi-spec (public + internal)
Provide an object of named spec files to generate separate public and internal entrypoints from the same package:
{
"serviceClient": {
"packageName": "@tetrascience/data-apps-client",
"spec": {
"public": "openapi/dataapps.yaml",
"internal": "openapi/dataapps-internal.yaml"
},
"version": "1.0.0"
}
}The first variant is the public-facing default. Consumers get two factories: create<Service>Client (public ops only) and create<Service>InternalClient (public + internal ops).
Axios output (deprecated)
For callers that still use axios interceptors, add an axios block:
{
"serviceClient": {
"packageName": "@tetrascience/data-apps-client",
"spec": "openapi/dataapps.yaml",
"version": "1.0.0",
"axios": {
"packageName": "@tetrascience/data-apps-client-axios",
"outDir": "client-axios"
}
}
}The fetch client is always generated. Axios output is opt-in and intended for migration, not new code.
Using a generated client
Generated clients export a factory that binds the mutator options to all operations and returns those operations as plain functions. Operations return Orval's response envelope: {data, status, headers}. HTTP statuses, including non-2xx statuses, flow through in that envelope; validation, network, auth guard, refresh, and malformed-response failures throw a RequestError.
import {createDataAppsClient} from '@tetrascience/data-apps-client';
import {RequestError} from '@tetrascience-npm/request';
const client = createDataAppsClient({
baseUrl: process.env.TDP_API_URL,
auth: 'direct',
});
try {
const response = await client.fetchAllDataApps({limit: 10});
console.log(response.data); // typed to the response schema
} catch (err) {
if (err instanceof RequestError) {
// err.code is 'NETWORK_ERROR' | 'VALIDATION_FAILED' | auth/response failure codes
// err.status, err.url, err.requestId, err.body available when applicable
console.error(err.code, err.status, err.body);
} else {
throw err;
}
}Multi-spec packages expose two factories:
import {
createDataAppsClient, // public ops
createDataAppsInternalClient, // public + internal ops
} from '@tetrascience/data-apps-client';Create one factory-returned client per configuration you need. Low-level direct calls to configureFetchMutator are module-level, but generated clients bind options before each operation call so multiple generated clients can coexist in one process.
Auth
auth is optional on ServiceClientOptions (defaults to 'direct'). Three forms:
// 1. Shorthand — direct/browser auth, resolves from request context or cookies
createDataAppsClient({auth: 'direct'});
// 2. Explicit direct auth
createDataAppsClient({
auth: {authToken: jwt, orgSlug: 'acme'},
});
// 3. Strategy plugin (e.g. internal service-to-service auth)
import {internalAuth} from '@tetrascience/request-internal';
createDataAppsClient({
baseUrl: process.env.TDP_API_URL,
auth: internalAuth(),
});Headers by mode
| Mode | Headers set | Auto-resolve source |
| --------------------- | --------------------------------------------- | ------------------------------------------------- |
| 'direct' (browser) | none (cookies carry auth) | n/a |
| 'direct' (Node) | ts-auth-token, x-org-slug | runWithRequestContext / createRequestMiddleware |
| Explicit DirectAuth | ts-auth-token, x-org-slug | from values you pass |
| AuthStrategy | whatever the strategy returns from resolveHeaders(url) | strategy-defined |
Callers can always override — headers are set with "set if absent" semantics, so init.headers and options.headers win.
Custom AuthStrategy
Implement the one-method AuthStrategy interface:
import type {AuthStrategy} from '@tetrascience-npm/request';
function bearerAuth(getToken: () => Promise<string>): AuthStrategy {
return {
async resolveHeaders() {
const token = await getToken();
return {authorization: `Bearer ${token}`};
},
};
}
createMyClient({auth: bearerAuth(fetchToken)});Middleware pipeline
The default pipeline, in order:
| # | Middleware | Purpose |
| - | ----------------------- | ----------------------------------------------------------- |
| 1 | tracingMiddleware | ts-request-id, ts-session-id, ts-initiating-service-name, request logging |
| 2 | authMiddleware | AuthStrategy or direct headers (see Auth) |
| 3 | validationMiddleware | Pre-flight Zod validation of request body |
| 4 | safeResponseMiddleware | Unwraps / normalizes non-JSON success responses |
onRequest hooks run in registration order; onResponse in reverse order; onError fires on every middleware when fetch throws.
Customizing the pipeline
Pass middleware: [...] in client options to replace the pipeline entirely, or spread defaultMiddleware(options) to extend it:
import {defaultMiddleware} from '@tetrascience-npm/request';
createDataAppsClient({
baseUrl,
auth: 'direct',
middleware: [
...defaultMiddleware({auth: 'direct', serviceName: 'my-service'}),
retryMiddleware({maxRetries: 3}),
],
});Writing a custom middleware
import type {FetchMiddleware} from '@tetrascience-npm/request';
function timingMiddleware(): FetchMiddleware {
const starts = new WeakMap<Headers, number>();
return {
name: 'timing',
onRequest(ctx) {
starts.set(ctx.headers, Date.now());
},
onResponse(ctx) {
const started = starts.get(ctx.requestHeaders);
if (started) console.log(`${ctx.url} took ${Date.now() - started}ms`);
},
onError(ctx) {
console.error(`${ctx.url} failed`, ctx.error);
},
};
}All three hooks are optional. Return a new Response from onResponse to replace the current one.
Server setup (Express)
createRequestMiddleware reads tracing + auth from headers and cookies (no cookie-parser needed), stores them in AsyncLocalStorage, and sets a sliding session cookie on the response.
import express from 'express';
import {createRequestMiddleware} from '@tetrascience-npm/request/express';
const app = express();
app.use(createRequestMiddleware());Any downstream code (including generated clients using auth: 'direct') automatically picks up requestId, sessionId, authToken, and orgSlug from context.
Non-Express frameworks
@tetrascience-npm/request/server exposes the context primitives directly:
import {runWithRequestContext, getRequestContext} from '@tetrascience-npm/request/server';
// Wrap your request handler
runWithRequestContext({requestId, sessionId, authToken, orgSlug}, () => handler(req));
// Read anywhere downstream
const {requestId} = getRequestContext();Browser setup
installRequestMiddleware patches globalThis.fetch to inject ts-request-id per request and a sticky ts-session-id from a cookie.
import {installRequestMiddleware, createConsoleLogger} from '@tetrascience-npm/request/web';
installRequestMiddleware({logger: createConsoleLogger({level: 'debug', prefix: 'app'})});Safe to call multiple times (guarded against double-install for Module Federation). Returns an uninstall() function that restores the original fetch and clears the session cookie.
Limitations: only patches fetch. XMLHttpRequest and axios are not covered. Any code that captured window.fetch before install time holds a stale reference.
Error handling
Pre-flight validation, network failures, auth guard failures, token-refresh failures, and malformed JSON responses raise RequestError:
import {RequestError, RequestErrorCode} from '@tetrascience-npm/request';
try {
const result = await client.createDataApp({name: 'foo'});
} catch (err) {
if (err instanceof RequestError) {
switch (err.code) {
case RequestErrorCode.VALIDATION_FAILED:
// Body failed Zod validation before fetch
console.error('Invalid request body', err.cause);
break;
case RequestErrorCode.HTTP_ERROR:
// Response was unusable, such as malformed JSON for a JSON content type
console.error('Unusable response', err.status, err.cause);
break;
case RequestErrorCode.NETWORK_ERROR:
// fetch() threw (DNS, TCP, abort, CORS, etc.)
console.error('Network failure', err.cause);
break;
}
}
}HTTP status responses do not throw. Inspect response.status and response.data from the operation result; middleware onError observers still receive a RequestError with code: 'HTTP_ERROR' for non-2xx statuses so logging and metrics can record failures.
RequestError carries message, code, status?, url?, requestId?, body?, and native cause.
Entrypoints
| Import path | Environment | Purpose |
| --------------------------------------- | ----------- | -------------------------------------------------------------- |
| @tetrascience-npm/request | any | Types, middleware factories, RequestError, defaultMiddleware |
| @tetrascience-npm/request/web | browser | installRequestMiddleware, createConsoleLogger |
| @tetrascience-npm/request/server | Node | runWithRequestContext, getRequestContext, getRequestId |
| @tetrascience-npm/request/express | Node | createRequestMiddleware (Express adapter) |
| @tetrascience-npm/request/mutator/fetch | any | configureFetchMutator, tsRequestMutator (used by generated clients) |
The /web, /server, and /express entrypoints do not re-export the root — import types and middleware from the root explicitly.
API reference
Root — @tetrascience-npm/request
Middleware factories:
| Export | Description |
| --------------------------------- | -------------------------------------------------------------------------- |
| defaultMiddleware(options) | Returns the standard 4-middleware pipeline |
| tracingMiddleware(options) | Injects ts-request-id, ts-session-id, ts-initiating-service-name |
| authMiddleware(auth) | Resolves auth headers from AuthStrategy or DirectAuth |
| validationMiddleware(opts) | Zod safeParse on the request body (disabled when skipValidation: true) |
| safeResponseMiddleware() | Normalizes non-JSON success responses |
Types:
| Export | Description |
| ------------------------- | ----------------------------------------------------------------- |
| FetchMiddleware | {name?, onRequest?, onResponse?, onError?} |
| MiddlewareContext | {url, init, headers, bodySchema?} — passed to onRequest |
| MiddlewareResponseContext | {url, method?, response, requestHeaders} |
| MiddlewareErrorContext | {url, method?, error, requestHeaders} |
| ServiceClientOptions | {baseUrl?, auth?, headers?, skipValidation?, ...TracingOptions} (middleware lives on MutatorOptions) |
| TracingOptions | {requestId?, sessionId?, serviceName?, logger?} |
| AuthStrategy | {resolveHeaders(url): Record<string,string> \| Promise<...>} |
| DirectAuth | AuthBase \| 'direct' |
| AuthBase | {authToken?, orgSlug?} |
| HeaderValue | string \| (() => string \| undefined) |
| RequestError | Thrown for validation, network, auth, refresh, and malformed-response failures |
| RequestErrorCode | {VALIDATION_FAILED, HTTP_ERROR, NETWORK_ERROR, AUTH_INSECURE_URL, AUTH_REFRESH_FAILED} |
| RequestTrackingLogger | {debug, info, warn, error} |
| SafeParseable | Zod 3/4-compatible schema interface |
Utilities / guards:
| Export | Description |
| -------------------- | ----------------------------------------------------- |
| isAuthStrategy(v) | Type guard for the strategy plugin interface |
| generateRequestId() | UUID for request correlation |
Constants: REQUEST_ID_HEADER, SESSION_ID_HEADER, ORG_SLUG_HEADER, AUTH_TOKEN_HEADER, INITIATING_SERVICE_NAME_HEADER.
/web
| Export | Description |
| --------------------------------- | -------------------------------------------------------------- |
| installRequestMiddleware(opts?) | Patches global fetch. Returns uninstall(). |
| createConsoleLogger(opts?) | Structured console logger with level filter and optional prefix |
| ClientTrackingOptions | {logger?} |
| ConsoleLoggerOptions | {level?, prefix?} |
/server
| Export | Description |
| ------------------------------- | ------------------------------------------------------- |
| runWithRequestContext(ctx, fn) | Runs fn within an AsyncLocalStorage scope |
| runWithoutRequestContext(fn) | Runs fn outside any context (test helper) |
| getRequestContext() | Current Partial<RequestContext> or {} |
| getRequestId() | Current request ID, or a freshly generated UUID |
| RequestContext | {requestId, sessionId, orgSlug?, authToken?} |
| REQUEST_ID_HEADER | Re-exported constant |
/express
| Export | Description |
| --------------------------- | -------------------------------------------------------------------------- |
| createRequestMiddleware() | Express middleware — parses headers/cookies, sets context, refreshes session cookie |
/mutator/fetch
| Export | Description |
| ----------------------------- | --------------------------------------------------------------- |
| configureFetchMutator(opts) | Module-level configuration for the orval mutator |
| tsRequestMutator<T>(url, init, bodySchema?) | The orval mutator function itself |
| bindFetchOperations(ops, opts) | Binds generated operation functions to a client configuration |
| MutatorOptions | {baseUrl?, auth?, headers?, skipValidation?, middleware?, ...TracingOptions} |
