UNPKG

@controlplane/cli

Version:

Control Plane Corporation CLI

715 lines 36.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PortForwarder = void 0; exports.deduplicateAddresses = deduplicateAddresses; exports.bindServer = bindServer; const net = require("net"); const WebSocket = require("ws"); const objects_1 = require("../util/objects"); const resolver_1 = require("../commands/resolver"); const client_1 = require("../session/client"); const helpers_1 = require("../terminal-server/helpers"); const async_mutex_1 = require("async-mutex"); const auth_1 = require("../login/auth"); // ANCHOR - Constants const PORT_PATTERN = /^(6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4})$/; const LOCALHOST_IPV4_ADDRESS = '127.0.0.1'; const LOCALHOST_IPV6_ADDRESS = '::1'; var PortForwardErrorType; (function (PortForwardErrorType) { PortForwardErrorType[PortForwardErrorType["Fatal"] = 0] = "Fatal"; PortForwardErrorType[PortForwardErrorType["TicketExpired"] = 1] = "TicketExpired"; })(PortForwardErrorType || (PortForwardErrorType = {})); // ANCHOR - PortForwarder /** * Forwards one or more local ports to a workload over the terminal server's * WebSocket port-forward protocol. */ class PortForwarder { constructor(client, session, quiet = false) { this.client = client; this.session = session; this.portRequestMap = new Map(); this.portMutexMap = new Map(); this.portTicketMap = new Map(); this.servers = []; this.quiet = quiet; } // Public Methods // /** * Validates inputs, resolves the location, and binds a local TCP listener per port mapping. * Resolves once all listeners are bound; the caller keeps this instance and calls {@link stop} * to tear it down. * * @param options - The workload, port mappings, and bind addresses to forward. * @returns The bound local addresses (address + OS-assigned port) per listener . */ async start(options) { // Format ports correctly const ports = (0, objects_1.toArray)(options.ports); // Validate options this.validate(ports, options.addresses); // Resolve to the concrete bind addresses, tracking which ones came from localhost expansion const finalBindAddresses = deduplicateAddresses(options.addresses); // Default to first location if no location is specified const location = await this.resolveLocation(options.location); // Create servers for each address and listen on ports, returning the bound addresses return await this.listenOnAddresses(ports, finalBindAddresses, options.workload, location, options.replica); } /** * Stops the forward, closing every TCP server it opened. */ stop() { for (const server of this.servers) { try { server.close(); } catch (_a) { // Ignore close errors; the server may already be closed } } } // Private Methods // printDefaults(type, name) { if (this.quiet) { return; } this.session.err((0, helpers_1.defaultingToMessage)(type, name)); } /** * Iterates over the provided ports and addresses, setting up TCP servers * to listen on the specified addresses and forward traffic via WebSocket. * * @param ports - List of port mappings to listen on. * @param addresses - List of addresses to bind the listeners to. * @param workloadName - The workload name to forward traffic to. * @param location - The location in which the workload exists. * @param replica - Optional replica of the specified workload. * @returns The bound local addresses (address + OS-assigned port) for every listener, so * callers can recover an OS-assigned local port (see {@link start}). */ async listenOnAddresses(ports, addresses, workloadName, location, replica) { // Get deployment remote endpoint using the specified location const remoteHost = await this.resolveDeploymentEndpoint(workloadName, location); // Create request configuration const config = await this.createConfig(remoteHost, workloadName, replica); // Collect each successful bind's local address (address + OS-assigned port) so start() can // hand it back. A caller that requested an OS-assigned port (empty local port) otherwise // has no way to learn which port the OS chose. const boundAddresses = []; // For each port mapping, parse and start a TCP server for (const mappingStr of ports) { let mapping; // Try to parse the port mapping string to an object try { mapping = this.parsePortMapping(mappingStr); } catch (e) { throw new Error(`Invalid port mapping "${mappingStr}": ${e.message}`); } // Initialize the request object let request; // Use cached request or create a new one if (this.portRequestMap.has(mapping.remotePort)) { request = this.portRequestMap.get(mapping.remotePort); } else { request = { ...config.request, port: mapping.remotePort, }; // Cache the request so it can be uniquely tied to the remote port this.portRequestMap.set(mapping.remotePort, request); } // Add a new record to the port mutex map so that we have a unique mutex for each remote port if (!this.portMutexMap.has(mapping.remotePort)) { this.portMutexMap.set(mapping.remotePort, new async_mutex_1.Mutex()); } // Per-group bind counters mirroring kubectl's failureMode grouping in // k8s.io/client-go/tools/portforward.listenOnPort: the localhost-expanded group // tolerates a partial bind as long as at least one of its addresses succeeded; // an explicit address failure aborts immediately on its own. let localhostSuccesses = 0; let localhostFailures = 0; // For each deduplicated bind address, create a TCP server for (const { address: bindAddress, fromLocalhostExpansion } of addresses) { // Create a TCP server for this port mapping const server = net.createServer((localSocket) => { // Save the ticket for this connection to determine if a refresh is needed, // preventing simultaneous refresh attempts from multiple connections let connectionTicket = undefined; // Extract port number from the local socket const localPort = localSocket.address().port; // Log the connection if (!this.quiet) { this.session.out(`Handling connection for ${localPort}`); } // Create a new WebSocket client const client = new WebSocket(config.remoteSocket, { origin: 'http://localhost' }); // Handle WebSocket events client.on('open', async () => { // Refresh token if necessary for the defined request request.token = await this.refreshToken(); // Print part of the token for debug purposes client_1.wire.debug('<<<<<<< WS Open Event with Token: ' + (0, client_1.convertAuthTokenForDebug)(request.token)); // Notify the user of the start of event locking client_1.wire.debug('<<<<<<< WS Open Event Locking...'); // Use the mutex to prevent race conditions const release = await this.portMutexMap.get(mapping.remotePort).acquire(); // Create a ticket and attach it to the request object ONLY if it is not specified there if (!request.ticket) { // Create a ticket so we can pass it on every port-forward request const ticket = await this.resolveTicketForPort(remoteHost, workloadName, mapping.remotePort, replica); // Attach the ticket to the request object before sending it, // this way the ticket creation is done only once throughout the whole on-going connection to the specified remote port request.ticket = ticket; } // After creating a ticket, release the lock // If a waiting connection has the same remote port, it will use the new ticket release(); // Notify the user that the event lock ended client_1.wire.debug('<<<<<<< WS Open Event Lock Released!'); // Set the connection ticket connectionTicket = request.ticket; // Initialize Request client.send(JSON.stringify(request)); // Handle local server events localSocket.on('data', (chunk) => { client.send(chunk); }); localSocket.on('error', (err) => { if (!err.message) { // If we are unable to extract the error message, then log the entire error object using console.error console.error(err); return; } // Log the error message to the user this.session.err(`Local server error during connection handling on port ${localPort}: ${err.message}`); }); }); client.on('error', (event) => { client_1.wire.debug('<<<<<<< WS Error Event info'); // Handle connection refused error differently if (event.hasOwnProperty('code') && event.code === 'ECONNREFUSED') { this.session.err(`error: connection refused by server, please try again.`); return; } // Show the error object for any other error this.session.err('error: lost connection to the server, details below:'); console.error(event); }); client.on('close', () => { client_1.wire.debug('<<<<<<< WS Close Event Triggered'); localSocket.end(); localSocket.destroy(); }); // Handle WebSocket events client.on('message', async (data) => { client_1.wire.debug('<<<<<<< WS Message Received'); // Check if the received message is representing an error const message = data.toString(); // Detect if there is a backend error const errorType = this.detectPortForwardError(message); // Handle each error type differently switch (errorType) { case PortForwardErrorType.TicketExpired: client_1.wire.debug('<<<<<<< WS Message Event Locking...'); // Lock so we avoid refreshing the ticket multiple times when there is a race condition const release = await this.portMutexMap.get(mapping.remotePort).acquire(); // After a lock is released, check if the ticket was refreshed. The way we will achieve this is by // comparing the connection's ticket (initially matching the request ticket) with the current request ticket. // If they differ, update the connection's ticket and return, indicating that a refresh is unnecessary if (connectionTicket != request.ticket) { connectionTicket = request.ticket; release(); return; } // Refresh the ticket for the specified request await this.refreshTicket(request, remoteHost, workloadName, mapping.remotePort, replica); // Unlock after refreshing the ticket release(); client_1.wire.debug('<<<<<<< WS Message Event Lock Released!'); return; case PortForwardErrorType.Fatal: // Signal the process to terminate on fatal errors this.session.abort({ message }); } // Write incoming data to the local socket if (typeof data === 'string') { localSocket.write(data); } else if (data instanceof Buffer) { localSocket.write(data); } }); }); // Determine local port: if not provided, use 0 (random) const localPortToBind = mapping.localPort !== undefined ? mapping.localPort : 0; // Attempt to bind the server; the helper attaches its own one-shot 'error' / 'listening' // handlers so a bind failure resolves the promise instead of crashing the process. const bindResult = await bindServer(server, localPortToBind, bindAddress); // Handle bind failure if (!bindResult.ok) { // For localhost-expanded addresses (the default), tolerate a single-family failure // (e.g. ::1 unbindable in containers where IPv6 loopback is disabled) and continue if (fromLocalhostExpansion) { localhostFailures++; this.session.err(`WARN: Could not bind on ${bindAddress}:${localPortToBind} (${bindResult.error.code || bindResult.error.message}). Continuing on remaining addresses.`); continue; } // For an address the user explicitly requested, treat the bind failure as fatal throw new Error(`ERROR: Failed to bind on ${bindAddress}:${localPortToBind}: ${bindResult.error.message}`); } // Track the bound server so the caller can close it later this.servers.push(server); // Record the bound local address so start() can report it back to the caller boundAddresses.push(bindResult.info); // Count this bind in the appropriate group for the post-loop kubectl-style decision if (fromLocalhostExpansion) { localhostSuccesses++; } // Attach a long-lived error handler so post-listen failures are logged instead of // crashing the process via an unhandled 'error' event server.on('error', (err) => { this.session.err(`Server error on ${bindAddress}:${bindResult.info.port}: ${err.message}`); }); // Extract the bind address for display let bindAddrStr = bindResult.info.address; // Wrap IPv6 addresses with square brackets if (bindResult.info.family === 'IPv6') { bindAddrStr = `[${bindAddrStr}]`; } // Log the forwarding information if (!this.quiet) { this.session.out(`Forwarding from ${bindAddrStr}:${bindResult.info.port} -> ${mapping.remotePort}`); } } // Mirror kubectl's localhost-group check: if the user asked for localhost (so at // least one expanded address was attempted) but none of those expansions succeeded, // the documented contract is to fail — even if some other explicit address bound. if (localhostFailures > 0 && localhostSuccesses === 0) { throw new Error(`ERROR: Could not bind any of the localhost addresses (127.0.0.1, ::1) for port mapping "${mappingStr}".`); } } return boundAddresses; } /** * Create a port forward configuration object. * * @param {string} remoteHost The remote endpoint of the selected deployment. * @param {string} workloadName The workload name. * @param {string} replica The name of the workload deployment replica. * @returns {Promise<PortForwardConfig>} A promise that resolves to a PortForwardConfig object. */ async createConfig(remoteHost, workloadName, replica) { // Determine WebSocket endpoint const remoteSocket = `${remoteHost.replace('https://', 'wss://')}/portforward`; // Prepare token const token = await this.refreshToken(); // Construct the result and return it return { remoteSocket: remoteSocket, request: { contextId: this.session.id, token, org: this.session.context.org, gvc: this.session.context.gvc, workload: workloadName, pod: replica, }, }; } /** * Refreshes the authentication token by generating a new authorization header and extracting the token. * * @returns {Promise<string>} The raw token string without the 'Bearer ' prefix. */ async refreshToken() { // Generate authorization header string using current session profile const authHeader = await (0, auth_1.makeAuthorizationHeader)(this.session.profile); // Strip the 'Bearer ' prefix from the header to get the token return authHeader.replace('Bearer ', ''); } /** * Resolves the provided location or defaults to the first available location within the GVC. * * @param location - The optional location to be resolved. * @returns The resolved location name. */ async resolveLocation(location) { var _a, _b; // If location is specified, no need to find one if (location) { return location; } // Resolve GVC link const gvcSelfLink = (0, resolver_1.resolveToLink)('gvc', this.session.context.gvc, this.session.context); // Fetch GVC const gvc = await this.client.get(gvcSelfLink); // Determine location self link let locationSelfLink = ''; // Extract the first location out of the GVC, abort if GVC has no locations if (((_b = (_a = gvc.spec) === null || _a === void 0 ? void 0 : _a.staticPlacement) === null || _b === void 0 ? void 0 : _b.locationLinks) && gvc.spec.staticPlacement.locationLinks.length > 0) { locationSelfLink = gvc.spec.staticPlacement.locationLinks[0]; } else { throw new Error('ERROR: Gvc has no locations'); } // Extract location name from location self link location = locationSelfLink.split('/location/')[1]; // Log the location that was picked this.printDefaults('location', location); // Return the location that has been picked return location; } /** * Retrieves the remote deployment endpoint for a given workload in a specified location. * * @param workloadName - The name of the workload. * @param location - The location of the deployment. * @returns The remote host endpoint of the deployment. */ async resolveDeploymentEndpoint(workloadName, location) { // Use the remote host from an environment variable if specified if (process.env.TERMINAL_SERVER_URL) { return process.env.TERMINAL_SERVER_URL; } // Construct workload self link const workloadSelfLink = (0, resolver_1.kindResolver)('workload').resourceLink(workloadName, this.session.context); // Determine deployment self link const deploymentSelfLink = `${workloadSelfLink}/deployment/${location}`; // Fetch deployment const deployment = await this.client.get(deploymentSelfLink); // Fail if the deployment is invalid if (!deployment.status || !deployment.status.remote) { throw new Error('ERROR: No deployment is present in the workload.'); } // Extract and return remote host endpoint from deployment status return deployment.status.remote; } /** * Resolves a port-forwarding ticket for a given port, either by retrieving an * existing ticket or creating a new one. * * @param {string} remoteHost - The remote endpoint of the selected deployment. * @param {string} workloadName - The name of the workload associated with the port. * @param {number} port - The port that requires a forwarding ticket. * @param {string | undefined} replica - (Optional) The specific workload replica, if applicable. * @returns {string} A promise that resolves to the port-forwarding ticket. */ async resolveTicketForPort(remoteHost, workloadName, port, replica) { // Use the cached ticket for the remote port if it has been created before if (this.portTicketMap.has(port)) { return this.portTicketMap.get(port); } // Create the ticket const ticket = await this.createTicket(remoteHost, workloadName, port, replica); // Cache and store the ticket so we don't have to create another ticket for the same port this.portTicketMap.set(port, ticket); // Return the ticket return ticket; } /** * Creates a new ticket, assigns it to the request object and updates the port ticket map. * * @param {PortForwardRequest} request - The request object that is being used to establish the port-forwarding connection. * @param {string} remoteHost - The remote endpoint of the selected deployment. * @param {string} workloadName - The name of the workload associated with the port. * @param {number} port - The port that requires a forwarding ticket. * @param {string | undefined} replica - (Optional) The specific workload replica, if applicable. */ async refreshTicket(request, remoteHost, workloadName, port, replica) { client_1.wire.debug(`<<<<<<< Port-forward refreshing ticket for port: ${port}`); // Create a new ticket const ticket = await this.createTicket(remoteHost, workloadName, port, replica); // Update the request object so on future connections, the new ticket will be used request.ticket = ticket; // Update the port ticket mapping so on future connections, the new ticket will be used. // (Use the Map API — `this.portTicketMap[port] = …` sets a plain property that // `resolveTicketForPort`'s `.get()`/`.has()` would never see, leaving the cache stale.) this.portTicketMap.set(port, ticket); } /** * Creates a port-forwarding ticket for a given port. * * @param {string} remoteHost - The remote endpoint of the selected deployment. * @param {string} workloadName - The name of the workload associated with the port. * @param {number} port - The port that requires a forwarding ticket. * @param {string | undefined} replica - (Optional) The specific workload replica, if applicable. * @returns {string} A promise that resolves to the port-forwarding ticket. */ async createTicket(remoteHost, workloadName, port, replica) { // Construct the request object const requestBody = { org: this.session.context.org, gvc: this.session.context.gvc, workload: workloadName, pod: replica, port, }; // Try to create a ticket, surfacing failures to the caller try { // Make a POST request const response = await this.client.post(`${remoteHost}/portforward-ticket`, requestBody); // Extract the ticket const ticket = response.ticket; // Show the ticket in the debug logs client_1.wire.debug('<<<<<<< Port-forward ticket created: ' + (0, client_1.convertAuthTokenForDebug)(ticket)); // Return the ticket return ticket; } catch (e) { this.session.err(`ERROR: Failed to create ticket, details:`); throw e; } } /** * Detect if the message indicates a port-forward error, and return the error type * * @param {string} message The WebSocket message. * @returns {PortForwardErrorType | null} The error type to handle later. */ detectPortForwardError(message) { // The port-forward error starts with the following, if it is not found then there are no errors if (!message.startsWith('Port-forward error:')) { return null; } // Attempt to extract a JSON error object from the message; default to fatal on failure try { // Extract the JSON from the error message const portForwardError = JSON.parse(this.extractPortForwardErrorJson(message)); // Log the message before returning the error type client_1.wire.debug(`Handling port-forward error: ${portForwardError.message}. code: ${portForwardError.code}`); // Determine the error type from the error code if (portForwardError.code === PortForwardErrorType.TicketExpired) { return PortForwardErrorType.TicketExpired; } // For every other case, return fatal type for now return PortForwardErrorType.Fatal; } catch (e) { // Log the error for debugging if necessary client_1.wire.debug('ERROR: Failed to extract/parse port-forward error JSON:', e); // Default to fatal, this is how the terminal server is signaling the CLI to simply die return PortForwardErrorType.Fatal; } } /** * Parses a port mapping string into an object containing local and remote ports. * * @param {string} mapping - The port mapping string in the format "LOCAL:REMOTE", ":REMOTE", or "REMOTE". * @returns {PortMapping} An object containing the localPort (if specified) and remotePort. */ parsePortMapping(mapping) { // Split the input string by ':' to separate local and remote ports const parts = mapping.split(':'); // If there's only one part, it must be a remote port if (parts.length === 1) { // Check if the single part is a valid port number if (!PORT_PATTERN.test(parts[0])) { throw new Error(`Invalid port value: ${mapping}`); } // Extract the port const targetPort = parseInt(parts[0], 10); // Return an object with only the remotePort property return { localPort: targetPort, remotePort: targetPort }; } // If there are two parts, it could be a valid "LOCAL:REMOTE" format else if (parts.length === 2) { // Destructure local and remote port values from the array const [localStr, remoteStr] = parts; // Ensure the remote port is valid and not empty if (remoteStr === '' || !PORT_PATTERN.test(remoteStr)) { throw new Error(`Invalid remote port in mapping: ${mapping}`); } // Convert the remote port string to a number const remotePort = parseInt(remoteStr, 10); // Initialize localPort as undefined by default let localPort = undefined; // If a local port is specified, validate it if (localStr !== '') { if (!PORT_PATTERN.test(localStr)) { throw new Error(`Invalid local port in mapping: ${mapping}`); } // Convert the local port string to a number localPort = parseInt(localStr, 10); } // Return an object containing both localPort and remotePort return { localPort, remotePort }; } // If there are more than two parts, the format is invalid throw new Error(`Invalid port mapping format: ${mapping}`); } /** * Validates the provided ports and addresses against the current session context. * * @param ports - The list of port mapping strings. * @param addresses - The list of addresses to validate. */ validate(ports, addresses) { // Validate org if (!this.session.context.org) { throw new Error('ERROR: An organization is required. Please, specify one using the --org option.'); } // Validate gvc if (!this.session.context.gvc) { throw new Error('ERROR: A GVC is required. Please, specify one using the --gvc option.'); } // Validate ports if (!this.arePortsValid(ports)) { throw new Error('Invalid port mapping. Each port must be between 1 and 65535. Correct format: "[LOCAL_PORT:]REMOTE_PORT". Example: "8080:80".'); } // Validate address if (!this.areAddressesValid(addresses)) { throw new Error('An invalid address option was specified, an address can only be a valid IP address or the value localhost.'); } } /** * Validates an array of port mappings to ensure they follow the correct format. * * @param {string[]} ports - An array of port mapping strings. * @returns {boolean} True if all port mappings are valid, false otherwise. */ arePortsValid(ports) { // Iterate over each port mapping and validate it for (const portMapping of ports) { // If the ports are separated by a colon, then separate them const parts = portMapping.split(':'); // Handle different cases if (parts.length === 1) { // Single port case (REMOTE_PORT) if (!PORT_PATTERN.test(parts[0])) { return false; } } else if (parts.length === 2) { // Port mapping case (LOCAL_PORT:REMOTE_PORT) const [localPort, remotePort] = parts; // Empty localPort (":80") is valid (random local port) if (localPort !== '' && !PORT_PATTERN.test(localPort)) { return false; } // Validate remote port if (!PORT_PATTERN.test(remotePort)) { return false; } } else { // Invalid format (e.g., "8080:80:443") return false; } } // If all ports are valid, return true return true; } /** * Validates an array of addresses to ensure they are either "localhost" or a valid IP address. * * @param {string[]} addresses - An array of address strings to validate. * @returns {boolean} True if all addresses are valid, false otherwise. */ areAddressesValid(addresses) { // Iterate over each address specified and check if it is invalid for (const address of addresses) { // If it is not localhost and it is not even a valid IP address, return false if (address != 'localhost' && net.isIP(address) == 0) { return false; } } // All specified addresses are valid return true; } /** * Extracts the JSON portion from an error message string. * * @param errorMessage - The full port-forward error message that contains a JSON object. * @returns The JSON portion of the error message as a string, or an empty string if not found. */ extractPortForwardErrorJson(errorMessage) { // Find the index of the first '{' character which indicates the start of the JSON object const jsonStartIndex = errorMessage.indexOf('{'); // If found, extract and return the JSON string; otherwise, return an empty string return jsonStartIndex !== -1 ? errorMessage.substring(jsonStartIndex) : ''; } } exports.PortForwarder = PortForwarder; // SECTION - Exported Helpers /** * Expands the provided list of addresses into the concrete bind addresses that should be used. * * When 'localhost' is supplied, it is expanded into both 127.0.0.1 and ::1. Each expanded * address is marked with fromLocalhostExpansion=true so callers can decide that a bind * failure on one of them is recoverable. Explicitly supplied addresses keep the flag at false. * * @param {string[]} addresses - The raw address values supplied by the user. * @returns {ResolvedBindAddress[]} The deduplicated, resolved bind addresses. */ function deduplicateAddresses(addresses) { // Track which addresses we've already added so duplicates collapse, // and remember the original source so explicit > expansion wins. const seen = new Map(); // Walk the input in order so the output preserves the user's listed addresses for (const raw of addresses) { // Trim whitespace from the input value const address = raw.trim(); // When the user passed "localhost", expand it into the IPv4 and IPv6 loopbacks if (address === 'localhost') { // Only add the IPv4 loopback if it wasn't already supplied explicitly if (!seen.has(LOCALHOST_IPV4_ADDRESS)) { seen.set(LOCALHOST_IPV4_ADDRESS, true); } // Only add the IPv6 loopback if it wasn't already supplied explicitly if (!seen.has(LOCALHOST_IPV6_ADDRESS)) { seen.set(LOCALHOST_IPV6_ADDRESS, true); } continue; } // Explicit address: overrides any prior expansion-flagged entry for the same address seen.set(address, false); } // Convert the map back to the structured result list const result = []; for (const [address, fromLocalhostExpansion] of seen) { result.push({ address, fromLocalhostExpansion }); } return result; } /** * Binds the given TCP server to the supplied address and port using promise semantics. * * Resolves with the bound AddressInfo on success or with a structured failure on bind error. * The 'error' and 'listening' listeners that drive this promise are removed once it settles * so the caller can attach its own long-lived 'error' listener afterwards. * * @param {net.Server} server - The TCP server to bind. * @param {number} port - The local port to bind to (0 lets the OS assign one). * @param {string} address - The local address to bind to. * @returns {Promise<BindResult>} A promise that resolves with the bind outcome. */ function bindServer(server, port, address) { return new Promise((resolve) => { // Handler that resolves the promise with a successful bind result const onListening = () => { // Detach the error handler so it does not fire for unrelated post-listen failures server.removeListener('error', onError); // Extract the bound address information from the server const info = server.address(); // Resolve the promise with the bound address details resolve({ ok: true, info }); }; // Handler that resolves the promise with the structured bind failure const onError = (err) => { // Detach the listening handler so a late "listening" event cannot resolve twice server.removeListener('listening', onListening); // Resolve the promise with the bind error resolve({ ok: false, error: err }); }; // Wire up the one-shot handlers server.once('listening', onListening); server.once('error', onError); // Begin listening — events above drive the promise resolution server.listen(port, address); }); } // !SECTION //# sourceMappingURL=port-forwarder.js.map