Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,9 @@ export class CronController {
Special care must be taken in case you're using [Proxy Providers](../03_features-and-use-cases/06_proxy-providers.md#outside-web-request).

:::

:::warning

If you are using Proxy Providers in a background worker (e.g. BullMQ), make sure the worker does not start consuming jobs before `app.init()` / `app.listen()` resolves, as the Proxy Provider resolver may not yet be initialized. See the [relevant caveat](./06_proxy-providers.md#proxy-providers-require-full-application-bootstrap) in the Proxy Providers documentation.

:::
67 changes: 65 additions & 2 deletions docs/docs/03_features-and-use-cases/06_proxy-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ await this.cls.proxy.resolve();

<small>since `v4.4.0`</small>

By default, accessing an unresolved Proxy Provider behaves as if it was an _empty object_. In order to prevent silent failures, you can set the `strict` option to `true` in the proxy provider registration. In this case, any attempt to access a property or a method on an unresolved Proxy Provider will throw an error.
By default, accessing an unresolved Proxy Provider behaves as if it was an _empty object_. In order to prevent silent failures, you can set the `strict` option to `true` in the proxy provider registration. In this case, any attempt to access a property or a method on an unresolved Proxy Provider will throw a `ProxyProviderNotResolvedException`.

For Class Proxy Providers, you can use the according option on the `@InjectableProxy()` decorator.

Expand Down Expand Up @@ -310,9 +310,72 @@ ClsModule.forFeatureAsync({

## Caveats

### Proxy Providers require full application bootstrap

The Proxy Provider resolver is initialized in `ClsRootModule.onModuleInit()`. Any code that runs before `app.init()` (or `app.listen()`) resolves may encounter an uninitialized resolver and will not be able to use Proxy Providers.

It goes without saying that any access to any property on a Proxy provider **in the constructor** will always evaluate to `undefined` (or throw in strict mode).

:::warning

This is a common pitfall with background job processors such as **BullMQ workers**, which start consuming messages in `onModuleInit`. If the worker's `onModuleInit` runs before `ClsRootModule.onModuleInit()` (which depends on module initialization order), Proxy Providers will not yet be available.

To avoid this, ensure the application is fully bootstrapped before your worker begins consuming. Either start consuming in `onApplicationBootstrap` (which is guaranteed to run after all `onModuleInit` hooks) or delay consumption until after `app.listen()` / `app.init()` resolves:

```ts
@Injectable()
export class WorkerService implements OnApplicationBootstrap {
constructor(private readonly worker: Worker) {}

// Use onApplicationBootstrap instead of onModuleInit
// to ensure Proxy Providers are available.
onApplicationBootstrap() {
this.worker.run();
}
}
```

:::

### Do not mix REQUEST-scoped providers with Proxy Providers

:::danger

Never inject a real NestJS `Scope.REQUEST` (or `durable: true`) provider as a dependency of a Proxy Provider or any `ClsModule` plugin.

:::

Proxy Providers are **singletons** from NestJS's DI perspective. When NestJS detects that a singleton depends on a REQUEST-scoped provider, it changes the scope of the singleton to REQUEST as well. This means the Proxy wrapper itself is re-created on every request, which defeats the purpose of Proxy Providers and can cause **cross-request contamination** (e.g. tenant connections leaking between requests).

```ts
// ❌ Wrong: TENANT_CONNECTION depends on a real Scope.REQUEST provider
{
provide: TENANT_CONNECTION,
scope: Scope.REQUEST,
durable: true,
inject: [REQUEST, TenantRegistry],
useFactory: (req: Request, registry: TenantRegistry) =>
registry.getConnection(req.headers['tenant-id']),
}
```

Convert it to a `ClsModule.forFeatureAsync` Proxy Provider using `CLS_REQ` or `ClsService` instead:

```ts
// ✅ Correct: factory is a singleton; request data comes from CLS context
ClsModule.forFeatureAsync({
provide: TENANT_CONNECTION,
inject: [CLS_REQ, TenantRegistry],
useFactory: (req: Request, registry: TenantRegistry) =>
registry.getConnection(req.headers['tenant-id']),
});
```

`CLS_REQ` is itself a Proxy Provider (a singleton that delegates to the per-request value stored in CLS), so the factory above remains a singleton from NestJS's point of view while still resolving the correct request on each access.

### No primitive values

Proxy Factory providers _cannot_ return a _primitive value_. This is because the provider itself is the Proxy and it only delegates access once a property or a method is called on it (or if it itself is called in case the factory returns a function).
Proxy Factory providers _cannot_ return a _primitive value_ (`string`, `number`, `boolean`, `null`, or `undefined`). Doing so throws a `ProxyProviderInvalidReturnTypeException` at resolution time. This is because the provider itself is the Proxy and it only delegates access once a property or a method is called on it (or if it itself is called in case the factory returns a function).

### `function` Proxies must be explicitly enabled

Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/lib/plugin/cls-plugin-base.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { InjectionToken, Provider } from '@nestjs/common';
import {
InjectionToken,
OptionalFactoryDependency,
Provider,
} from '@nestjs/common';
import { ClsPlugin, ClsPluginHooks } from './cls-plugin.interface';

/**
Expand Down Expand Up @@ -41,7 +45,7 @@ export abstract class ClsPluginBase implements ClsPlugin {
* ```
*/
protected registerHooks(opts: {
inject?: InjectionToken<any>[];
inject?: Array<InjectionToken | OptionalFactoryDependency>;
useFactory: (...args: any[]) => ClsPluginHooks;
}) {
this.providers.push({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { globalClsService } from '../cls-service.globals';
import { ProxyProviderInvalidReturnTypeException } from './proxy-provider.exceptions';
import { ProxyProviderManager } from './proxy-provider-manager';

describe('ProxyProviderManager', () => {
Expand All @@ -24,6 +25,100 @@ describe('ProxyProviderManager', () => {
);
});

describe('factory return type validation', () => {
it.each([
['undefined', undefined],
['null', null],
['a string', 'hello'],
['a number', 42],
['a boolean', true],
])(
'throws ProxyProviderInvalidReturnTypeException when factory returns %s',
async (_, returnValue) => {
await globalClsService.run(async () => {
const providerToken = Symbol('example-provider');
const { useFactory } =
ProxyProviderManager.createProxyProvider({
provide: providerToken,
useFactory: () => returnValue as any,
});

useFactory();

ProxyProviderManager.init();
await expect(
ProxyProviderManager.resolveProxyProviders(),
).rejects.toThrow(
ProxyProviderInvalidReturnTypeException,
);
});
},
);

it('does not throw when factory returns an object', async () => {
await globalClsService.run(async () => {
const providerToken = Symbol('example-provider');
const { useFactory } =
ProxyProviderManager.createProxyProvider({
provide: providerToken,
useFactory: () => ({ key: 'value' }),
});

useFactory();

ProxyProviderManager.init();
await expect(
ProxyProviderManager.resolveProxyProviders(),
).resolves.not.toThrow();
});
});

it('does not throw when factory returns a function', async () => {
await globalClsService.run(async () => {
const providerToken = Symbol('example-provider');
const { useFactory } =
ProxyProviderManager.createProxyProvider({
provide: providerToken,
useFactory: () => () => 'result',
type: 'function',
});

useFactory();

ProxyProviderManager.init();
await expect(
ProxyProviderManager.resolveProxyProviders(),
).resolves.not.toThrow();
});
});
});

describe('resolution tracking', () => {
it('does not re-resolve an already-resolved provider in the same CLS context', async () => {
await globalClsService.run(async () => {
let callCount = 0;
const providerToken = Symbol('example-provider');
const { useFactory } =
ProxyProviderManager.createProxyProvider({
provide: providerToken,
useFactory: () => {
callCount++;
return { value: callCount };
},
});

useFactory();

ProxyProviderManager.init();
await ProxyProviderManager.resolveProxyProviders();
await ProxyProviderManager.resolveProxyProviders();

// Factory should have only been called once
expect(callCount).toBe(1);
});
});
});

describe('the provider factory', () => {
it('allows access to the underlying provider properties', async () => {
await globalClsService.run(async () => {
Expand Down
56 changes: 55 additions & 1 deletion packages/core/src/lib/proxy-provider/proxy-provider-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { InjectionToken, OptionalFactoryDependency } from '@nestjs/common';
import { UnknownDependenciesException } from '@nestjs/core/errors/exceptions/unknown-dependencies.exception';
import { ClsService } from '../cls.service';
import { isProxyClassProvider } from './proxy-provider.functions';
Expand All @@ -7,6 +8,7 @@ import {
ProxyProviderDefinition,
} from './proxy-provider.interfaces';
import {
ProxyProviderInvalidReturnTypeException,
ProxyProviderNotRegisteredException,
ProxyProvidersResolutionTimeoutException,
UnknownProxyDependenciesException,
Expand All @@ -25,6 +27,13 @@ type ProxyProviderPromisesMap = Map<symbol, Promise<unknown>>;

const CLS_PROXY_PROVIDER_PROMISES_MAP = Symbol('CLS_PROVIDER_PROMISES_MAP');

/**
* A Set stored in the CLS context that tracks which Proxy Providers have been
* resolved. Tracking resolution separately from the stored value allows
* distinguishing "not resolved yet" from "resolved to null or undefined".
*/
const CLS_PROXY_PROVIDER_RESOLVED_SET = Symbol('CLS_PROVIDER_RESOLVED_SET');

export class ProxyProvidersResolver {
private readonly proxyProviderDependenciesMap = new Map<symbol, symbol[]>();

Expand All @@ -38,6 +47,7 @@ export class ProxyProvidersResolver {
this.proxyProviderDependenciesMap.set(
provider.symbol,
provider.dependencies
.map(extractInjectionToken)
.map(getProxyProviderSymbol)
.filter((symbol) => !defaultProxyProviderTokens.has(symbol))
.filter((symbol) => proxyProviderMap.has(symbol)),
Expand Down Expand Up @@ -118,14 +128,34 @@ export class ProxyProvidersResolver {
return resolutionPromisesMap;
}

/**
* ResolvedSet is a Set scoped to the current CLS context that tracks
* which Proxy Providers have been successfully resolved. Tracking this
* separately from the stored value allows distinguishing "not resolved yet"
* from "resolved to null or undefined" (which would be an error, but we
* use this set so the check is unambiguous regardless of the stored value).
*/
private getOrCreateCurrentResolvedSet(): Set<symbol> {
const resolvedSet =
this.cls.get<Set<symbol>>(CLS_PROXY_PROVIDER_RESOLVED_SET) ??
new Set<symbol>();
this.cls.setIfUndefined(CLS_PROXY_PROVIDER_RESOLVED_SET, resolvedSet);
return resolvedSet;
}

/**
* Gets a set of all Proxy Provider symbols that need to be resolved
* and symbols of their dependencies (that have not been resolved yet)
*/
private getAllNeededProviderSymbols(providerSymbols: symbol[]) {
const resolvedSet = this.getOrCreateCurrentResolvedSet();
return new Set<symbol>(
providerSymbols
.filter((providerSymbol) => !this.cls.get(providerSymbol))
.filter(
(providerSymbol) =>
!resolvedSet.has(providerSymbol) &&
!this.cls.has(providerSymbol),
)
Comment on lines +154 to +158
.map((providerSymbol) => {
const deps =
Comment thread
Papooch marked this conversation as resolved.
this.proxyProviderDependenciesMap.get(providerSymbol) ??
Expand Down Expand Up @@ -160,7 +190,22 @@ export class ProxyProvidersResolver {
await Promise.all(ownDependencyPromises);
const providerInstance =
await this.resolveProxyProviderInstance(providerDefinition);

const instanceType = typeof providerInstance;
if (
providerInstance === null ||
providerInstance === undefined ||
(instanceType !== 'object' && instanceType !== 'function')
) {
throw ProxyProviderInvalidReturnTypeException.create(
providerSymbol,
providerInstance,
);
}

this.cls.set(providerSymbol, providerInstance);
const resolvedSet = this.getOrCreateCurrentResolvedSet();
resolvedSet.add(providerSymbol);
return ownPromise.resolve();
} catch (e) {
return ownPromise.reject(e);
Expand Down Expand Up @@ -198,3 +243,12 @@ export class ProxyProvidersResolver {
return await provider.useFactory.apply(null, injected);
}
}

function extractInjectionToken(
dep: InjectionToken | OptionalFactoryDependency,
): InjectionToken {
if (dep !== null && typeof dep === 'object' && 'token' in dep) {
return dep.token;
}
return dep as InjectionToken;
}
12 changes: 12 additions & 0 deletions packages/core/src/lib/proxy-provider/proxy-provider.exceptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,15 @@ export class ProxyProvidersResolutionTimeoutException extends ProxyProviderError
return new this(message);
}
}

export class ProxyProviderInvalidReturnTypeException extends ProxyProviderError {
static create(providerSymbol: symbol, value: unknown) {
const providerName = providerSymbol.description ?? 'unknown';
const type = value === null ? 'null' : typeof value;
const message =
`The factory for Proxy provider "${providerName}" returned a value of type "${type}", ` +
`but Proxy providers must return an object or a function. ` +
`Primitive values (string, number, boolean, etc.) and null/undefined are not supported.`;
return new this(message);
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { InjectionToken, Provider, Type } from '@nestjs/common';
import {
InjectionToken,
OptionalFactoryDependency,
Provider,
Type,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';

interface ClsModuleProxyProviderCommonOptions {
Expand Down Expand Up @@ -54,7 +59,7 @@ export interface ClsModuleProxyFactoryProviderOptions extends ClsModuleProxyProv
/**
* An array of injection tokens for providers used in the `useFactory`.
*/
inject?: InjectionToken[];
inject?: Array<InjectionToken | OptionalFactoryDependency>;

/**
* Factory function that accepts an array of providers in the order of the according tokens in the `inject` array.
Expand Down Expand Up @@ -98,7 +103,7 @@ export interface ProxyFactoryProviderDefinition {
provide: InjectionToken;
symbol: symbol;
injected: any[];
dependencies: InjectionToken[];
dependencies: Array<InjectionToken | OptionalFactoryDependency>;
useFactory: (...args: any[]) => any | Promise<any>;
}

Expand Down
Loading
Loading