ports-manager 1.0.0

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/ide.js ADDED
@@ -0,0 +1,107 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const { parse } = require('jsonc-parser');
6
+ const { SHIM_SOURCE } = require('./cors');
7
+
8
+ const OWNED_PREFIX = 'Ports Manager:';
9
+
10
+ function isIdeTerminal(env = process.env) {
11
+ return env.TERM_PROGRAM === 'vscode';
12
+ }
13
+
14
+ function readJsonc(filename, fallback) {
15
+ if (!fs.existsSync(filename)) return fallback;
16
+ const errors = [];
17
+ const value = parse(fs.readFileSync(filename, 'utf8'), errors, { allowTrailingComma: true });
18
+ if (errors.length || value === undefined) throw new Error(`cannot parse ${filename} as JSON with comments`);
19
+ return value;
20
+ }
21
+
22
+ function taskFromSpec(label, spec) {
23
+ return {
24
+ label: `${OWNED_PREFIX} ${label}`,
25
+ type: 'process',
26
+ command: spec.command,
27
+ args: spec.args,
28
+ options: { cwd: spec.cwd, env: spec.env },
29
+ presentation: {
30
+ reveal: 'always',
31
+ panel: 'dedicated',
32
+ group: 'ports-manager',
33
+ clear: true
34
+ },
35
+ isBackground: true,
36
+ problemMatcher: []
37
+ };
38
+ }
39
+
40
+ function mergeTasks(existing, specs, proxySpec) {
41
+ const document = existing && typeof existing === 'object' ? existing : {};
42
+ const unrelated = Array.isArray(document.tasks)
43
+ ? document.tasks.filter((task) => !task || typeof task.label !== 'string' || !task.label.startsWith(OWNED_PREFIX))
44
+ : [];
45
+ const owned = [
46
+ taskFromSpec('Backend', specs.backend),
47
+ taskFromSpec('Frontend', specs.frontend)
48
+ ];
49
+ if (proxySpec) owned.push(taskFromSpec('Proxy', proxySpec));
50
+ owned.push({
51
+ label: `${OWNED_PREFIX} Run All`,
52
+ dependsOn: owned.map((task) => task.label),
53
+ dependsOrder: 'parallel',
54
+ problemMatcher: [],
55
+ runOptions: { reevaluateOnRerun: true }
56
+ });
57
+ return { ...document, version: document.version || '2.0.0', tasks: [...unrelated, ...owned] };
58
+ }
59
+
60
+ function mergeKeybindings(existing, key = 'ctrl+alt+p') {
61
+ const entries = Array.isArray(existing) ? existing : [];
62
+ const unrelated = entries.filter((entry) => entry && entry.portsManagerOwned !== true);
63
+ return [...unrelated, {
64
+ key,
65
+ command: 'workbench.action.tasks.runTask',
66
+ args: `${OWNED_PREFIX} Run All`,
67
+ portsManagerOwned: true
68
+ }];
69
+ }
70
+
71
+ function generateIdeFiles(root, specs, proxySpec, options = {}) {
72
+ const { keybinding = 'ctrl+alt+p', needsShim = false, withKeybinding = false } = options;
73
+ const vscode = path.join(root, '.vscode');
74
+ fs.mkdirSync(vscode, { recursive: true });
75
+ const tasksFile = path.join(vscode, 'tasks.json');
76
+ const keybindingsFile = path.join(vscode, 'ports-manager-keybindings.json');
77
+ if (needsShim) {
78
+ const shimFile = path.join(vscode, 'ports-manager-cors-preload.cjs');
79
+ fs.writeFileSync(shimFile, SHIM_SOURCE, 'utf8');
80
+ }
81
+ const tasks = mergeTasks(readJsonc(tasksFile, { version: '2.0.0', tasks: [] }), specs, proxySpec);
82
+ fs.writeFileSync(tasksFile, `${JSON.stringify(tasks, null, 2)}\n`, 'utf8');
83
+ if (withKeybinding) {
84
+ const keybindings = mergeKeybindings(readJsonc(keybindingsFile, []), keybinding);
85
+ fs.writeFileSync(keybindingsFile, `${JSON.stringify(keybindings, null, 2)}\n`, 'utf8');
86
+ }
87
+ return { tasksFile, keybindingsFile: withKeybinding ? keybindingsFile : null };
88
+ }
89
+
90
+ function shellQuote(value, platform = process.platform) {
91
+ const text = String(value);
92
+ if (/^[A-Za-z0-9_./\\:@%+=,-]+$/.test(text)) return text;
93
+ if (platform === 'win32') {
94
+ if (text.includes('"')) throw new Error('cannot safely quote a double quote for an unknown Windows terminal shell');
95
+ return `"${text}"`;
96
+ }
97
+ return `'${text.replace(/'/g, `'\\''`)}'`;
98
+ }
99
+
100
+ function terminalCommand(spec, platform = process.platform) {
101
+ return [spec.command, ...spec.args].map((part) => shellQuote(part, platform)).join(' ');
102
+ }
103
+
104
+ module.exports = {
105
+ OWNED_PREFIX, generateIdeFiles, isIdeTerminal, mergeKeybindings, mergeTasks,
106
+ readJsonc, shellQuote, taskFromSpec, terminalCommand
107
+ };
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ ...require('./config'),
5
+ ...require('./detect'),
6
+ ...require('./runtime'),
7
+ ...require('./ide'),
8
+ ...require('./bridge-client')
9
+ };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ const { spawn } = require('node:child_process');
4
+ const treeKill = require('tree-kill');
5
+ const chalk = require('chalk');
6
+ const { isPortFree } = require('./runtime');
7
+
8
+ function pipeLines(stream, output, prefix, color) {
9
+ let pending = '';
10
+ stream.on('data', (chunk) => {
11
+ pending += chunk.toString();
12
+ const lines = pending.split(/\r?\n/);
13
+ pending = lines.pop();
14
+ for (const line of lines) output.write(`${chalk[color](`[${prefix}]`)} ${line}\n`);
15
+ });
16
+ stream.on('end', () => {
17
+ if (pending) output.write(`${chalk[color](`[${prefix}]`)} ${pending}\n`);
18
+ });
19
+ }
20
+
21
+ function spawnSpec(spec) {
22
+ const child = spawn(spec.command, spec.args, {
23
+ cwd: spec.cwd,
24
+ env: { ...process.env, ...spec.env },
25
+ windowsHide: false,
26
+ stdio: ['inherit', 'pipe', 'pipe'],
27
+ shell: false
28
+ });
29
+ pipeLines(child.stdout, process.stdout, spec.name, spec.color);
30
+ pipeLines(child.stderr, process.stderr, spec.name, spec.color);
31
+ return child;
32
+ }
33
+
34
+ async function waitForListening(port, timeout) {
35
+ const deadline = Date.now() + timeout;
36
+ while (Date.now() < deadline) {
37
+ if (!await isPortFree(port)) return true;
38
+ await new Promise((resolve) => setTimeout(resolve, 100));
39
+ }
40
+ return false;
41
+ }
42
+
43
+ function killTree(pid, signal = 'SIGTERM') {
44
+ return new Promise((resolve) => {
45
+ if (!pid) return resolve();
46
+ treeKill(pid, signal, () => resolve());
47
+ });
48
+ }
49
+
50
+ async function runProcesses(specs, options = {}) {
51
+ const children = [];
52
+ let stopping = false;
53
+ let resolveDone;
54
+ const done = new Promise((resolve) => { resolveDone = resolve; });
55
+
56
+ const stop = async (code = 0) => {
57
+ if (stopping) return;
58
+ stopping = true;
59
+ await Promise.all(children.filter((child) => child.exitCode === null)
60
+ .map((child) => killTree(child.pid)));
61
+ resolveDone(code);
62
+ };
63
+
64
+ const register = (spec) => {
65
+ const child = spawnSpec(spec);
66
+ children.push(child);
67
+ child.once('error', (error) => {
68
+ console.error(`[${spec.name}] failed to launch: ${error.message}`);
69
+ stop(1);
70
+ });
71
+ child.once('exit', (code, signal) => {
72
+ if (!stopping) {
73
+ console.error(`[${spec.name}] exited (${signal || `code ${code}`}); stopping the other services.`);
74
+ stop(code || 1);
75
+ }
76
+ });
77
+ };
78
+
79
+ register(specs.backend);
80
+ if (options.waitForBackend > 0) {
81
+ const ready = await waitForListening(options.backendPort, options.waitForBackend);
82
+ if (!ready) {
83
+ console.warn(`[Ports Manager] Backend did not listen within ${options.waitForBackend}ms; starting frontend anyway.`);
84
+ }
85
+ }
86
+ register(specs.frontend);
87
+
88
+ const onSignal = () => stop(0);
89
+ process.once('SIGINT', onSignal);
90
+ process.once('SIGTERM', onSignal);
91
+ const code = await done;
92
+ process.removeListener('SIGINT', onSignal);
93
+ process.removeListener('SIGTERM', onSignal);
94
+ return code;
95
+ }
96
+
97
+ module.exports = { killTree, pipeLines, runProcesses, spawnSpec, waitForListening };
package/src/proxy.js ADDED
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+
5
+ function loadProxyFactory() {
6
+ try {
7
+ return require('http-proxy-middleware').createProxyMiddleware;
8
+ } catch {
9
+ throw new Error('proxy mode requires http-proxy-middleware; reinstall optional dependencies with npm install');
10
+ }
11
+ }
12
+
13
+ function startProxy({ port, frontendPort, backendPort, apiPrefix }, logger = console) {
14
+ const createProxyMiddleware = loadProxyFactory();
15
+ const onError = (error, req, res) => {
16
+ logger.error(`[Proxy] ${error.message}`);
17
+ if (res && !res.headersSent) {
18
+ res.writeHead(502, { 'Content-Type': 'text/plain' });
19
+ res.end('Ports Manager proxy target unavailable');
20
+ }
21
+ };
22
+ const backend = createProxyMiddleware({
23
+ target: `http://127.0.0.1:${backendPort}`,
24
+ changeOrigin: true,
25
+ ws: true,
26
+ on: { error: onError }
27
+ });
28
+ const frontend = createProxyMiddleware({
29
+ target: `http://127.0.0.1:${frontendPort}`,
30
+ changeOrigin: true,
31
+ ws: true,
32
+ on: { error: onError }
33
+ });
34
+ const server = http.createServer((req, res) => {
35
+ const selected = req.url === apiPrefix || req.url.startsWith(`${apiPrefix}/`) ? backend : frontend;
36
+ selected(req, res, (error) => onError(error || new Error('proxy request was not handled'), req, res));
37
+ });
38
+ server.on('upgrade', (req, socket, head) => {
39
+ const selected = req.url === apiPrefix || req.url.startsWith(`${apiPrefix}/`) ? backend : frontend;
40
+ selected.upgrade(req, socket, head);
41
+ });
42
+ return new Promise((resolve, reject) => {
43
+ server.once('error', reject);
44
+ server.listen(port, '127.0.0.1', () => {
45
+ logger.log(`[Proxy] http://127.0.0.1:${port} (${apiPrefix} -> backend)`);
46
+ resolve({
47
+ close: () => new Promise((done) => server.close(done)),
48
+ server
49
+ });
50
+ });
51
+ });
52
+ }
53
+
54
+ module.exports = { startProxy };
package/src/runtime.js ADDED
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ const net = require('node:net');
4
+
5
+ function isPortFree(port, host = '127.0.0.1') {
6
+ return new Promise((resolve) => {
7
+ const server = net.createServer();
8
+ server.unref();
9
+ server.once('error', () => resolve(false));
10
+ server.listen({ port, host, exclusive: true }, () => {
11
+ server.close(() => resolve(true));
12
+ });
13
+ });
14
+ }
15
+
16
+ async function allocatePorts(config, count) {
17
+ const [minimum, maximum] = config.portRange;
18
+ const banned = new Set([3000, 5000, ...config.bannedPorts]);
19
+ const requested = [config.backendPort, config.frontendPort, config.proxyPort].slice(0, count);
20
+ const result = [];
21
+ for (const requestedPort of requested) {
22
+ if (requestedPort === undefined) {
23
+ let selected;
24
+ for (let port = minimum; port <= maximum; port += 1) {
25
+ if (!banned.has(port) && !result.includes(port) && await isPortFree(port)) {
26
+ selected = port;
27
+ break;
28
+ }
29
+ }
30
+ if (!selected) throw new Error(`no free ports in range ${minimum}-${maximum}`);
31
+ result.push(selected);
32
+ } else {
33
+ if (banned.has(requestedPort)) throw new Error(`port ${requestedPort} is banned`);
34
+ if (result.includes(requestedPort)) throw new Error(`port ${requestedPort} was requested more than once`);
35
+ if (!await isPortFree(requestedPort)) throw new Error(`requested port ${requestedPort} is unavailable`);
36
+ result.push(requestedPort);
37
+ }
38
+ }
39
+ return result;
40
+ }
41
+
42
+ function renderEnv(env, values) {
43
+ return Object.fromEntries(Object.entries(env || {}).map(([key, value]) => {
44
+ if (typeof value !== 'string') return [key, String(value)];
45
+ return [key, value.replace(/\{(frontendPort|backendPort|proxyPort|apiPrefix)\}/g,
46
+ (_, name) => String(values[name] ?? ''))];
47
+ }));
48
+ }
49
+
50
+ function buildSpecs(frontend, backend, ports, config, corsShim) {
51
+ const values = {
52
+ backendPort: ports.backend,
53
+ frontendPort: ports.frontend,
54
+ proxyPort: ports.proxy,
55
+ apiPrefix: config.apiPrefix
56
+ };
57
+ const backendUrl = config.proxy
58
+ ? `http://127.0.0.1:${ports.proxy}${config.apiPrefix}`
59
+ : `http://127.0.0.1:${ports.backend}`;
60
+ const configuredEnv = config.env || {};
61
+ const commonEnv = renderEnv(Object.fromEntries(Object.entries(configuredEnv)
62
+ .filter(([key]) => key !== 'frontend' && key !== 'backend')), values);
63
+ const backendEnv = {
64
+ ...commonEnv,
65
+ ...renderEnv(configuredEnv.backend, values),
66
+ PORT: String(ports.backend)
67
+ };
68
+ if (corsShim) {
69
+ backendEnv.PORTS_MANAGER_CORS_ORIGIN = `http://127.0.0.1:${config.proxy ? ports.proxy : ports.frontend}`;
70
+ backendEnv.PORTS_MANAGER_CORS_CREDENTIALS = String(config.corsCredentials);
71
+ backendEnv.NODE_OPTIONS = appendNodeRequire(process.env.NODE_OPTIONS || '', corsShim);
72
+ }
73
+
74
+ const frontendEnv = {
75
+ ...commonEnv,
76
+ ...renderEnv(configuredEnv.frontend, values)
77
+ };
78
+ const npm = npmInvocation();
79
+ const frontendArgs = [...npm.argsPrefix, 'run', frontend.script];
80
+ if (frontend.framework === 'cra') {
81
+ frontendEnv.PORT = String(ports.frontend);
82
+ frontendEnv.REACT_APP_API_URL = backendUrl;
83
+ } else if (frontend.framework === 'vite') {
84
+ frontendArgs.push('--', '--port', String(ports.frontend), '--strictPort');
85
+ frontendEnv.VITE_API_URL = backendUrl;
86
+ } else if (frontend.framework === 'next') {
87
+ frontendArgs.push('--', '-p', String(ports.frontend));
88
+ frontendEnv.NEXT_PUBLIC_API_URL = backendUrl;
89
+ } else {
90
+ frontendEnv.PORT = String(ports.frontend);
91
+ }
92
+
93
+ return {
94
+ backend: {
95
+ name: 'Backend', cwd: backend.directory, command: npm.command,
96
+ args: [...npm.argsPrefix, 'run', backend.script],
97
+ env: backendEnv, color: 'cyan'
98
+ },
99
+ frontend: {
100
+ name: 'Frontend', cwd: frontend.directory, command: npm.command, args: frontendArgs,
101
+ env: frontendEnv, color: 'magenta'
102
+ }
103
+ };
104
+ }
105
+
106
+ function npmInvocation() {
107
+ if (process.platform === 'win32') {
108
+ return {
109
+ command: process.env.ComSpec || 'cmd.exe',
110
+ argsPrefix: ['/d', '/s', '/c', 'npm.cmd']
111
+ };
112
+ }
113
+ return { command: 'npm', argsPrefix: [] };
114
+ }
115
+
116
+ function appendNodeRequire(existing, shimPath) {
117
+ const escaped = shimPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
118
+ return `${existing ? `${existing} ` : ''}--require "${escaped}"`;
119
+ }
120
+
121
+ module.exports = { allocatePorts, appendNodeRequire, buildSpecs, isPortFree, npmInvocation, renderEnv };
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "node16",
5
+ "moduleResolution": "node16",
6
+ "outDir": "dist/bridge",
7
+ "rootDir": "bridge",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "skipLibCheck": true,
12
+ "sourceMap": true
13
+ },
14
+ "include": [
15
+ "bridge/src/**/*.ts",
16
+ "bridge/test/**/*.ts"
17
+ ]
18
+ }