sap-ai-dev-toolkit 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/agents/abap-developer.agent.md +25 -0
- package/.github/agents/abap-runtime-debugger.agent.md +27 -0
- package/.github/agents/rap-service-developer.agent.md +27 -0
- package/.github/agents/sap-solution-architect.agent.md +31 -0
- package/.github/skills/abap-debugging/SKILL.md +15 -0
- package/.github/skills/abap-development/SKILL.md +16 -0
- package/.github/skills/abap-runtime-analysis/SKILL.md +17 -0
- package/.github/skills/abap-testing-quality/SKILL.md +19 -0
- package/.github/skills/cds-development/SKILL.md +16 -0
- package/.github/skills/clean-core-extensibility/SKILL.md +16 -0
- package/.github/skills/rap-development/SKILL.md +16 -0
- package/.github/skills/rap-service-delivery/SKILL.md +17 -0
- package/.github/skills/sap-sdlc-orchestration/SKILL.md +16 -0
- package/.github/skills/sap-standard-api-analysis/SKILL.md +16 -0
- package/.github/skills/sap-transport-release/SKILL.md +14 -0
- package/LICENSE +22 -0
- package/LICENSE-APACHE-2.0.txt +201 -0
- package/NOTICE +33 -0
- package/README.md +723 -0
- package/dist/checksums.txt +5 -0
- package/dist/vsp-darwin-arm64 +0 -0
- package/dist/vsp-darwin-x64 +0 -0
- package/dist/vsp-linux-arm64 +0 -0
- package/dist/vsp-linux-x64 +0 -0
- package/dist/vsp-win32-x64.exe +0 -0
- package/package.json +52 -0
- package/patches/vsp-bas-proxy-auth.patch +310 -0
- package/scripts/build-vsp.mjs +52 -0
- package/scripts/ensure-go.mjs +59 -0
- package/scripts/install-user-copilot-assets.mjs +110 -0
- package/scripts/mutation-terminal-ui.mjs +69 -0
- package/scripts/postinstall.mjs +269 -0
- package/scripts/publish-npm.mjs +97 -0
- package/src/abaplint.mjs +105 -0
- package/src/bas-discovery.mjs +210 -0
- package/src/binary.mjs +99 -0
- package/src/cf-connectivity.mjs +317 -0
- package/src/cf-destination.mjs +793 -0
- package/src/launcher.mjs +144 -0
- package/src/mcp-config.mjs +253 -0
- package/src/mcp-proxy.mjs +388 -0
- package/src/setup.mjs +275 -0
- package/src/terminal-ui.mjs +67 -0
- package/test/cf-connectivity.test.mjs +287 -0
- package/test/cf-destination.test.mjs +307 -0
- package/test/cf-runtime.test.mjs +505 -0
- package/test/copilot-assets.test.mjs +37 -0
- package/test/copilot-content.test.mjs +145 -0
- package/test/discovery.test.mjs +199 -0
- package/test/fixtures/fake-vsp.mjs +244 -0
- package/test/launcher.test.mjs +164 -0
- package/test/mcp-config-cf.test.mjs +149 -0
- package/test/mcp-proxy.test.mjs +337 -0
- package/test/setup-cf.test.mjs +362 -0
- package/test/setup.test.mjs +376 -0
- package/test/terminal-ui.test.mjs +47 -0
- package/tools.md +68 -0
package/src/binary.mjs
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, rename, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir, tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
|
+
export const UPSTREAM_COMMIT = '9886d2727f47506368b0a3c2f1c1766f1200f747';
|
|
9
|
+
|
|
10
|
+
const platformMap = {
|
|
11
|
+
'linux:x64': ['linux', 'x64', ''],
|
|
12
|
+
'linux:arm64': ['linux', 'arm64', ''],
|
|
13
|
+
'darwin:x64': ['darwin', 'x64', ''],
|
|
14
|
+
'darwin:arm64': ['darwin', 'arm64', ''],
|
|
15
|
+
'win32:x64': ['windows', 'x64', '.exe']
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function binaryTarget(platform = process.platform, arch = process.arch) {
|
|
19
|
+
const target = platformMap[`${platform}:${arch}`];
|
|
20
|
+
if (!target) throw new Error(`Unsupported platform ${platform}/${arch}. Set BAS_VSP_BINARY or build cmd/vsp with scripts/build-vsp.mjs.`);
|
|
21
|
+
return { os: target[0], arch: target[1], extension: target[2], asset: `vsp-${target[0]}-${target[1]}${target[2]}` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function bundledBinaryPath(platform = process.platform, arch = process.arch) {
|
|
25
|
+
return join(packageRoot, 'dist', binaryTarget(platform, arch).asset);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function packageRepositoryUrl(pkg) {
|
|
29
|
+
const raw = typeof pkg.repository === 'string' ? pkg.repository : pkg.repository?.url;
|
|
30
|
+
if (!raw) throw new Error('package.json repository.url is required to resolve VSP release assets');
|
|
31
|
+
return raw.replace(/^git\+/, '').replace(/\.git$/, '').replace(/\/$/, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function releaseBaseUrl(pkg, version = pkg.version) {
|
|
35
|
+
const override = process.env.BAS_VSP_RELEASE_BASE_URL;
|
|
36
|
+
if (override) return `${override.replace(/\/$/, '')}/v${version}`;
|
|
37
|
+
const repo = packageRepositoryUrl(pkg).replace(/^ssh:\/\/git@github.com:/, 'https://github.com/').replace(/^git@github.com:/, 'https://github.com/');
|
|
38
|
+
if (!repo.startsWith('http')) throw new Error('package repository URL must be an HTTPS URL for release assets');
|
|
39
|
+
return `${repo}/releases/download/v${version}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function cacheDirectory() {
|
|
43
|
+
return process.env.BAS_VSP_CACHE_DIR || join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'sap-ai-dev-toolkit');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function cachedBinaryPath(pkg, platform = process.platform, arch = process.arch) {
|
|
47
|
+
const target = binaryTarget(platform, arch);
|
|
48
|
+
return join(cacheDirectory(), `${pkg.name}-${pkg.version}-${target.asset}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function findBinary(pkg, options = {}) {
|
|
52
|
+
const env = options.env || process.env;
|
|
53
|
+
if (env.BAS_VSP_BINARY) return env.BAS_VSP_BINARY;
|
|
54
|
+
if (!env.BAS_VSP_BINARY_URL) {
|
|
55
|
+
const bundled = bundledBinaryPath(options.platform, options.arch);
|
|
56
|
+
try { await stat(bundled); return bundled; } catch {}
|
|
57
|
+
}
|
|
58
|
+
const path = cachedBinaryPath(pkg, options.platform, options.arch);
|
|
59
|
+
try { await stat(path); return path; } catch { return null; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function installBinary(pkg, options = {}) {
|
|
63
|
+
const env = options.env || process.env;
|
|
64
|
+
if (env.BAS_VSP_BINARY) return env.BAS_VSP_BINARY;
|
|
65
|
+
if (!env.BAS_VSP_BINARY_URL) {
|
|
66
|
+
const bundled = bundledBinaryPath(options.platform, options.arch);
|
|
67
|
+
try { await stat(bundled); return bundled; } catch {}
|
|
68
|
+
}
|
|
69
|
+
const target = binaryTarget(options.platform, options.arch);
|
|
70
|
+
const base = releaseBaseUrl(pkg, pkg.version);
|
|
71
|
+
const assetUrl = env.BAS_VSP_BINARY_URL || `${base}/${target.asset}`;
|
|
72
|
+
const checksumUrl = `${base}/checksums.txt`;
|
|
73
|
+
const response = await fetch(assetUrl);
|
|
74
|
+
if (!response.ok) throw new Error(`VSP binary download failed (${response.status}); set BAS_VSP_BINARY or BAS_VSP_BINARY_URL`);
|
|
75
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
76
|
+
const checksumResponse = await fetch(checksumUrl);
|
|
77
|
+
if (!checksumResponse.ok) throw new Error(`VSP checksum download failed (${checksumResponse.status}); set BAS_VSP_BINARY or BAS_VSP_BINARY_URL`);
|
|
78
|
+
const checksumText = await checksumResponse.text();
|
|
79
|
+
const expected = checksumText.split(/\r?\n/).map(line => line.trim().split(/\s+/)).find(parts => parts.length >= 2 && (parts[1] === target.asset || parts[1].endsWith(`/${target.asset}`)))?.[0];
|
|
80
|
+
if (!expected) throw new Error(`checksums.txt has no entry for ${target.asset}; set BAS_VSP_BINARY or BAS_VSP_BINARY_URL`);
|
|
81
|
+
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
82
|
+
if (actual.toLowerCase() !== expected.toLowerCase()) throw new Error(`VSP checksum mismatch for ${target.asset}`);
|
|
83
|
+
const dir = cacheDirectory();
|
|
84
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
85
|
+
await chmod(dir, 0o700);
|
|
86
|
+
const destination = cachedBinaryPath(pkg, options.platform, options.arch);
|
|
87
|
+
const temporary = join(tmpdir(), `bas-vsp-${process.pid}-${Date.now()}`);
|
|
88
|
+
await writeFile(temporary, bytes, { mode: 0o700 });
|
|
89
|
+
await chmod(temporary, 0o700);
|
|
90
|
+
await rename(temporary, destination);
|
|
91
|
+
await chmod(destination, 0o700);
|
|
92
|
+
return destination;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function ensureBinary(pkg, options = {}) {
|
|
96
|
+
const existing = await findBinary(pkg, options);
|
|
97
|
+
if (existing) return existing;
|
|
98
|
+
return installBinary(pkg, options);
|
|
99
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
|
|
5
|
+
const TOKEN_TIMEOUT_MS = 15_000;
|
|
6
|
+
const TOKEN_REFRESH_SKEW_MS = 60_000;
|
|
7
|
+
const MAX_TOKEN_RESPONSE_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
function safeError(message) {
|
|
10
|
+
return new Error(message);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function configuredProxy(credentials) {
|
|
14
|
+
if (!credentials || typeof credentials !== 'object') {
|
|
15
|
+
throw safeError('Connectivity service credentials are unavailable');
|
|
16
|
+
}
|
|
17
|
+
const host = credentials.onpremise_proxy_host;
|
|
18
|
+
const port = Number(credentials.onpremise_proxy_http_port);
|
|
19
|
+
const tokenServiceUrl = credentials.token_service_url;
|
|
20
|
+
const clientId = credentials.clientid;
|
|
21
|
+
const clientSecret = credentials.clientsecret;
|
|
22
|
+
if (typeof host !== 'string' || !host || !Number.isInteger(port) || port < 1 || port > 65535 ||
|
|
23
|
+
typeof tokenServiceUrl !== 'string' || !clientId || !clientSecret) {
|
|
24
|
+
throw safeError('Connectivity service credentials are incomplete');
|
|
25
|
+
}
|
|
26
|
+
let tokenUrl;
|
|
27
|
+
try {
|
|
28
|
+
tokenUrl = new URL(tokenServiceUrl);
|
|
29
|
+
} catch {
|
|
30
|
+
throw safeError('Connectivity token service URL is invalid');
|
|
31
|
+
}
|
|
32
|
+
if ((tokenUrl.protocol !== 'https:' && tokenUrl.protocol !== 'http:') || tokenUrl.username || tokenUrl.password) {
|
|
33
|
+
throw safeError('Connectivity token service URL is invalid');
|
|
34
|
+
}
|
|
35
|
+
const pathname = tokenUrl.pathname.replace(/\/+$/, '');
|
|
36
|
+
if (!pathname.toLowerCase().endsWith('/oauth/token')) tokenUrl.pathname = `${pathname}/oauth/token`;
|
|
37
|
+
tokenUrl.hash = '';
|
|
38
|
+
return { host, port, tokenUrl, clientId, clientSecret };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function targetFromUrl(value) {
|
|
42
|
+
let target;
|
|
43
|
+
try {
|
|
44
|
+
target = new URL(value);
|
|
45
|
+
} catch {
|
|
46
|
+
throw safeError('Destination URL is invalid');
|
|
47
|
+
}
|
|
48
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
49
|
+
throw safeError('Destination URL must use HTTP or HTTPS');
|
|
50
|
+
}
|
|
51
|
+
if (target.username || target.password || !target.hostname) {
|
|
52
|
+
throw safeError('Destination URL must not contain user information');
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
url: target,
|
|
56
|
+
host: target.hostname.toLowerCase().replace(/^\[|\]$/g, ''),
|
|
57
|
+
port: Number(target.port || (target.protocol === 'https:' ? 443 : 80)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function hostPort(authority, defaultPort) {
|
|
62
|
+
if (typeof authority !== 'string' || !authority || /[@/?#\\]/.test(authority)) return null;
|
|
63
|
+
let parsed;
|
|
64
|
+
try {
|
|
65
|
+
parsed = new URL(`http://${authority}`);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!parsed.hostname || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null;
|
|
70
|
+
const explicit = authority.startsWith('[') ? authority.match(/^\[[^\]]+\](?::(\d+))?$/) : authority.match(/^[^:]+(?::(\d+))?$/);
|
|
71
|
+
if (!explicit) return null;
|
|
72
|
+
const port = explicit[1] ? Number(explicit[1]) : (defaultPort === 80 || defaultPort === 443 ? defaultPort : null);
|
|
73
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
|
74
|
+
return { host: parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''), port };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function targetMatches(hostPortValue, target) {
|
|
78
|
+
return hostPortValue?.host === target.host && hostPortValue.port === target.port;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function respond(res, status, text) {
|
|
82
|
+
if (res.headersSent) return res.destroy();
|
|
83
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'connection': 'close' });
|
|
84
|
+
res.end(`${text}\n`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function boundedSocketError(socket) {
|
|
88
|
+
socket.on('error', () => socket.destroy());
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function createCfConnectivityProxy({
|
|
92
|
+
destinationUrl,
|
|
93
|
+
connectivityCredentials,
|
|
94
|
+
locationId,
|
|
95
|
+
authMode,
|
|
96
|
+
userTokenProvider,
|
|
97
|
+
fetchImpl = globalThis.fetch,
|
|
98
|
+
}) {
|
|
99
|
+
const target = targetFromUrl(destinationUrl);
|
|
100
|
+
const proxy = configuredProxy(connectivityCredentials);
|
|
101
|
+
if (authMode !== 'application' && authMode !== 'principal-propagation') {
|
|
102
|
+
throw safeError('Connectivity authentication mode is unsupported');
|
|
103
|
+
}
|
|
104
|
+
if (locationId != null && /[\r\n]/.test(String(locationId))) {
|
|
105
|
+
throw safeError('Connectivity location identifier is invalid');
|
|
106
|
+
}
|
|
107
|
+
if (typeof fetchImpl !== 'function') throw safeError('Token request implementation is unavailable');
|
|
108
|
+
if (authMode === 'principal-propagation' && typeof userTokenProvider !== 'function') {
|
|
109
|
+
throw safeError('Cloud Foundry user token provider is unavailable');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let cachedToken;
|
|
113
|
+
let tokenExpiry = 0;
|
|
114
|
+
let refreshing;
|
|
115
|
+
let closed = false;
|
|
116
|
+
const sockets = new Set();
|
|
117
|
+
|
|
118
|
+
async function acquireToken() {
|
|
119
|
+
if (cachedToken && Date.now() < tokenExpiry - TOKEN_REFRESH_SKEW_MS) return cachedToken;
|
|
120
|
+
if (refreshing) return refreshing;
|
|
121
|
+
refreshing = (async () => {
|
|
122
|
+
let body;
|
|
123
|
+
if (authMode === 'principal-propagation') {
|
|
124
|
+
let assertion;
|
|
125
|
+
try {
|
|
126
|
+
assertion = await userTokenProvider();
|
|
127
|
+
} catch {
|
|
128
|
+
throw safeError('Unable to obtain the current Cloud Foundry user token');
|
|
129
|
+
}
|
|
130
|
+
if (typeof assertion !== 'string' || !assertion) {
|
|
131
|
+
throw safeError('Unable to obtain the current Cloud Foundry user token');
|
|
132
|
+
}
|
|
133
|
+
body = new URLSearchParams({
|
|
134
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
|
135
|
+
assertion,
|
|
136
|
+
token_format: 'jwt',
|
|
137
|
+
response_type: 'token',
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
body = new URLSearchParams({ grant_type: 'client_credentials' });
|
|
141
|
+
}
|
|
142
|
+
let response;
|
|
143
|
+
try {
|
|
144
|
+
response = await fetchImpl(proxy.tokenUrl, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: {
|
|
147
|
+
authorization: `Basic ${Buffer.from(`${proxy.clientId}:${proxy.clientSecret}`).toString('base64')}`,
|
|
148
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
149
|
+
accept: 'application/json',
|
|
150
|
+
},
|
|
151
|
+
body,
|
|
152
|
+
signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
|
|
153
|
+
});
|
|
154
|
+
} catch {
|
|
155
|
+
throw safeError('Connectivity token request failed');
|
|
156
|
+
}
|
|
157
|
+
let data;
|
|
158
|
+
try {
|
|
159
|
+
const raw = await response.text();
|
|
160
|
+
if (Buffer.byteLength(raw) > MAX_TOKEN_RESPONSE_BYTES) throw new Error('large');
|
|
161
|
+
data = JSON.parse(raw);
|
|
162
|
+
} catch {
|
|
163
|
+
throw safeError('Connectivity token response was invalid');
|
|
164
|
+
}
|
|
165
|
+
if (!response.ok || typeof data?.access_token !== 'string' || !data.access_token ||
|
|
166
|
+
!Number.isFinite(Number(data.expires_in)) || Number(data.expires_in) <= 0) {
|
|
167
|
+
throw safeError('Connectivity token exchange was rejected');
|
|
168
|
+
}
|
|
169
|
+
cachedToken = data.access_token;
|
|
170
|
+
tokenExpiry = Date.now() + Number(data.expires_in) * 1000;
|
|
171
|
+
return cachedToken;
|
|
172
|
+
})();
|
|
173
|
+
try {
|
|
174
|
+
return await refreshing;
|
|
175
|
+
} finally {
|
|
176
|
+
refreshing = undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function connectivityHeaders() {
|
|
181
|
+
const token = await acquireToken();
|
|
182
|
+
const headers = { 'Proxy-Authorization': `Bearer ${token}` };
|
|
183
|
+
if (locationId) headers['SAP-Connectivity-SCC-Location_ID'] = String(locationId);
|
|
184
|
+
return headers;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Fail during route preparation when the CF identity or service credentials
|
|
188
|
+
// cannot obtain the required Connectivity token.
|
|
189
|
+
await connectivityHeaders();
|
|
190
|
+
|
|
191
|
+
const server = http.createServer((req, res) => {
|
|
192
|
+
void (async () => {
|
|
193
|
+
let requested;
|
|
194
|
+
try {
|
|
195
|
+
const requestUrl = new URL(req.url, `${target.url.protocol}//${req.headers.host || ''}`);
|
|
196
|
+
if (requestUrl.username || requestUrl.password) return respond(res, 400, 'Invalid proxy target');
|
|
197
|
+
const defaultPort = requestUrl.protocol === 'https:' ? 443 : 80;
|
|
198
|
+
requested = { host: requestUrl.hostname.toLowerCase().replace(/^\[|\]$/g, ''), port: Number(requestUrl.port || defaultPort) };
|
|
199
|
+
if ((requestUrl.protocol !== 'http:' && requestUrl.protocol !== 'https:') || !targetMatches(requested, target)) {
|
|
200
|
+
return respond(res, 403, 'Proxy target is not permitted');
|
|
201
|
+
}
|
|
202
|
+
// The BTP Connectivity endpoint accepts HTTP forward requests; HTTPS destinations use CONNECT.
|
|
203
|
+
if (requestUrl.protocol !== 'http:') return respond(res, 400, 'HTTPS destinations must use CONNECT');
|
|
204
|
+
const headers = await connectivityHeaders();
|
|
205
|
+
if (closed) return respond(res, 503, 'Proxy is closed');
|
|
206
|
+
const forwardedHeaders = { ...req.headers };
|
|
207
|
+
for (const name of ['host', 'proxy-authorization', 'proxy-connection', 'connection', 'sap-connectivity-scc-location_id']) {
|
|
208
|
+
delete forwardedHeaders[name];
|
|
209
|
+
}
|
|
210
|
+
const upstream = http.request({
|
|
211
|
+
host: proxy.host,
|
|
212
|
+
port: proxy.port,
|
|
213
|
+
method: req.method,
|
|
214
|
+
path: requestUrl.href,
|
|
215
|
+
headers: { ...forwardedHeaders, host: requestUrl.host, ...headers, connection: 'close' },
|
|
216
|
+
agent: false,
|
|
217
|
+
}, (upstreamRes) => {
|
|
218
|
+
res.writeHead(upstreamRes.statusCode || 502, upstreamRes.statusMessage, upstreamRes.headers);
|
|
219
|
+
upstreamRes.pipe(res);
|
|
220
|
+
});
|
|
221
|
+
sockets.add(upstream);
|
|
222
|
+
upstream.once('close', () => sockets.delete(upstream));
|
|
223
|
+
req.once('aborted', () => upstream.destroy());
|
|
224
|
+
res.once('close', () => {
|
|
225
|
+
if (!res.writableEnded) upstream.destroy();
|
|
226
|
+
});
|
|
227
|
+
upstream.on('error', () => respond(res, 502, 'Connectivity proxy request failed'));
|
|
228
|
+
req.pipe(upstream);
|
|
229
|
+
} catch {
|
|
230
|
+
respond(res, 502, 'Connectivity proxy request failed');
|
|
231
|
+
}
|
|
232
|
+
})();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
server.on('connect', (req, clientSocket, head) => {
|
|
236
|
+
sockets.add(clientSocket);
|
|
237
|
+
clientSocket.once('close', () => sockets.delete(clientSocket));
|
|
238
|
+
boundedSocketError(clientSocket);
|
|
239
|
+
void (async () => {
|
|
240
|
+
const requested = hostPort(req.url, target.port);
|
|
241
|
+
if (!targetMatches(requested, target)) {
|
|
242
|
+
clientSocket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
let upstream;
|
|
246
|
+
try {
|
|
247
|
+
const headers = await connectivityHeaders();
|
|
248
|
+
if (closed) throw safeError('Proxy is closed');
|
|
249
|
+
upstream = net.connect({ host: proxy.host, port: proxy.port });
|
|
250
|
+
sockets.add(upstream);
|
|
251
|
+
upstream.once('close', () => sockets.delete(upstream));
|
|
252
|
+
boundedSocketError(upstream);
|
|
253
|
+
await once(upstream, 'connect');
|
|
254
|
+
const authority = req.url;
|
|
255
|
+
const headerText = Object.entries(headers).map(([name, value]) => `${name}: ${value}`).join('\r\n');
|
|
256
|
+
upstream.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n${headerText}\r\n\r\n`);
|
|
257
|
+
let response = Buffer.alloc(0);
|
|
258
|
+
const onData = (chunk) => {
|
|
259
|
+
response = Buffer.concat([response, chunk]);
|
|
260
|
+
if (response.length > 64 * 1024) {
|
|
261
|
+
upstream.destroy();
|
|
262
|
+
clientSocket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const boundary = response.indexOf('\r\n\r\n');
|
|
266
|
+
if (boundary < 0) return;
|
|
267
|
+
upstream.removeListener('data', onData);
|
|
268
|
+
const statusLine = response.subarray(0, boundary).toString('latin1').split('\r\n', 1)[0];
|
|
269
|
+
const match = /^HTTP\/1\.[01] (\d{3})(?:\s|$)/.exec(statusLine);
|
|
270
|
+
if (!match || Number(match[1]) !== 200) {
|
|
271
|
+
upstream.destroy();
|
|
272
|
+
clientSocket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
|
276
|
+
const remaining = response.subarray(boundary + 4);
|
|
277
|
+
if (remaining.length) clientSocket.write(remaining);
|
|
278
|
+
if (head.length) upstream.write(head);
|
|
279
|
+
clientSocket.pipe(upstream);
|
|
280
|
+
upstream.pipe(clientSocket);
|
|
281
|
+
};
|
|
282
|
+
upstream.on('data', onData);
|
|
283
|
+
} catch {
|
|
284
|
+
upstream?.destroy();
|
|
285
|
+
if (!clientSocket.destroyed) clientSocket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
|
|
286
|
+
}
|
|
287
|
+
})();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
server.on('clientError', (_error, socket) => {
|
|
291
|
+
if (!socket.destroyed) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
await new Promise((resolve, reject) => {
|
|
295
|
+
server.once('error', () => reject(safeError('Unable to start Connectivity proxy')));
|
|
296
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
297
|
+
});
|
|
298
|
+
const close = async () => {
|
|
299
|
+
if (closed) return;
|
|
300
|
+
closed = true;
|
|
301
|
+
cachedToken = undefined;
|
|
302
|
+
if (server.listening) {
|
|
303
|
+
const done = once(server, 'close');
|
|
304
|
+
server.close();
|
|
305
|
+
server.closeAllConnections();
|
|
306
|
+
for (const socket of sockets) socket.destroy();
|
|
307
|
+
await done;
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
return {
|
|
311
|
+
get url() {
|
|
312
|
+
const address = server.address();
|
|
313
|
+
return typeof address === 'object' && address ? `http://127.0.0.1:${address.port}` : 'http://127.0.0.1';
|
|
314
|
+
},
|
|
315
|
+
close,
|
|
316
|
+
};
|
|
317
|
+
}
|