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/LICENSE +21 -0
- package/README.md +276 -0
- package/bin/ports-manager.js +8 -0
- package/bridge/src/extension.ts +96 -0
- package/bridge/src/server.ts +185 -0
- package/dist/bridge/src/extension.js +133 -0
- package/dist/bridge/src/extension.js.map +1 -0
- package/dist/bridge/src/server.js +202 -0
- package/dist/bridge/src/server.js.map +1 -0
- package/package.json +82 -0
- package/src/bridge-client.js +81 -0
- package/src/cli.js +278 -0
- package/src/config.js +151 -0
- package/src/cors.js +55 -0
- package/src/detect.js +114 -0
- package/src/ide.js +107 -0
- package/src/index.js +9 -0
- package/src/process-manager.js +97 -0
- package/src/proxy.js +54 -0
- package/src/runtime.js +121 -0
- package/tsconfig.bridge.json +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Muhammad Saad Amin (SENODROOM)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# ๐ฆ Ports Manager
|
|
2
|
+
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Launch your frontend and backend together โ on free ports, without CORS headaches.</strong>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<img alt="Node.js 18+" src="https://img.shields.io/badge/Node.js-18%2B-339933?logo=node.js&logoColor=white">
|
|
9
|
+
<img alt="License MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
10
|
+
<img alt="VS Code" src="https://img.shields.io/badge/VS%20Code-supported-007ACC?logo=visualstudiocode">
|
|
11
|
+
<img alt="Cursor" src="https://img.shields.io/badge/Cursor-supported-black">
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Why Ports Manager?
|
|
17
|
+
|
|
18
|
+
Local frontend/backend development should not require manually checking ports, editing `.env`
|
|
19
|
+
files, fixing CORS, and juggling multiple terminals.
|
|
20
|
+
|
|
21
|
+
Run one command from your project root:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx ports-manager
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Ports Manager will:
|
|
28
|
+
|
|
29
|
+
- ๐ Detect your `frontend` + `backend` or `client` + `server`
|
|
30
|
+
- ๐ฌ Find separate free ports while always avoiding `3000` and `5000`
|
|
31
|
+
- โ๏ธ Configure CRA, Vite, or Next.js automatically
|
|
32
|
+
- ๐ข Start Express, Koa, Fastify, NestJS, or generic Node backends
|
|
33
|
+
- ๐ Provide the backend URL to the frontend at runtime
|
|
34
|
+
- ๐ก๏ธ Handle development CORS without changing application source
|
|
35
|
+
- ๐งน Stop both process trees cleanly when you press `Ctrl+C`
|
|
36
|
+
- ๐ฅ๏ธ Optionally open each app in its own VS Code/Cursor terminal
|
|
37
|
+
|
|
38
|
+
No application source files are rewritten.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
Your project should look like one of these:
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
my-project/
|
|
46
|
+
โโโ frontend/ # or client/
|
|
47
|
+
โ โโโ package.json
|
|
48
|
+
โโโ backend/ # or server/
|
|
49
|
+
โโโ package.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Run Ports Manager from `my-project`, not from inside either child:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
cd my-project
|
|
56
|
+
npx ports-manager
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Example output:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
Backend: express / npm run dev / 4000
|
|
63
|
+
Frontend: vite / npm run dev / 4001
|
|
64
|
+
CORS shim: enabled
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Ports Manager prefers `npm run dev`, then falls back to `npm start`.
|
|
68
|
+
|
|
69
|
+
## Common recipes
|
|
70
|
+
|
|
71
|
+
### Preview without starting anything
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npx ports-manager --dry-run
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Use custom folder names
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npx ports-manager \
|
|
81
|
+
--frontend-dir apps/web \
|
|
82
|
+
--backend-dir apps/api
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Choose a port range and ban extra ports
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx ports-manager --range 4100-4900 --ban 4200,4300
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Wait for the backend before starting the frontend
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npx ports-manager --wait-for-backend
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Use a same-origin development proxy
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx ports-manager --proxy --api-prefix /api
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Open separate VS Code/Cursor terminals
|
|
104
|
+
|
|
105
|
+
From an integrated terminal:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npx ports-manager --ide-terminals
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`auto` mode uses the Ports Manager extension bridge when available. Otherwise, it
|
|
112
|
+
creates dedicated VS Code tasks. Launch generated tasks through:
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
Ctrl/Cmd+Shift+P โ Tasks: Run Task โ Ports Manager: Run All
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
To generate a shortcut reference:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npx ports-manager --ide-terminals=tasks --with-keybinding
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
VS Code does not apply workspace keybindings automatically. Copy the generated
|
|
125
|
+
entry from `.vscode/ports-manager-keybindings.json` into **Keyboard Shortcuts
|
|
126
|
+
(JSON)**.
|
|
127
|
+
|
|
128
|
+
## Supported stacks
|
|
129
|
+
|
|
130
|
+
| Role | Supported detection | Port strategy |
|
|
131
|
+
|---|---|---|
|
|
132
|
+
| Frontend | Create React App | `PORT` environment variable |
|
|
133
|
+
| Frontend | Vite | `--port` + `--strictPort` |
|
|
134
|
+
| Frontend | Next.js | `-p` argument |
|
|
135
|
+
| Backend | Express, Koa, Fastify | `PORT` environment variable |
|
|
136
|
+
| Backend | NestJS | `PORT` environment variable, best effort |
|
|
137
|
+
| Either | Generic Node project | `PORT`, with a compatibility warning |
|
|
138
|
+
|
|
139
|
+
The backend must read `process.env.PORT`. Ports Manager warns when its static scan
|
|
140
|
+
cannot find that behavior or detects a possible literal `.listen(5000)`.
|
|
141
|
+
|
|
142
|
+
## CORS and API URLs
|
|
143
|
+
|
|
144
|
+
The development CORS shim is enabled by default. It is injected into the
|
|
145
|
+
backend through `NODE_OPTIONS` and:
|
|
146
|
+
|
|
147
|
+
- reflects the browser's `Origin`
|
|
148
|
+
- supports credentials
|
|
149
|
+
- handles `OPTIONS` preflight with `204`
|
|
150
|
+
- preserves the existing `Vary` header
|
|
151
|
+
|
|
152
|
+
If Ports Manager detects existing CORS middleware, it skips the shim. Override or
|
|
153
|
+
disable that behavior with:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npx ports-manager --force-cors
|
|
157
|
+
npx ports-manager --no-cors-shim
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Framework-specific API variables are supplied automatically:
|
|
161
|
+
|
|
162
|
+
- CRA: `REACT_APP_API_URL`
|
|
163
|
+
- Vite: `VITE_API_URL`
|
|
164
|
+
- Next.js: `NEXT_PUBLIC_API_URL`
|
|
165
|
+
|
|
166
|
+
> [!WARNING]
|
|
167
|
+
> The CORS shim is intentionally permissive and intended only for local
|
|
168
|
+
> development. It is not a production security policy.
|
|
169
|
+
|
|
170
|
+
## Configuration
|
|
171
|
+
|
|
172
|
+
Create `ports-manager.config.json` in the project root:
|
|
173
|
+
|
|
174
|
+
```json
|
|
175
|
+
{
|
|
176
|
+
"pairs": [
|
|
177
|
+
["frontend", "backend"],
|
|
178
|
+
["client", "server"]
|
|
179
|
+
],
|
|
180
|
+
"portRange": [4000, 4999],
|
|
181
|
+
"bannedPorts": [3000, 5000],
|
|
182
|
+
"apiPrefix": "/api",
|
|
183
|
+
"cors": {
|
|
184
|
+
"mode": "shim",
|
|
185
|
+
"credentials": true
|
|
186
|
+
},
|
|
187
|
+
"proxy": {
|
|
188
|
+
"enabled": false
|
|
189
|
+
},
|
|
190
|
+
"ideTerminals": {
|
|
191
|
+
"mode": "off",
|
|
192
|
+
"withKeybinding": false
|
|
193
|
+
},
|
|
194
|
+
"env": {
|
|
195
|
+
"frontend": {
|
|
196
|
+
"CUSTOM_API_URL": "http://127.0.0.1:{backendPort}"
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Environment values can use `{frontendPort}`, `{backendPort}`, `{proxyPort}`,
|
|
203
|
+
and `{apiPrefix}` placeholders. CLI flags override configuration values.
|
|
204
|
+
|
|
205
|
+
## CLI reference
|
|
206
|
+
|
|
207
|
+
```text
|
|
208
|
+
--frontend-dir PATH Override frontend directory
|
|
209
|
+
--backend-dir PATH Override backend directory
|
|
210
|
+
--frontend-port PORT Request a specific frontend port
|
|
211
|
+
--backend-port PORT Request a specific backend port
|
|
212
|
+
--proxy-port PORT Request a specific proxy port
|
|
213
|
+
--range MIN-MAX Port search range
|
|
214
|
+
--ban PORTS Comma-separated banned ports
|
|
215
|
+
--no-cors-shim Disable the development CORS shim
|
|
216
|
+
--force-cors Override detected CORS middleware
|
|
217
|
+
--proxy Enable the same-origin proxy
|
|
218
|
+
--api-prefix PATH Backend proxy prefix
|
|
219
|
+
--wait-for-backend[=MS] Wait for the backend to listen
|
|
220
|
+
--ide-terminals[=MODE] auto, off, tasks, or extension
|
|
221
|
+
--with-keybinding Generate a shortcut reference
|
|
222
|
+
--config PATH Use an explicit configuration file
|
|
223
|
+
--dry-run Print the plan without writing or starting
|
|
224
|
+
--stop Stop bridge-managed workspace terminals
|
|
225
|
+
--version, -v Show version and author
|
|
226
|
+
--help, -h Show all options
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Run `npx ports-manager --help` for the canonical list.
|
|
230
|
+
|
|
231
|
+
## VS Code/Cursor extension
|
|
232
|
+
|
|
233
|
+
This repository is one package that produces both the npm CLI and an optional
|
|
234
|
+
VSIX:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
npm install
|
|
238
|
+
npm run package:vsix
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Install `ports-manager-1.0.0.vsix` in VS Code or Cursor. The bridge listens
|
|
242
|
+
only on `127.0.0.1`, requires a random bearer token, validates request data,
|
|
243
|
+
and tracks terminals by workspace.
|
|
244
|
+
|
|
245
|
+
Stop managed terminals with:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
npx ports-manager --stop
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
or run **Ports Manager: Stop All Managed Terminals** from the command palette.
|
|
252
|
+
|
|
253
|
+
## Limitations
|
|
254
|
+
|
|
255
|
+
- Port checks cannot reserve a port until the framework binds to it.
|
|
256
|
+
- Framework, CORS, and hardcoded-port scans are heuristic.
|
|
257
|
+
- Backends that ignore `process.env.PORT` cannot be redirected without code changes.
|
|
258
|
+
- IDE task mode needs one manual task launch unless the extension bridge is installed.
|
|
259
|
+
- Workspaces/monorepos and non-VS-Code-family IDEs are not currently supported.
|
|
260
|
+
|
|
261
|
+
## Development
|
|
262
|
+
|
|
263
|
+
```bash
|
|
264
|
+
npm install
|
|
265
|
+
npm test
|
|
266
|
+
npm run test:cli
|
|
267
|
+
npm run test:bridge
|
|
268
|
+
npm run package:vsix
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
<p align="center">
|
|
274
|
+
Built by <strong>Muhammad Saad Amin</strong> โ <strong>SENODROOM</strong><br>
|
|
275
|
+
Released under the MIT License.
|
|
276
|
+
</p>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as vscode from 'vscode';
|
|
2
|
+
import * as crypto from 'node:crypto';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import * as path from 'node:path';
|
|
6
|
+
import { BridgeServer, TerminalHost, TerminalSpec } from './server';
|
|
7
|
+
|
|
8
|
+
let bridge: BridgeServer | undefined;
|
|
9
|
+
let descriptorFile: string | undefined;
|
|
10
|
+
let activeToken: string | undefined;
|
|
11
|
+
|
|
12
|
+
function writeDescriptor(port: number, token: string): string {
|
|
13
|
+
const directory = path.join(os.homedir(), '.ports-manager');
|
|
14
|
+
const filename = path.join(directory, 'bridge.json');
|
|
15
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
16
|
+
try {
|
|
17
|
+
fs.chmodSync(directory, 0o700);
|
|
18
|
+
} catch {
|
|
19
|
+
// Windows ACLs do not map directly to POSIX modes.
|
|
20
|
+
}
|
|
21
|
+
const temporary = path.join(directory, `.bridge-${process.pid}-${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
22
|
+
const payload = JSON.stringify({
|
|
23
|
+
endpoint: `http://127.0.0.1:${port}/`,
|
|
24
|
+
token,
|
|
25
|
+
pid: process.pid
|
|
26
|
+
});
|
|
27
|
+
fs.writeFileSync(temporary, payload, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
28
|
+
try {
|
|
29
|
+
fs.renameSync(temporary, filename);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (process.platform !== 'win32') {
|
|
32
|
+
fs.rmSync(temporary, { force: true });
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
fs.rmSync(filename, { force: true });
|
|
36
|
+
fs.renameSync(temporary, filename);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
fs.chmodSync(filename, 0o600);
|
|
40
|
+
} catch {
|
|
41
|
+
// Best effort on Windows.
|
|
42
|
+
}
|
|
43
|
+
return filename;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function removeOwnDescriptor(): void {
|
|
47
|
+
if (!descriptorFile || !activeToken) return;
|
|
48
|
+
try {
|
|
49
|
+
const current = JSON.parse(fs.readFileSync(descriptorFile, 'utf8')) as { token?: string };
|
|
50
|
+
if (current.token === activeToken) fs.rmSync(descriptorFile, { force: true });
|
|
51
|
+
} catch {
|
|
52
|
+
// Already removed or replaced.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function activate(context: vscode.ExtensionContext): Promise<void> {
|
|
57
|
+
const token = crypto.randomBytes(32).toString('hex');
|
|
58
|
+
activeToken = token;
|
|
59
|
+
const host: TerminalHost = {
|
|
60
|
+
create(spec: TerminalSpec) {
|
|
61
|
+
return vscode.window.createTerminal({
|
|
62
|
+
name: spec.name,
|
|
63
|
+
cwd: spec.cwd,
|
|
64
|
+
env: spec.env,
|
|
65
|
+
isTransient: true
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
bridge = new BridgeServer(token, host);
|
|
70
|
+
try {
|
|
71
|
+
const port = await bridge.listen();
|
|
72
|
+
descriptorFile = writeDescriptor(port, token);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
await bridge.close();
|
|
75
|
+
bridge = undefined;
|
|
76
|
+
activeToken = undefined;
|
|
77
|
+
vscode.window.showErrorMessage(`Ports Manager bridge failed to start: ${error instanceof Error ? error.message : error}`);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
context.subscriptions.push(
|
|
82
|
+
vscode.window.onDidCloseTerminal((terminal) => bridge?.forget(terminal)),
|
|
83
|
+
vscode.commands.registerCommand('ports-manager.stopAll', () => {
|
|
84
|
+
const count = bridge?.stopAll() || 0;
|
|
85
|
+
vscode.window.showInformationMessage(`Stopped ${count} Ports Manager terminal${count === 1 ? '' : 's'}.`);
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function deactivate(): Promise<void> {
|
|
91
|
+
removeOwnDescriptor();
|
|
92
|
+
if (bridge) await bridge.close();
|
|
93
|
+
bridge = undefined;
|
|
94
|
+
activeToken = undefined;
|
|
95
|
+
descriptorFile = undefined;
|
|
96
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import * as http from 'node:http';
|
|
2
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export interface TerminalSpec {
|
|
5
|
+
name: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
command: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ManagedTerminal {
|
|
12
|
+
show(preserveFocus?: boolean): void;
|
|
13
|
+
sendText(text: string, addNewLine?: boolean): void;
|
|
14
|
+
dispose(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TerminalHost {
|
|
18
|
+
create(spec: TerminalSpec): ManagedTerminal;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface LaunchBody {
|
|
22
|
+
tag: string;
|
|
23
|
+
terminals: TerminalSpec[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface StopBody {
|
|
27
|
+
tag: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const MAX_BODY = 64 * 1024;
|
|
31
|
+
|
|
32
|
+
function plainObject(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function shortString(value: unknown, maximum = 4096): value is string {
|
|
37
|
+
return typeof value === 'string' && value.length > 0 && value.length <= maximum;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function validateLaunchBody(value: unknown): LaunchBody {
|
|
41
|
+
if (!plainObject(value) || !shortString(value.tag, 1024) ||
|
|
42
|
+
!Array.isArray(value.terminals) || value.terminals.length < 1 || value.terminals.length > 8) {
|
|
43
|
+
throw new Error('invalid terminal launch body');
|
|
44
|
+
}
|
|
45
|
+
const terminals = value.terminals.map((candidate): TerminalSpec => {
|
|
46
|
+
if (!plainObject(candidate) || !shortString(candidate.name, 200) ||
|
|
47
|
+
!shortString(candidate.cwd, 4096) || !shortString(candidate.command, 16384) ||
|
|
48
|
+
!plainObject(candidate.env)) {
|
|
49
|
+
throw new Error('invalid terminal specification');
|
|
50
|
+
}
|
|
51
|
+
const env: Record<string, string> = {};
|
|
52
|
+
for (const [key, item] of Object.entries(candidate.env)) {
|
|
53
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof item !== 'string' || item.length > 16384) {
|
|
54
|
+
throw new Error('invalid terminal environment');
|
|
55
|
+
}
|
|
56
|
+
env[key] = item;
|
|
57
|
+
}
|
|
58
|
+
return { name: candidate.name, cwd: candidate.cwd, command: candidate.command, env };
|
|
59
|
+
});
|
|
60
|
+
return { tag: value.tag, terminals };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateStopBody(value: unknown): StopBody {
|
|
64
|
+
if (!plainObject(value) || !shortString(value.tag, 1024)) throw new Error('invalid stop body');
|
|
65
|
+
return { tag: value.tag };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isLoopback(address: string | undefined): boolean {
|
|
69
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function authorized(header: string | undefined, token: string): boolean {
|
|
73
|
+
if (!header?.startsWith('Bearer ')) return false;
|
|
74
|
+
const supplied = Buffer.from(header.slice(7));
|
|
75
|
+
const expected = Buffer.from(token);
|
|
76
|
+
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function readJson(request: http.IncomingMessage): Promise<unknown> {
|
|
80
|
+
if (!String(request.headers['content-type'] || '').toLowerCase().startsWith('application/json')) {
|
|
81
|
+
throw new Error('content-type must be application/json');
|
|
82
|
+
}
|
|
83
|
+
const chunks: Buffer[] = [];
|
|
84
|
+
let size = 0;
|
|
85
|
+
for await (const chunk of request) {
|
|
86
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
87
|
+
size += buffer.length;
|
|
88
|
+
if (size > MAX_BODY) throw new Error('request body too large');
|
|
89
|
+
chunks.push(buffer);
|
|
90
|
+
}
|
|
91
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function send(response: http.ServerResponse, status: number, body: object): void {
|
|
95
|
+
const payload = JSON.stringify(body);
|
|
96
|
+
response.writeHead(status, {
|
|
97
|
+
'Content-Type': 'application/json',
|
|
98
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
99
|
+
'Cache-Control': 'no-store'
|
|
100
|
+
});
|
|
101
|
+
response.end(payload);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class BridgeServer {
|
|
105
|
+
private server?: http.Server;
|
|
106
|
+
private readonly tracked = new Map<string, Set<ManagedTerminal>>();
|
|
107
|
+
|
|
108
|
+
constructor(private readonly token: string, private readonly host: TerminalHost) {}
|
|
109
|
+
|
|
110
|
+
async listen(): Promise<number> {
|
|
111
|
+
if (this.server) throw new Error('bridge already listening');
|
|
112
|
+
this.server = http.createServer((request, response) => void this.handle(request, response));
|
|
113
|
+
await new Promise<void>((resolve, reject) => {
|
|
114
|
+
this.server!.once('error', reject);
|
|
115
|
+
this.server!.listen(0, '127.0.0.1', resolve);
|
|
116
|
+
});
|
|
117
|
+
const address = this.server.address();
|
|
118
|
+
if (!address || typeof address === 'string') throw new Error('bridge failed to bind');
|
|
119
|
+
return address.port;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async handle(request: http.IncomingMessage, response: http.ServerResponse): Promise<void> {
|
|
123
|
+
try {
|
|
124
|
+
if (!isLoopback(request.socket.remoteAddress)) return send(response, 403, { error: 'loopback only' });
|
|
125
|
+
if (!authorized(request.headers.authorization, this.token)) return send(response, 401, { error: 'unauthorized' });
|
|
126
|
+
if (request.method !== 'POST') return send(response, 405, { error: 'POST required' });
|
|
127
|
+
const body = await readJson(request);
|
|
128
|
+
if (request.url === '/terminals') {
|
|
129
|
+
const launch = validateLaunchBody(body);
|
|
130
|
+
this.stopTag(launch.tag);
|
|
131
|
+
const terminals = new Set<ManagedTerminal>();
|
|
132
|
+
try {
|
|
133
|
+
for (const spec of launch.terminals) {
|
|
134
|
+
const terminal = this.host.create(spec);
|
|
135
|
+
terminals.add(terminal);
|
|
136
|
+
terminal.show(true);
|
|
137
|
+
terminal.sendText(spec.command, true);
|
|
138
|
+
}
|
|
139
|
+
} catch (error) {
|
|
140
|
+
for (const terminal of terminals) terminal.dispose();
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
this.tracked.set(launch.tag, terminals);
|
|
144
|
+
return send(response, 201, { created: terminals.size });
|
|
145
|
+
}
|
|
146
|
+
if (request.url === '/stop') {
|
|
147
|
+
const stop = validateStopBody(body);
|
|
148
|
+
const stopped = this.stopTag(stop.tag);
|
|
149
|
+
return send(response, 200, { stopped });
|
|
150
|
+
}
|
|
151
|
+
return send(response, 404, { error: 'not found' });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return send(response, 400, { error: error instanceof Error ? error.message : 'bad request' });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
forget(terminal: ManagedTerminal): void {
|
|
158
|
+
for (const [tag, terminals] of this.tracked) {
|
|
159
|
+
terminals.delete(terminal);
|
|
160
|
+
if (!terminals.size) this.tracked.delete(tag);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
stopTag(tag: string): number {
|
|
165
|
+
const terminals = this.tracked.get(tag);
|
|
166
|
+
if (!terminals) return 0;
|
|
167
|
+
this.tracked.delete(tag);
|
|
168
|
+
for (const terminal of terminals) terminal.dispose();
|
|
169
|
+
return terminals.size;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
stopAll(): number {
|
|
173
|
+
let count = 0;
|
|
174
|
+
for (const tag of [...this.tracked.keys()]) count += this.stopTag(tag);
|
|
175
|
+
return count;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async close(): Promise<void> {
|
|
179
|
+
this.stopAll();
|
|
180
|
+
if (!this.server) return;
|
|
181
|
+
const server = this.server;
|
|
182
|
+
this.server = undefined;
|
|
183
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
184
|
+
}
|
|
185
|
+
}
|