@api.global/typedrequest

A TypeScript library for making fully typed request/response cycles across any transport — HTTP, WebSockets, broadcast channels, or custom protocols. Define your API contract once as a TypeScript interface, then use it on both client and server with compile-time safety, automatic routing, middleware chains, authority guards, unified VirtualStreams, and built-in traffic monitoring hooks.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm install @api.global/typedrequest

You'll also want the interfaces package for defining stream types:

pnpm install @api.global/typedrequest-interfaces

Usage

All examples use ESM imports and TypeScript.

import {
  TypedRequest,
  TypedHandler,
  TypedRouter,
  TypedTarget,
  VirtualStream,
  TypedResponseError,
} from '@api.global/typedrequest';

🔑 Define Your API Contract

Every request/response pair is described by a simple interface extending ITypedRequest:

import type { ITypedRequest } from '@api.global/typedrequest-interfaces';

interface IGetUser extends ITypedRequest {
  method: 'getUser';
  request: { userId: string };
  response: { username: string; email: string };
}

interface IAddNumbers extends ITypedRequest {
  method: 'add';
  request: { a: number; b: number };
  response: { result: number };
}

The method field acts as a discriminator — it's what the router uses to match requests to handlers.

📡 Making Typed Requests (Client Side)

TypedRequest fires a typed request against an HTTP endpoint or a TypedTarget:

// Against an HTTP endpoint
const getUser = new TypedRequest<IGetUser>('https://api.example.com/rpc', 'getUser');
const user = await getUser.fire({ userId: 'user-123' });
console.log(user.username); // fully typed!

// With response caching
const cachedUser = await getUser.fire({ userId: 'user-123' }, true);

// With an end-to-end HTTP deadline
const boundedUser = await getUser.fire(
  { userId: 'user-123' },
  {
    timeoutMs: 5_000,
    abortSignal: navigationAbortController.signal,
    useCache: false,
    maxRetries: 3,
  }
);

The boolean second argument remains supported. The options form accepts:

  • useCache?: booleanfalse uses the network only; true uses Webrequest's persistent HTTP cache under keys isolated by endpoint, RPC method, and request body. Webrequest enforces Cache-Control, no-store, freshness, conditional revalidation, and its 256-entry default bound. Cache hits are accepted only for successful non-stream response payloads and are placed in a new envelope for the current request identity; cached identity and correlation fields are never trusted.
  • timeoutMs?: number — a positive integer up to 2_147_483_647. One monotonic deadline is checked during request preparation and bounds HTTP transport, JSON body consumption, server-directed retries, and retry waits. Synchronous code cannot be interrupted between checks.
  • maxRetries?: number — a safe integer from 0 to 1_000; defaults to 3. This bounds server-directed retry responses even when no request deadline is configured.
  • abortSignal?: AbortSignal — requests cancellation at preparation checkpoints and is passed to transport, retry waits, response decoding, and VirtualStream staging. Custom targets must declare supportsAbortSignal: true and cooperate with the signal; TypedRequest cannot forcibly stop synchronous or non-cooperative work.

Deadline failures throw TypedRequestTimeoutError, which exposes the RPC method and original timeoutMs. An HTTP abort or timeout is never converted into a stale-cache fallback. A TypedTarget can opt into the same contract by declaring supportsAbortSignal: true and settling its transport work with options.signal.reason when the signal aborts. Deadlines are rejected for custom targets that do not declare that support.

Exhausting maxRetries throws TypedRequestRetryLimitError, which exposes the RPC method and configured maxRetries.

VirtualStream traffic after the parent request is owned by the parent transport's bounded endpoint lifecycle rather than the initial request deadline.

🛠️ Handling Requests (Server Side)

TypedHandler processes a specific method and returns a typed response:

const addHandler = new TypedHandler<IAddNumbers>('add', async (req) => {
  return { result: req.a + req.b };
});

The second argument to the handler function is an optional TypedTools instance that gives access to guard validation and transport-layer context:

const secureHandler = new TypedHandler<IGetUser>('getUser', async (req, tools) => {
  // Access transport-layer context (e.g., authenticated user info)
  const peer = tools.localData.peer;
  tools.abortSignal.throwIfAborted();

  // Validate with guards
  await tools.passGuards([myAuthGuard], req);

  return { username: 'Alice', email: 'alice@example.com' };
});

tools.abortSignal is transport-owned and never derived from localData or other wire-controlled fields. A local call or a transport without remote cancellation receives a non-aborted fallback signal; handlers must observe the signal themselves, and must not rely on fallback signal object identity.

🚦 Routing Requests

TypedRouter dispatches incoming requests to the correct handler based on the method field:

const router = new TypedRouter();
router.addTypedHandler(addHandler);
router.addTypedHandler(secureHandler);

// Route an incoming request object
const response = await router.routeAndAddResponse(incomingTypedRequest);

When a transport supplies authenticated connection or request context, pass it as trustedLocalData. Before authority checks or stream graph decoding, the router deletes configurable own localData properties without reading them and shadows inherited properties with a non-enumerable undefined data property. An envelope is dropped before any authority callback if an own property cannot be removed or an inherited property cannot be safely masked. Authority contexts receive a frozen trustedLocalData copy, and handlers receive a separate copy only after decoding succeeds:

const response = await router.routeAndAddResponse(incomingTypedRequest, {
  trustedLocalData: {
    requestContext,
    authenticatedUser,
  },
});

Handlers receive these server-owned values through tools.localData. Use this option at network trust boundaries instead of copying localData from the request.

Incoming request guards, incoming response guards, and incoming request signal resolvers receive the readonly wireLocalDataPresent boolean in their authority context. It is captured before sanitization and is true only when the raw envelope had its own enumerable localData property, matching the property shape serialized by ordinary object transports. Inherited and non-enumerable properties do not count. No raw localData value is exposed at any of these boundaries, and neither route options nor localRequest can set or suppress the captured flag.

Cancellation-aware transports register the trusted signal separately and own its terminal cleanup:

const detachSignalResolver = router.registerIncomingRequestSignalResolver(
  (request, context) => cancellationRegistry.register({ request, context }),
);

The resolver returns { abortSignal, complete }. Once a registration is returned, the router attempts its complete() callback once when that routing attempt terminates, including missing handlers, middleware denial, decode failure, and handler failure. Callback errors are contained. A transport that routes a request directly may instead pass trustedAbortSignal to routeAndAddResponse(). Neither channel is serialized into localData. The resolver's authority context contains the same wireLocalDataPresent and frozen trustedLocalData values seen by the incoming request guard. Requests entering a registered signal resolver must carry a non-empty transport-generated requestInstanceId; malformed identities are rejected before resolver or handler invocation. Local routing without a resolver remains available for request objects that do not carry this transport identity.

A transport should install one synchronous incoming request authority guard on each application entry router:

const detachRequestGuard = router.registerIncomingRequestAuthorityGuard(
  (request, context) =>
    context.typedRouter === router &&
    !context.wireLocalDataPresent &&
    context.trustedLocalData.peer === exactPeer &&
    request.correlation?.phase === 'request'
);

The guard must return literal true. False, truthy non-booleans, and thrown results deny the request with null. Denial occurs before stream transport resolution, descriptor validation or staging, hooks, handler lookup, middleware, and handlers. localRequest does not bypass this boundary, and a guard on an application router also gates handlers reached through fallback routers. Only one owner may register a request guard; its identity-safe detach function is idempotent.

Routers are composable — you can nest them to build modular API architectures:

const coreRouter = new TypedRouter();
const authRouter = new TypedRouter();

// Each sub-router manages its own handlers
coreRouter.addTypedHandler(addHandler);
authRouter.addTypedHandler(secureHandler);

// Link them together — requests flow through the entire chain
const mainRouter = new TypedRouter();
const detachCoreRouter = mainRouter.addTypedRouter(coreRouter);
const detachAuthRouter = mainRouter.addTypedRouter(authRouter);

function shutdown() {
  detachCoreRouter();
  detachAuthRouter();
}

addTypedRouter() returns an idempotent disposer for the bidirectional edge created by that call. Adding an already-connected router returns a no-op disposer, so it cannot detach another call's edge. Disposal removes the child from both routers, permits a later re-add, and affects future handler lookups; a request that already selected its handler continues to completion.

Use a one-way fallback when several isolated application routers need access to the same protocol handlers without gaining access to each other:

const publicRouter = new TypedRouter();
const internalProtocolRouter = new TypedRouter();

internalProtocolRouter.addTypedHandler(protocolHandler);
const detachProtocolRouter = publicRouter.addFallbackRouter(internalProtocolRouter);

function shutdown() {
  // Release the owned composition edge after the router's active lifetime.
  detachProtocolRouter();
}

Requests entering publicRouter can reach protocolHandler; requests entering internalProtocolRouter cannot reach public handlers. TypedRouter rejects method collisions both when routers are composed and when handlers are added later, and rejects fallback edges that would create a routing cycle. The returned idempotent disposer removes only an edge created by that call; adding an already-connected fallback returns a no-op disposer.

Use router.getMethodNames() to inspect the sorted set of method names reachable through the router's local, bidirectional, and fallback composition.

🛡️ Middleware

Add middleware functions to a TypedRouter that run before any handler on that router executes. Middleware is great for authentication, logging, rate limiting, or input validation:

const router = new TypedRouter();

// Add authentication middleware
router.addMiddleware(async (typedRequest) => {
  const token = typedRequest.localData?.authToken;
  if (!token || !isValidToken(token)) {
    throw new TypedResponseError('Unauthorized', { reason: 'invalid_token' });
  }
});

// Add logging middleware
router.addMiddleware(async (typedRequest) => {
  console.log(`Processing ${typedRequest.method}`);
});

// Handlers are only reached if all middleware passes
router.addTypedHandler(secureHandler);

Middleware functions receive the full ITypedRequest object and run in the order they were added. Throw a TypedResponseError from any middleware to reject the request before it reaches the handler.

🎯 Custom Targets with TypedTarget

For non-HTTP transports (WebSockets, broadcast channels, IPC), use TypedTarget with a custom post function:

// Synchronous target (post returns the response directly)
const target = new TypedTarget({
  postMethod: async (payload) => {
    // Send via your custom transport and return the response
    return await myWebSocket.sendAndWait(payload);
  },
});

const request = new TypedRequest<IGetUser>(target, 'getUser');
const user = await request.fire({ userId: 'user-123' });

Custom targets that support request deadlines or VirtualStreams must declare and honor cancellation:

const boundedTarget = new TypedTarget({
  supportsAbortSignal: true,
  postMethod: async (payload, options) => {
    return myTransport.sendAndWait(payload, {
      signal: options?.signal,
    });
  },
});

Every fire() creates a fresh top-level requestInstanceId, including a fresh ID for each server-directed retry. Network and custom-target responses must echo the exact ID. Async response interests and transport cancellation registries must key the exact method, correlation ID, and request instance ID so a stale response or cancel cannot affect a later attempt that reuses the correlation ID.

For async targets where the response arrives separately (e.g., WebSocket push), pair a TypedTarget with a TypedRouter:

const router = new TypedRouter();
const exactPeer = getTransportPeer();

const detachResponseGuard = router.registerIncomingResponseAuthorityGuard(
  (response, context) =>
    context.typedRouter === router &&
    !context.wireLocalDataPresent &&
    context.trustedLocalData.peer === exactPeer
);

const asyncTarget = new TypedTarget({
  postMethodWithTypedRouter: async (payload) => {
    // Fire-and-forget — response will arrive via router
    mySocket.send(JSON.stringify(payload));
  },
  typedRouterRef: router,
});

// When the response arrives later, route it back:
mySocket.onMessage((data) => {
  router.routeAndAddResponse(JSON.parse(data), {
    trustedLocalData: { peer: exactPeer },
  });
});

// During transport shutdown:
detachResponseGuard();

Separately routed responses are bounded by responseTimeoutMs, which defaults to 30 seconds. Expired and aborted waits remove their router interest immediately. A transport should install one response authority guard and bind each response to its exact peer or session authority. A guard must return literal true; false and thrown results drop the response before hooks and correlation-interest fulfillment. Only one guard owner may be registered, and its detach function is idempotent. Use context.wireLocalDataPresent when the transport protocol forbids localData on incoming response envelopes.

Virtual Streams

VirtualStream is one transport-neutral facade for finite transfers and open-ended streams such as camera frames. It carries ordered logical Uint8Array chunks through an exact virtual-stream-v1 endpoint. One send() or WritableStream.write() maps to one receive() result or readable enqueue even when the parent transport fragments data internally.

Shared DTOs use TVirtualStream<'send'> or TVirtualStream<'receive'>. The direction is always local to the requester. TypedHandler applies TReverseVirtualStreamDirections recursively to objects, tuples, and function signatures, so a request stream declared as requester-local send reaches the handler as local receive. A response declared as requester-local send is created by the handler as local receive and arrives at the requester as local send.

import type {
  ITypedRequest,
  TVirtualStream,
} from '@api.global/typedrequest-interfaces';

interface IUploadRequest extends ITypedRequest {
  method: 'upload';
  request: { stream: TVirtualStream<'send'> };
  response: { stored: boolean };
}

A transport creates an explicit, already-authorized creator registration through its own high-level stream factory. Bind that exact registration to the public facade:

import type {
  IVirtualStreamTransport,
  IVirtualStreamTransportRegistration,
} from '@api.global/typedrequest-interfaces';

declare const virtualStreamTransport: IVirtualStreamTransport;
declare const registration: IVirtualStreamTransportRegistration<'send'>;

const sendingStream = VirtualStream.fromRegistration({
  transport: virtualStreamTransport,
  registration,
});

VirtualStream has no public empty constructor and never registers a stream during encoding. The registration direction, endpoint direction, stream ID, content type, and integrity must match exactly. Encoding emits the registration descriptor directly:

{
  _isVirtualStream: true,
  protocol: 'virtual-stream-v1',
  streamId: 'transport-scoped-id',
  creatorDirection: 'send',
  contentType: 'application/octet-stream', // optional
  integrity: {                             // optional for open-ended streams
    algorithm: 'sha256',
    byteLength: 12345,
    digest: `sha256:${'0'.repeat(64)}`,
  },
  transport: { opaqueTransportData: true },
}

The transport value is JSON-compatible and interpreted only by the parent transport. TypedRequest validates its shape but does not normalize or compare it. Repeated stream IDs in one graph deduplicate to one facade and one staged open when creator direction, content type, and integrity match; the first transport payload is authoritative. Streams beneath the echoed .request inside a response envelope are omitted so they are not opened twice.

All facades expose protocol, local direction, streamId, optional contentType and integrity, opened, completion, closed, and abort(). Senders add send(), writable, and close(). Receivers add receive(), readable, accept(), and reject(). undefined from receive() is graceful EOF. A finite receiver drains EOF and calls accept(); an open-ended stream omits integrity. completion resolves with the same accepted receipt on both peers and rejects for every abnormal outcome.

Decoded endpoints are transport-staged and application-blocked. The descriptor consumer alone may initiate physical OPEN, and only after TypedRequest reaches an established application boundary. On incoming requests, the router runs the owning middleware chain while every stream operation remains blocked, then authorizes request facades immediately before invoking the handler. On successful responses, TypedRequest authorizes decoded facades only after response authority, envelope, protocol-error, and descriptor validation. The transport's openVirtualStream() must return a staged opposite-direction endpoint without control or data activity. Its second argument contains the remaining timeoutMs and an abortSignal; transports must settle staging promptly with the signal's exact reason when it aborts.

Published or opened endpoints are aborted on parent failure, timeout, middleware denial, handler error, partial staging failure, or response encoding failure. An unpublished, unused explicit registration is rolled back only with its synchronous no-I/O disposer. Published registration disposal waits for the endpoint's mandatory closed settlement. Transports must always resolve closed within a bounded terminal-cleanup deadline, including when a terminal operation rejects. Terminal close, accept, reject, and abort operations are serialized so cleanup and disposer ownership cannot race.

Custom targets carrying streams must supply the exact creator transport and support parent cancellation:

const target = new TypedTarget({
  virtualStreamTransport,
  supportsAbortSignal: true,
  postMethod: sendRequest,
});

Response caching is unavailable for requests containing stream descriptors. The initial request deadline also bounds response stream staging. Every openVirtualStream() call is capped at 10 seconds by the package, and decodePayloadFromNetworkAsync() accepts an absolute openDeadlineAt override based on performance.now(). All descriptors in one graph share that deadline; each sequential transport call receives only the remaining budget. The stream transport owns the subsequent physical OPEN and terminal lifecycle. Creator operations may wait boundedly for the descriptor consumer. A staged endpoint that resolves after its timeout is aborted and held through closed rather than orphaned.

On a server, resolve the exact per-peer transport from trusted route-local data:

const detachVirtualStreamResolver =
  router.registerVirtualStreamTransportResolver(
    (trustedLocalData) =>
      trustedLocalData.virtualStreamTransport as
        | IVirtualStreamTransport
        | undefined
  );

const response = await router.routeAndAddResponse(incomingRequest, {
  trustedLocalData: {
    virtualStreamTransport: peer.virtualStreamTransport,
  },
});

detachVirtualStreamResolver();

Only one resolver owner can be registered. Its identity-safe disposer is idempotent. Resolver selection sees only frozen trusted transport data and runs only after the incoming request authority guard succeeds. The resolved transport is reused for request decoding and response encoding on that route.

Decoding is asynchronous and graph-first. Use await VirtualStream.decodePayloadFromNetworkAsync(payload, commFunctions) outside TypedRequest or TypedRouter. Standalone decoding authorizes before returning. Framework callers pass a VirtualStreamActivationScope, stage every descriptor only after complete graph validation and deduplication, and call scope.authorizeApplicationUse() at their established application boundary.

⚠️ Error Handling

Throw TypedResponseError inside handlers to send structured errors back to the caller:

const handler = new TypedHandler<IGetUser>('getUser', async (req) => {
  const user = await db.findUser(req.userId);
  if (!user) {
    throw new TypedResponseError('User not found', { userId: req.userId });
  }
  return { username: user.name, email: user.email };
});

On the client side, TypedResponseError is thrown when the server responds with an error:

try {
  await getUser.fire({ userId: 'nonexistent' });
} catch (err) {
  if (err instanceof TypedResponseError) {
    console.error(err.errorText); // 'User not found'
    console.error(err.errorData); // { userId: 'nonexistent' }
  }
}

📊 Traffic Monitoring Hooks

Hooks are metadata-only by default. Every runtime entry includes correlation, method, direction, phase, timing, and hasError, while payload is TYPED_REQUEST_REDACTED_PAYLOAD, payloadRedacted is true, and error is omitted. Treat payloadRedacted as authoritative; sentinel object identity is not stable across serialization or independently loaded bundles. For source compatibility, ITypedRequestLogEntry remains mutable with payload: any, and the two new flags are optional so pre-3.8 constructed entries still compile. Entries emitted to callbacks always contain both flags and are frozen at runtime.

TypedRouter.setGlobalHooks({
  onOutgoingRequest: (entry) => {
    console.log(`${entry.method} [${entry.correlationId}]`);
  },
  onIncomingResponse: async (entry) => {
    await metrics.record({
      method: entry.method,
      durationMs: entry.durationMs,
      failed: entry.hasError,
    });
  },
});

Payload or error access requires explicit projector options. Projectors are a trusted boundary: they synchronously receive the live raw value before the hook callback receives an independent structuredClone snapshot with a top-level freeze. Project only an allowlist rather than returning the complete payload. Projections containing SharedArrayBuffer, including nested typed arrays or DataView instances backed by one, fail closed to the redacted sentinel because cloning them would retain shared mutable memory.

TypedRouter.setGlobalHooks(
  {
    onIncomingRequest: (entry) => metrics.trackRequest(entry),
    onOutgoingResponse: (entry) => metrics.trackResponse(entry),
  },
  {
    payloadProjector: (payload, metadata) => ({
      phase: metadata.phase,
      tenantId: (payload as { tenantId?: string }).tenantId,
    }),
    errorProjector: (_errorText, metadata) =>
      metadata.hasError ? 'request failed' : undefined,
  }
);

The globalHooks getter/setter and setGlobalHooks() remain available. Assignments and phases updated without projector options use metadata-only redaction; projector options from an earlier callback are never inherited by a replacement callback. Assignment preserves exact object identity, so TypedRouter.globalHooks === hooks and router.hooks === hooks after their respective assignments. Direct callback replacement invalidates projector options unless that exact callback received them. Per-router hooks accept the same optional projector argument through router.setHooks(hooks, projectionOptions).

Use addGlobalHooks() when independently owned subscribers need the same phase. Each subscriber receives its own projection and snapshot. The returned disposer is idempotent.

const disposeAuditHooks = TypedRouter.addGlobalHooks(
  {
    onIncomingRequest: (entry) => auditQueue.push(entry),
  },
  {
    payloadProjector: (payload) => ({
      tenantId: (payload as { tenantId?: string }).tenantId,
    }),
  }
);

disposeAuditHooks();

Hook, projector, clone, and freeze failures are isolated from routing and from other subscribers. Recipients, callbacks, and projector options are snapshotted at dispatch start, so adding, disposing, clearing, or replacing hooks during a callback affects only future entries. Set request.skipHooks = true for a client request, or pass { skipHooks: true } to router.routeAndAddResponse() when routing an internal message that must not emit hooks.

🏗️ Architecture Overview

┌─────────────┐       ┌──────────────┐       ┌──────────────┐
│ TypedRequest │──────▶│ HTTP / WS /  │──────▶│ TypedRouter  │
│  (client)    │       │ TypedTarget  │       │  (server)    │
└─────────────┘       └──────────────┘       └──────┬───────┘
                                                     │
                                              ┌──────▼───────┐
                                              │  Middleware   │
                                              └──────┬───────┘
                                                     │
                                              ┌──────▼───────┐
                                              │ TypedHandler  │
                                              │ (your logic)  │
                                              └──────────────┘
Component Role
TypedRequest Fires typed requests against a URL or TypedTarget
TypedTarget Abstracts the transport layer (HTTP, WebSocket, custom)
TypedRouter Routes incoming requests to the correct handler; supports bidirectional addTypedRouter(), one-way addFallbackRouter(), and sorted reachable-method introspection through getMethodNames()
TypedHandler Processes a single method and returns a typed response
Middleware Pre-handler functions for auth, validation, logging — throw TypedResponseError to reject
VirtualStream Direction-specific facade over an exact staged virtual-stream-v1 transport endpoint
TypedResponseError Structured error propagation across the wire
TypedTools Guard validation and transport-layer context available inside handlers

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in license.md.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
No description provided
Readme
3.2 MiB
Languages
TypeScript 100%