2026-08-17 16:44:38 -04:00
|
|
|
import http from 'node:http';
|
|
|
|
|
import https from 'node:https';
|
|
|
|
|
|
|
|
|
|
import { createProxyMiddleware } from 'http-proxy-middleware';
|
2026-05-24 08:46:11 -04:00
|
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
|
|
2026-08-17 16:44:38 -04:00
|
|
|
import {
|
|
|
|
|
createDirectoryQueryCanonicalizer,
|
|
|
|
|
createOpenCodeProxyAgent,
|
|
|
|
|
normalizeForwardedDirectoryHeaders,
|
|
|
|
|
} from './proxy.js';
|
2026-05-24 08:46:11 -04:00
|
|
|
|
|
|
|
|
describe('createDirectoryQueryCanonicalizer', () => {
|
|
|
|
|
it('canonicalizes directory query params and preserves other params', async () => {
|
|
|
|
|
const canonicalize = createDirectoryQueryCanonicalizer({
|
|
|
|
|
realpath: async (value) => value === '/link/project' ? '/real/project' : value,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(canonicalize('/session?foo=1&directory=/link/project&bar=2'))
|
|
|
|
|
.resolves.toBe('/session?foo=1&directory=%2Freal%2Fproject&bar=2');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('caches directory realpath lookups', async () => {
|
|
|
|
|
let calls = 0;
|
|
|
|
|
const canonicalize = createDirectoryQueryCanonicalizer({
|
|
|
|
|
realpath: async () => {
|
|
|
|
|
calls += 1;
|
|
|
|
|
return '/real/project';
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(canonicalize('/session?directory=/link/project')).resolves.toBe('/session?directory=%2Freal%2Fproject');
|
|
|
|
|
await expect(canonicalize('/session?directory=/link/project')).resolves.toBe('/session?directory=%2Freal%2Fproject');
|
|
|
|
|
expect(calls).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('deduplicates concurrent directory realpath lookups', async () => {
|
|
|
|
|
let calls = 0;
|
|
|
|
|
let release = () => undefined;
|
|
|
|
|
const pending = new Promise((resolve) => {
|
|
|
|
|
release = () => resolve('/real/project');
|
|
|
|
|
});
|
|
|
|
|
const canonicalize = createDirectoryQueryCanonicalizer({
|
|
|
|
|
realpath: async () => {
|
|
|
|
|
calls += 1;
|
|
|
|
|
return pending;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const first = canonicalize('/session?directory=/link/project');
|
|
|
|
|
const second = canonicalize('/session?directory=/link/project');
|
|
|
|
|
await Promise.resolve();
|
|
|
|
|
|
|
|
|
|
expect(calls).toBe(1);
|
|
|
|
|
release();
|
|
|
|
|
await expect(Promise.all([first, second])).resolves.toEqual([
|
|
|
|
|
'/session?directory=%2Freal%2Fproject',
|
|
|
|
|
'/session?directory=%2Freal%2Fproject',
|
|
|
|
|
]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to the original URL when realpath fails', async () => {
|
|
|
|
|
const canonicalize = createDirectoryQueryCanonicalizer({
|
|
|
|
|
realpath: async () => {
|
|
|
|
|
throw new Error('missing');
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(canonicalize('/session?foo=1&directory=/missing/project'))
|
|
|
|
|
.resolves.toBe('/session?foo=1&directory=/missing/project');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('leaves URLs without directory params unchanged', async () => {
|
|
|
|
|
const canonicalize = createDirectoryQueryCanonicalizer({
|
|
|
|
|
realpath: async () => '/real/project',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await expect(canonicalize('/session?foo=1')).resolves.toBe('/session?foo=1');
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-06-24 00:43:11 +03:00
|
|
|
|
|
|
|
|
describe('normalizeForwardedDirectoryHeaders', () => {
|
|
|
|
|
it('decodes marked directory headers before forwarding to OpenCode', () => {
|
|
|
|
|
const headers = normalizeForwardedDirectoryHeaders({
|
|
|
|
|
'x-opencode-directory': encodeURIComponent('/Users/example/project'),
|
|
|
|
|
'x-opencode-directory-encoding': 'uri',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(headers).toEqual({
|
|
|
|
|
'x-opencode-directory': '/Users/example/project',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('preserves unmarked percent sequences from direct clients', () => {
|
|
|
|
|
const headers = normalizeForwardedDirectoryHeaders({
|
|
|
|
|
'x-opencode-directory': '/Users/example/project%20literal',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
expect(headers).toEqual({
|
|
|
|
|
'x-opencode-directory': '/Users/example/project%20literal',
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-08-17 16:44:38 -04:00
|
|
|
|
|
|
|
|
const listen = (server) => new Promise((resolve, reject) => {
|
|
|
|
|
server.once('error', reject);
|
|
|
|
|
server.listen(0, '127.0.0.1', () => {
|
|
|
|
|
server.removeListener('error', reject);
|
|
|
|
|
resolve(server.address().port);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const closeServer = (server) => new Promise((resolve) => {
|
|
|
|
|
server.close(resolve);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const request = (port, agent) => new Promise((resolve, reject) => {
|
|
|
|
|
const req = http.request({ host: '127.0.0.1', port, path: '/', method: 'GET', agent }, (res) => {
|
|
|
|
|
res.resume();
|
|
|
|
|
res.on('end', resolve);
|
|
|
|
|
res.on('error', reject);
|
|
|
|
|
});
|
|
|
|
|
req.on('error', reject);
|
|
|
|
|
req.end();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Proxies two sequential requests through `createProxyMiddleware` and reports
|
|
|
|
|
* what the upstream server observed for each one.
|
|
|
|
|
*/
|
|
|
|
|
const proxyTwoRequests = async (proxyAgent) => {
|
|
|
|
|
const seen = [];
|
|
|
|
|
let middleware;
|
|
|
|
|
const upstream = http.createServer((req, res) => {
|
|
|
|
|
seen.push({ connection: req.headers.connection, remotePort: req.socket.remotePort });
|
|
|
|
|
res.end('ok');
|
|
|
|
|
});
|
|
|
|
|
const front = http.createServer((req, res) => {
|
|
|
|
|
middleware(req, res, () => {
|
|
|
|
|
res.statusCode = 502;
|
|
|
|
|
res.end();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
const clientAgent = new http.Agent({ keepAlive: true });
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const upstreamPort = await listen(upstream);
|
|
|
|
|
middleware = createProxyMiddleware({
|
|
|
|
|
target: `http://127.0.0.1:${upstreamPort}`,
|
|
|
|
|
...(proxyAgent ? { agent: proxyAgent } : {}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const frontPort = await listen(front);
|
|
|
|
|
await request(frontPort, clientAgent);
|
|
|
|
|
await request(frontPort, clientAgent);
|
|
|
|
|
} finally {
|
|
|
|
|
clientAgent.destroy();
|
|
|
|
|
proxyAgent?.destroy();
|
|
|
|
|
await closeServer(front);
|
|
|
|
|
await closeServer(upstream);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return seen;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
describe('createOpenCodeProxyAgent', () => {
|
|
|
|
|
it('reuses a single upstream socket across sequential proxied requests', async () => {
|
|
|
|
|
const seen = await proxyTwoRequests(createOpenCodeProxyAgent('http://127.0.0.1'));
|
|
|
|
|
|
|
|
|
|
expect(seen).toHaveLength(2);
|
|
|
|
|
expect(seen[0].connection).not.toBe('close');
|
|
|
|
|
expect(seen[1].remotePort).toBe(seen[0].remotePort);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('without an agent, http-proxy forces Connection: close and a new socket per request', async () => {
|
|
|
|
|
const seen = await proxyTwoRequests(null);
|
|
|
|
|
|
|
|
|
|
expect(seen).toHaveLength(2);
|
|
|
|
|
expect(seen[0].connection).toBe('close');
|
|
|
|
|
expect(seen[1].remotePort).not.toBe(seen[0].remotePort);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// http-proxy dispatches through `https.request` when the target protocol is
|
|
|
|
|
// `https:`, so an http.Agent would open a plaintext socket to a TLS port.
|
|
|
|
|
// External OpenCode servers can be configured over https via OPENCODE_HOST.
|
|
|
|
|
it('returns an https agent for https targets', () => {
|
|
|
|
|
const agent = createOpenCodeProxyAgent('https://opencode.example.com:4096');
|
|
|
|
|
|
|
|
|
|
expect(agent).toBeInstanceOf(https.Agent);
|
|
|
|
|
expect(agent.options.keepAlive).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('returns a plain http agent for http targets', () => {
|
|
|
|
|
const agent = createOpenCodeProxyAgent('http://127.0.0.1:4096');
|
|
|
|
|
|
|
|
|
|
// https.Agent extends http.Agent, so the negative assertion is the load-bearing one.
|
|
|
|
|
expect(agent).toBeInstanceOf(http.Agent);
|
|
|
|
|
expect(agent).not.toBeInstanceOf(https.Agent);
|
|
|
|
|
expect(agent.options.keepAlive).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('falls back to an http agent for missing or unparseable targets', () => {
|
|
|
|
|
expect(createOpenCodeProxyAgent(undefined)).not.toBeInstanceOf(https.Agent);
|
|
|
|
|
expect(createOpenCodeProxyAgent('not a url')).not.toBeInstanceOf(https.Agent);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// The cold-start fix relies on http-proxy-middleware rebuilding its per-request
|
|
|
|
|
// options via `Object.assign({}, this.proxyOptions)` in prepareProxyRequest,
|
|
|
|
|
// which invokes getters. If that ever changes to a cached or shallow-reference
|
|
|
|
|
// copy, the agent would freeze at its registration-time value and https targets
|
|
|
|
|
// would silently regress — so pin the behavior here against the real library.
|
|
|
|
|
it('http-proxy-middleware re-reads the agent option on every proxied request', async () => {
|
|
|
|
|
let reads = 0;
|
|
|
|
|
let middleware;
|
|
|
|
|
const agent = createOpenCodeProxyAgent('http://127.0.0.1');
|
|
|
|
|
const upstream = http.createServer((_req, res) => res.end('ok'));
|
|
|
|
|
const front = http.createServer((req, res) => {
|
|
|
|
|
middleware(req, res, () => {
|
|
|
|
|
res.statusCode = 502;
|
|
|
|
|
res.end();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
const clientAgent = new http.Agent({ keepAlive: true });
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const upstreamPort = await listen(upstream);
|
|
|
|
|
middleware = createProxyMiddleware({
|
|
|
|
|
target: `http://127.0.0.1:${upstreamPort}`,
|
|
|
|
|
get agent() {
|
|
|
|
|
reads += 1;
|
|
|
|
|
return agent;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Construction itself must not read the getter — otherwise the assertion
|
|
|
|
|
// below could be satisfied without any per-request resolution happening.
|
|
|
|
|
expect(reads).toBe(0);
|
|
|
|
|
|
|
|
|
|
const frontPort = await listen(front);
|
|
|
|
|
await request(frontPort, clientAgent);
|
|
|
|
|
expect(reads).toBe(1);
|
|
|
|
|
|
|
|
|
|
await request(frontPort, clientAgent);
|
|
|
|
|
expect(reads).toBe(2);
|
|
|
|
|
} finally {
|
|
|
|
|
clientAgent.destroy();
|
|
|
|
|
agent.destroy();
|
|
|
|
|
await closeServer(front);
|
|
|
|
|
await closeServer(upstream);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|