@reopt-ai/dev-proxy 1.1.1
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 +371 -0
- package/README_KO.md +371 -0
- package/bin/dev-proxy.js +3 -0
- package/dist/bootstrap.js +3 -0
- package/dist/cli/config-io.js +110 -0
- package/dist/cli/output.js +37 -0
- package/dist/cli.js +78 -0
- package/dist/commands/config.js +60 -0
- package/dist/commands/doctor.js +334 -0
- package/dist/commands/help.js +7 -0
- package/dist/commands/init.js +199 -0
- package/dist/commands/project.js +69 -0
- package/dist/commands/status.js +30 -0
- package/dist/commands/version.js +10 -0
- package/dist/commands/worktree.js +292 -0
- package/dist/components/app.js +394 -0
- package/dist/components/detail-panel.js +122 -0
- package/dist/components/footer-bar.js +62 -0
- package/dist/components/request-list.js +104 -0
- package/dist/components/splash.js +32 -0
- package/dist/components/status-bar.js +19 -0
- package/dist/hooks/use-mouse.js +66 -0
- package/dist/index.js +153 -0
- package/dist/proxy/certs.js +68 -0
- package/dist/proxy/config.js +78 -0
- package/dist/proxy/routes.js +70 -0
- package/dist/proxy/server.js +403 -0
- package/dist/proxy/types.js +1 -0
- package/dist/proxy/worktrees.js +116 -0
- package/dist/store.js +567 -0
- package/dist/utils/format.js +121 -0
- package/dist/utils/list-layout.js +48 -0
- package/package.json +83 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
// ── Paths ────────────────────────────────────────────────────
|
|
5
|
+
export const CONFIG_DIR = resolve(homedir(), ".dev-proxy");
|
|
6
|
+
export const GLOBAL_CONFIG_PATH = resolve(CONFIG_DIR, "config.json");
|
|
7
|
+
export const PROJECT_CONFIG_NAME = ".dev-proxy.json";
|
|
8
|
+
// ── Loaders ──────────────────────────────────────────────────
|
|
9
|
+
function loadJson(path) {
|
|
10
|
+
try {
|
|
11
|
+
if (existsSync(path)) {
|
|
12
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
console.error(`[dev-proxy] Failed to parse ${path}: ${err.message}`);
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function resolveFilePath(rawPath, basePath) {
|
|
21
|
+
if (!rawPath)
|
|
22
|
+
return undefined;
|
|
23
|
+
if (isAbsolute(rawPath))
|
|
24
|
+
return rawPath;
|
|
25
|
+
return resolve(dirname(basePath), rawPath);
|
|
26
|
+
}
|
|
27
|
+
function parsePort(label, raw, fallback) {
|
|
28
|
+
if (raw === undefined)
|
|
29
|
+
return fallback;
|
|
30
|
+
if (Number.isInteger(raw) && raw > 0 && raw <= 65535)
|
|
31
|
+
return raw;
|
|
32
|
+
console.error(`[dev-proxy] Ignoring ${label}: expected integer port, received "${raw}"`);
|
|
33
|
+
return fallback;
|
|
34
|
+
}
|
|
35
|
+
function loadProjectConfig(projectDir) {
|
|
36
|
+
const configPath = resolve(projectDir, PROJECT_CONFIG_NAME);
|
|
37
|
+
if (!existsSync(configPath))
|
|
38
|
+
return null;
|
|
39
|
+
const raw = loadJson(configPath);
|
|
40
|
+
if (!raw)
|
|
41
|
+
return null;
|
|
42
|
+
return {
|
|
43
|
+
path: projectDir,
|
|
44
|
+
configPath,
|
|
45
|
+
routes: raw.routes ?? {},
|
|
46
|
+
worktrees: raw.worktrees ?? {},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// ── Main loader ──────────────────────────────────────────────
|
|
50
|
+
function loadConfig() {
|
|
51
|
+
const global = loadJson(GLOBAL_CONFIG_PATH) ?? {};
|
|
52
|
+
const domain = global.domain ?? "localhost";
|
|
53
|
+
const port = parsePort("port", global.port, 3000);
|
|
54
|
+
const httpsPort = parsePort("httpsPort", global.httpsPort, 3443);
|
|
55
|
+
const certPath = resolveFilePath(global.certPath, GLOBAL_CONFIG_PATH);
|
|
56
|
+
const keyPath = resolveFilePath(global.keyPath, GLOBAL_CONFIG_PATH);
|
|
57
|
+
const projects = [];
|
|
58
|
+
if (global.projects) {
|
|
59
|
+
for (const projectDir of global.projects) {
|
|
60
|
+
const resolved = isAbsolute(projectDir)
|
|
61
|
+
? projectDir
|
|
62
|
+
: resolve(CONFIG_DIR, projectDir);
|
|
63
|
+
const project = loadProjectConfig(resolved);
|
|
64
|
+
if (project) {
|
|
65
|
+
projects.push(project);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.error(`[dev-proxy] Project ${resolved}: no ${PROJECT_CONFIG_NAME} found`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { domain, port, httpsPort, certPath, keyPath, projects };
|
|
73
|
+
}
|
|
74
|
+
export const config = loadConfig();
|
|
75
|
+
export const __testing = {
|
|
76
|
+
parsePort,
|
|
77
|
+
resolveFilePath,
|
|
78
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { config } from "./config.js";
|
|
2
|
+
import { getWorktreeTarget } from "./worktrees.js";
|
|
3
|
+
const ALLOWED_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]);
|
|
4
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
5
|
+
function formatTarget(url) {
|
|
6
|
+
return url.pathname === "/" && !url.search && !url.hash ? url.origin : url.toString();
|
|
7
|
+
}
|
|
8
|
+
function parseTarget(label, raw) {
|
|
9
|
+
try {
|
|
10
|
+
const target = new URL(raw);
|
|
11
|
+
if (!ALLOWED_PROTOCOLS.has(target.protocol)) {
|
|
12
|
+
console.error(`[dev-proxy] Ignoring ${label}: unsupported protocol "${target.protocol}"`);
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return target;
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
console.error(`[dev-proxy] Ignoring ${label}: ${err.message}`);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// ── Build from project configs ──────────────────────────────
|
|
23
|
+
const parsedRoutes = new Map();
|
|
24
|
+
let wildcardTarget = null;
|
|
25
|
+
for (const project of config.projects) {
|
|
26
|
+
for (const [subdomain, target] of Object.entries(project.routes)) {
|
|
27
|
+
if (subdomain === "*") {
|
|
28
|
+
// First wildcard wins
|
|
29
|
+
wildcardTarget ??= parseTarget(`${project.path} routes.*`, target);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
// First registration wins — later projects don't override
|
|
33
|
+
if (!parsedRoutes.has(subdomain)) {
|
|
34
|
+
const parsed = parseTarget(`${project.path} routes.${subdomain}`, target);
|
|
35
|
+
if (parsed)
|
|
36
|
+
parsedRoutes.set(subdomain, parsed);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
console.warn(`[dev-proxy] Ignoring duplicate subdomain "${subdomain}" from ${project.path} (already registered)`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const DOMAIN = config.domain;
|
|
44
|
+
export const ROUTES = Object.fromEntries([...parsedRoutes.entries()].map(([sub, url]) => [sub, formatTarget(url)]));
|
|
45
|
+
export const DEFAULT_TARGET = wildcardTarget ? formatTarget(wildcardTarget) : null;
|
|
46
|
+
export const PROXY_PORT = config.port;
|
|
47
|
+
export const HTTPS_PORT = config.httpsPort;
|
|
48
|
+
export const CERT_PATH = config.certPath;
|
|
49
|
+
export const KEY_PATH = config.keyPath;
|
|
50
|
+
export function parseHost(host) {
|
|
51
|
+
// Strip port number before parsing (HTTP Host header may include :port)
|
|
52
|
+
const hostOnly = host.replace(/:\d+$/, "").toLowerCase();
|
|
53
|
+
const subdomain = hostOnly.split(".")[0] ?? hostOnly;
|
|
54
|
+
const delimIdx = subdomain.indexOf("--");
|
|
55
|
+
if (delimIdx !== -1) {
|
|
56
|
+
return {
|
|
57
|
+
worktree: subdomain.slice(0, delimIdx),
|
|
58
|
+
app: subdomain.slice(delimIdx + 2),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { app: subdomain, worktree: null };
|
|
62
|
+
}
|
|
63
|
+
export function getTarget(host) {
|
|
64
|
+
const { app, worktree } = parseHost(host);
|
|
65
|
+
if (worktree) {
|
|
66
|
+
const target = getWorktreeTarget(worktree, app);
|
|
67
|
+
return { url: target, worktree };
|
|
68
|
+
}
|
|
69
|
+
return { url: parsedRoutes.get(app) ?? wildcardTarget, worktree: null };
|
|
70
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import tls from "node:tls";
|
|
6
|
+
import { EventEmitter } from "node:events";
|
|
7
|
+
import { getTarget, PROXY_PORT, HTTPS_PORT, CERT_PATH, KEY_PATH } from "./routes.js";
|
|
8
|
+
import { resolveCerts } from "./certs.js";
|
|
9
|
+
import { isDetailActive } from "../store.js";
|
|
10
|
+
// Keep-Alive agent — reuses TCP connections to target servers
|
|
11
|
+
const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
|
|
12
|
+
// Dev targets frequently use self-signed certs; keep the proxy permissive.
|
|
13
|
+
// This only affects outbound connections to LOCAL dev servers, never public internet.
|
|
14
|
+
const httpsAgent = new https.Agent({
|
|
15
|
+
keepAlive: true,
|
|
16
|
+
maxSockets: 64,
|
|
17
|
+
rejectUnauthorized: false,
|
|
18
|
+
});
|
|
19
|
+
// Monotonic counter — cheaper than crypto.randomUUID() for a dev tool
|
|
20
|
+
let _nextId = 0;
|
|
21
|
+
function nextId() {
|
|
22
|
+
return String(++_nextId);
|
|
23
|
+
}
|
|
24
|
+
function escapeHtml(s) {
|
|
25
|
+
return s
|
|
26
|
+
.replace(/&/g, "&")
|
|
27
|
+
.replace(/</g, "<")
|
|
28
|
+
.replace(/>/g, ">")
|
|
29
|
+
.replace(/"/g, """);
|
|
30
|
+
}
|
|
31
|
+
function worktreeErrorPage(worktree, target, error) {
|
|
32
|
+
return `<!DOCTYPE html>
|
|
33
|
+
<html lang="ko"><head><meta charset="utf-8"><title>Worktree Offline</title>
|
|
34
|
+
<style>
|
|
35
|
+
body{font-family:system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0a0a0a;color:#e5e5e5}
|
|
36
|
+
.card{max-width:480px;padding:2.5rem;border:1px solid #333;border-radius:12px;text-align:center}
|
|
37
|
+
h1{font-size:1.25rem;color:#f97316;margin:0 0 1rem}
|
|
38
|
+
code{background:#1a1a2e;padding:0.6rem 1rem;border-radius:6px;display:block;margin:1rem 0;color:#60a5fa;font-size:0.9rem;text-align:left}
|
|
39
|
+
.dim{color:#888;font-size:0.85rem}
|
|
40
|
+
</style></head>
|
|
41
|
+
<body><div class="card">
|
|
42
|
+
<h1>Worktree “${escapeHtml(worktree)}” is offline</h1>
|
|
43
|
+
<p>Target <strong>${escapeHtml(target.origin)}</strong> is not responding.</p>
|
|
44
|
+
<code>cd worktree-${escapeHtml(worktree)} && pnpm dev</code>
|
|
45
|
+
<p class="dim">${escapeHtml(error)}</p>
|
|
46
|
+
</div></body></html>`;
|
|
47
|
+
}
|
|
48
|
+
function parseCookies(header) {
|
|
49
|
+
if (!header)
|
|
50
|
+
return {};
|
|
51
|
+
const cookies = {};
|
|
52
|
+
for (const pair of header.split(";")) {
|
|
53
|
+
const [name, ...rest] = pair.split("=");
|
|
54
|
+
if (name)
|
|
55
|
+
cookies[name.trim()] = rest.join("=").trim();
|
|
56
|
+
}
|
|
57
|
+
return cookies;
|
|
58
|
+
}
|
|
59
|
+
function parseQuery(url) {
|
|
60
|
+
if (!url)
|
|
61
|
+
return {};
|
|
62
|
+
const idx = url.indexOf("?");
|
|
63
|
+
if (idx === -1)
|
|
64
|
+
return {};
|
|
65
|
+
const params = {};
|
|
66
|
+
const search = new URLSearchParams(url.slice(idx + 1));
|
|
67
|
+
for (const [key, value] of search) {
|
|
68
|
+
params[key] = value;
|
|
69
|
+
}
|
|
70
|
+
return params;
|
|
71
|
+
}
|
|
72
|
+
function headersToRecord(raw) {
|
|
73
|
+
const result = {};
|
|
74
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
75
|
+
if (value !== undefined) {
|
|
76
|
+
result[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
function formatListenError(err, port) {
|
|
82
|
+
const code = err.code;
|
|
83
|
+
if (code === "EADDRINUSE") {
|
|
84
|
+
return new Error(`port ${port} is already in use (another dev-proxy instance may already be running)`);
|
|
85
|
+
}
|
|
86
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
87
|
+
return new Error(`port ${port} cannot be opened (${err.message})`);
|
|
88
|
+
}
|
|
89
|
+
return err;
|
|
90
|
+
}
|
|
91
|
+
function normalizeTargetProtocol(protocol) {
|
|
92
|
+
return protocol === "https:" || protocol === "wss:" ? "https:" : "http:";
|
|
93
|
+
}
|
|
94
|
+
function targetPort(target) {
|
|
95
|
+
if (target.port) {
|
|
96
|
+
return Number(target.port);
|
|
97
|
+
}
|
|
98
|
+
return normalizeTargetProtocol(target.protocol) === "https:" ? 443 : 80;
|
|
99
|
+
}
|
|
100
|
+
function requestTransport(target) {
|
|
101
|
+
return normalizeTargetProtocol(target.protocol) === "https:"
|
|
102
|
+
? { request: https.request, agent: httpsAgent }
|
|
103
|
+
: { request: http.request, agent: httpAgent };
|
|
104
|
+
}
|
|
105
|
+
function connectToTarget(target, onConnect) {
|
|
106
|
+
const port = targetPort(target);
|
|
107
|
+
if (normalizeTargetProtocol(target.protocol) === "https:") {
|
|
108
|
+
return tls.connect({
|
|
109
|
+
host: target.hostname,
|
|
110
|
+
port,
|
|
111
|
+
servername: target.hostname,
|
|
112
|
+
rejectUnauthorized: false,
|
|
113
|
+
}, onConnect);
|
|
114
|
+
}
|
|
115
|
+
return net.connect(port, target.hostname, onConnect);
|
|
116
|
+
}
|
|
117
|
+
function createRequestHandler(emitter, proto) {
|
|
118
|
+
return (clientReq, clientRes) => {
|
|
119
|
+
const { url: target, worktree } = getTarget(clientReq.headers.host ?? "");
|
|
120
|
+
const start = performance.now();
|
|
121
|
+
const id = nextId();
|
|
122
|
+
const host = clientReq.headers.host ?? "";
|
|
123
|
+
// No target resolved — either unregistered worktree or unknown subdomain
|
|
124
|
+
if (!target) {
|
|
125
|
+
const errorMsg = worktree ? "worktree not registered" : "no route configured";
|
|
126
|
+
const event = {
|
|
127
|
+
id,
|
|
128
|
+
type: "http",
|
|
129
|
+
protocol: proto,
|
|
130
|
+
timestamp: Date.now(),
|
|
131
|
+
method: clientReq.method ?? "GET",
|
|
132
|
+
url: clientReq.url ?? "/",
|
|
133
|
+
host,
|
|
134
|
+
target: "",
|
|
135
|
+
worktree: worktree ?? undefined,
|
|
136
|
+
error: errorMsg,
|
|
137
|
+
cookies: {},
|
|
138
|
+
query: {},
|
|
139
|
+
requestHeaders: {},
|
|
140
|
+
responseHeaders: {},
|
|
141
|
+
};
|
|
142
|
+
emitter.emit("request", event);
|
|
143
|
+
emitter.emit("request:error", event);
|
|
144
|
+
if (worktree) {
|
|
145
|
+
clientRes.writeHead(502, { "Content-Type": "text/html; charset=utf-8" });
|
|
146
|
+
clientRes.end(worktreeErrorPage(worktree, new URL("http://unknown"), errorMsg));
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
clientRes.writeHead(502, { "Content-Type": "text/plain" });
|
|
150
|
+
clientRes.end(`Dev proxy: ${errorMsg} for ${host}`);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const collectDetail = isDetailActive();
|
|
155
|
+
const event = {
|
|
156
|
+
id,
|
|
157
|
+
type: "http",
|
|
158
|
+
protocol: proto,
|
|
159
|
+
timestamp: Date.now(),
|
|
160
|
+
method: clientReq.method ?? "GET",
|
|
161
|
+
url: clientReq.url ?? "/",
|
|
162
|
+
host,
|
|
163
|
+
target: target.origin,
|
|
164
|
+
worktree: worktree ?? undefined,
|
|
165
|
+
cookies: collectDetail ? parseCookies(clientReq.headers.cookie) : {},
|
|
166
|
+
query: collectDetail ? parseQuery(clientReq.url) : {},
|
|
167
|
+
requestHeaders: collectDetail ? headersToRecord(clientReq.headers) : {},
|
|
168
|
+
responseHeaders: {},
|
|
169
|
+
};
|
|
170
|
+
emitter.emit("request", event);
|
|
171
|
+
// Strip any client-supplied forwarding headers, then set ours
|
|
172
|
+
const headers = clientReq.headers;
|
|
173
|
+
delete headers["x-forwarded-for"];
|
|
174
|
+
delete headers["x-forwarded-host"];
|
|
175
|
+
delete headers["x-forwarded-proto"];
|
|
176
|
+
headers["x-forwarded-for"] = clientReq.socket.remoteAddress ?? "127.0.0.1";
|
|
177
|
+
headers["x-forwarded-host"] = host;
|
|
178
|
+
headers["x-forwarded-proto"] = proto;
|
|
179
|
+
// Rewrite Host so Next.js proxy.ts middleware sees the original subdomain
|
|
180
|
+
if (worktree) {
|
|
181
|
+
headers.host = host.replace(`${worktree}--`, "");
|
|
182
|
+
}
|
|
183
|
+
const transport = requestTransport(target);
|
|
184
|
+
const proxyReq = transport.request({
|
|
185
|
+
hostname: target.hostname,
|
|
186
|
+
port: targetPort(target),
|
|
187
|
+
path: clientReq.url,
|
|
188
|
+
method: clientReq.method,
|
|
189
|
+
headers,
|
|
190
|
+
agent: transport.agent,
|
|
191
|
+
}, (proxyRes) => {
|
|
192
|
+
// Track response body size, emit once on end
|
|
193
|
+
let responseSize = 0;
|
|
194
|
+
proxyRes.on("data", (chunk) => {
|
|
195
|
+
responseSize += chunk.length;
|
|
196
|
+
});
|
|
197
|
+
proxyRes.on("end", () => {
|
|
198
|
+
event.statusCode = proxyRes.statusCode;
|
|
199
|
+
event.duration = Math.round(performance.now() - start);
|
|
200
|
+
event.responseHeaders = collectDetail ? headersToRecord(proxyRes.headers) : {};
|
|
201
|
+
event.responseSize = responseSize;
|
|
202
|
+
emitter.emit("request:complete", event);
|
|
203
|
+
});
|
|
204
|
+
proxyRes.on("error", (err) => {
|
|
205
|
+
event.error = err.message;
|
|
206
|
+
event.duration = Math.round(performance.now() - start);
|
|
207
|
+
emitter.emit("request:error", event);
|
|
208
|
+
if (!clientRes.headersSent)
|
|
209
|
+
clientRes.writeHead(502);
|
|
210
|
+
clientRes.end();
|
|
211
|
+
});
|
|
212
|
+
clientRes.on("error", () => {
|
|
213
|
+
// Client disconnected — best-effort, nothing to do
|
|
214
|
+
proxyRes.destroy();
|
|
215
|
+
});
|
|
216
|
+
clientRes.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
|
|
217
|
+
proxyRes.pipe(clientRes);
|
|
218
|
+
});
|
|
219
|
+
proxyReq.on("error", (err) => {
|
|
220
|
+
event.error = err.message;
|
|
221
|
+
event.duration = Math.round(performance.now() - start);
|
|
222
|
+
emitter.emit("request:error", event);
|
|
223
|
+
if (!clientRes.headersSent) {
|
|
224
|
+
if (worktree) {
|
|
225
|
+
clientRes.writeHead(502, {
|
|
226
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
227
|
+
});
|
|
228
|
+
clientRes.end(worktreeErrorPage(worktree, target, err.message));
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
clientRes.writeHead(502, { "Content-Type": "text/plain" });
|
|
232
|
+
clientRes.end(`Dev proxy: target not ready (${err.message})`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
clientRes.end();
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
clientReq.pipe(proxyReq);
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
function createUpgradeHandler(emitter, proto) {
|
|
243
|
+
return (clientReq, clientSocket, head) => {
|
|
244
|
+
const { url: target, worktree } = getTarget(clientReq.headers.host ?? "");
|
|
245
|
+
const host = clientReq.headers.host ?? "";
|
|
246
|
+
// No target resolved — close socket immediately
|
|
247
|
+
if (!target) {
|
|
248
|
+
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const id = nextId();
|
|
252
|
+
const wsStart = performance.now();
|
|
253
|
+
const wsEvent = {
|
|
254
|
+
id,
|
|
255
|
+
type: "ws",
|
|
256
|
+
protocol: proto === "https" ? "wss" : "ws",
|
|
257
|
+
timestamp: Date.now(),
|
|
258
|
+
url: clientReq.url ?? "/",
|
|
259
|
+
host,
|
|
260
|
+
target: target.origin,
|
|
261
|
+
worktree: worktree ?? undefined,
|
|
262
|
+
status: "open",
|
|
263
|
+
};
|
|
264
|
+
emitter.emit("ws", wsEvent);
|
|
265
|
+
// Rewrite Host header for worktree routing
|
|
266
|
+
if (worktree) {
|
|
267
|
+
clientReq.headers.host = host.replace(`${worktree}--`, "");
|
|
268
|
+
}
|
|
269
|
+
let closed = false;
|
|
270
|
+
const emitClose = (error) => {
|
|
271
|
+
if (closed)
|
|
272
|
+
return;
|
|
273
|
+
closed = true;
|
|
274
|
+
const closeEvent = {
|
|
275
|
+
...wsEvent,
|
|
276
|
+
status: error ? "error" : "closed",
|
|
277
|
+
error,
|
|
278
|
+
duration: Math.round(performance.now() - wsStart),
|
|
279
|
+
};
|
|
280
|
+
emitter.emit("ws", closeEvent);
|
|
281
|
+
};
|
|
282
|
+
// Guard: bail out if the client already disconnected during routing
|
|
283
|
+
if (clientSocket.destroyed) {
|
|
284
|
+
emitClose("client disconnected before upgrade");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const proxySocket = connectToTarget(target, () => {
|
|
288
|
+
const reqHeaders = [`${clientReq.method} ${clientReq.url} HTTP/1.1`];
|
|
289
|
+
for (const [key, value] of Object.entries(clientReq.headers)) {
|
|
290
|
+
if (value &&
|
|
291
|
+
key !== "x-forwarded-for" &&
|
|
292
|
+
key !== "x-forwarded-host" &&
|
|
293
|
+
key !== "x-forwarded-proto") {
|
|
294
|
+
reqHeaders.push(`${key}: ${Array.isArray(value) ? value.join(", ") : value}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
reqHeaders.push(`x-forwarded-for: ${clientReq.socket.remoteAddress ?? "127.0.0.1"}`);
|
|
298
|
+
reqHeaders.push(`x-forwarded-host: ${clientReq.headers.host ?? ""}`);
|
|
299
|
+
reqHeaders.push(`x-forwarded-proto: ${proto}`);
|
|
300
|
+
proxySocket.write(reqHeaders.join("\r\n") + "\r\n\r\n");
|
|
301
|
+
if (head.length)
|
|
302
|
+
proxySocket.write(head);
|
|
303
|
+
proxySocket.pipe(clientSocket);
|
|
304
|
+
clientSocket.pipe(proxySocket);
|
|
305
|
+
});
|
|
306
|
+
// Register error handler immediately to prevent unhandled errors
|
|
307
|
+
// between socket creation and the onConnect callback
|
|
308
|
+
proxySocket.on("error", (err) => {
|
|
309
|
+
emitClose(err.message);
|
|
310
|
+
clientSocket.destroy();
|
|
311
|
+
});
|
|
312
|
+
proxySocket.on("close", () => {
|
|
313
|
+
emitClose();
|
|
314
|
+
});
|
|
315
|
+
clientSocket.on("close", () => {
|
|
316
|
+
emitClose();
|
|
317
|
+
proxySocket.destroy();
|
|
318
|
+
});
|
|
319
|
+
clientSocket.on("error", (err) => {
|
|
320
|
+
emitClose(err.message);
|
|
321
|
+
proxySocket.destroy();
|
|
322
|
+
});
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
export function createProxyServer() {
|
|
326
|
+
const emitter = new EventEmitter();
|
|
327
|
+
const server = http.createServer(createRequestHandler(emitter, "http"));
|
|
328
|
+
server.on("upgrade", createUpgradeHandler(emitter, "http"));
|
|
329
|
+
server.on("error", (err) => {
|
|
330
|
+
console.error(`[dev-proxy] HTTP server error: ${err.message}`);
|
|
331
|
+
});
|
|
332
|
+
let httpsServer = null;
|
|
333
|
+
const certs = resolveCerts(CERT_PATH, KEY_PATH);
|
|
334
|
+
if (certs) {
|
|
335
|
+
try {
|
|
336
|
+
const cert = fs.readFileSync(certs.certPath);
|
|
337
|
+
const key = fs.readFileSync(certs.keyPath);
|
|
338
|
+
httpsServer = https.createServer({ cert, key }, createRequestHandler(emitter, "https"));
|
|
339
|
+
httpsServer.on("upgrade", createUpgradeHandler(emitter, "https"));
|
|
340
|
+
httpsServer.on("error", (err) => {
|
|
341
|
+
console.error(`[dev-proxy] HTTPS server error: ${err.message}`);
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
console.error(`[dev-proxy] HTTPS disabled — failed to read certificates: ${err.message}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return { server, httpsServer, emitter };
|
|
349
|
+
}
|
|
350
|
+
/** Destroy keep-alive agents to close lingering sockets on shutdown. */
|
|
351
|
+
export function destroyAgents() {
|
|
352
|
+
httpAgent.destroy();
|
|
353
|
+
httpsAgent.destroy();
|
|
354
|
+
}
|
|
355
|
+
export function startProxyServer(server, httpsServer) {
|
|
356
|
+
return new Promise((resolve, reject) => {
|
|
357
|
+
const fail = (err, port) => {
|
|
358
|
+
try {
|
|
359
|
+
server.close();
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
/* ignored: best-effort cleanup */
|
|
363
|
+
}
|
|
364
|
+
try {
|
|
365
|
+
httpsServer?.close();
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
/* ignored: best-effort cleanup */
|
|
369
|
+
}
|
|
370
|
+
reject(formatListenError(err, port));
|
|
371
|
+
};
|
|
372
|
+
const failHttp = (err) => {
|
|
373
|
+
fail(err, PROXY_PORT);
|
|
374
|
+
};
|
|
375
|
+
const failHttps = (err) => {
|
|
376
|
+
fail(err, HTTPS_PORT);
|
|
377
|
+
};
|
|
378
|
+
server.once("error", failHttp);
|
|
379
|
+
server.listen(PROXY_PORT, () => {
|
|
380
|
+
if (httpsServer) {
|
|
381
|
+
httpsServer.once("error", failHttps);
|
|
382
|
+
httpsServer.listen(HTTPS_PORT, () => {
|
|
383
|
+
server.off("error", failHttp);
|
|
384
|
+
httpsServer.off("error", failHttps);
|
|
385
|
+
resolve();
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
server.off("error", failHttp);
|
|
390
|
+
resolve();
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
export const __testing = {
|
|
396
|
+
escapeHtml,
|
|
397
|
+
parseCookies,
|
|
398
|
+
parseQuery,
|
|
399
|
+
headersToRecord,
|
|
400
|
+
formatListenError,
|
|
401
|
+
normalizeTargetProtocol,
|
|
402
|
+
targetPort,
|
|
403
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
2
|
+
import { basename, dirname } from "node:path";
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
import { config } from "./config.js";
|
|
5
|
+
// ── State ────────────────────────────────────────────────────
|
|
6
|
+
let worktreeMap = new Map();
|
|
7
|
+
const watchers = [];
|
|
8
|
+
let debounceTimer = null;
|
|
9
|
+
const listeners = new Set();
|
|
10
|
+
function notify() {
|
|
11
|
+
for (const l of listeners)
|
|
12
|
+
l();
|
|
13
|
+
}
|
|
14
|
+
// ── Core API ─────────────────────────────────────────────────
|
|
15
|
+
function isValidEntry(entry) {
|
|
16
|
+
if ("ports" in entry) {
|
|
17
|
+
return Object.keys(entry.ports).length > 0;
|
|
18
|
+
}
|
|
19
|
+
return typeof entry.port === "number";
|
|
20
|
+
}
|
|
21
|
+
function readRegistry() {
|
|
22
|
+
const next = new Map();
|
|
23
|
+
for (const project of config.projects) {
|
|
24
|
+
const worktrees = readProjectWorktrees(project);
|
|
25
|
+
for (const [branch, entry] of Object.entries(worktrees)) {
|
|
26
|
+
if (isValidEntry(entry) && !next.has(branch)) {
|
|
27
|
+
next.set(branch, entry);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
worktreeMap = next;
|
|
32
|
+
notify();
|
|
33
|
+
}
|
|
34
|
+
function readProjectWorktrees(project) {
|
|
35
|
+
try {
|
|
36
|
+
if (!existsSync(project.configPath))
|
|
37
|
+
return project.worktrees;
|
|
38
|
+
const raw = readFileSync(project.configPath, "utf-8");
|
|
39
|
+
const data = JSON.parse(raw);
|
|
40
|
+
return data.worktrees ?? {};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Config read/parse failed — fall back to cached worktrees
|
|
44
|
+
return project.worktrees;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function loadRegistry() {
|
|
48
|
+
readRegistry();
|
|
49
|
+
for (const project of config.projects) {
|
|
50
|
+
if (!existsSync(project.configPath))
|
|
51
|
+
continue;
|
|
52
|
+
try {
|
|
53
|
+
const dir = dirname(project.configPath);
|
|
54
|
+
const base = basename(project.configPath);
|
|
55
|
+
const watcher = watch(dir, (_event, filename) => {
|
|
56
|
+
if (filename !== base)
|
|
57
|
+
return;
|
|
58
|
+
if (debounceTimer)
|
|
59
|
+
clearTimeout(debounceTimer);
|
|
60
|
+
debounceTimer = setTimeout(readRegistry, 100);
|
|
61
|
+
});
|
|
62
|
+
watcher.on("error", () => {
|
|
63
|
+
/* intentional no-op: watcher errors are non-fatal */
|
|
64
|
+
});
|
|
65
|
+
watchers.push(watcher);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Directory doesn't exist — no watcher needed
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Resolve worktree target URL.
|
|
74
|
+
* For multi-service entries, `service` selects the subdomain port.
|
|
75
|
+
* For legacy single-port entries, `service` is ignored.
|
|
76
|
+
*/
|
|
77
|
+
export function getWorktreeTarget(branch, service) {
|
|
78
|
+
const entry = worktreeMap.get(branch);
|
|
79
|
+
if (!entry)
|
|
80
|
+
return null;
|
|
81
|
+
if ("ports" in entry) {
|
|
82
|
+
const port = service && service in entry.ports
|
|
83
|
+
? entry.ports[service]
|
|
84
|
+
: Object.values(entry.ports)[0];
|
|
85
|
+
if (port === undefined)
|
|
86
|
+
return null;
|
|
87
|
+
return new URL(`http://localhost:${port}`);
|
|
88
|
+
}
|
|
89
|
+
return new URL(`http://localhost:${entry.port}`);
|
|
90
|
+
}
|
|
91
|
+
export function getActiveWorktrees() {
|
|
92
|
+
return new Map(worktreeMap);
|
|
93
|
+
}
|
|
94
|
+
// ── React hook ──────────────────────────────────────────────
|
|
95
|
+
function subscribe(cb) {
|
|
96
|
+
listeners.add(cb);
|
|
97
|
+
return () => {
|
|
98
|
+
listeners.delete(cb);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function getSnapshot() {
|
|
102
|
+
return worktreeMap;
|
|
103
|
+
}
|
|
104
|
+
export function useWorktrees() {
|
|
105
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
106
|
+
}
|
|
107
|
+
export const __testing = { isValidEntry };
|
|
108
|
+
export function stopRegistry() {
|
|
109
|
+
for (const w of watchers)
|
|
110
|
+
w.close();
|
|
111
|
+
watchers.length = 0;
|
|
112
|
+
if (debounceTimer) {
|
|
113
|
+
clearTimeout(debounceTimer);
|
|
114
|
+
debounceTimer = null;
|
|
115
|
+
}
|
|
116
|
+
}
|