Skip to content

Commit 5c459e4

Browse files
Dipper30fengmk2
andauthored
feat: pick dns cache commits to v4 (#5841)
PR Description This PR ports the complete custom DNS lookup and httpclient proxy/interceptor module design from 3.x to the next branch. Key changes: Implements createTransparentProxy utility for lazy, mm()-compatible httpClient instantiation. Refactors app.httpClient to use the proxy, enabling plugins to modify config before real client creation. Adds support for config.httpclient.interceptors (undici dispatcher interceptors) and applies them in httpclient constructor. Provides full unit tests for proxy and interceptor logic. Ensures all changes are TypeScript, ESM, and monorepo compatible. All tests pass except for known unrelated flaky cases. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Add middleware-style HTTP client interceptors via configuration to modify outgoing requests. * HTTP client now lazily initializes on first use (deferred construction). * Public utility for creating transparent/lazy proxies (with configurable binding) is now exposed from the package entrypoint. * **Tests** * New test suites and app fixtures validating interceptor behavior, proxy semantics, lazy initialization, and compatibility with mocking utilities. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: MK (fengmk2) <fengmk2@gmail.com>
1 parent b7008e6 commit 5c459e4

File tree

13 files changed

+680
-2
lines changed

13 files changed

+680
-2
lines changed

packages/egg/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ export type { LoggerLevel, EggLogger, EggLogger as Logger } from 'egg-logger';
5454
export * from './lib/core/httpclient.ts';
5555
export * from './lib/core/context_httpclient.ts';
5656

57+
// export utils
58+
export { createTransparentProxy, type CreateTransparentProxyOptions } from './lib/core/utils.ts';
59+
5760
/**
5861
* Start egg application with cluster mode
5962
* @since 1.0.0

packages/egg/src/lib/core/httpclient.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ export class HttpClient extends RawHttpClient {
3636
};
3737
super(initOptions);
3838
this.#app = app;
39+
40+
// Apply custom interceptors via Dispatcher.compose() if configured.
41+
// This enables tracer injection, custom headers, retry logic, etc.
42+
if (config.interceptors?.length) {
43+
const originalDispatcher = this.getDispatcher();
44+
this.setDispatcher(originalDispatcher.compose(...config.interceptors));
45+
}
3946
}
4047

4148
async request<T = any>(

packages/egg/src/lib/core/utils.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,109 @@ export function safeParseURL(url: string): URL | null {
7373
return null;
7474
}
7575
}
76+
77+
export interface CreateTransparentProxyOptions<T> {
78+
/**
79+
* Factory function to lazily create the real object.
80+
* Called at most once, on first property access.
81+
*/
82+
createReal: () => T;
83+
/**
84+
* Whether to bind functions from the real object to the real instance.
85+
* Defaults to true.
86+
*/
87+
bindFunctions?: boolean;
88+
}
89+
90+
/**
91+
* Create a Proxy that behaves like the real object, but remains transparent to
92+
* monkeypatch libraries (e.g. defineProperty-based overrides like egg-mock's `mm()`).
93+
*
94+
* - Lazily creates the real object on first access.
95+
* - Allows overriding properties on the proxy target (overlay) — e.g. via `Object.defineProperty`.
96+
* - Delegates everything else to the real object.
97+
*
98+
* This is used to defer HttpClient construction so plugins can modify
99+
* `config.httpclient.lookup` after the first access to `app.httpClient` but
100+
* before any actual HTTP request is made.
101+
*/
102+
export function createTransparentProxy<T extends object>(options: CreateTransparentProxyOptions<T>): T {
103+
const { createReal, bindFunctions = true } = options;
104+
if (typeof createReal !== 'function') {
105+
throw new TypeError('createReal must be a function');
106+
}
107+
108+
let real: T | undefined;
109+
let cachedError: unknown;
110+
const boundFnCache = new WeakMap<Function, Function>();
111+
112+
const getReal = (): T => {
113+
if (real) return real;
114+
if (cachedError) throw cachedError;
115+
try {
116+
return (real = createReal());
117+
} catch (err) {
118+
cachedError = err;
119+
throw err;
120+
}
121+
};
122+
123+
const hasOwn = (obj: object, prop: PropertyKey) => Reflect.getOwnPropertyDescriptor(obj, prop) !== undefined;
124+
125+
// The overlay target stores defineProperty-based overrides (e.g. from egg-mock's mm()).
126+
// Mocks live here so mm.restore() can delete them and reveal the real object underneath.
127+
const overlay = {} as T;
128+
129+
return new Proxy(overlay, {
130+
get(target, prop, receiver) {
131+
const r = getReal();
132+
// Overlay (defineProperty-based overrides) takes precedence
133+
if (hasOwn(target, prop)) {
134+
return Reflect.get(target, prop, receiver);
135+
}
136+
const value = Reflect.get(r, prop);
137+
if (bindFunctions && typeof value === 'function') {
138+
let bound = boundFnCache.get(value);
139+
if (!bound) {
140+
bound = value.bind(r) as Function;
141+
boundFnCache.set(value, bound);
142+
}
143+
return bound;
144+
}
145+
return value;
146+
},
147+
148+
set(target, prop, value) {
149+
const r = getReal();
150+
if (hasOwn(target, prop)) {
151+
return Reflect.set(target, prop, value);
152+
}
153+
return Reflect.set(r, prop, value);
154+
},
155+
156+
has(target, prop) {
157+
return Reflect.has(target, prop) || Reflect.has(getReal(), prop);
158+
},
159+
160+
ownKeys(target) {
161+
return [...new Set([...Reflect.ownKeys(getReal()), ...Reflect.ownKeys(target)])];
162+
},
163+
164+
getOwnPropertyDescriptor(target, prop) {
165+
return Reflect.getOwnPropertyDescriptor(target, prop) ?? Reflect.getOwnPropertyDescriptor(getReal(), prop);
166+
},
167+
168+
deleteProperty(target, prop) {
169+
if (hasOwn(target, prop)) return Reflect.deleteProperty(target, prop);
170+
return Reflect.deleteProperty(getReal(), prop);
171+
},
172+
173+
getPrototypeOf() {
174+
return Reflect.getPrototypeOf(getReal());
175+
},
176+
177+
defineProperty(target, prop, descriptor) {
178+
return Reflect.defineProperty(target, prop, descriptor);
179+
},
180+
});
181+
}

packages/egg/src/lib/egg.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import {
3434
} from './core/httpclient.ts';
3535
import { createLoggers } from './core/logger.ts';
3636
import { create as createMessenger, type IMessenger } from './core/messenger/index.ts';
37-
import { convertObject } from './core/utils.ts';
37+
import { convertObject, createTransparentProxy } from './core/utils.ts';
3838
import type { EggApplicationLoader } from './loader/index.ts';
3939
import type { EggAppConfig } from './types.ts';
4040

@@ -377,12 +377,21 @@ export class EggApplicationCore extends EggCore {
377377

378378
/**
379379
* HttpClient instance
380+
*
381+
* Returns a transparent proxy that defers actual HttpClient construction
382+
* until a method/property is first accessed. This allows plugins to modify
383+
* `config.httpclient.lookup` or other options during lifecycle hooks
384+
* (e.g. `configWillLoad`, `didLoad`) even after `app.httpClient` is
385+
* first referenced.
386+
*
380387
* @see https://github.com/node-modules/urllib
381388
* @member {HttpClient}
382389
*/
383390
get httpClient(): HttpClient {
384391
if (!this.#httpClient) {
385-
this.#httpClient = this.createHttpClient();
392+
this.#httpClient = createTransparentProxy<HttpClient>({
393+
createReal: () => this.createHttpClient(),
394+
});
386395
}
387396
return this.#httpClient;
388397
}

packages/egg/src/lib/types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Socket, LookupFunction } from 'node:net';
33
import type { FileLoaderOptions, EggAppConfig as EggCoreAppConfig, EggAppInfo } from '@eggjs/core';
44
import type { EggLoggerOptions, EggLoggersOptions } from 'egg-logger';
55
import type { PartialDeep } from 'type-fest';
6+
import type { Dispatcher } from 'urllib';
67
import type { RequestOptions as HttpClientRequestOptions } from 'urllib';
78

89
import type { MetaMiddlewareOptions } from '../app/middleware/meta.ts';
@@ -67,6 +68,24 @@ export interface HttpClientConfig {
6768
*/
6869
allowH2?: boolean;
6970
lookup?: LookupFunction;
71+
/**
72+
* Interceptors for request composition, applied via `Dispatcher.compose()`.
73+
* Each interceptor receives a `dispatch` function and returns a new `dispatch` function.
74+
*
75+
* @example
76+
* ```ts
77+
* // config.default.ts
78+
* config.httpclient = {
79+
* interceptors: [
80+
* (dispatch) => (opts, handler) => {
81+
* opts.headers = { ...opts.headers, 'x-trace-id': generateTraceId() };
82+
* return dispatch(opts, handler);
83+
* },
84+
* ],
85+
* };
86+
* ```
87+
*/
88+
interceptors?: Dispatcher.DispatcherComposeInterceptor[];
7089
}
7190

7291
/**

packages/egg/test/__snapshots__/index.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ exports[`should expose properties 1`] = `
8989
"Singleton": [Function],
9090
"SingletonProto": [Function],
9191
"Subscription": [Function],
92+
"createTransparentProxy": [Function],
9293
"defineConfig": [Function],
9394
"defineConfigFactory": [Function],
9495
"definePluginFactory": [Function],

packages/egg/test/fixtures/apps/dnscache_httpclient/config/config.default.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
'use strict';
22

3+
const os = require('os');
4+
const path = require('path');
5+
6+
// Use unique log directory per vitest worker to avoid Windows file locking issues
7+
const workerId = process.env.VITEST_WORKER_ID || '0';
8+
const tempBase = path.join(os.tmpdir(), `egg-httpclient-test-${workerId}`);
9+
310
exports.httpclient = {
411
lookup: function (hostname, options, callback) {
512
const IP_REGEX = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/;
@@ -21,4 +28,10 @@ exports.httpclient = {
2128
},
2229
};
2330

31+
exports.logger = {
32+
dir: path.join(tempBase, 'logs', 'dnscache_httpclient'),
33+
};
34+
35+
exports.rundir = path.join(tempBase, 'run');
36+
2437
exports.keys = 'test key';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
'use strict';
2+
3+
module.exports = (app) => {
4+
app.get('/', async (ctx) => {
5+
ctx.body = 'ok';
6+
});
7+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use strict';
2+
3+
const os = require('os');
4+
const path = require('path');
5+
6+
let rpcIdCounter = 0;
7+
8+
// Use unique log directory per vitest worker to avoid Windows file locking issues
9+
const workerId = process.env.VITEST_WORKER_ID || '0';
10+
const tempBase = path.join(os.tmpdir(), `egg-httpclient-test-${workerId}`);
11+
12+
exports.httpclient = {
13+
interceptors: [
14+
// Tracer interceptor: injects trace headers into every request
15+
(dispatch) => {
16+
return (opts, handler) => {
17+
opts.headers = opts.headers || {};
18+
opts.headers['x-trace-id'] = 'trace-123';
19+
rpcIdCounter++;
20+
opts.headers['x-rpc-id'] = `rpc-${rpcIdCounter}`;
21+
return dispatch(opts, handler);
22+
};
23+
},
24+
],
25+
};
26+
27+
exports.logger = {
28+
dir: path.join(tempBase, 'logs', 'httpclient-interceptor'),
29+
};
30+
31+
exports.rundir = path.join(tempBase, 'run');
32+
33+
exports.keys = 'test key';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"name": "httpclient-interceptor-app"
3+
}

0 commit comments

Comments
 (0)