hoversource 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/dist/bootstrap.d.ts +1 -0
- package/dist/bootstrap.js +75 -0
- package/dist/cert-ca.d.ts +19 -0
- package/dist/cert-ca.js +180 -0
- package/dist/cert.d.ts +15 -0
- package/dist/cert.js +76 -0
- package/dist/cli.d.ts +22 -0
- package/dist/cli.js +770 -0
- package/dist/custom-jsx-dev-runtime-cjs.cjs +21 -0
- package/dist/custom-jsx-dev-runtime.d.ts +3 -0
- package/dist/custom-jsx-dev-runtime.js +21 -0
- package/dist/custom-jsx-runtime-cjs.cjs +76 -0
- package/dist/custom-jsx-runtime.d.ts +3 -0
- package/dist/custom-jsx-runtime.js +72 -0
- package/dist/launcher/ElectronCdpLauncher.d.ts +4 -0
- package/dist/launcher/ElectronCdpLauncher.js +63 -0
- package/dist/launcher/WebProxyLauncher.d.ts +5 -0
- package/dist/launcher/WebProxyLauncher.js +114 -0
- package/dist/launcher/types.d.ts +10 -0
- package/dist/launcher/types.js +1 -0
- package/dist/loader.d.ts +1 -0
- package/dist/loader.js +31 -0
- package/dist/patcher/ReactRuntimePatcher.d.ts +5 -0
- package/dist/patcher/ReactRuntimePatcher.js +263 -0
- package/dist/patcher/types.d.ts +4 -0
- package/dist/patcher/types.js +1 -0
- package/dist/pipeline.d.ts +14 -0
- package/dist/pipeline.js +45 -0
- package/dist/port.d.ts +76 -0
- package/dist/port.js +560 -0
- package/dist/proxy.d.ts +16 -0
- package/dist/proxy.js +220 -0
- package/dist/utils/patchState.d.ts +3 -0
- package/dist/utils/patchState.js +59 -0
- package/package.json +27 -0
package/dist/port.js
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port resolution module for HoverSource CLI.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates all port-checking, port-finding, and conflict-resolution logic.
|
|
5
|
+
* The two main entry points are:
|
|
6
|
+
* - `resolveDevServerPort()` — ensures the dev port is free and returns env/args to inject
|
|
7
|
+
* - `resolveAllPorts()` — one-shot collision-free resolution of all ports HS needs
|
|
8
|
+
*/
|
|
9
|
+
import { exec } from "node:child_process";
|
|
10
|
+
import net from "node:net";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import readline from "node:readline";
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Low-level primitives (moved from cli.ts)
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
export function probeHostPort(port, host) {
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
const server = net.createServer();
|
|
22
|
+
server.unref();
|
|
23
|
+
server.on("error", (err) => {
|
|
24
|
+
if (err.code === "EADDRINUSE") {
|
|
25
|
+
resolve(false);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
// Other errors (e.g. EADDRNOTAVAIL for IPv6 on some systems) do not mean the port is in use
|
|
29
|
+
resolve(true);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
server.listen(port, host, () => {
|
|
33
|
+
server.close();
|
|
34
|
+
resolve(true);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export async function isPortFree(port) {
|
|
39
|
+
if (!await probeHostPort(port, "127.0.0.1"))
|
|
40
|
+
return false;
|
|
41
|
+
if (!await probeHostPort(port, "localhost"))
|
|
42
|
+
return false;
|
|
43
|
+
if (!await probeHostPort(port, "::1"))
|
|
44
|
+
return false;
|
|
45
|
+
if (!await probeHostPort(port, "0.0.0.0"))
|
|
46
|
+
return false;
|
|
47
|
+
if (!await probeHostPort(port, "::"))
|
|
48
|
+
return false;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
export async function findFreePort(startPort, excludePorts, maxPort = 65535) {
|
|
52
|
+
let excludeArray = [];
|
|
53
|
+
if (excludePorts !== undefined) {
|
|
54
|
+
excludeArray = Array.isArray(excludePorts) ? excludePorts : [excludePorts];
|
|
55
|
+
}
|
|
56
|
+
const excluded = new Set(excludeArray);
|
|
57
|
+
let port = startPort;
|
|
58
|
+
while (port <= maxPort) {
|
|
59
|
+
if (excluded.has(port)) {
|
|
60
|
+
port++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (await isPortFree(port)) {
|
|
64
|
+
return port;
|
|
65
|
+
}
|
|
66
|
+
port++;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`[HoverSource] No free port found between ${startPort} and ${maxPort}. ` +
|
|
69
|
+
`Close some applications or specify a port manually with --port=<port>.`);
|
|
70
|
+
}
|
|
71
|
+
export function getPidUsingPort(port) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
if (process.platform === "win32") {
|
|
74
|
+
exec(`netstat -ano | findstr :${port}`, (err, stdout) => {
|
|
75
|
+
if (err || !stdout)
|
|
76
|
+
return resolve(undefined);
|
|
77
|
+
const lines = stdout.split("\n");
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
if (line.includes("LISTENING")) {
|
|
80
|
+
const parts = line.trim().split(/\s+/);
|
|
81
|
+
const pidStr = parts[parts.length - 1];
|
|
82
|
+
const pid = Number.parseInt(pidStr, 10);
|
|
83
|
+
if (!Number.isNaN(pid) && pid > 0) {
|
|
84
|
+
return resolve(pid);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
resolve(undefined);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
exec(`lsof -t -i:${port}`, (err, stdout) => {
|
|
93
|
+
if (err || !stdout)
|
|
94
|
+
return resolve(undefined);
|
|
95
|
+
const pid = Number.parseInt(stdout.trim(), 10);
|
|
96
|
+
if (!Number.isNaN(pid) && pid > 0) {
|
|
97
|
+
resolve(pid);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
resolve(undefined);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
export function getProcessName(pid) {
|
|
107
|
+
return new Promise((resolve) => {
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, (err, stdout) => {
|
|
110
|
+
if (err || !stdout)
|
|
111
|
+
return resolve(undefined);
|
|
112
|
+
if (stdout.includes("No tasks are running")) {
|
|
113
|
+
return resolve(undefined);
|
|
114
|
+
}
|
|
115
|
+
const parts = stdout.trim().split(",");
|
|
116
|
+
if (parts[0]) {
|
|
117
|
+
const name = parts[0].replaceAll('"', "");
|
|
118
|
+
return resolve(name);
|
|
119
|
+
}
|
|
120
|
+
resolve(undefined);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
exec(`ps -p ${pid} -o comm=`, (err, stdout) => {
|
|
125
|
+
if (err || !stdout)
|
|
126
|
+
return resolve(undefined);
|
|
127
|
+
resolve(stdout.trim());
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
function killProcessWindowsUac(pid, port) {
|
|
133
|
+
return new Promise((resolve) => {
|
|
134
|
+
console.log(`[HoverSource] Normal termination failed. Requesting Administrator elevation (UAC)...`);
|
|
135
|
+
const tempFile = path.join(os.tmpdir(), `hs_taskkill_${pid}.log`);
|
|
136
|
+
if (fs.existsSync(tempFile)) {
|
|
137
|
+
try {
|
|
138
|
+
fs.unlinkSync(tempFile);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
console.debug("[HoverSource] Failed to delete existing temp file:", err);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const elevatorCmd = String.raw `powershell -Command "Start-Process cmd.exe -ArgumentList '/c taskkill /F /PID ${pid} > \"${tempFile}\" 2>&1' -Verb RunAs -WindowStyle Hidden"`;
|
|
145
|
+
exec(elevatorCmd, (elevatorErr) => {
|
|
146
|
+
if (elevatorErr) {
|
|
147
|
+
console.error(`[HoverSource] UAC request canceled or failed: ${elevatorErr.message}`);
|
|
148
|
+
resolve(false);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Poll the port to see if it frees up
|
|
152
|
+
let checks = 0;
|
|
153
|
+
const checkInterval = setInterval(async () => {
|
|
154
|
+
const free = await isPortFree(port);
|
|
155
|
+
checks++;
|
|
156
|
+
if (free) {
|
|
157
|
+
clearInterval(checkInterval);
|
|
158
|
+
try {
|
|
159
|
+
fs.unlinkSync(tempFile);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
console.debug("[HoverSource] Temp file delete ignored:", err);
|
|
163
|
+
}
|
|
164
|
+
resolve(true);
|
|
165
|
+
}
|
|
166
|
+
else if (checks > 20) { // 4 seconds total
|
|
167
|
+
clearInterval(checkInterval);
|
|
168
|
+
// Read temp file for failure reasons
|
|
169
|
+
let errorDetails = "";
|
|
170
|
+
if (fs.existsSync(tempFile)) {
|
|
171
|
+
try {
|
|
172
|
+
errorDetails = fs.readFileSync(tempFile, "utf-8").trim();
|
|
173
|
+
fs.unlinkSync(tempFile);
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
console.debug("[HoverSource] Failed to read/delete temp file:", err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (errorDetails) {
|
|
180
|
+
console.error(`\x1b[31m[HoverSource] UAC Taskkill failed: ${errorDetails}\x1b[0m`);
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
console.error(`\x1b[31m[HoverSource] UAC Taskkill failed or was canceled by the user.\x1b[0m`);
|
|
184
|
+
}
|
|
185
|
+
resolve(false);
|
|
186
|
+
}
|
|
187
|
+
}, 200);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
export function killProcess(pid, port) {
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const cmd = process.platform === "win32" ? `taskkill /F /PID ${pid}` : `kill -9 ${pid}`;
|
|
195
|
+
exec(cmd, (err, _stdout, stderr) => {
|
|
196
|
+
if (!err) {
|
|
197
|
+
// Normal kill reported success, but let's double check the port is free
|
|
198
|
+
let checks = 0;
|
|
199
|
+
const checkInterval = setInterval(async () => {
|
|
200
|
+
const free = await isPortFree(port);
|
|
201
|
+
checks++;
|
|
202
|
+
if (free) {
|
|
203
|
+
clearInterval(checkInterval);
|
|
204
|
+
resolve(true);
|
|
205
|
+
}
|
|
206
|
+
else if (checks > 10) {
|
|
207
|
+
clearInterval(checkInterval);
|
|
208
|
+
console.error(`[HoverSource] Normal kill reported success but port is still occupied.`);
|
|
209
|
+
if (stderr)
|
|
210
|
+
console.error(`[HoverSource] Details: ${stderr.trim()}`);
|
|
211
|
+
resolve(false);
|
|
212
|
+
}
|
|
213
|
+
}, 200);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// If failed on Windows, try elevating with UAC
|
|
217
|
+
if (process.platform === "win32") {
|
|
218
|
+
killProcessWindowsUac(pid, port).then(resolve);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
if (stderr)
|
|
222
|
+
console.error(`[HoverSource] Details: ${stderr.trim()}`);
|
|
223
|
+
resolve(false);
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
export function askQuestion(query) {
|
|
229
|
+
const rl = readline.createInterface({
|
|
230
|
+
input: process.stdin,
|
|
231
|
+
output: process.stdout,
|
|
232
|
+
});
|
|
233
|
+
return new Promise((resolve) => rl.question(query, (ans) => {
|
|
234
|
+
rl.close();
|
|
235
|
+
resolve(ans);
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
export function isZombieOfProject(pid, projectRoot) {
|
|
239
|
+
return new Promise((resolve) => {
|
|
240
|
+
const normalizedRoot = path.normalize(projectRoot).toLowerCase();
|
|
241
|
+
if (process.platform === "win32") {
|
|
242
|
+
exec(`wmic process where processid=${pid} get CommandLine /format:list`, (err, stdout) => {
|
|
243
|
+
if (err || !stdout) {
|
|
244
|
+
exec(`powershell -Command "Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine"`, (psErr, psStdout) => {
|
|
245
|
+
if (psErr || !psStdout)
|
|
246
|
+
return resolve(false);
|
|
247
|
+
const cmd = psStdout.trim().toLowerCase();
|
|
248
|
+
resolve(cmd.includes(normalizedRoot));
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const cmd = stdout.trim().toLowerCase();
|
|
253
|
+
resolve(cmd.includes(normalizedRoot));
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
exec(`ps -p ${pid} -o args=`, (err, stdout) => {
|
|
258
|
+
if (!err && stdout?.toLowerCase().includes(normalizedRoot)) {
|
|
259
|
+
return resolve(true);
|
|
260
|
+
}
|
|
261
|
+
exec(`lsof -a -d cwd -p ${pid} -fn`, (lsofErr, lsofStdout) => {
|
|
262
|
+
if (lsofErr || !lsofStdout)
|
|
263
|
+
return resolve(false);
|
|
264
|
+
resolve(lsofStdout.toLowerCase().includes(normalizedRoot));
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Companion port resolution (moved from cli.ts)
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
/**
|
|
274
|
+
* If the requested port is occupied by another HoverSource instance, tell it
|
|
275
|
+
* to shut down and wait for the port to free up so we can reuse it.
|
|
276
|
+
* Returns the port we should actually bind to.
|
|
277
|
+
*/
|
|
278
|
+
export async function resolveCompanionPort(requestedPort, targetPort) {
|
|
279
|
+
// If requestedPort collides with targetPort, shift it to next free port starting from 7300 upwards (excluding targetPort)
|
|
280
|
+
if (targetPort !== undefined && requestedPort === targetPort) {
|
|
281
|
+
console.log(`[HoverSource] Requested companion port ${requestedPort} conflicts with target application port ${targetPort}. Shifting...`);
|
|
282
|
+
return findFreePort(7300, targetPort);
|
|
283
|
+
}
|
|
284
|
+
// Try to bind immediately — port is free
|
|
285
|
+
const free = await isPortFree(requestedPort);
|
|
286
|
+
if (free)
|
|
287
|
+
return requestedPort;
|
|
288
|
+
// Port taken — check if it's an HS companion
|
|
289
|
+
const isHs = await new Promise((resolve) => {
|
|
290
|
+
const req = http.get(`http://127.0.0.1:${requestedPort}/ping`, (res) => {
|
|
291
|
+
let body = "";
|
|
292
|
+
res.on("data", (c) => (body += c.toString()));
|
|
293
|
+
res.on("end", () => resolve(body.trim() === "pong"));
|
|
294
|
+
});
|
|
295
|
+
req.setTimeout(1500, () => {
|
|
296
|
+
req.destroy();
|
|
297
|
+
resolve(false);
|
|
298
|
+
});
|
|
299
|
+
req.on("error", () => resolve(false));
|
|
300
|
+
});
|
|
301
|
+
if (isHs) {
|
|
302
|
+
console.log(`[HoverSource] Previous instance found on port ${requestedPort}. Taking over...`);
|
|
303
|
+
await new Promise((resolve) => {
|
|
304
|
+
const req = http.get(`http://127.0.0.1:${requestedPort}/shutdown`, () => resolve());
|
|
305
|
+
req.setTimeout(1500, () => {
|
|
306
|
+
req.destroy();
|
|
307
|
+
resolve();
|
|
308
|
+
});
|
|
309
|
+
req.on("error", () => resolve());
|
|
310
|
+
});
|
|
311
|
+
// Wait for the port to free up
|
|
312
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
313
|
+
// Double check if the port was successfully freed
|
|
314
|
+
const isNowFree = await isPortFree(requestedPort);
|
|
315
|
+
if (isNowFree) {
|
|
316
|
+
return requestedPort;
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
console.log(`[HoverSource] Previous instance on port ${requestedPort} could not be terminated (ghost port).`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Something else owns this port — find the next free one
|
|
323
|
+
console.log(`[HoverSource] Port ${requestedPort} in use by another process, using next free port.`);
|
|
324
|
+
return findFreePort(requestedPort + 1, targetPort);
|
|
325
|
+
}
|
|
326
|
+
function detectFrameworkFromScript(scriptCmd) {
|
|
327
|
+
if (scriptCmd.includes("vite") && !scriptCmd.includes("electron"))
|
|
328
|
+
return "vite";
|
|
329
|
+
if (scriptCmd.includes("next"))
|
|
330
|
+
return "next";
|
|
331
|
+
if (scriptCmd.includes("nuxt"))
|
|
332
|
+
return "nuxt";
|
|
333
|
+
if (scriptCmd.includes("ng ") || scriptCmd.includes("@angular"))
|
|
334
|
+
return "angular";
|
|
335
|
+
if (scriptCmd.includes("react-scripts"))
|
|
336
|
+
return "cra";
|
|
337
|
+
if (scriptCmd.includes("webpack"))
|
|
338
|
+
return "webpack";
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
function detectFrameworkFromDeps(allDeps) {
|
|
342
|
+
if ("vite" in allDeps || "@sveltejs/kit" in allDeps)
|
|
343
|
+
return "vite";
|
|
344
|
+
if ("next" in allDeps)
|
|
345
|
+
return "next";
|
|
346
|
+
if ("nuxt" in allDeps)
|
|
347
|
+
return "nuxt";
|
|
348
|
+
if ("@angular/cli" in allDeps || "@angular/core" in allDeps)
|
|
349
|
+
return "angular";
|
|
350
|
+
if ("react-scripts" in allDeps)
|
|
351
|
+
return "cra";
|
|
352
|
+
if ("webpack-dev-server" in allDeps)
|
|
353
|
+
return "webpack";
|
|
354
|
+
return "generic";
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Detect which framework the project uses, to know how to inject the port.
|
|
358
|
+
*/
|
|
359
|
+
function detectFramework(projectRoot, execCommand) {
|
|
360
|
+
const pkgPath = path.join(projectRoot, "package.json");
|
|
361
|
+
let allDeps = {};
|
|
362
|
+
let scriptCmd = "";
|
|
363
|
+
try {
|
|
364
|
+
if (fs.existsSync(pkgPath)) {
|
|
365
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
366
|
+
allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
367
|
+
if (execCommand) {
|
|
368
|
+
const npmRunMatch = /^(?:npm|yarn|pnpm|bun)\s+(?:run\s+)?([^\s]+)/.exec(execCommand);
|
|
369
|
+
scriptCmd = (npmRunMatch && pkg.scripts?.[npmRunMatch[1]])
|
|
370
|
+
? pkg.scripts[npmRunMatch[1]].toLowerCase()
|
|
371
|
+
: execCommand.toLowerCase();
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
catch { }
|
|
376
|
+
const fromScript = detectFrameworkFromScript(scriptCmd);
|
|
377
|
+
if (fromScript)
|
|
378
|
+
return fromScript;
|
|
379
|
+
return detectFrameworkFromDeps(allDeps);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Build the env vars and extra CLI args needed to tell a specific framework
|
|
383
|
+
* to use a particular port.
|
|
384
|
+
*/
|
|
385
|
+
function buildPortInjection(framework, port) {
|
|
386
|
+
// PORT env is a universal fallback — most Node frameworks read it
|
|
387
|
+
const env = { PORT: String(port) };
|
|
388
|
+
const extraArgs = [];
|
|
389
|
+
switch (framework) {
|
|
390
|
+
case "vite":
|
|
391
|
+
// Vite: PORT env is not read by default; it needs --port or VITE_PORT hack.
|
|
392
|
+
// However, if vite.config.ts hardcodes `server.port`, only --port overrides it.
|
|
393
|
+
// We don't append --port to the user's command directly (they might use npm run dev),
|
|
394
|
+
// so we inject via env. Vite 5+ respects `--port` in the script, and we also set PORT.
|
|
395
|
+
env.VITE_PORT = String(port);
|
|
396
|
+
break;
|
|
397
|
+
case "next":
|
|
398
|
+
// Next.js reads PORT env natively since v13+
|
|
399
|
+
break;
|
|
400
|
+
case "nuxt":
|
|
401
|
+
// Nuxt reads PORT and NUXT_PORT
|
|
402
|
+
env.NUXT_PORT = String(port);
|
|
403
|
+
break;
|
|
404
|
+
case "angular":
|
|
405
|
+
// Angular CLI doesn't read PORT env, but we set it anyway for NODE_OPTIONS.
|
|
406
|
+
// The real mechanism is the --port flag, but that requires modifying the npm script.
|
|
407
|
+
break;
|
|
408
|
+
case "cra":
|
|
409
|
+
// CRA reads PORT env natively
|
|
410
|
+
break;
|
|
411
|
+
case "webpack":
|
|
412
|
+
// webpack-dev-server reads --port but not PORT env by default.
|
|
413
|
+
// Setting PORT anyway since many setups use it via custom scripts.
|
|
414
|
+
break;
|
|
415
|
+
case "generic":
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
return { env, extraArgs };
|
|
419
|
+
}
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Dev server port conflict resolution
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
async function handleElectronBlocker(pid, procName, expectedPort, autoResolve, framework) {
|
|
424
|
+
console.warn(`\n\x1b[33m[HoverSource] ⚠️ Dev server port ${expectedPort} is occupied by: ${procName || "Unknown"} (PID: ${pid})\x1b[0m`);
|
|
425
|
+
console.warn(`\x1b[33m[HoverSource] Electron projects require the original port — port shifting is not possible.\x1b[0m`);
|
|
426
|
+
let shouldKill = autoResolve;
|
|
427
|
+
if (shouldKill) {
|
|
428
|
+
console.log(`[HoverSource] --auto-resolve: Automatically terminating blocker on port ${expectedPort}...`);
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
|
|
432
|
+
if (isInteractive) {
|
|
433
|
+
const answer = await askQuestion(`\x1b[36m[HoverSource] Terminate this process to free port ${expectedPort}? (Y/n): \x1b[0m`);
|
|
434
|
+
shouldKill = answer.trim().toLowerCase() !== "n";
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
shouldKill = true;
|
|
438
|
+
console.log(`[HoverSource] Non-interactive mode. Automatically terminating blocker on port ${expectedPort}...`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
if (shouldKill) {
|
|
442
|
+
console.log(`[HoverSource] Terminating process ${pid}...`);
|
|
443
|
+
const success = await killProcess(pid, expectedPort);
|
|
444
|
+
if (success) {
|
|
445
|
+
console.log(`[HoverSource] Port ${expectedPort} is now free.`);
|
|
446
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
447
|
+
const injection = buildPortInjection(framework, expectedPort);
|
|
448
|
+
return { port: expectedPort, ...injection };
|
|
449
|
+
}
|
|
450
|
+
console.error(`\x1b[31m[HoverSource] Failed to free port ${expectedPort}. The dev server will likely fail.\x1b[0m`);
|
|
451
|
+
console.error(`[HoverSource] Please close the process manually or run as administrator.`);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
console.warn(`\x1b[33m[HoverSource] Proceeding without freeing port ${expectedPort}. The dev server will likely fail.\x1b[0m`);
|
|
455
|
+
}
|
|
456
|
+
const injection = buildPortInjection(framework, expectedPort);
|
|
457
|
+
return { port: expectedPort, ...injection };
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Ensures the dev server port is free before the app launches.
|
|
461
|
+
*
|
|
462
|
+
* Two distinct strategies based on mode:
|
|
463
|
+
* - **web**: Shift to next free port + inject PORT/VITE_PORT env vars. Works because
|
|
464
|
+
* web frameworks read these env vars to determine which port to bind.
|
|
465
|
+
* - **electron**: MUST free the original port. Port shifting won't work because Electron
|
|
466
|
+
* projects embed the dev server inside concurrently/scripts with hardcoded port references
|
|
467
|
+
* (e.g. VITE_DEV_SERVER_URL=http://localhost:5173). For Electron, the function will:
|
|
468
|
+
* 1. Auto-kill zombie processes from the same project
|
|
469
|
+
* 2. Prompt to kill any other blocker (default: YES, since there's no alternative)
|
|
470
|
+
* 3. In non-interactive mode, auto-kill the blocker
|
|
471
|
+
*/
|
|
472
|
+
export async function resolveDevServerPort(opts) {
|
|
473
|
+
const { projectRoot, execCommand, expectedPort, mode, autoResolve, excludePorts = [] } = opts;
|
|
474
|
+
const framework = detectFramework(projectRoot, execCommand);
|
|
475
|
+
// 1. Check if expected port is free
|
|
476
|
+
const free = await isPortFree(expectedPort);
|
|
477
|
+
if (free && !excludePorts.includes(expectedPort)) {
|
|
478
|
+
// Port is free, return it with injection info (even though the port matches default,
|
|
479
|
+
// we still inject env so the dev server explicitly binds to it — prevents race conditions)
|
|
480
|
+
const injection = buildPortInjection(framework, expectedPort);
|
|
481
|
+
return { port: expectedPort, ...injection };
|
|
482
|
+
}
|
|
483
|
+
// 2. Try to identify and handle the blocker
|
|
484
|
+
const pid = await getPidUsingPort(expectedPort);
|
|
485
|
+
if (pid) {
|
|
486
|
+
const procName = await getProcessName(pid);
|
|
487
|
+
// 2a. If blocker is a zombie from this project, auto-kill it regardless of mode
|
|
488
|
+
const zombie = await isZombieOfProject(pid, projectRoot);
|
|
489
|
+
if (zombie) {
|
|
490
|
+
console.log(`[HoverSource] Detected zombie process from this project occupying port ${expectedPort} (PID: ${pid}). Automatically terminating it...`);
|
|
491
|
+
const success = await killProcess(pid, expectedPort);
|
|
492
|
+
if (success) {
|
|
493
|
+
console.log(`[HoverSource] Port ${expectedPort} is now free.`);
|
|
494
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
495
|
+
const injection = buildPortInjection(framework, expectedPort);
|
|
496
|
+
return { port: expectedPort, ...injection };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
// 2b. For electron mode, handle the blocker appropriately
|
|
500
|
+
if (mode === "electron") {
|
|
501
|
+
return handleElectronBlocker(pid, procName, expectedPort, autoResolve, framework);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// 3. Web mode: shift to next free port (non-destructive resolution)
|
|
505
|
+
// This works because we inject PORT/VITE_PORT env vars that web frameworks read.
|
|
506
|
+
const newPort = await findFreePort(expectedPort + 1, [...excludePorts, expectedPort]);
|
|
507
|
+
console.log(`[HoverSource] Port ${expectedPort} is occupied. Using next free port: ${newPort}`);
|
|
508
|
+
const injection = buildPortInjection(framework, newPort);
|
|
509
|
+
return { port: newPort, ...injection };
|
|
510
|
+
}
|
|
511
|
+
// ---------------------------------------------------------------------------
|
|
512
|
+
// One-shot all-ports resolution
|
|
513
|
+
// ---------------------------------------------------------------------------
|
|
514
|
+
/**
|
|
515
|
+
* Resolves all ports HS needs in one call. Guarantees no collisions between
|
|
516
|
+
* companion, dev server, debug, and proxy ports. Returns a PortPlan.
|
|
517
|
+
*/
|
|
518
|
+
export async function resolveAllPorts(opts) {
|
|
519
|
+
const { projectRoot, execCommand, requestedCompanionPort, expectedDevPort, debugPort, requestedProxyPort, mode, autoResolve } = opts;
|
|
520
|
+
// 1. Resolve companion port first (it's HS's own server, highest priority)
|
|
521
|
+
const companionPort = await resolveCompanionPort(requestedCompanionPort, expectedDevPort);
|
|
522
|
+
// 2. Resolve dev server port, excluding companion
|
|
523
|
+
const devResult = await resolveDevServerPort({
|
|
524
|
+
projectRoot,
|
|
525
|
+
execCommand: execCommand || "",
|
|
526
|
+
expectedPort: expectedDevPort,
|
|
527
|
+
mode,
|
|
528
|
+
autoResolve,
|
|
529
|
+
excludePorts: [companionPort]
|
|
530
|
+
});
|
|
531
|
+
// 3. Resolve debug port (for Electron), excluding companion + dev
|
|
532
|
+
let resolvedDebugPort = debugPort;
|
|
533
|
+
if (mode === "electron") {
|
|
534
|
+
const debugFree = await isPortFree(debugPort);
|
|
535
|
+
if (!debugFree || debugPort === companionPort || debugPort === devResult.port) {
|
|
536
|
+
resolvedDebugPort = await findFreePort(debugPort + 1, [companionPort, devResult.port]);
|
|
537
|
+
if (resolvedDebugPort !== debugPort) {
|
|
538
|
+
console.log(`[HoverSource] Debug port ${debugPort} unavailable, using ${resolvedDebugPort}.`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// 4. Resolve proxy port, excluding all three above
|
|
543
|
+
const defaultProxyPort = requestedProxyPort ?? (10000 + devResult.port);
|
|
544
|
+
const usedPorts = [companionPort, devResult.port, resolvedDebugPort];
|
|
545
|
+
let proxyPort = defaultProxyPort;
|
|
546
|
+
if (!await isPortFree(proxyPort) || usedPorts.includes(proxyPort)) {
|
|
547
|
+
proxyPort = await findFreePort(defaultProxyPort + 1, usedPorts);
|
|
548
|
+
if (proxyPort !== defaultProxyPort) {
|
|
549
|
+
console.log(`[HoverSource] Proxy port ${defaultProxyPort} unavailable, using ${proxyPort}.`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return {
|
|
553
|
+
companionPort,
|
|
554
|
+
devServerPort: devResult.port,
|
|
555
|
+
debugPort: resolvedDebugPort,
|
|
556
|
+
proxyPort,
|
|
557
|
+
devServerEnv: devResult.env,
|
|
558
|
+
devServerExtraArgs: devResult.extraArgs
|
|
559
|
+
};
|
|
560
|
+
}
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
export declare function rewriteCookieHeaders(headers: http.IncomingHttpHeaders, useHttps: boolean): void;
|
|
3
|
+
export interface ProxyOptions {
|
|
4
|
+
targetUrl: string;
|
|
5
|
+
proxyPort: number;
|
|
6
|
+
companionPort: number;
|
|
7
|
+
overlayScriptUrl: string;
|
|
8
|
+
useHttps?: boolean;
|
|
9
|
+
sslKeyPath?: string;
|
|
10
|
+
sslCertPath?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Starts a reverse HTTP/HTTPS proxy that forwards requests to `targetUrl` and
|
|
14
|
+
* injects `<script src="${overlayScriptUrl}"></script>` into HTML responses.
|
|
15
|
+
*/
|
|
16
|
+
export declare function startProxy(options: ProxyOptions): Promise<void>;
|