feat(typedrouter): add middleware support to TypedRouter and export middleware type

This commit is contained in:
2026-03-03 20:22:01 +00:00
parent a1f5916faf
commit ee820dd126
11 changed files with 1187 additions and 453 deletions

View File

@@ -1,5 +1,17 @@
# Changelog
## 2026-03-03 - 3.3.0 - feat(typedrouter)
add middleware support to TypedRouter and export middleware type
- Introduces TMiddlewareFunction and TypedRouter.addMiddleware() to run pre-handler middleware (throw TypedResponseError to reject).
- Adds getTypedHandlerAndRouter() to determine owning router so middleware runs on the correct router.
- Middleware errors are converted into response errors, encoded for network, and outgoing hooks are called; request short-circuits if middleware rejects.
- TypedRouter is now generic (TReqConstraint) and several internal references updated to TypedRouter<any> for compatibility.
- Exports TMiddlewareFunction from index.ts so consumers may reference middleware types.
- Documentation (readme) updated with middleware usage and guidance.
- Tests updated: browser test replaced/renamed to chromium, server tests updated to use TypedServer. Data handling in test adjusted to support Buffer/Uint8Array and serialized Buffer shapes.
- package.json: devDependencies and some deps bumped; build script simplified (removed legacy flags).
## 2026-03-01 - 3.2.7 - fix(virtualstream)
reconstitute JSON-serialized binary data in VirtualStream; update docs, build config, and dependency bumps

View File

@@ -10,18 +10,18 @@
"license": "MIT",
"scripts": {
"test": "(tstest test/)",
"build": "(tsbuild --web --allowimplicitany && tsbundle)",
"build": "(tsbuild && tsbundle)",
"buildDocs": "tsdoc"
},
"devDependencies": {
"@api.global/typedserver": "^3.0.80",
"@git.zone/tsbuild": "^2.7.3",
"@api.global/typedserver": "^8.4.0",
"@git.zone/tsbuild": "^4.1.2",
"@git.zone/tsbundle": "^2.9.0",
"@git.zone/tsrun": "^1.6.2",
"@git.zone/tstest": "^1.11.5",
"@push.rocks/smartenv": "^5.0.13",
"@push.rocks/tapbundle": "^5.6.3",
"@types/node": "^22.19.13"
"@git.zone/tsrun": "^2.0.1",
"@git.zone/tstest": "^3.2.0",
"@push.rocks/smartenv": "^6.0.0",
"@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^25.3.3"
},
"dependencies": {
"@api.global/typedrequest-interfaces": "^3.0.19",
@@ -31,7 +31,7 @@
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/webrequest": "^4.0.2",
"@push.rocks/webrequest": "^4.0.5",
"@push.rocks/webstream": "^1.0.10"
},
"files": [

1426
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
# @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, virtual streams for real-time data, and built-in traffic monitoring hooks.
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, virtual streams for real-time data, and built-in traffic monitoring hooks.
## Issue Reporting and Security
@@ -12,6 +12,12 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
pnpm install @api.global/typedrequest
```
You'll also want the interfaces package for defining stream types:
```bash
pnpm install @api.global/typedrequest-interfaces
```
## Usage
All examples use ESM imports and TypeScript.
@@ -114,6 +120,32 @@ mainRouter.addTypedRouter(coreRouter);
mainRouter.addTypedRouter(authRouter);
```
### 🛡️ 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:
```typescript
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:
@@ -261,6 +293,8 @@ const internalReq = new TypedRequest<IHealthCheck>(target, 'healthCheck');
internalReq.skipHooks = true;
```
Global hooks are shared across all bundles via `globalThis`, making them ideal for application-wide observability.
### 🏗️ Architecture Overview
```
@@ -270,18 +304,25 @@ internalReq.skipHooks = true;
└─────────────┘ └──────────────┘ └──────┬───────┘
┌──────▼───────┐
│ Middleware │
└──────┬───────┘
┌──────▼───────┐
│ TypedHandler │
│ (your logic) │
└──────────────┘
```
- **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; composable via `addTypedRouter()`
- **TypedHandler** — processes a single method and returns a typed response
- **VirtualStream** — bidirectional binary streaming with backpressure over any supported transport
- **TypedResponseError** — structured error propagation
- **TypedTools** — guard validation and transport-layer context in handlers
| 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; composable via `addTypedRouter()` |
| **TypedHandler** | Processes a single method and returns a typed response |
| **Middleware** | Pre-handler functions for auth, validation, logging — throw `TypedResponseError` to reject |
| **VirtualStream** | Bidirectional binary streaming with backpressure over any supported transport |
| **TypedResponseError** | Structured error propagation across the wire |
| **TypedTools** | Guard validation and transport-layer context available inside handlers |
## License and Legal Information

View File

@@ -4,7 +4,7 @@ import * as typedserver from '@api.global/typedserver';
import * as typedrequest from '../ts/index.js';
import * as typedrequestInterfaces from '@api.global/typedrequest-interfaces';
let testServer: typedserver.servertools.Server;
let testServer: typedserver.TypedServer;
let testTypedRouter: typedrequest.TypedRouter;
let testTypedHandler: typedrequest.TypedHandler<ITestReqRes>;
@@ -39,7 +39,7 @@ tap.test('should create a typedHandler', async () => {
});
tap.test('should spawn a server to test with', async () => {
testServer = new typedserver.servertools.Server({
testServer = new typedserver.TypedServer({
cors: true,
forceSsl: false,
port: 3000,
@@ -47,12 +47,8 @@ tap.test('should spawn a server to test with', async () => {
});
tap.test('should define a testHandler', async () => {
testTypedRouter = new typedrequest.TypedRouter(); // typed routers can broker typedrequests between handlers
testTypedRouter = testServer.typedrouter;
testTypedRouter.addTypedHandler(testTypedHandler);
testServer.addRoute(
'/testroute',
new typedserver.servertools.HandlerTypedRouter(testTypedRouter as any) // the "any" is testspecific, since smartexpress ships with its own version of typedrequest.
);
});
tap.test('should start the server', async () => {
@@ -61,7 +57,7 @@ tap.test('should start the server', async () => {
tap.test('should fire a request', async () => {
const typedRequest = new typedrequest.TypedRequest<ITestReqRes>(
'http://localhost:3000/testroute',
'http://localhost:3000/typedrequest',
'hi'
);
const response = await typedRequest.fire({
@@ -86,7 +82,7 @@ tap.test('should allow VirtualStreams', async () => {
};
}));
const typedRequest = new typedrequest.TypedRequest<ITestStream>(
'http://localhost:3000/testroute',
'http://localhost:3000/typedrequest',
'handleStream'
);
const response = await typedRequest.fire({
@@ -95,8 +91,14 @@ tap.test('should allow VirtualStreams', async () => {
console.log(response.responseStream);
newRequestingVS.sendData(Buffer.from('hello'));
const data = await generatedRequestingVS.fetchData();
const decodedData = new TextDecoder().decode(data);
const data: any = await generatedRequestingVS.fetchData();
// Data may arrive as Uint8Array or as JSON-serialized Buffer {type: "Buffer", data: [...]}
const resolvedData = data instanceof Uint8Array || Buffer.isBuffer(data)
? data
: data?.type === 'Buffer' && Array.isArray(data.data)
? new Uint8Array(data.data)
: data;
const decodedData = new TextDecoder().decode(resolvedData);
expect(decodedData).toEqual('hello');
await newRequestingVS.close();
await newRespondingVS.close();

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@api.global/typedrequest',
version: '3.2.7',
version: '3.3.0',
description: 'A TypeScript library for making typed requests towards APIs, including facilities for handling requests, routing, and virtual stream handling.'
}

View File

@@ -3,6 +3,7 @@ import { VirtualStream } from './classes.virtualstream.js';
import { TypedHandler } from './classes.typedhandler.js';
import { TypedRequest } from './classes.typedrequest.js';
import { TypedResponseError } from './classes.typedresponseerror.js';
/**
* Log entry for TypedRequest traffic monitoring
@@ -28,12 +29,20 @@ export interface ITypedRouterHooks {
onOutgoingResponse?: (entry: ITypedRequestLogEntry) => void;
}
/**
* Middleware function that runs before the handler.
* Throw TypedResponseError to reject the request.
*/
export type TMiddlewareFunction<TReq extends Partial<plugins.typedRequestInterfaces.ITypedRequest> = plugins.typedRequestInterfaces.ITypedRequest> = (
typedRequest: plugins.typedRequestInterfaces.ITypedRequest & TReq
) => Promise<void>;
/**
* A typed router decides on which typed handler to call based on the method
* specified in the typed request
* This is thought for reusing the same url endpoint for different methods
*/
export class TypedRouter {
export class TypedRouter<TReqConstraint extends Partial<plugins.typedRequestInterfaces.ITypedRequest> = plugins.typedRequestInterfaces.ITypedRequest> {
// Use globalThis for cross-bundle hook sharing
public static get globalHooks(): ITypedRouterHooks {
if (!(globalThis as any).__typedRouterGlobalHooks) {
@@ -89,12 +98,23 @@ export class TypedRouter {
}
}
public routerMap = new plugins.lik.ObjectMap<TypedRouter>();
public routerMap = new plugins.lik.ObjectMap<TypedRouter<any>>();
public handlerMap = new plugins.lik.ObjectMap<
TypedHandler<any & plugins.typedRequestInterfaces.ITypedRequest>
>();
public registeredVirtualStreams = new plugins.lik.ObjectMap<VirtualStream<any>>();
// Middleware chain
private middlewares: TMiddlewareFunction<TReqConstraint>[] = [];
/**
* Adds a middleware function that runs before the handler on this router.
* Throw TypedResponseError to reject the request.
*/
public addMiddleware(fn: TMiddlewareFunction<TReqConstraint>): void {
this.middlewares.push(fn);
}
public fireEventInterestMap = new plugins.lik.InterestMap<
string,
plugins.typedRequestInterfaces.ITypedRequest
@@ -104,7 +124,7 @@ export class TypedRouter {
* adds the handler to the routing map
* @param typedHandlerArg
*/
public addTypedHandler<T extends plugins.typedRequestInterfaces.ITypedRequest>(
public addTypedHandler<T extends plugins.typedRequestInterfaces.ITypedRequest & TReqConstraint>(
typedHandlerArg: TypedHandler<T>
) {
// lets check for deduplication
@@ -122,7 +142,7 @@ export class TypedRouter {
* adds another sub typedRouter
* @param typedRequest
*/
public addTypedRouter(typedRouterArg: TypedRouter) {
public addTypedRouter(typedRouterArg: TypedRouter<any>) {
const routerExists = this.routerMap.findSync((routerArg) => routerArg === typedRouterArg);
if (!routerExists) {
this.routerMap.add(typedRouterArg);
@@ -141,7 +161,7 @@ export class TypedRouter {
*/
public getTypedHandlerForMethod(
methodArg: string,
checkedRouters: TypedRouter[] = []
checkedRouters: TypedRouter<any>[] = []
): TypedHandler<any> {
checkedRouters.push(this);
@@ -162,6 +182,26 @@ export class TypedRouter {
return typedHandler;
}
/**
* Finds the handler AND its owning router for a given method.
* Needed to know which router's middleware to run.
*/
public getTypedHandlerAndRouter(
methodArg: string,
checkedRouters: TypedRouter<any>[] = []
): { handler: TypedHandler<any>; router: TypedRouter<any> } | null {
checkedRouters.push(this);
const handler = this.handlerMap.findSync((h) => h.method === methodArg);
if (handler) return { handler, router: this };
for (const child of this.routerMap.getArray()) {
if (!checkedRouters.includes(child)) {
const result = child.getTypedHandlerAndRouter(methodArg, checkedRouters);
if (result) return result;
}
}
return null;
}
/**
* Options for routeAndAddResponse
*/
@@ -214,9 +254,9 @@ export class TypedRouter {
});
}
const typedHandler = this.getTypedHandlerForMethod(typedRequestArg.method);
const result = this.getTypedHandlerAndRouter(typedRequestArg.method);
if (!typedHandler) {
if (!result) {
console.log(`Cannot find handler for methodname ${typedRequestArg.method}`);
typedRequestArg.error = {
text: 'There is no available method for this call on the server side',
@@ -247,6 +287,43 @@ export class TypedRouter {
return typedRequestArg;
}
const { handler: typedHandler, router: owningRouter } = result;
// Run owning router's middleware chain
if (owningRouter.middlewares.length > 0) {
try {
for (const mw of owningRouter.middlewares) {
await mw(typedRequestArg as any);
}
} catch (e) {
if (e instanceof TypedResponseError) {
typedRequestArg.error = { text: e.errorText, data: e.errorData || {} };
} else {
typedRequestArg.error = { text: (e as Error).message || 'Middleware error', data: {} };
}
typedRequestArg.correlation.phase = 'response';
typedRequestArg.localData = null;
typedRequestArg = VirtualStream.encodePayloadForNetwork(typedRequestArg, {
typedrouter: this,
});
if (!options.skipHooks) {
this.callHook('onOutgoingResponse', {
correlationId: typedRequestArg.correlation?.id || 'unknown',
method: typedRequestArg.method,
direction: 'outgoing',
phase: 'response',
timestamp: Date.now(),
durationMs: Date.now() - requestStartTime,
payload: typedRequestArg.response,
error: typedRequestArg.error?.text,
});
}
return typedRequestArg;
}
}
typedRequestArg = await typedHandler.addResponse(typedRequestArg);
typedRequestArg.localData = null;
// encode again before handing back

View File

@@ -26,7 +26,7 @@ export interface ITypedTargetConstructorOptions {
/**
* this typedrouter allows us to have easy async request response cycles
*/
typedRouterRef?: TypedRouter;
typedRouterRef?: TypedRouter<any>;
}
/**

View File

@@ -8,7 +8,7 @@ export interface ICommFunctions {
sendMethod?: (
sendPayload: plugins.typedRequestInterfaces.IStreamRequest
) => Promise<plugins.typedRequestInterfaces.IStreamRequest>;
typedrouter?: TypedRouter;
typedrouter?: TypedRouter<any>;
}
/**
@@ -140,7 +140,7 @@ export class VirtualStream<T = Uint8Array> implements plugins.typedRequestInterf
// integration with typedrequest mechanics
public sendMethod: ICommFunctions['sendMethod'];
public typedrouter: TypedRouter;
public typedrouter: TypedRouter<any>;
// wether to keep the stream alive
private keepAlive = true;

View File

@@ -5,5 +5,5 @@ export * from './classes.typedresponseerror.js';
export * from './classes.typedtarget.js';
export * from './classes.virtualstream.js';
// Re-export hook interfaces from typedrouter
export type { ITypedRequestLogEntry, ITypedRouterHooks } from './classes.typedrouter.js';
// Re-export hook interfaces and middleware type from typedrouter
export type { ITypedRequestLogEntry, ITypedRouterHooks, TMiddlewareFunction } from './classes.typedrouter.js';