@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?: boolean—falseuses the network only;trueuses Webrequest's persistent HTTP cache under keys isolated by endpoint, RPC method, and request body. Webrequest enforcesCache-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 to2_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 from0to1_000; defaults to3. 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 declaresupportsAbortSignal: trueand 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 |
License and Legal Information
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.