Merge pull request #16429 from suuuuuuminnnnnn/feat/microservices-pre-request-hook-v12

Feat/microservices pre request hook v12
This commit is contained in:
Kamil Mysliwiec
2026-02-25 12:25:14 +01:00
committed by GitHub
11 changed files with 486 additions and 28 deletions

View File

@@ -41,6 +41,7 @@ export {
NestHybridApplicationOptions,
NestInterceptor,
NestMiddleware,
PreRequestHook,
NestModule,
OnApplicationBootstrap,
OnApplicationShutdown,

View File

@@ -17,6 +17,7 @@ export * from './hooks/index.js';
export * from './http/index.js';
export * from './injectable.interface.js';
export * from './microservices/nest-hybrid-application-options.interface.js';
export * from './microservices/pre-request-hook.interface.js';
export * from './middleware/index.js';
export * from './modules/index.js';
export * from './nest-application-context.interface.js';

View File

@@ -0,0 +1,26 @@
import { Observable } from 'rxjs';
import { ExecutionContext } from '../features/execution-context.interface.js';
/**
* Interface describing a global preRequest hook for microservices.
*
* Hooks are executed before guards, allowing setup of context (e.g. AsyncLocalStorage)
* that is available to all downstream enhancers.
*
* @example
* ```typescript
* const als = new AsyncLocalStorage();
* app.registerPreRequestHook((context, next) => {
* als.enterWith({ correlationId: uuid() });
* return next();
* });
* ```
*
* @publicApi
*/
export interface PreRequestHook {
(
context: ExecutionContext,
next: () => Observable<unknown>,
): Observable<unknown>;
}

View File

@@ -3,6 +3,7 @@ import { ExceptionFilter } from './exceptions/exception-filter.interface.js';
import { CanActivate } from './features/can-activate.interface.js';
import { NestInterceptor } from './features/nest-interceptor.interface.js';
import { PipeTransform } from './features/pipe-transform.interface.js';
import { PreRequestHook } from './microservices/pre-request-hook.interface.js';
import { INestApplicationContext } from './nest-application-context.interface.js';
import { WebSocketAdapter } from './websockets/web-socket-adapter.interface.js';
@@ -56,6 +57,15 @@ export interface INestMicroservice extends INestApplicationContext {
*/
useGlobalGuards(...guards: CanActivate[]): this;
/**
* Registers a global preRequest hook (executed before all enhancers for every pattern handler).
* Hooks receive an `ExecutionContext` and a `next` function that executes the rest of the pipeline.
* Useful for setting up AsyncLocalStorage context, tracing, or correlation IDs.
*
* @param {...PreRequestHook} hooks
*/
registerPreRequestHook(...hooks: PreRequestHook[]): this;
/**
* Terminates the application.
*

View File

@@ -3,6 +3,7 @@ import type {
ExceptionFilter,
NestInterceptor,
PipeTransform,
PreRequestHook,
VersioningOptions,
WebSocketAdapter,
} from '@nestjs/common';
@@ -17,6 +18,7 @@ export class ApplicationConfig {
private globalFilters: Array<ExceptionFilter> = [];
private globalInterceptors: Array<NestInterceptor> = [];
private globalGuards: Array<CanActivate> = [];
private globalPreRequestHooks: Array<PreRequestHook> = [];
private versioningOptions: VersioningOptions;
private readonly globalRequestPipes: InstanceWrapper<PipeTransform>[] = [];
private readonly globalRequestFilters: InstanceWrapper<ExceptionFilter>[] =
@@ -135,6 +137,14 @@ export class ApplicationConfig {
return this.globalRequestGuards;
}
public registerPreRequestHook(...hooks: PreRequestHook[]) {
this.globalPreRequestHooks = this.globalPreRequestHooks.concat(hooks);
}
public getGlobalPreRequestHooks(): PreRequestHook[] {
return this.globalPreRequestHooks;
}
public enableVersioning(options: VersioningOptions): void {
if (Array.isArray(options.defaultVersion)) {
// Drop duplicated versions

View File

@@ -105,6 +105,22 @@ describe('ApplicationConfig', () => {
expect(appConfig.getGlobalRequestGuards()).toContain(guard);
});
});
describe('PreRequestHooks', () => {
it('should set global preRequest hooks', () => {
const hooks = [() => {}, () => {}];
appConfig.registerPreRequestHook(...(hooks as any));
expect(appConfig.getGlobalPreRequestHooks()).toEqual(hooks);
});
it('should accumulate multiple registerPreRequestHook calls', () => {
const hook1 = () => {};
const hook2 = () => {};
appConfig.registerPreRequestHook(hook1 as any);
appConfig.registerPreRequestHook(hook2 as any);
expect(appConfig.getGlobalPreRequestHooks()).toEqual([hook1, hook2]);
});
});
describe('Interceptors', () => {
it('should set global interceptors', () => {
const interceptors = ['test', 'test2'];

View File

@@ -1,13 +1,18 @@
import type { ContextType, PipeTransform } from '@nestjs/common';
import type {
ContextType,
PipeTransform,
PreRequestHook,
} from '@nestjs/common';
import {
type Controller,
CUSTOM_ROUTE_ARGS_METADATA,
isEmptyArray,
PARAMTYPES_METADATA,
} from '@nestjs/common/internal';
import type { ApplicationConfig } from '@nestjs/core';
import {
ContextUtils,
type ExecutionContextHost,
ExecutionContextHost,
FORBIDDEN_MESSAGE,
type GuardsConsumer,
type GuardsContextCreator,
@@ -20,7 +25,7 @@ import {
type PipesContextCreator,
STATIC_CONTEXT,
} from '@nestjs/core/internal';
import { Observable } from 'rxjs';
import { defer, from, mergeMap, Observable } from 'rxjs';
import { PARAM_ARGS_METADATA } from '../constants.js';
import { RpcException } from '../exceptions/index.js';
import { RpcParamsFactory } from '../factories/rpc-params-factory.js';
@@ -50,6 +55,7 @@ export class RpcContextCreator {
private readonly guardsConsumer: GuardsConsumer,
private readonly interceptorsContextCreator: InterceptorsContextCreator,
private readonly interceptorsConsumer: InterceptorsConsumer,
private readonly applicationConfig?: ApplicationConfig,
) {}
public create<T extends ParamsMetadata = ParamsMetadata>(
@@ -119,18 +125,46 @@ export class RpcContextCreator {
return callback.apply(instance, args);
};
const preRequestHooks =
this.applicationConfig?.getGlobalPreRequestHooks() ?? [];
return this.rpcProxy.create(async (...args: unknown[]) => {
const initialArgs = this.contextUtils.createNullArray(argsLength);
fnCanActivate && (await fnCanActivate(args));
return this.interceptorsConsumer.intercept(
interceptors,
const executePipeline = async () => {
fnCanActivate && (await fnCanActivate(args));
return this.interceptorsConsumer.intercept(
interceptors,
args,
instance,
callback,
handler(initialArgs, args),
contextType,
) as Promise<Observable<unknown>>;
};
if (preRequestHooks.length === 0) {
return executePipeline();
}
const executionContext = new ExecutionContextHost(
args,
instance,
instance.constructor as any,
callback,
handler(initialArgs, args),
contextType,
) as Promise<Observable<unknown>>;
);
executionContext.setType(contextType);
const pipelineObs: Observable<unknown> = defer(() =>
from(executePipeline()).pipe(mergeMap(obs => obs)),
);
let index = 0;
const next = (): Observable<unknown> => {
if (index >= preRequestHooks.length) return pipelineObs;
return preRequestHooks[index++](executionContext, next);
};
return next();
}, exceptionHandler);
}

View File

@@ -54,6 +54,7 @@ export class MicroservicesModule<
new GuardsConsumer(),
new InterceptorsContextCreator(container, config),
new InterceptorsConsumer(),
config,
);
const injector = new Injector({

View File

@@ -4,6 +4,7 @@ import type {
INestMicroservice,
NestInterceptor,
PipeTransform,
PreRequestHook,
WebSocketAdapter,
} from '@nestjs/common';
import { Transport } from './enums/transport.enum.js';
@@ -247,6 +248,21 @@ export class NestMicroservice
return this;
}
/**
* Registers a global preRequest hook (executed before all enhancers for every pattern handler).
*
* @param {...PreRequestHook} hooks
*/
public registerPreRequestHook(...hooks: PreRequestHook[]): this {
if (this.isInitialized) {
this.logger.warn(
'Cannot apply global preRequest hooks: registration must occur before initialization.',
);
}
this.applicationConfig.registerPreRequestHook(...hooks);
return this;
}
public async init(): Promise<this> {
if (this.isInitialized) {
return this;

View File

@@ -1,21 +1,21 @@
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host.js';
import { of } from 'rxjs';
import { CUSTOM_ROUTE_ARGS_METADATA } from '../../../common/constants.js';
import { Injectable, UseGuards, UsePipes } from '../../../common/index.js';
import { ApplicationConfig } from '../../../core/application-config.js';
import { GuardsConsumer } from '../../../core/guards/guards-consumer.js';
import { GuardsContextCreator } from '../../../core/guards/guards-context-creator.js';
import { NestContainer } from '../../../core/injector/container.js';
import { InterceptorsConsumer } from '../../../core/interceptors/interceptors-consumer.js';
import { InterceptorsContextCreator } from '../../../core/interceptors/interceptors-context-creator.js';
import { PipesConsumer } from '../../../core/pipes/pipes-consumer.js';
import { PipesContextCreator } from '../../../core/pipes/pipes-context-creator.js';
import { ExceptionFiltersContext } from '../../context/exception-filters-context.js';
import { RpcContextCreator } from '../../context/rpc-context-creator.js';
import { RpcProxy } from '../../context/rpc-proxy.js';
import { RpcParamtype } from '../../enums/rpc-paramtype.enum.js';
import { RpcParamsFactory } from '../../factories/rpc-params-factory.js';
import { RpcException } from '../../index.js';
import { Observable, of } from 'rxjs';
import { Injectable, UseGuards, UsePipes } from '../../../common';
import { CUSTOM_ROUTE_ARGS_METADATA } from '../../../common/constants';
import { ApplicationConfig } from '../../../core/application-config';
import { GuardsConsumer } from '../../../core/guards/guards-consumer';
import { GuardsContextCreator } from '../../../core/guards/guards-context-creator';
import { NestContainer } from '../../../core/injector/container';
import { InterceptorsConsumer } from '../../../core/interceptors/interceptors-consumer';
import { InterceptorsContextCreator } from '../../../core/interceptors/interceptors-context-creator';
import { PipesConsumer } from '../../../core/pipes/pipes-consumer';
import { PipesContextCreator } from '../../../core/pipes/pipes-context-creator';
import { ExceptionFiltersContext } from '../../context/exception-filters-context';
import { RpcContextCreator } from '../../context/rpc-context-creator';
import { RpcProxy } from '../../context/rpc-proxy';
import { RpcParamtype } from '../../enums/rpc-paramtype.enum';
import { RpcParamsFactory } from '../../factories/rpc-params-factory';
import { RpcException } from '../../index';
@Injectable()
class TestGuard {
@@ -76,6 +76,11 @@ describe('RpcContextCreator', () => {
instance = new Test();
module = 'test';
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('create', () => {
it('should create exception handler', () => {
const handlerCreateSpy = vi.spyOn(exceptionFiltersContext, 'create');
@@ -199,7 +204,7 @@ describe('RpcContextCreator', () => {
});
});
describe('getParamValue', () => {
let consumerApplySpy: ReturnType<typeof vi.fn>;
let consumerApplySpy: any;
const value = 3,
metatype = null,
transforms = [{ transform: vi.fn() }];
@@ -242,4 +247,280 @@ describe('RpcContextCreator', () => {
});
});
});
describe('preRequest hooks', () => {
function makeCreatorWithHooks(hooks: any[]) {
const container: any = new NestContainer();
const localRpcProxy = new RpcProxy();
const localExceptionFiltersContext = new ExceptionFiltersContext(
container,
new ApplicationConfig() as any,
);
vi.spyOn(localRpcProxy, 'create').mockImplementation(a => a);
const mockConfig = {
getGlobalPreRequestHooks: () => hooks,
} as any;
return new RpcContextCreator(
localRpcProxy,
localExceptionFiltersContext,
new PipesContextCreator(container) as any,
new PipesConsumer() as any,
new GuardsContextCreator(container) as any,
new GuardsConsumer() as any,
new InterceptorsContextCreator(container) as any,
new InterceptorsConsumer() as any,
mockConfig,
);
}
it('should execute preRequest hook before guards', async () => {
const executionOrder: string[] = [];
const hookFn = (_ctx: any, next: () => Observable<unknown>) => {
executionOrder.push('hook');
return next();
};
const creator = makeCreatorWithHooks([hookFn]);
vi.spyOn(
new GuardsContextCreator(new NestContainer() as any),
'create',
).mockReturnValue([] as any);
const localGuardsConsumer = new GuardsConsumer();
vi.spyOn(localGuardsConsumer, 'tryActivate').mockImplementation(
async () => {
executionOrder.push('guard');
return true;
},
);
const container: any = new NestContainer();
const localRpcProxy = new RpcProxy();
const localExceptionFiltersContext = new ExceptionFiltersContext(
container,
new ApplicationConfig() as any,
);
vi.spyOn(localRpcProxy, 'create').mockImplementation(a => a);
const mockConfig = { getGlobalPreRequestHooks: () => [hookFn] } as any;
const localGuardsContextCreator = new GuardsContextCreator(container);
vi.spyOn(localGuardsContextCreator, 'create').mockReturnValue([
{
canActivate: () => {
executionOrder.push('guard');
return true;
},
},
] as any);
const hookCreator = new RpcContextCreator(
localRpcProxy,
localExceptionFiltersContext,
new PipesContextCreator(container) as any,
new PipesConsumer() as any,
localGuardsContextCreator as any,
new GuardsConsumer() as any,
new InterceptorsContextCreator(container) as any,
new InterceptorsConsumer() as any,
mockConfig,
);
const proxy = hookCreator.create(instance, instance.test, module, 'test');
const result = await proxy('data');
if (result && typeof (result as any).subscribe === 'function') {
await new Promise<void>(resolve => {
(result as Observable<unknown>).subscribe({
complete: resolve,
error: resolve,
});
});
}
expect(executionOrder[0]).toBe('hook');
expect(executionOrder[1]).toBe('guard');
});
it('should chain multiple hooks in registration order', async () => {
const order: string[] = [];
const hook1 = (_ctx: any, next: () => Observable<unknown>) => {
order.push('hook1');
return next();
};
const hook2 = (_ctx: any, next: () => Observable<unknown>) => {
order.push('hook2');
return next();
};
const container: any = new NestContainer();
const localRpcProxy = new RpcProxy();
vi.spyOn(localRpcProxy, 'create').mockImplementation(a => a);
const mockConfig = {
getGlobalPreRequestHooks: () => [hook1, hook2],
} as any;
const localGuardsContextCreator = new GuardsContextCreator(container);
vi.spyOn(localGuardsContextCreator, 'create').mockReturnValue([
{
canActivate: () => {
order.push('guard');
return true;
},
},
] as any);
const hookCreator = new RpcContextCreator(
localRpcProxy,
new ExceptionFiltersContext(container, new ApplicationConfig() as any),
new PipesContextCreator(container) as any,
new PipesConsumer() as any,
localGuardsContextCreator as any,
new GuardsConsumer() as any,
new InterceptorsContextCreator(container) as any,
new InterceptorsConsumer() as any,
mockConfig,
);
const proxy = hookCreator.create(instance, instance.test, module, 'test');
const result = await proxy('data');
if (result && typeof (result as any).subscribe === 'function') {
await new Promise<void>(resolve => {
(result as Observable<unknown>).subscribe({
complete: resolve,
error: resolve,
});
});
}
expect(order).toEqual(['hook1', 'hook2', 'guard']);
});
it('should not call hook when no hooks are registered (fast-path)', async () => {
const hookFn = vi.fn((_ctx: any, next: () => Observable<unknown>) =>
next(),
);
const creator = makeCreatorWithHooks([]);
const guardSpy = vi.spyOn(guardsConsumer, 'tryActivate');
vi.spyOn(guardsContextCreator, 'create').mockImplementation(
() => [{ canActivate: () => true }] as any,
);
contextCreator = new RpcContextCreator(
rpcProxy,
exceptionFiltersContext,
pipesCreator as any,
pipesConsumer as any,
guardsContextCreator as any,
guardsConsumer as any,
new InterceptorsContextCreator(new NestContainer() as any) as any,
new InterceptorsConsumer() as any,
{ getGlobalPreRequestHooks: () => [] } as any,
);
const proxy = contextCreator.create(
instance,
instance.test,
module,
'test',
);
await proxy('data');
expect(hookFn).not.toHaveBeenCalled();
expect(guardSpy).toHaveBeenCalled();
});
it('should provide ExecutionContext with getClass() and getHandler() to the hook', async () => {
let capturedContext: any;
const hookFn = (ctx: any, next: () => Observable<unknown>) => {
capturedContext = ctx;
return next();
};
const container: any = new NestContainer();
const localRpcProxy = new RpcProxy();
vi.spyOn(localRpcProxy, 'create').mockImplementation(a => a);
const mockConfig = { getGlobalPreRequestHooks: () => [hookFn] } as any;
const localGuardsContextCreator = new GuardsContextCreator(container);
vi.spyOn(localGuardsContextCreator, 'create').mockReturnValue([] as any);
const hookCreator = new RpcContextCreator(
localRpcProxy,
new ExceptionFiltersContext(container, new ApplicationConfig() as any),
new PipesContextCreator(container) as any,
new PipesConsumer() as any,
localGuardsContextCreator as any,
new GuardsConsumer() as any,
new InterceptorsContextCreator(container) as any,
new InterceptorsConsumer() as any,
mockConfig,
);
const proxy = hookCreator.create(instance, instance.test, module, 'test');
const result = await proxy('data');
if (result && typeof (result as any).subscribe === 'function') {
await new Promise<void>(resolve => {
(result as Observable<unknown>).subscribe({
complete: resolve,
error: resolve,
});
});
}
expect(capturedContext).not.toBeUndefined();
expect(capturedContext.getClass()).toBe(Test);
expect(capturedContext.getHandler()).toBe(instance.test);
expect(capturedContext.getType()).toBe('rpc');
});
it('should simulate ALS context available in guard (AsyncLocalStorage scenario)', async () => {
const store = new Map<string, string>();
let correlationIdInGuard: string | undefined;
const hookFn = (_ctx: any, next: () => Observable<unknown>) => {
store.set('correlationId', 'test-id-123');
return next();
};
const container: any = new NestContainer();
const localRpcProxy = new RpcProxy();
vi.spyOn(localRpcProxy, 'create').mockImplementation(a => a);
const mockConfig = { getGlobalPreRequestHooks: () => [hookFn] } as any;
const localGuardsContextCreator = new GuardsContextCreator(container);
vi.spyOn(localGuardsContextCreator, 'create').mockReturnValue([
{
canActivate: () => {
correlationIdInGuard = store.get('correlationId');
return true;
},
},
] as any);
const hookCreator = new RpcContextCreator(
localRpcProxy,
new ExceptionFiltersContext(container, new ApplicationConfig() as any),
new PipesContextCreator(container) as any,
new PipesConsumer() as any,
localGuardsContextCreator as any,
new GuardsConsumer() as any,
new InterceptorsContextCreator(container) as any,
new InterceptorsConsumer() as any,
mockConfig,
);
const proxy = hookCreator.create(instance, instance.test, module, 'test');
const result = await proxy('data');
if (result && typeof (result as any).subscribe === 'function') {
await new Promise<void>(resolve => {
(result as Observable<unknown>).subscribe({
complete: resolve,
error: resolve,
});
});
}
expect(correlationIdInGuard).toBe('test-id-123');
});
});
});

View File

@@ -152,4 +152,66 @@ describe('NestMicroservice', () => {
instance.on('test:event', cb);
expect(onStub).toHaveBeenCalledWith('test:event', cb);
});
describe('registerPreRequestHook', () => {
it('should delegate to applicationConfig.registerPreRequestHook', () => {
const mockConfig = {
...createMockAppConfig(),
registerPreRequestHook: vi.fn(),
} as unknown as ApplicationConfig;
const instance = new NestMicroservice(
mockContainer,
{ transport: Transport.TCP },
mockGraphInspector,
mockConfig,
);
const hook = (_ctx: any, next: any) => next();
instance.registerPreRequestHook(hook);
expect(mockConfig.registerPreRequestHook).toHaveBeenCalledWith(hook);
});
it('should warn when called after initialization', () => {
const mockConfig = {
...createMockAppConfig(),
registerPreRequestHook: vi.fn(),
} as unknown as ApplicationConfig;
const instance = new NestMicroservice(
mockContainer,
{ transport: Transport.TCP },
mockGraphInspector,
mockConfig,
);
const warnSpy = vi.spyOn((instance as any).logger, 'warn');
(instance as any).isInitialized = true;
const hook = (_ctx: any, next: any) => next();
instance.registerPreRequestHook(hook);
expect(warnSpy).toHaveBeenCalled();
});
it('should return this for fluent API chaining', () => {
const mockConfig = {
...createMockAppConfig(),
registerPreRequestHook: vi.fn(),
} as unknown as ApplicationConfig;
const instance = new NestMicroservice(
mockContainer,
{ transport: Transport.TCP },
mockGraphInspector,
mockConfig,
);
const hook = (_ctx: any, next: any) => next();
const result = instance.registerPreRequestHook(hook);
expect(result).toBe(instance);
});
});
});