Skip to content

Commit 04cfa1b

Browse files
authored
fix: egg-mock for httpclient_next proxy (#5768)
## Background Previously, app.httpclient was initialized lazily. However, certain plugins might access the instance earlier than expected—potentially before the final configuration is fully composed. To address this, a Proxy wrapper was introduced so that the actual HTTP client is only instantiated when its properties or methods are actually accessed. ## Issue This Proxy implementation broke compatibility with egg-mock. Specifically, mocking via: ```js mm(app.httpclient, 'request', async () => ({ status: 500 })) ``` no longer works as expected due to improper handling in the Proxy’s traps (e.g., missing or incorrect get/set behavior). ## Solution Refactor the Proxy to be more robust and compliant with standard object semantics, ensuring it correctly supports property access, method calls, and mocking libraries like egg-mock. ## Impact Only the httpclient_next initialization logic in egg.js and utils.js is affected. Existing behavior remains unchanged for all other use cases.
1 parent 6421bc6 commit 04cfa1b

File tree

4 files changed

+434
-59
lines changed

4 files changed

+434
-59
lines changed

lib/core/utils.js

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const URL = require('url').URL;
77
module.exports = {
88
convertObject,
99
safeParseURL,
10+
createTransparentProxy,
1011
};
1112

1213
function convertObject(obj, ignore, ignoreKeyPaths) {
@@ -90,3 +91,118 @@ function safeParseURL(url) {
9091
return null;
9192
}
9293
}
94+
95+
/**
96+
* Create a Proxy that behaves like the real object, but remains transparent to
97+
* monkeypatch libraries (e.g. defineProperty-based overrides).
98+
*
99+
* - Lazily creates the real object on first access.
100+
* - Allows overriding properties on the proxy target (overlay) to take effect.
101+
* - Delegates everything else to the real object.
102+
*
103+
* @param {Object} options
104+
* @param {Function} options.createReal Create the real object (lazy)
105+
* @param {boolean} [options.bindFunctions=true] Bind real methods to the real object
106+
* @return {Proxy}
107+
*/
108+
function createTransparentProxy({ createReal, bindFunctions = true }) {
109+
if (typeof createReal !== 'function') {
110+
throw new TypeError('createReal must be a function');
111+
}
112+
113+
let real = null;
114+
let error = null;
115+
let initialized = false;
116+
117+
const init = () => {
118+
if (initialized) {
119+
if (error) throw error;
120+
return;
121+
}
122+
initialized = true;
123+
try {
124+
real = createReal();
125+
} catch (err) {
126+
error = err;
127+
throw err;
128+
}
129+
};
130+
131+
return new Proxy({}, {
132+
get(target, prop, receiver) {
133+
init();
134+
// Check if property is defined on proxy target (monkeypatch overlay)
135+
if (Object.getOwnPropertyDescriptor(target, prop)) {
136+
return Reflect.get(target, prop, receiver);
137+
}
138+
const value = real[prop];
139+
if (bindFunctions && typeof value === 'function') {
140+
return value.bind(real);
141+
}
142+
return value;
143+
},
144+
145+
set(target, prop, value, receiver) {
146+
init();
147+
if (Object.getOwnPropertyDescriptor(target, prop)) {
148+
return Reflect.set(target, prop, value, receiver);
149+
}
150+
return Reflect.set(real, prop, value);
151+
},
152+
153+
has(target, prop) {
154+
init();
155+
return prop in target || prop in real;
156+
},
157+
158+
ownKeys(target) {
159+
init();
160+
const keys = new Set([ ...Reflect.ownKeys(real), ...Reflect.ownKeys(target) ]);
161+
return Array.from(keys);
162+
},
163+
164+
getOwnPropertyDescriptor(target, prop) {
165+
init();
166+
return Object.getOwnPropertyDescriptor(target, prop)
167+
|| Object.getOwnPropertyDescriptor(real, prop);
168+
},
169+
170+
deleteProperty(target, prop) {
171+
init();
172+
if (Object.getOwnPropertyDescriptor(target, prop)) {
173+
return delete target[prop];
174+
}
175+
return delete real[prop];
176+
},
177+
178+
getPrototypeOf() {
179+
init();
180+
return Object.getPrototypeOf(real);
181+
},
182+
183+
setPrototypeOf(_target, proto) {
184+
init();
185+
return Reflect.setPrototypeOf(real, proto);
186+
},
187+
188+
isExtensible() {
189+
init();
190+
return Reflect.isExtensible(real);
191+
},
192+
193+
preventExtensions(target) {
194+
init();
195+
// Must also prevent extensions on target to satisfy Proxy invariants
196+
const result = Reflect.preventExtensions(real);
197+
if (result) {
198+
Reflect.preventExtensions(target);
199+
}
200+
return result;
201+
},
202+
203+
defineProperty(target, prop, descriptor) {
204+
// Used by monkeypatch libs: keep overrides on proxy target (overlay layer).
205+
return Reflect.defineProperty(target, prop, descriptor);
206+
},
207+
});
208+
}

lib/egg.js

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -316,39 +316,10 @@ class EggApplication extends EggCore {
316316
options.lookup = options.lookup ?? self.config.httpclient.lookup;
317317
realClient = new self.HttpClientNext(self, options);
318318
};
319-
return new Proxy({}, {
320-
get(_target, prop) {
319+
return utils.createTransparentProxy({
320+
createReal() {
321321
init();
322-
const value = realClient[prop];
323-
if (typeof value === 'function') {
324-
return value.bind(realClient);
325-
}
326-
return value;
327-
},
328-
set(_target, prop, value) {
329-
init();
330-
realClient[prop] = value;
331-
return true;
332-
},
333-
has(_target, prop) {
334-
init();
335-
return prop in realClient;
336-
},
337-
ownKeys() {
338-
init();
339-
return Reflect.ownKeys(realClient);
340-
},
341-
getOwnPropertyDescriptor(_target, prop) {
342-
init();
343-
return Object.getOwnPropertyDescriptor(realClient, prop);
344-
},
345-
deleteProperty(_target, prop) {
346-
init();
347-
return delete realClient[prop];
348-
},
349-
getPrototypeOf() {
350-
init();
351-
return Object.getPrototypeOf(realClient);
322+
return realClient;
352323
},
353324
});
354325
}

test/lib/core/httpclient.test.js

Lines changed: 9 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -383,8 +383,16 @@ describe('test/lib/core/httpclient.test.js', () => {
383383
});
384384
});
385385

386-
it('should Proxy be fully functional', () => {
386+
it('should Proxy be fully functional', async () => {
387387
const httpclient = app.httpclient;
388+
mm(app.httpclient, 'request', async () => ({
389+
status: 500,
390+
headers: { 'x-oss-request-id': 'mock request id' },
391+
}));
392+
393+
const res = await app.curl(url + '/get_headers', { dataType: 'json' });
394+
assert(res.status === 500);
395+
mm.restore();
388396

389397
// Test get trap - method access
390398
assert(typeof httpclient.request === 'function');
@@ -399,35 +407,9 @@ describe('test/lib/core/httpclient.test.js', () => {
399407
const ownKeys = Reflect.ownKeys(httpclient);
400408
assert(ownKeys.length > 0);
401409

402-
httpclient.testProp = 'test';
403-
const customDescriptor = Object.getOwnPropertyDescriptor(httpclient, 'testProp');
404-
assert(customDescriptor);
405-
assert.equal(customDescriptor.value, 'test');
406-
assert.equal(customDescriptor.writable, true);
407-
assert.equal(customDescriptor.enumerable, true);
408-
assert.equal(customDescriptor.configurable, true);
409-
410-
const proto = Object.getPrototypeOf(httpclient);
411-
assert(proto);
412-
assert(proto instanceof HttpclientNext);
413-
assert(proto.constructor);
414-
415-
httpclient.customProperty = 'test-value';
416-
assert.equal(httpclient.customProperty, 'test-value');
417-
418-
delete httpclient.customProperty;
419-
assert.equal(httpclient.customProperty, undefined);
420-
421-
delete httpclient.testProp;
422-
assert.equal(httpclient.testProp, undefined);
423-
424410
// Test that methods are properly bound
425411
const { request } = httpclient;
426412
assert(typeof request === 'function');
427-
428-
// Test Object.getOwnPropertyNames() which uses ownKeys trap
429-
const propNames = Object.getOwnPropertyNames(httpclient);
430-
assert(Array.isArray(propNames));
431413
});
432414
});
433415

0 commit comments

Comments
 (0)