Skip to content
Merged
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
3 changes: 3 additions & 0 deletions packages/egg/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export type { LoggerLevel, EggLogger, EggLogger as Logger } from 'egg-logger';
export * from './lib/core/httpclient.ts';
export * from './lib/core/context_httpclient.ts';

// export utils
export { createTransparentProxy, type CreateTransparentProxyOptions } from './lib/core/utils.ts';

/**
* Start egg application with cluster mode
* @since 1.0.0
Expand Down
7 changes: 7 additions & 0 deletions packages/egg/src/lib/core/httpclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export class HttpClient extends RawHttpClient {
};
super(initOptions);
this.#app = app;

// Apply custom interceptors via Dispatcher.compose() if configured.
// This enables tracer injection, custom headers, retry logic, etc.
if (config.interceptors?.length) {
const originalDispatcher = this.getDispatcher();
this.setDispatcher(originalDispatcher.compose(...config.interceptors));
}
}

async request<T = any>(
Expand Down
106 changes: 106 additions & 0 deletions packages/egg/src/lib/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,109 @@ export function safeParseURL(url: string): URL | null {
return null;
}
}

export interface CreateTransparentProxyOptions<T> {
/**
* Factory function to lazily create the real object.
* Called at most once, on first property access.
*/
createReal: () => T;
/**
* Whether to bind functions from the real object to the real instance.
* Defaults to true.
*/
bindFunctions?: boolean;
}

/**
* Create a Proxy that behaves like the real object, but remains transparent to
* monkeypatch libraries (e.g. defineProperty-based overrides like egg-mock's `mm()`).
*
* - Lazily creates the real object on first access.
* - Allows overriding properties on the proxy target (overlay) — e.g. via `Object.defineProperty`.
* - Delegates everything else to the real object.
*
* This is used to defer HttpClient construction so plugins can modify
* `config.httpclient.lookup` after the first access to `app.httpClient` but
* before any actual HTTP request is made.
*/
export function createTransparentProxy<T extends object>(options: CreateTransparentProxyOptions<T>): T {
const { createReal, bindFunctions = true } = options;
if (typeof createReal !== 'function') {
throw new TypeError('createReal must be a function');
}

let real: T | undefined;
let cachedError: unknown;
const boundFnCache = new WeakMap<Function, Function>();

const getReal = (): T => {
if (real) return real;
if (cachedError) throw cachedError;
try {
return (real = createReal());
} catch (err) {
cachedError = err;
throw err;
}
};

const hasOwn = (obj: object, prop: PropertyKey) => Reflect.getOwnPropertyDescriptor(obj, prop) !== undefined;

// The overlay target stores defineProperty-based overrides (e.g. from egg-mock's mm()).
// Mocks live here so mm.restore() can delete them and reveal the real object underneath.
const overlay = {} as T;

return new Proxy(overlay, {
get(target, prop, receiver) {
const r = getReal();
// Overlay (defineProperty-based overrides) takes precedence
if (hasOwn(target, prop)) {
return Reflect.get(target, prop, receiver);
}
const value = Reflect.get(r, prop);
if (bindFunctions && typeof value === 'function') {
let bound = boundFnCache.get(value);
if (!bound) {
bound = value.bind(r) as Function;
boundFnCache.set(value, bound);
}
return bound;
}
return value;
},

set(target, prop, value) {
const r = getReal();
if (hasOwn(target, prop)) {
return Reflect.set(target, prop, value);
}
return Reflect.set(r, prop, value);
},

has(target, prop) {
return Reflect.has(target, prop) || Reflect.has(getReal(), prop);
},

ownKeys(target) {
return [...new Set([...Reflect.ownKeys(getReal()), ...Reflect.ownKeys(target)])];
},

getOwnPropertyDescriptor(target, prop) {
return Reflect.getOwnPropertyDescriptor(target, prop) ?? Reflect.getOwnPropertyDescriptor(getReal(), prop);
},

deleteProperty(target, prop) {
if (hasOwn(target, prop)) return Reflect.deleteProperty(target, prop);
return Reflect.deleteProperty(getReal(), prop);
},

getPrototypeOf() {
return Reflect.getPrototypeOf(getReal());
},

defineProperty(target, prop, descriptor) {
return Reflect.defineProperty(target, prop, descriptor);
},
});
}
13 changes: 11 additions & 2 deletions packages/egg/src/lib/egg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
} from './core/httpclient.ts';
import { createLoggers } from './core/logger.ts';
import { create as createMessenger, type IMessenger } from './core/messenger/index.ts';
import { convertObject } from './core/utils.ts';
import { convertObject, createTransparentProxy } from './core/utils.ts';
import type { EggApplicationLoader } from './loader/index.ts';
import type { EggAppConfig } from './types.ts';

Expand Down Expand Up @@ -377,12 +377,21 @@ export class EggApplicationCore extends EggCore {

/**
* HttpClient instance
*
* Returns a transparent proxy that defers actual HttpClient construction
* until a method/property is first accessed. This allows plugins to modify
* `config.httpclient.lookup` or other options during lifecycle hooks
* (e.g. `configWillLoad`, `didLoad`) even after `app.httpClient` is
* first referenced.
*
* @see https://github.com/node-modules/urllib
* @member {HttpClient}
*/
get httpClient(): HttpClient {
if (!this.#httpClient) {
this.#httpClient = this.createHttpClient();
this.#httpClient = createTransparentProxy<HttpClient>({
createReal: () => this.createHttpClient(),
});
}
return this.#httpClient;
}
Expand Down
19 changes: 19 additions & 0 deletions packages/egg/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Socket, LookupFunction } from 'node:net';
import type { FileLoaderOptions, EggAppConfig as EggCoreAppConfig, EggAppInfo } from '@eggjs/core';
import type { EggLoggerOptions, EggLoggersOptions } from 'egg-logger';
import type { PartialDeep } from 'type-fest';
import type { Dispatcher } from 'urllib';
import type { RequestOptions as HttpClientRequestOptions } from 'urllib';

import type { MetaMiddlewareOptions } from '../app/middleware/meta.ts';
Expand Down Expand Up @@ -67,6 +68,24 @@ export interface HttpClientConfig {
*/
allowH2?: boolean;
lookup?: LookupFunction;
/**
* Interceptors for request composition, applied via `Dispatcher.compose()`.
* Each interceptor receives a `dispatch` function and returns a new `dispatch` function.
*
* @example
* ```ts
* // config.default.ts
* config.httpclient = {
* interceptors: [
* (dispatch) => (opts, handler) => {
* opts.headers = { ...opts.headers, 'x-trace-id': generateTraceId() };
* return dispatch(opts, handler);
* },
* ],
* };
* ```
*/
interceptors?: Dispatcher.DispatcherComposeInterceptor[];
Comment on lines +71 to +88
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

urllib npm package Dispatcher.DispatcherComposeInterceptor type

💡 Result:

No such type "Dispatcher.DispatcherComposeInterceptor" exists in the urllib npm package or its underlying undici dependency. Urllib (v4.9.0, latest as of 2025-12-13) is a high-level HTTP client built on undici that exports undici's Dispatcher types like Agent, ProxyAgent, MockAgent, but does not define custom types like DispatcherComposeInterceptor. Undici (v7.1.1 dependency) has Dispatcher.compose method which accepts interceptors of type DispatcherComposeInterceptor (in newer versions) or DispatchInterceptor (older deprecated versions). This type is: type DispatcherComposeInterceptor = (dispatch: Dispatcher['dispatch']) => Dispatcher['dispatch']; It takes the base dispatch function and returns a new dispatch-like function for chaining interceptors (e.g., retry, redirect). Example usage from undici docs: const { Agent, interceptors } = require('undici'); const agent = new Agent.compose( interceptors.retry, interceptors.redirect ); Urllib usage example with custom dispatcher: import { ProxyAgent, request } from 'urllib'; const proxyAgent = new ProxyAgent('http://proxy:8080'); const response = await request('https://example.com', { dispatcher: proxyAgent }); To use compose interceptors in urllib, pass a composed undici Dispatcher via options.dispatcher.

Citations:


🏁 Script executed:

cat -n packages/egg/src/lib/types.ts | head -100

Repository: eggjs/egg

Length of output: 4100


🏁 Script executed:

rg "Dispatcher" packages/egg/src/lib/types.ts -B 5 -A 5

Repository: eggjs/egg

Length of output: 1194


🏁 Script executed:

rg "import.*Dispatcher" packages/egg/src/lib/ -t ts

Repository: eggjs/egg

Length of output: 124


🏁 Script executed:

cat packages/egg/package.json | grep -A 5 -B 5 "urllib\|undici"

Repository: eggjs/egg

Length of output: 1058


🏁 Script executed:

rg "DispatcherComposeInterceptor" packages/ -t ts

Repository: eggjs/egg

Length of output: 142


🏁 Script executed:

find packages -name "*.d.ts" -type f | xargs rg "DispatcherComposeInterceptor" 2>/dev/null || echo "No .d.ts files found with this type"

Repository: eggjs/egg

Length of output: 88


🏁 Script executed:

# Check urllib package.json to see what version is used
rg "urllib" packages/egg/package.json -A 2 -B 2

Repository: eggjs/egg

Length of output: 514


🏁 Script executed:

# Check the root pnpm-workspace.yaml or package.json for version catalog
cat pnpm-workspace.yaml 2>/dev/null || cat package.json | grep -A 50 "catalog"

Repository: eggjs/egg

Length of output: 5462


🏁 Script executed:

# Look for urllib version in root catalog
rg "urllib.*:" . --type yaml --type json | head -20

Repository: eggjs/egg

Length of output: 859


🏁 Script executed:

# Check if there's a monorepo-level package.json that defines catalog versions
cat package.json | jq '.catalog' 2>/dev/null || grep -A 100 '"catalog"' package.json | head -50

Repository: eggjs/egg

Length of output: 57


Type reference Dispatcher.DispatcherComposeInterceptor does not exist in urllib.

The type Dispatcher.DispatcherComposeInterceptor is not exported from urllib v4.8.2 or its underlying undici dependency. The actual type from undici is DispatcherComposeInterceptor (without the Dispatcher namespace), defined as (dispatch: Dispatcher['dispatch']) => Dispatcher['dispatch']. This code will fail TypeScript compilation. The type annotation should be corrected to reference the proper type from undici, or import DispatcherComposeInterceptor directly rather than accessing it as a namespace member.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/egg/src/lib/types.ts` around lines 71 - 88, The interceptors
property is typed as Dispatcher.DispatcherComposeInterceptor which doesn't
exist; change its type to the correct undici type (DispatcherComposeInterceptor)
or import and use that type directly. Update the declaration of interceptors?:
Dispatcher.DispatcherComposeInterceptor[] to use DispatcherComposeInterceptor[]
(or import { DispatcherComposeInterceptor } from 'undici' and use that), or
alternatively declare it as (dispatch: Dispatcher['dispatch']) =>
Dispatcher['dispatch'][] if you prefer inline typing; ensure references to
Dispatcher.DispatcherComposeInterceptor are removed and replaced with
DispatcherComposeInterceptor or the equivalent imported/inline type so
TypeScript compiles.

}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/egg/test/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ exports[`should expose properties 1`] = `
"Singleton": [Function],
"SingletonProto": [Function],
"Subscription": [Function],
"createTransparentProxy": [Function],
"defineConfig": [Function],
"defineConfigFactory": [Function],
"definePluginFactory": [Function],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
'use strict';

const os = require('os');
const path = require('path');

// Use unique log directory per vitest worker to avoid Windows file locking issues
const workerId = process.env.VITEST_WORKER_ID || '0';
const tempBase = path.join(os.tmpdir(), `egg-httpclient-test-${workerId}`);

exports.httpclient = {
lookup: function (hostname, options, callback) {
const IP_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
Expand All @@ -21,4 +28,10 @@ exports.httpclient = {
},
};

exports.logger = {
dir: path.join(tempBase, 'logs', 'dnscache_httpclient'),
};

exports.rundir = path.join(tempBase, 'run');

exports.keys = 'test key';
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
'use strict';

module.exports = (app) => {
app.get('/', async (ctx) => {
ctx.body = 'ok';
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use strict';

const os = require('os');
const path = require('path');

let rpcIdCounter = 0;

// Use unique log directory per vitest worker to avoid Windows file locking issues
const workerId = process.env.VITEST_WORKER_ID || '0';
const tempBase = path.join(os.tmpdir(), `egg-httpclient-test-${workerId}`);

exports.httpclient = {
interceptors: [
// Tracer interceptor: injects trace headers into every request
(dispatch) => {
return (opts, handler) => {
opts.headers = opts.headers || {};
opts.headers['x-trace-id'] = 'trace-123';
rpcIdCounter++;
opts.headers['x-rpc-id'] = `rpc-${rpcIdCounter}`;
Comment on lines +6 to +20
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a module-level variable rpcIdCounter for state can be problematic in a concurrent environment, as it creates shared mutable state. While this might be acceptable for this specific test which runs requests sequentially, it's a pattern that can lead to race conditions in a real application. To promote better practices even in tests, consider managing this state without relying on a shared module-level variable. For example, you could use a factory function that creates an interceptor with its own encapsulated counter.

return dispatch(opts, handler);
};
},
],
};

exports.logger = {
dir: path.join(tempBase, 'logs', 'httpclient-interceptor'),
};

exports.rundir = path.join(tempBase, 'run');

exports.keys = 'test key';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "httpclient-interceptor-app"
}
80 changes: 80 additions & 0 deletions packages/egg/test/lib/core/httpclient_interceptor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { strict as assert } from 'node:assert';
import http from 'node:http';

import { describe, it, beforeAll, afterAll } from 'vitest';

import { createApp, type MockApplication, startNewLocalServer } from '../../utils.js';

describe('test/lib/core/httpclient_interceptor.test.ts', () => {
describe('with interceptors configured', () => {
let app: MockApplication;
let url: string;
let serverInfo: { url: string; server: http.Server };

beforeAll(async () => {
app = createApp('apps/httpclient-interceptor');
await app.ready();
serverInfo = await startNewLocalServer();
url = serverInfo.url;
});

afterAll(async () => {
if (serverInfo?.server?.listening) {
serverInfo.server.close();
}
await app.close();
});

it('should inject trace headers via interceptor', async () => {
const res = await app.curl(url + '/get_headers', { dataType: 'json' });
assert.equal(res.status, 200);
assert.equal(res.data.headers['x-trace-id'], 'trace-123');
// rpcId should be present
assert(res.data.headers['x-rpc-id']);
assert(res.data.headers['x-rpc-id'].startsWith('rpc-'));
});

it('should increment rpcId on each request', async () => {
const res1 = await app.curl(url + '/get_headers', { dataType: 'json' });
const rpcId1 = parseInt(res1.data.headers['x-rpc-id'].replace('rpc-', ''), 10);

const res2 = await app.curl(url + '/get_headers', { dataType: 'json' });
const rpcId2 = parseInt(res2.data.headers['x-rpc-id'].replace('rpc-', ''), 10);

assert(rpcId2 > rpcId1, `Expected ${rpcId2} > ${rpcId1}`);
});

it('should work with httpClient.request directly', async () => {
const res = await app.httpClient.request(url + '/get_headers', { dataType: 'json' });
assert.equal(res.status, 200);
assert.equal(res.data.headers['x-trace-id'], 'trace-123');
});
});

describe('without interceptors configured', () => {
let app: MockApplication;
let url: string;
let serverInfo: { url: string; server: http.Server };

beforeAll(async () => {
app = createApp('apps/dnscache_httpclient');
await app.ready();
serverInfo = await startNewLocalServer();
url = serverInfo.url;
});

afterAll(async () => {
if (serverInfo?.server?.listening) {
serverInfo.server.close();
}
await app.close();
});

it('should work normally without interceptors', async () => {
const res = await app.curl(url + '/get_headers', { dataType: 'json' });
assert.equal(res.status, 200);
// No trace headers injected
assert.equal(res.data.headers['x-trace-id'], undefined);
});
});
});
Loading
Loading