feat(typedrouter): add middleware support to TypedRouter and export middleware type
This commit is contained in:
12
changelog.md
12
changelog.md
@@ -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
|
||||
|
||||
|
||||
18
package.json
18
package.json
@@ -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
1426
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
57
readme.md
57
readme.md
@@ -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
|
||||
|
||||
|
||||
24
test/test.ts
24
test/test.ts
@@ -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();
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface ITypedTargetConstructorOptions {
|
||||
/**
|
||||
* this typedrouter allows us to have easy async request response cycles
|
||||
*/
|
||||
typedRouterRef?: TypedRouter;
|
||||
typedRouterRef?: TypedRouter<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user