Add plannotator extension v0.19.10
This commit is contained in:
173
extensions/plannotator/server/network.ts
Normal file
173
extensions/plannotator/server/network.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Network utilities — remote detection, port binding, browser opening.
|
||||
* isRemoteSession, getServerPort, listenOnPort, openBrowser
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import type { Server } from "node:http";
|
||||
import { release } from "node:os";
|
||||
|
||||
const DEFAULT_REMOTE_PORT = 19432;
|
||||
const LOOPBACK_HOST = "127.0.0.1";
|
||||
|
||||
/**
|
||||
* Check if running in a remote session (SSH, devcontainer, etc.)
|
||||
* Honors PLANNOTATOR_REMOTE as a tri-state override, or detects SSH_TTY/SSH_CONNECTION.
|
||||
*/
|
||||
function getRemoteOverride(): boolean | null {
|
||||
const remote = process.env.PLANNOTATOR_REMOTE;
|
||||
if (remote === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (remote === "1" || remote?.toLowerCase() === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (remote === "0" || remote?.toLowerCase() === "false") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isRemoteSession(): boolean {
|
||||
const remoteOverride = getRemoteOverride();
|
||||
if (remoteOverride !== null) {
|
||||
return remoteOverride;
|
||||
}
|
||||
// Legacy SSH detection
|
||||
if (process.env.SSH_TTY || process.env.SSH_CONNECTION) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server port to use.
|
||||
* - PLANNOTATOR_PORT env var takes precedence
|
||||
* - Remote sessions default to 19432 (for port forwarding)
|
||||
* - Local sessions use random port
|
||||
* Returns { port, portSource } so caller can notify user if needed.
|
||||
*/
|
||||
export function getServerPort(): {
|
||||
port: number;
|
||||
portSource: "env" | "remote-default" | "random";
|
||||
} {
|
||||
const envPort = process.env.PLANNOTATOR_PORT;
|
||||
if (envPort) {
|
||||
const parsed = parseInt(envPort, 10);
|
||||
if (!Number.isNaN(parsed) && parsed > 0 && parsed < 65536) {
|
||||
return { port: parsed, portSource: "env" };
|
||||
}
|
||||
// Invalid port - fall back silently, caller can check env var themselves
|
||||
}
|
||||
if (isRemoteSession()) {
|
||||
return { port: DEFAULT_REMOTE_PORT, portSource: "remote-default" };
|
||||
}
|
||||
return { port: 0, portSource: "random" };
|
||||
}
|
||||
|
||||
export function getServerHostname(): string {
|
||||
return isRemoteSession() ? "0.0.0.0" : LOOPBACK_HOST;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
const RETRY_DELAY_MS = 500;
|
||||
|
||||
export async function listenOnPort(
|
||||
server: Server,
|
||||
): Promise<{ port: number; portSource: "env" | "remote-default" | "random" }> {
|
||||
const result = getServerPort();
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(
|
||||
result.port,
|
||||
getServerHostname(),
|
||||
() => {
|
||||
server.removeListener("error", reject);
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
const addr = server.address() as { port: number };
|
||||
return { port: addr.port, portSource: result.portSource };
|
||||
} catch (err: unknown) {
|
||||
const isAddressInUse =
|
||||
err instanceof Error && err.message.includes("EADDRINUSE");
|
||||
if (isAddressInUse && attempt < MAX_RETRIES) {
|
||||
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
||||
continue;
|
||||
}
|
||||
if (isAddressInUse) {
|
||||
const hint = isRemoteSession()
|
||||
? " (set PLANNOTATOR_PORT to use a different port)"
|
||||
: "";
|
||||
throw new Error(
|
||||
`Port ${result.port} in use after ${MAX_RETRIES} retries${hint}`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable, but satisfies TypeScript
|
||||
throw new Error("Failed to bind port");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open URL in system browser (Node-compatible, no Bun $ dependency).
|
||||
* Honors PLANNOTATOR_BROWSER and BROWSER env vars.
|
||||
* Returns { opened: true } if browser was opened, { opened: false, isRemote: true, url } if remote session.
|
||||
*/
|
||||
export function openBrowser(url: string): {
|
||||
opened: boolean;
|
||||
isRemote?: boolean;
|
||||
url?: string;
|
||||
} {
|
||||
const browser = process.env.PLANNOTATOR_BROWSER || process.env.BROWSER;
|
||||
if (isRemoteSession() && !browser) {
|
||||
return { opened: false, isRemote: true, url };
|
||||
}
|
||||
|
||||
try {
|
||||
const platform = process.platform;
|
||||
const wsl =
|
||||
platform === "linux" && release().toLowerCase().includes("microsoft");
|
||||
|
||||
let cmd: string;
|
||||
let args: string[];
|
||||
|
||||
if (browser) {
|
||||
if (process.env.PLANNOTATOR_BROWSER && platform === "darwin") {
|
||||
cmd = "open";
|
||||
args = ["-a", browser, url];
|
||||
} else if (platform === "win32" || wsl) {
|
||||
cmd = "cmd.exe";
|
||||
args = ["/c", "start", "", browser, url];
|
||||
} else {
|
||||
cmd = browser;
|
||||
args = [url];
|
||||
}
|
||||
} else if (platform === "win32" || wsl) {
|
||||
cmd = "cmd.exe";
|
||||
args = ["/c", "start", "", url];
|
||||
} else if (platform === "darwin") {
|
||||
cmd = "open";
|
||||
args = [url];
|
||||
} else {
|
||||
cmd = "xdg-open";
|
||||
args = [url];
|
||||
}
|
||||
|
||||
const child = spawn(cmd, args, { detached: true, stdio: "ignore" });
|
||||
child.once("error", () => {});
|
||||
child.unref();
|
||||
return { opened: true };
|
||||
} catch {
|
||||
return { opened: false };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user