sap-ai-dev-toolkit 0.7.5 → 0.7.9

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/src/setup.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { stdin, stdout } from 'node:process';
3
- import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
3
+ import { platform } from 'node:os';
4
+ import { checkboxPrompt, colorText, formatStatus, textPrompt } from './terminal-ui.mjs';
4
5
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
6
+ import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
5
7
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
6
8
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
7
9
 
@@ -11,6 +13,24 @@ function print(output, message) {
11
13
  output.write(`${message}\n`);
12
14
  }
13
15
 
16
+ function windowsSsoSetupAvailable(env = process.env) {
17
+ return platform() === 'win32' || env.SAP_AI_DEV_TOOLKIT_ENABLE_WINDOWS_SSO_SETUP === 'true';
18
+ }
19
+
20
+ async function chooseLocalAuthMode(destination, { input = stdin, output = stdout, env = process.env } = {}) {
21
+ if (!windowsSsoSetupAvailable(env)) return 'basic';
22
+ const answer = await textPrompt({
23
+ message: `Authentication for ${destination.name} (password/sso)`,
24
+ placeholder: 'password',
25
+ required: false,
26
+ validate: value => {
27
+ const candidate = String(value || 'password').trim().toLowerCase();
28
+ return ['password', 'basic', 'sso', 'windows-sso'].includes(candidate) || 'Enter password or sso.';
29
+ }
30
+ }, { input, output });
31
+ const normalized = String(answer || 'password').trim().toLowerCase();
32
+ return ['sso', 'windows-sso'].includes(normalized) ? 'windows-sso' : 'basic';
33
+ }
14
34
 
15
35
  async function confirmCloudFoundryImport({ input = stdin, output = stdout } = {}) {
16
36
  const readline = createInterface({ input, output });
@@ -46,6 +66,24 @@ function safeBasDestination(destination) {
46
66
  };
47
67
  }
48
68
 
69
+ function safeLocalSapGuiDestination(destination) {
70
+ return {
71
+ source: 'sap-gui-local',
72
+ name: destination.name,
73
+ serverName: destination.serverName || destination.name,
74
+ url: destination.url,
75
+ host: destination.host,
76
+ systemId: destination.systemId,
77
+ instance: destination.instance,
78
+ client: destination.client || '001',
79
+ authentication: destination.authentication || 'Basic',
80
+ authMode: destination.authMode,
81
+ proxyType: 'Internet',
82
+ probe: safeProbe(destination.probe),
83
+ childEnv: destination.childEnv || {}
84
+ };
85
+ }
86
+
49
87
  function safeCloudFoundryDestination(destination) {
50
88
  const cf = destination.cf || {};
51
89
  return {
@@ -99,28 +137,41 @@ export async function runSetup({
99
137
  output = stdout,
100
138
  discover = discoverDestinations,
101
139
  discoverCf = discoverCloudFoundryDestinations,
140
+ discoverLocalSapGui = discoverSapGuiSystems,
102
141
  confirmCfImport = confirmCloudFoundryImport,
103
142
  install = installMcpConfig,
104
143
  includeSapDevelopmentToolsPrompt = false
105
144
  } = {}) {
106
- if (!env.H2O_URL) {
107
- print(output, formatStatus(`Setup skipped: H2O_URL is not set. Run ${SETUP_COMMAND} in a BAS dev space.`, 'info', output, 'SAP AI Dev Toolkit'));
145
+ const isBas = Boolean(env.H2O_URL);
146
+ if (!isBas && (!input.isTTY || !output.isTTY)) {
147
+ print(output, formatStatus('BAS destination setup was skipped because H2O_URL is not set.', 'info', output, 'SAP AI Dev Toolkit'));
108
148
  return { skipped: true, reason: 'non-bas' };
109
149
  }
110
150
  if (!input.isTTY || !output.isTTY) {
111
- print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a BAS terminal.`, 'warning', output, 'SAP AI Dev Toolkit'));
151
+ print(output, formatStatus(`Interactive setup needs a terminal. Rerun ${SETUP_COMMAND} from a terminal.`, 'warning', output, 'SAP AI Dev Toolkit'));
112
152
  return { skipped: true, reason: 'non-tty' };
113
153
  }
114
154
  print(output, '');
115
- print(output, formatStatus('Contacting BAS to discover destinations; this may take a moment.', 'progress', output, 'Setup'));
155
+ print(output, formatStatus(isBas ? 'Contacting BAS to discover destinations; this may take a moment.' : 'Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
116
156
 
117
- let basDestinations;
118
- try {
119
- basDestinations = await discover({ env: { ...env, SAP_AI_DEV_TOOLKIT_DESTINATION: '' } });
120
- } catch (error) {
121
- throw new Error(`BAS discovery failed: ${error.message}`);
157
+ let destinations = [];
158
+ if (isBas) {
159
+ let basDestinations;
160
+ try {
161
+ basDestinations = await discover({ env: { ...env, SAP_AI_DEV_TOOLKIT_DESTINATION: '' } });
162
+ } catch (error) {
163
+ throw new Error(`BAS discovery failed: ${error.message}`);
164
+ }
165
+ destinations = basDestinations.map(safeBasDestination);
166
+ } else {
167
+ let localSystems;
168
+ try {
169
+ localSystems = await discoverLocalSapGui({ env });
170
+ } catch (error) {
171
+ throw new Error(`SAP GUI discovery failed: ${error.message}`);
172
+ }
173
+ destinations = localSystems.map(safeLocalSapGuiDestination);
122
174
  }
123
- const destinations = basDestinations.map(safeBasDestination);
124
175
  const warnings = [];
125
176
  let createdKeys = [];
126
177
  let configPath;
@@ -132,9 +183,9 @@ export async function runSetup({
132
183
  warnings.push('Existing MCP config could not be read before setup; existing CF service keys will not be reused.');
133
184
  }
134
185
 
135
- const target = await getCloudFoundryTarget({ env });
186
+ const target = isBas ? await getCloudFoundryTarget({ env }) : { available: false, reason: 'Cloud Foundry import is only available in BAS setup.' };
136
187
  if (!target.available) {
137
- warnings.push(target.reason);
188
+ if (isBas) warnings.push(target.reason);
138
189
  } else {
139
190
  // Sweep for service keys this toolkit created but no config references
140
191
  // (an interrupted earlier setup leaves them behind; a Destination key
@@ -205,8 +256,8 @@ export async function runSetup({
205
256
  };
206
257
  const selectable = destinations.filter(destination => !destination.disabledReason);
207
258
  if (!selectable.length) {
208
- print(output, formatStatus('No selectable BAS or Cloud Foundry destinations were found.', 'warning', output, 'Setup'));
209
- print(output, remediation);
259
+ print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
260
+ print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or set SAP_AI_DEV_MCP_CONFIG and add systems manually. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
210
261
  await reportWarnings(warnings);
211
262
  const cleanupWarnings = await cleanupNewKeysWithoutConfigChange();
212
263
  await reportWarnings(cleanupWarnings);
@@ -222,9 +273,9 @@ export async function runSetup({
222
273
  const probe = destination.probe?.status || 'unknown';
223
274
  const source = destination.source === 'cloud-foundry'
224
275
  ? `CF ${destination.cf.destinationInstanceName}`
225
- : 'BAS';
276
+ : (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
226
277
  const disabled = destination.disabledReason;
227
- const state = disabled ? 'disabled' : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`);
278
+ const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
228
279
  return {
229
280
  value: destination,
230
281
  name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
@@ -245,6 +296,52 @@ export async function runSetup({
245
296
  shortcuts: { all: 'a' }
246
297
  }, { input, output });
247
298
 
299
+ if (!isBas && selected.length) {
300
+ print(output, '');
301
+ print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints. Confirm the ADT URL and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
302
+ if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication. Username/password remains the default.');
303
+ for (const destination of selected) {
304
+ const fallback = defaultAdtUrl(destination);
305
+ const url = await textPrompt({
306
+ message: `ADT URL for ${destination.name}`,
307
+ placeholder: fallback || 'https://host:44300',
308
+ validate: value => {
309
+ const candidate = value || fallback;
310
+ if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
311
+ try {
312
+ const parsed = new URL(candidate);
313
+ if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
314
+ if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
315
+ if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
316
+ return true;
317
+ }
318
+ catch { return 'Enter a valid URL.'; }
319
+ },
320
+ required: false
321
+ }, { input, output });
322
+ destination.url = url || fallback;
323
+ const authMode = await chooseLocalAuthMode(destination, { input, output, env });
324
+ destination.authentication = authMode === 'windows-sso' ? 'WindowsSSO' : 'Basic';
325
+ destination.authMode = authMode;
326
+ if (authMode === 'windows-sso') {
327
+ destination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
328
+ destination.inputs = [];
329
+ } else {
330
+ const userInputId = `sap-ai-dev-${destination.serverName || destination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
331
+ const passwordInputId = `sap-ai-dev-${destination.serverName || destination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
332
+ destination.childEnv = {
333
+ SAP_AUTH_MODE: 'basic',
334
+ SAP_USER: `\${input:${userInputId}}`,
335
+ SAP_PASSWORD: `\${input:${passwordInputId}}`
336
+ };
337
+ destination.inputs = [
338
+ { id: userInputId, type: 'promptString', description: `SAP user for ${destination.name}` },
339
+ { id: passwordInputId, type: 'promptString', description: `SAP password for ${destination.name}`, password: true }
340
+ ];
341
+ }
342
+ }
343
+ }
344
+
248
345
  let sapDevelopmentServers = [];
249
346
  if (includeSapDevelopmentToolsPrompt) {
250
347
  print(output, '');
@@ -269,18 +366,20 @@ export async function runSetup({
269
366
  const location = result?.path ? ` in ${result.path}` : '';
270
367
  print(output, '');
271
368
  const configuredCount = selected.length + sapDevelopmentServers.length;
272
- print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, 'BAS setup'));
369
+ print(output, formatStatus(`Configured ${configuredCount} MCP server${configuredCount === 1 ? '' : 's'}${location}.`, 'success', output, isBas ? 'BAS setup' : 'Local setup'));
273
370
  printConnectionInstructions(output, result);
274
371
  let cleanupWarnings = [];
275
372
  const finalPath = result?.path || configPath;
276
- if (!finalPath) {
277
- cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config path is unavailable.');
278
- } else {
279
- try {
280
- const finalConfig = await readMcpConfig(finalPath);
281
- cleanupWarnings = await cleanupKeys([...managedKeys, ...createdKeys], finalConfig);
282
- } catch {
283
- cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config could not be read.');
373
+ if (isBas) {
374
+ if (!finalPath) {
375
+ cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config path is unavailable.');
376
+ } else {
377
+ try {
378
+ const finalConfig = await readMcpConfig(finalPath);
379
+ cleanupWarnings = await cleanupKeys([...managedKeys, ...createdKeys], finalConfig);
380
+ } catch {
381
+ cleanupWarnings.push('CF service-key cleanup skipped because the final MCP config could not be read.');
382
+ }
284
383
  }
285
384
  }
286
385
  await reportWarnings(warnings);
@@ -0,0 +1,224 @@
1
+ import http from 'node:http';
2
+ import { once } from 'node:events';
3
+ import { execFile as nodeExecFile } from 'node:child_process';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const MAX_HELPER_OUTPUT = 1024 * 1024;
8
+ const HOP_BY_HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
9
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
10
+
11
+ function safeError(message) { return new Error(message); }
12
+
13
+ function validateTarget(value) {
14
+ let url;
15
+ try { url = new URL(value); }
16
+ catch { throw safeError('Windows SSO target ADT URL is invalid'); }
17
+ if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) {
18
+ throw safeError('Windows SSO target ADT URL must be an HTTP(S) URL without embedded user information');
19
+ }
20
+ url.hash = '';
21
+ return url;
22
+ }
23
+
24
+ function hasNegotiateChallenge(headers) {
25
+ const value = headers.get?.('www-authenticate') || '';
26
+ return /(?:^|,)\s*Negotiate\b/i.test(value);
27
+ }
28
+
29
+ function headerObject(headers) {
30
+ const result = {};
31
+ for (const [name, value] of Object.entries(headers || {})) {
32
+ const lower = name.toLowerCase();
33
+ if (HOP_BY_HOP.has(lower) || lower === 'host') continue;
34
+ if (lower === 'authorization') continue;
35
+ if (value == null) continue;
36
+ result[name] = Array.isArray(value) ? value.join(', ') : String(value);
37
+ }
38
+ return result;
39
+ }
40
+
41
+ function copyResponseHeaders(response, { proxyOrigin, targetOrigin } = {}) {
42
+ const headers = {};
43
+ for (const [name, value] of response.headers) {
44
+ const lower = name.toLowerCase();
45
+ if (HOP_BY_HOP.has(lower) || ['content-encoding', 'content-length', 'set-cookie'].includes(lower)) continue;
46
+ // fetch exposes decompressed bytes, so upstream framing is no longer valid.
47
+ if (lower === 'location' && proxyOrigin && targetOrigin) {
48
+ try {
49
+ const location = new URL(value, targetOrigin);
50
+ if (location.origin === targetOrigin) {
51
+ headers[name] = `${proxyOrigin}${location.pathname}${location.search}${location.hash}`;
52
+ continue;
53
+ }
54
+ } catch {
55
+ // Keep unparsable Location values unchanged; the client/backend can decide.
56
+ }
57
+ }
58
+ headers[name] = value;
59
+ }
60
+ const cookies = setCookies(response.headers);
61
+ if (cookies.length) headers['set-cookie'] = cookies;
62
+ return headers;
63
+ }
64
+
65
+ function setCookies(headers) {
66
+ if (typeof headers?.getSetCookie === 'function') return headers.getSetCookie();
67
+ const value = headers?.get?.('set-cookie');
68
+ return value ? [value] : [];
69
+ }
70
+
71
+ function mergeCookieHeader(existing, cookies) {
72
+ const pairs = new Map();
73
+ for (const part of String(existing || '').split(';')) {
74
+ const trimmed = part.trim();
75
+ const index = trimmed.indexOf('=');
76
+ if (index > 0) pairs.set(trimmed.slice(0, index), trimmed.slice(index + 1));
77
+ }
78
+ for (const cookie of cookies || []) {
79
+ const first = String(cookie || '').split(';', 1)[0]?.trim();
80
+ const index = first.indexOf('=');
81
+ if (index > 0) pairs.set(first.slice(0, index), first.slice(index + 1));
82
+ }
83
+ return [...pairs].map(([name, value]) => `${name}=${value}`).join('; ');
84
+ }
85
+
86
+ async function readRequestBody(req, limit = 50 * 1024 * 1024) {
87
+ if (['GET', 'HEAD'].includes(req.method || 'GET')) return undefined;
88
+ const chunks = [];
89
+ let size = 0;
90
+ for await (const chunk of req) {
91
+ size += chunk.length;
92
+ if (size > limit) throw safeError('Windows SSO proxy request body is too large');
93
+ chunks.push(chunk);
94
+ }
95
+ return Buffer.concat(chunks);
96
+ }
97
+
98
+ function helperCommand(env) {
99
+ const command = env.SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER || env.SAP_AI_DEV_TOOLKIT_SSPI_HELPER;
100
+ if (command) return { command, args: [] };
101
+ if (process.platform !== 'win32') return null;
102
+ return {
103
+ command: env.SystemRoot ? join(env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe') : 'powershell.exe',
104
+ args: ['-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', join(root, 'scripts', 'windows-negotiate-helper.ps1')]
105
+ };
106
+ }
107
+
108
+ function tokenLooksLikeNtlm(token) {
109
+ try {
110
+ return Buffer.from(token, 'base64').subarray(0, 8).toString('ascii') === 'NTLMSSP\0';
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+
116
+ function execHelper(helper, payload, execFileImpl = nodeExecFile, env = process.env) {
117
+ return new Promise((resolve, reject) => {
118
+ const child = execFileImpl(helper.command, helper.args, {
119
+ windowsHide: true,
120
+ timeout: 30_000,
121
+ maxBuffer: MAX_HELPER_OUTPUT,
122
+ env: { ...process.env, ...env }
123
+ }, (error, stdout) => {
124
+ // execFile errors can contain helper stdout/stderr, including tokens.
125
+ if (error) return reject(safeError('Windows SSO helper failed; verify the helper and Windows Kerberos configuration.'));
126
+ const token = String(stdout || '').trim();
127
+ if (!/^[A-Za-z0-9+/=_-]+$/.test(token)) return reject(safeError('Windows SSO helper returned an invalid Negotiate token'));
128
+ if (tokenLooksLikeNtlm(token)) return reject(safeError('Windows SSO helper returned an NTLM token. ADT SSO requires Kerberos/SPNEGO; verify the backend SPN and Kerberos configuration, or rerun sap-ai-dev --setup and choose username/password.'));
129
+ resolve(token);
130
+ });
131
+ child.stdin?.end(`${JSON.stringify(payload)}\n`);
132
+ });
133
+ }
134
+
135
+ async function acquireNegotiateToken({ targetUrl, challenge, env, execFileImpl }) {
136
+ const helper = helperCommand(env);
137
+ if (!helper) {
138
+ throw safeError('Windows SSO requires an SSPI/Negotiate helper. Set SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER to a trusted helper executable, or rerun sap-ai-dev --setup and choose username/password.');
139
+ }
140
+ return execHelper(helper, { url: targetUrl, challenge, mechanism: 'Negotiate' }, execFileImpl, env);
141
+ }
142
+
143
+ async function forward(req, targetUrl, headers, body, fetchImpl) {
144
+ return fetchImpl(targetUrl, {
145
+ method: req.method,
146
+ headers,
147
+ body,
148
+ duplex: body ? 'half' : undefined,
149
+ redirect: 'manual'
150
+ });
151
+ }
152
+
153
+ function writeFetchResponse(res, response, base, proxyOrigin) {
154
+ res.writeHead(response.status, response.statusText, copyResponseHeaders(response, { proxyOrigin, targetOrigin: base.origin }));
155
+ if (!response.body) return res.end();
156
+ response.body.pipeTo(new WritableStream({
157
+ write(chunk) { res.write(chunk); },
158
+ close() { res.end(); },
159
+ abort() { res.destroy(); }
160
+ })).catch(() => res.destroy());
161
+ }
162
+
163
+ export async function createWindowsSsoAdtProxy({ destinationUrl, env = process.env, fetchImpl = globalThis.fetch, execFileImpl = nodeExecFile } = {}) {
164
+ if (process.platform !== 'win32' && env.SAP_AI_DEV_TOOLKIT_ALLOW_SSO_PROXY_ON_NON_WINDOWS !== 'true') {
165
+ throw safeError('Windows SSO for local ADT destinations is only supported on Windows');
166
+ }
167
+ if (typeof fetchImpl !== 'function') throw safeError('HTTP fetch implementation is unavailable');
168
+ const base = validateTarget(destinationUrl);
169
+ const sockets = new Set();
170
+ let closed = false;
171
+ let proxyOrigin = '';
172
+
173
+ const server = http.createServer(async (req, res) => {
174
+ if (closed) return res.destroy();
175
+ let target;
176
+ try {
177
+ target = new URL(req.url || '/', base);
178
+ if (target.origin !== base.origin) throw safeError('Cross-origin Windows SSO proxy request rejected');
179
+ const headers = headerObject(req.headers);
180
+ const body = await readRequestBody(req);
181
+ let response = await forward(req, target, headers, body, fetchImpl);
182
+ if (response.status === 401 && hasNegotiateChallenge(response.headers)) {
183
+ const challenge = response.headers.get('www-authenticate') || '';
184
+ const cancel = response.body?.cancel?.();
185
+ await cancel?.catch?.(() => {});
186
+ const token = await acquireNegotiateToken({ targetUrl: target.href, challenge, env, execFileImpl });
187
+ const retryHeaders = { ...headers, Authorization: `Negotiate ${token}` };
188
+ const cookies = mergeCookieHeader(headers.cookie || headers.Cookie, setCookies(response.headers));
189
+ delete retryHeaders.cookie;
190
+ delete retryHeaders.Cookie;
191
+ if (cookies) retryHeaders.Cookie = cookies;
192
+ response = await forward(req, target, retryHeaders, body, fetchImpl);
193
+ }
194
+ writeFetchResponse(res, response, base, proxyOrigin);
195
+ } catch (error) {
196
+ if (!res.headersSent) {
197
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', connection: 'close' });
198
+ res.end(`${error.message || 'Windows SSO proxy request failed'}\n`);
199
+ } else {
200
+ res.destroy();
201
+ }
202
+ }
203
+ });
204
+
205
+ server.on('connection', socket => {
206
+ sockets.add(socket);
207
+ socket.on('close', () => sockets.delete(socket));
208
+ });
209
+
210
+ server.listen(0, '127.0.0.1');
211
+ await once(server, 'listening');
212
+ const address = server.address();
213
+ proxyOrigin = `http://127.0.0.1:${address.port}`;
214
+ return {
215
+ url: proxyOrigin,
216
+ close: async () => {
217
+ if (closed) return;
218
+ closed = true;
219
+ for (const socket of sockets) socket.destroy();
220
+ server.close();
221
+ await once(server, 'close').catch(() => {});
222
+ }
223
+ };
224
+ }