@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,122 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import React, { useEffect, useRef } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { useSelected, useSelectedDetail } from "../store.js";
|
|
5
|
+
import { formatTime, formatDuration, formatSize, methodColor, statusColor, durationColor, truncate, palette, } from "../utils/format.js";
|
|
6
|
+
const BORDER_ROWS = 2;
|
|
7
|
+
const BORDER_COLS = 2;
|
|
8
|
+
const PADDING_X = 1;
|
|
9
|
+
// summary(3) + marginBottom(1) + separator(1)
|
|
10
|
+
const DETAIL_RESERVED = 5;
|
|
11
|
+
function buildDetailLines(detail, nameMax, valueMax) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
const pushPlain = (text) => {
|
|
14
|
+
lines.push(_jsx(Box, { children: text }, `l-${lines.length}`));
|
|
15
|
+
};
|
|
16
|
+
const blank = () => {
|
|
17
|
+
pushPlain(_jsx(Text, { children: " " }));
|
|
18
|
+
};
|
|
19
|
+
if (!detail) {
|
|
20
|
+
pushPlain(_jsx(Text, { color: palette.muted, italic: true, children: "Detail expired (only last 50 requests retained)" }));
|
|
21
|
+
return lines;
|
|
22
|
+
}
|
|
23
|
+
const renderEntries = (entries, sep) => {
|
|
24
|
+
if (entries.length === 0) {
|
|
25
|
+
pushPlain(_jsxs(Text, { color: palette.muted, italic: true, children: [" ", "(empty)"] }));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const [name, value] of entries) {
|
|
29
|
+
const display = Array.isArray(value) ? value.join(", ") : value;
|
|
30
|
+
pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.accent, children: [" ", truncate(name, nameMax)] }), _jsx(Text, { color: palette.subtle, children: sep }), _jsx(Text, { color: palette.dim, children: truncate(display, valueMax) })] }));
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
// Request Headers
|
|
34
|
+
const reqEntries = Object.entries(detail.requestHeaders);
|
|
35
|
+
pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.success, bold: true, children: ["\u25B2", " Request Headers"] }), _jsxs(Text, { color: palette.muted, children: [" (", reqEntries.length, ")"] })] }));
|
|
36
|
+
renderEntries(reqEntries, ": ");
|
|
37
|
+
blank();
|
|
38
|
+
// Response Headers
|
|
39
|
+
const resEntries = Object.entries(detail.responseHeaders);
|
|
40
|
+
const hasResponse = resEntries.length > 0;
|
|
41
|
+
pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.accent, bold: true, children: ["\u25BC", " Response Headers"] }), _jsxs(Text, { color: palette.muted, children: [" ", hasResponse ? `(${resEntries.length})` : "(pending)"] })] }));
|
|
42
|
+
if (hasResponse) {
|
|
43
|
+
renderEntries(resEntries, ": ");
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
pushPlain(_jsxs(Text, { color: palette.muted, italic: true, children: [" ", "(awaiting response)"] }));
|
|
47
|
+
}
|
|
48
|
+
blank();
|
|
49
|
+
// Cookies
|
|
50
|
+
const cookieEntries = Object.entries(detail.cookies);
|
|
51
|
+
pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.warning, bold: true, children: ["\u25CF", " Cookies"] }), _jsxs(Text, { color: palette.muted, children: [" (", cookieEntries.length, ")"] })] }));
|
|
52
|
+
renderEntries(cookieEntries, " = ");
|
|
53
|
+
blank();
|
|
54
|
+
// Query
|
|
55
|
+
const queryEntries = Object.entries(detail.query);
|
|
56
|
+
pushPlain(_jsxs(_Fragment, { children: [_jsxs(Text, { color: palette.info, bold: true, children: ["\u25CF", " Query"] }), _jsxs(Text, { color: palette.muted, children: [" (", queryEntries.length, ")"] })] }));
|
|
57
|
+
renderEntries(queryEntries, " = ");
|
|
58
|
+
return lines;
|
|
59
|
+
}
|
|
60
|
+
function buildWsDetailLines() {
|
|
61
|
+
const lines = [];
|
|
62
|
+
lines.push(_jsx(Box, { children: _jsx(Text, { color: palette.muted, italic: true, children: "(WebSocket \u2014 no headers/body captured)" }) }, "ws-note"));
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
function PanelEmpty({ active, height }) {
|
|
66
|
+
return (_jsxs(Box, { width: "50%", height: height, flexDirection: "column", borderStyle: "double", borderColor: active ? palette.accent : palette.border, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: palette.brand, bold: true, children: "INSPECTOR" }), _jsxs(Text, { color: palette.muted, italic: true, children: ["\u25CB", " Select a request to inspect"] })] }));
|
|
67
|
+
}
|
|
68
|
+
function HttpSummary({ event, urlMax, targetMax, }) {
|
|
69
|
+
const sColor = event.error ? palette.error : statusColor(event.statusCode);
|
|
70
|
+
const status = event.error
|
|
71
|
+
? `ERR ${event.error}`
|
|
72
|
+
: String(event.statusCode ?? "\u2022\u2022\u2022");
|
|
73
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.brand, bold: true, children: "INSPECT" }), _jsx(Text, { color: palette.subtle, children: "\u2022" }), _jsx(Text, { color: methodColor(event.method), bold: true, children: event.method }), _jsx(Text, { color: palette.text, bold: true, children: truncate(event.url, urlMax) })] }), _jsxs(Box, { gap: 1, marginTop: 0, children: [_jsx(Text, { color: palette.dim, children: formatTime(event.timestamp) }), _jsx(Text, { color: sColor, bold: true, children: status }), _jsx(Text, { color: durationColor(event.duration), children: formatDuration(event.duration) }), event.responseSize !== undefined && (_jsx(Text, { color: palette.dim, children: formatSize(event.responseSize) }))] }), _jsxs(Box, { children: [_jsx(Text, { color: palette.muted, children: event.host }), _jsxs(Text, { color: palette.subtle, children: [" ", "\u2192", " "] }), _jsx(Text, { color: palette.dim, children: truncate(event.target, targetMax) })] })] }));
|
|
74
|
+
}
|
|
75
|
+
function WsSummary({ event, urlMax, targetMax, }) {
|
|
76
|
+
const wsStatusColor = event.wsStatus === "open"
|
|
77
|
+
? palette.success
|
|
78
|
+
: event.wsStatus === "error"
|
|
79
|
+
? palette.error
|
|
80
|
+
: palette.dim;
|
|
81
|
+
const wsStatusText = event.wsStatus.toUpperCase();
|
|
82
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.brand, bold: true, children: "INSPECT" }), _jsx(Text, { color: palette.subtle, children: "\u2022" }), _jsx(Text, { color: palette.ws, bold: true, children: "WS" }), _jsx(Text, { color: palette.text, bold: true, children: truncate(event.url, urlMax) })] }), _jsxs(Box, { gap: 1, marginTop: 0, children: [_jsx(Text, { color: palette.dim, children: formatTime(event.timestamp) }), _jsx(Text, { color: wsStatusColor, bold: true, children: wsStatusText }), _jsx(Text, { color: durationColor(event.duration), children: formatDuration(event.duration) })] }), _jsxs(Box, { children: [_jsx(Text, { color: palette.muted, children: event.host }), _jsxs(Text, { color: palette.subtle, children: [" ", "\u2192", " "] }), _jsx(Text, { color: palette.dim, children: truncate(event.target, targetMax) })] }), event.error && (_jsx(Box, { children: _jsx(Text, { color: palette.error, children: truncate(event.error, urlMax) }) }))] }));
|
|
83
|
+
}
|
|
84
|
+
export function DetailPanel({ termSize, scrollOffset, onScrollChange, onSelectionChange, active, }) {
|
|
85
|
+
const event = useSelected();
|
|
86
|
+
const detail = useSelectedDetail();
|
|
87
|
+
const panelWidth = Math.max(20, Math.floor(termSize.cols / 2));
|
|
88
|
+
const panelHeight = Math.max(4, termSize.rows);
|
|
89
|
+
const innerWidth = Math.max(12, panelWidth - BORDER_COLS - PADDING_X * 2);
|
|
90
|
+
const innerHeight = Math.max(1, panelHeight - BORDER_ROWS);
|
|
91
|
+
const nameMax = Math.max(8, Math.floor(innerWidth * 0.3));
|
|
92
|
+
const valueMax = Math.max(12, innerWidth - nameMax - 4);
|
|
93
|
+
const urlMax = Math.max(16, innerWidth - 10);
|
|
94
|
+
const targetMax = Math.max(16, innerWidth - 6);
|
|
95
|
+
const separatorWidth = Math.max(1, innerWidth - 1);
|
|
96
|
+
// Reset scroll when selection changes
|
|
97
|
+
const prevEventIdRef = useRef(undefined);
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (event?.id !== prevEventIdRef.current) {
|
|
100
|
+
prevEventIdRef.current = event?.id;
|
|
101
|
+
onSelectionChange();
|
|
102
|
+
}
|
|
103
|
+
}, [event?.id, onSelectionChange]);
|
|
104
|
+
const availableRows = Math.max(1, innerHeight - DETAIL_RESERVED);
|
|
105
|
+
const isWs = event?.type === "ws";
|
|
106
|
+
const lines = React.useMemo(() => (isWs ? buildWsDetailLines() : buildDetailLines(detail, nameMax, valueMax)), [isWs, detail, nameMax, valueMax]);
|
|
107
|
+
const totalLines = lines.length;
|
|
108
|
+
const maxScroll = Math.max(0, totalLines - availableRows);
|
|
109
|
+
const clampedOffset = Math.min(scrollOffset, maxScroll);
|
|
110
|
+
// Sync clamped value back to parent after paint (avoids flicker from render-time setState)
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (scrollOffset > maxScroll) {
|
|
113
|
+
onScrollChange(() => maxScroll);
|
|
114
|
+
}
|
|
115
|
+
}, [scrollOffset, maxScroll, onScrollChange]);
|
|
116
|
+
// When the event changes, the parent's onSelectionChange resets scrollOffset to 0.
|
|
117
|
+
// clampedOffset will be 0 on the subsequent render.
|
|
118
|
+
const displayOffset = clampedOffset;
|
|
119
|
+
if (!event)
|
|
120
|
+
return _jsx(PanelEmpty, { active: active, height: panelHeight });
|
|
121
|
+
return (_jsxs(Box, { width: "50%", height: panelHeight, flexDirection: "column", borderStyle: "double", borderColor: active ? palette.accent : palette.border, paddingX: PADDING_X, children: [event.type === "ws" ? (_jsx(WsSummary, { event: event, urlMax: urlMax, targetMax: targetMax })) : (_jsx(HttpSummary, { event: event, urlMax: urlMax, targetMax: targetMax })), _jsx(Text, { color: palette.subtle, children: "─".repeat(separatorWidth) }), _jsx(Box, { height: availableRows, flexDirection: "column", children: lines.slice(displayOffset, displayOffset + availableRows) })] }));
|
|
122
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import React, { useState, useEffect } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { palette } from "../utils/format.js";
|
|
5
|
+
function HintItem({ keys, label }) {
|
|
6
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: palette.accent, bold: true, children: keys }), _jsxs(Text, { color: palette.muted, children: [" ", label] })] }));
|
|
7
|
+
}
|
|
8
|
+
const MEM_INTERVAL = 5000;
|
|
9
|
+
function formatMB(bytes) {
|
|
10
|
+
const mb = bytes / (1024 * 1024);
|
|
11
|
+
return mb >= 100 ? `${mb.toFixed(0)} MB` : `${mb.toFixed(1)} MB`;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Standalone memory display — does NOT subscribe to the main store.
|
|
15
|
+
* Only triggers a re-render when the formatted string actually changes,
|
|
16
|
+
* keeping FooterBar completely idle during normal request traffic.
|
|
17
|
+
*/
|
|
18
|
+
function useMemoryDisplay() {
|
|
19
|
+
const [display, setDisplay] = useState(() => formatMB(process.memoryUsage.rss()));
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const id = setInterval(() => {
|
|
22
|
+
setDisplay((prev) => {
|
|
23
|
+
const next = formatMB(process.memoryUsage.rss());
|
|
24
|
+
return prev === next ? prev : next;
|
|
25
|
+
});
|
|
26
|
+
}, MEM_INTERVAL);
|
|
27
|
+
return () => {
|
|
28
|
+
clearInterval(id);
|
|
29
|
+
};
|
|
30
|
+
}, []);
|
|
31
|
+
return display;
|
|
32
|
+
}
|
|
33
|
+
export function FooterBar({ termSize, focus, showDetail }) {
|
|
34
|
+
const mem = useMemoryDisplay();
|
|
35
|
+
const base = [
|
|
36
|
+
...(showDetail ? [{ keys: "←/→", label: "FOCUS" }] : []),
|
|
37
|
+
focus === "detail" ? { keys: "↑/↓", label: "SCROLL" } : { keys: "↑/↓", label: "NAV" },
|
|
38
|
+
{ keys: "J/K", label: "NAV" },
|
|
39
|
+
{ keys: "G", label: "TOP" },
|
|
40
|
+
{ keys: "SHIFT+G", label: "END" },
|
|
41
|
+
{ keys: "ENTER", label: showDetail ? "INSPECT" : "DETAIL" },
|
|
42
|
+
{ keys: "/", label: "FILTER" },
|
|
43
|
+
{ keys: "F", label: "FOLLOW" },
|
|
44
|
+
{ keys: "N", label: "QUIET" },
|
|
45
|
+
{ keys: "E", label: "ERRORS" },
|
|
46
|
+
{ keys: "R", label: "REPLAY" },
|
|
47
|
+
{ keys: "Y", label: "COPY" },
|
|
48
|
+
{ keys: "X", label: "CLEAR" },
|
|
49
|
+
...(showDetail ? [{ keys: "ESC", label: "CLOSE" }] : []),
|
|
50
|
+
];
|
|
51
|
+
const maxHints = termSize.cols >= 140
|
|
52
|
+
? 12
|
|
53
|
+
: termSize.cols >= 120
|
|
54
|
+
? 10
|
|
55
|
+
: termSize.cols >= 100
|
|
56
|
+
? 8
|
|
57
|
+
: termSize.cols >= 80
|
|
58
|
+
? 6
|
|
59
|
+
: 4;
|
|
60
|
+
const hints = base.slice(0, maxHints);
|
|
61
|
+
return (_jsxs(Box, { width: "100%", paddingX: 1, justifyContent: "space-between", children: [_jsx(Box, { gap: 1, children: hints.map((hint, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { color: palette.subtle, children: "\u00B7" }), _jsx(HintItem, { ...hint })] }, `${hint.keys}-${hint.label}`))) }), _jsx(Text, { color: palette.dim, children: mem })] }));
|
|
62
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo, useMemo } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
import { useStore } from "../store.js";
|
|
5
|
+
import { DOMAIN, PROXY_PORT } from "../proxy/routes.js";
|
|
6
|
+
import { formatTime, formatDuration, formatSize, methodColor, statusColor, durationColor, compactUrl, truncate, pad, subdomainFrom, subdomainColor, palette, } from "../utils/format.js";
|
|
7
|
+
import { LIST_COL as COL, getListDimensions, buildListHeaderTokens, LIST_PADDING_X, } from "../utils/list-layout.js";
|
|
8
|
+
function headerTokenColor(token) {
|
|
9
|
+
switch (token.kind) {
|
|
10
|
+
case "requests":
|
|
11
|
+
return { color: palette.brand, bold: true };
|
|
12
|
+
case "follow":
|
|
13
|
+
return {
|
|
14
|
+
color: token.active ? palette.success : palette.dim,
|
|
15
|
+
bold: !!token.active,
|
|
16
|
+
};
|
|
17
|
+
case "meta":
|
|
18
|
+
return { color: palette.dim };
|
|
19
|
+
case "filter-label":
|
|
20
|
+
return { color: palette.subtle };
|
|
21
|
+
case "err":
|
|
22
|
+
return {
|
|
23
|
+
color: token.active ? palette.error : palette.dim,
|
|
24
|
+
bold: !!token.active,
|
|
25
|
+
};
|
|
26
|
+
case "quiet":
|
|
27
|
+
return {
|
|
28
|
+
color: token.active ? palette.accent : palette.dim,
|
|
29
|
+
bold: !!token.active,
|
|
30
|
+
};
|
|
31
|
+
case "query":
|
|
32
|
+
return { color: palette.accent, bold: true };
|
|
33
|
+
default:
|
|
34
|
+
return { color: palette.text };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const ListHeader = memo(function ListHeader({ count, available, followMode, errorsOnly, hideNextStatic, searchQuery, }) {
|
|
38
|
+
const { left, right } = buildListHeaderTokens({
|
|
39
|
+
available,
|
|
40
|
+
count,
|
|
41
|
+
followMode,
|
|
42
|
+
errorsOnly,
|
|
43
|
+
hideNextStatic,
|
|
44
|
+
searchQuery,
|
|
45
|
+
});
|
|
46
|
+
return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { gap: 1, children: left.map((token) => {
|
|
47
|
+
const { color, bold } = headerTokenColor(token);
|
|
48
|
+
return (_jsx(Text, { color: color, bold: bold, children: token.text }, `left-${token.kind}`));
|
|
49
|
+
}) }), _jsx(Box, { gap: 1, children: right.map((token, i) => {
|
|
50
|
+
const { color, bold } = headerTokenColor(token);
|
|
51
|
+
return (_jsx(Text, { color: color, bold: bold, children: token.text }, `right-${token.kind}-${i}`));
|
|
52
|
+
}) })] }));
|
|
53
|
+
});
|
|
54
|
+
const TableHeader = memo(function TableHeader({ pathW }) {
|
|
55
|
+
return (_jsx(Box, { children: _jsxs(Text, { color: palette.brand, bold: true, children: [" ", pad("REQ TIME", COL.time), pad("METHOD", COL.method), pad("SUB", COL.sub), pad("URL", pathW), pad("ST", COL.status), pad("SIZE", COL.size), "DR"] }) }));
|
|
56
|
+
});
|
|
57
|
+
function EmptyState() {
|
|
58
|
+
return (_jsxs(Box, { flexDirection: "column", paddingY: 2, paddingX: 2, children: [_jsxs(Text, { color: palette.brand, bold: true, children: ["\u25C9", " Listening for traffic", "\u2026"] }), _jsxs(Text, { color: palette.muted, children: ["Open ", _jsx(Text, { color: palette.accent, bold: true, children: `http://*.${DOMAIN}:${PROXY_PORT}` }), " ", "in your browser"] })] }));
|
|
59
|
+
}
|
|
60
|
+
const RequestRow = memo(function RequestRow({ event, isSelected, isEven, pathW, }) {
|
|
61
|
+
const pointer = isSelected ? "\u27A4" : " ";
|
|
62
|
+
const time = formatTime(event.timestamp);
|
|
63
|
+
const method = event.method.toUpperCase();
|
|
64
|
+
const sub = subdomainFrom(event.host);
|
|
65
|
+
const path = truncate(compactUrl(event.url), pathW - 1);
|
|
66
|
+
// WS-specific rendering
|
|
67
|
+
if (event.type === "ws") {
|
|
68
|
+
const wsStatusText = event.wsStatus === "open" ? "OPEN" : event.wsStatus === "closed" ? "CLOS" : "ERR";
|
|
69
|
+
const wsStatusColor = event.wsStatus === "open"
|
|
70
|
+
? palette.success
|
|
71
|
+
: event.wsStatus === "error"
|
|
72
|
+
? palette.error
|
|
73
|
+
: palette.dim;
|
|
74
|
+
const dur = formatDuration(event.duration);
|
|
75
|
+
const dColor = durationColor(event.duration);
|
|
76
|
+
const rowBg = isSelected ? palette.selection : undefined;
|
|
77
|
+
return (_jsx(Box, { children: _jsxs(Text, { backgroundColor: rowBg, dimColor: !isSelected && !isEven, children: [_jsx(Text, { color: isSelected ? palette.accent : palette.subtle, children: pointer }), _jsxs(Text, { color: isSelected ? palette.accent : palette.muted, children: [time, " "] }), _jsx(Text, { color: palette.ws, bold: true, children: pad(method, 7) }), _jsx(Text, { children: " " }), _jsx(Text, { color: subdomainColor(sub), bold: isSelected, children: pad(sub, COL.sub - 1) }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? palette.text : palette.dim, children: pad(path, pathW - 1) }), _jsx(Text, { color: wsStatusColor, bold: true, children: pad(wsStatusText, 5) }), _jsx(Text, { color: palette.muted, children: pad("-", COL.size) }), _jsxs(Text, { color: dColor, children: [" ", dur] })] }) }));
|
|
78
|
+
}
|
|
79
|
+
// HTTP rendering
|
|
80
|
+
const dur = formatDuration(event.duration);
|
|
81
|
+
const size = formatSize(event.responseSize);
|
|
82
|
+
const status = event.error
|
|
83
|
+
? "ERR"
|
|
84
|
+
: event.statusCode
|
|
85
|
+
? String(event.statusCode)
|
|
86
|
+
: "\u2022\u2022\u2022";
|
|
87
|
+
const sColor = event.error ? palette.error : statusColor(event.statusCode);
|
|
88
|
+
const dColor = durationColor(event.duration);
|
|
89
|
+
// Selected row: subtle background tint + brighter text, no inverse
|
|
90
|
+
const rowBg = isSelected ? palette.selection : undefined;
|
|
91
|
+
return (_jsx(Box, { children: _jsxs(Text, { backgroundColor: rowBg, dimColor: !isSelected && !isEven, children: [_jsx(Text, { color: isSelected ? palette.accent : palette.subtle, children: pointer }), _jsxs(Text, { color: isSelected ? palette.accent : palette.muted, children: [time, " "] }), _jsx(Text, { color: methodColor(method), bold: true, children: pad(method, 7) }), _jsx(Text, { children: " " }), _jsx(Text, { color: subdomainColor(sub), bold: isSelected, children: pad(sub, COL.sub - 1) }), _jsx(Text, { children: " " }), _jsx(Text, { color: isSelected ? palette.text : palette.dim, children: pad(path, pathW - 1) }), _jsx(Text, { color: sColor, bold: !!event.statusCode || !!event.error, children: pad(status, 5) }), _jsx(Text, { color: palette.dim, children: pad(size, COL.size) }), _jsxs(Text, { color: dColor, children: [" ", dur] })] }) }));
|
|
92
|
+
});
|
|
93
|
+
export function RequestList({ halfWidth, termSize, active }) {
|
|
94
|
+
const { events, selectedIndex, followMode, errorsOnly, hideNextStatic, searchQuery } = useStore();
|
|
95
|
+
const { innerWidth, pathW, visibleRows } = getListDimensions(termSize, halfWidth);
|
|
96
|
+
// Auto-scroll: keep selectedIndex visible
|
|
97
|
+
const scrollOffset = useMemo(() => {
|
|
98
|
+
if (events.length <= visibleRows)
|
|
99
|
+
return 0;
|
|
100
|
+
return Math.max(0, Math.min(selectedIndex - Math.floor(visibleRows / 2), events.length - visibleRows));
|
|
101
|
+
}, [events.length, selectedIndex, visibleRows]);
|
|
102
|
+
const visible = useMemo(() => events.slice(scrollOffset, scrollOffset + visibleRows), [events, scrollOffset, visibleRows]);
|
|
103
|
+
return (_jsx(Box, { width: halfWidth ? "50%" : "100%", height: termSize.rows, borderStyle: "double", borderColor: active ? palette.accent : palette.border, paddingX: LIST_PADDING_X, children: _jsxs(Box, { flexDirection: "column", flexGrow: 1, width: "100%", children: [_jsx(ListHeader, { count: events.length, available: innerWidth, followMode: followMode, errorsOnly: errorsOnly, hideNextStatic: hideNextStatic, searchQuery: searchQuery }), _jsx(TableHeader, { pathW: pathW }), _jsx(Text, { color: palette.subtle, children: "─".repeat(Math.max(1, innerWidth - 1)) }), events.length === 0 ? (_jsx(EmptyState, {})) : (visible.map((event, i) => (_jsx(RequestRow, { event: event, isSelected: scrollOffset + i === selectedIndex, isEven: (scrollOffset + i) % 2 === 0, pathW: pathW }, event.id))))] }) }));
|
|
104
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { DOMAIN, ROUTES, DEFAULT_TARGET, PROXY_PORT, HTTPS_PORT, } from "../proxy/routes.js";
|
|
4
|
+
import { useWorktrees } from "../proxy/worktrees.js";
|
|
5
|
+
import { palette } from "../utils/format.js";
|
|
6
|
+
export function Splash({ httpsEnabled = false }) {
|
|
7
|
+
const sorted = Object.entries(ROUTES).sort(([a], [b]) => a.localeCompare(b));
|
|
8
|
+
const worktrees = useWorktrees();
|
|
9
|
+
const line = "─".repeat(44);
|
|
10
|
+
return (_jsx(Box, { alignItems: "center", justifyContent: "center", flexGrow: 1, children: _jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: palette.accent, paddingX: 4, paddingY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { color: palette.accent, bold: true, children: "DEV-PROXY" }) }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.dim, children: "LIVE TRAFFIC INSPECTOR" }) }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.subtle, children: line }) }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [sorted.map(([sub, target]) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.brand, children: `${sub}.${DOMAIN}`.padEnd(22) }), _jsx(Text, { color: palette.subtle, children: "\u279C" }), _jsx(Text, { color: palette.dim, children: target })] }, sub))), DEFAULT_TARGET && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.muted, children: `*.${DOMAIN}`.padEnd(22) }), _jsx(Text, { color: palette.subtle, children: "\u279C" }), _jsx(Text, { color: palette.dim, children: DEFAULT_TARGET })] }))] }), (() => {
|
|
11
|
+
const wts = [...worktrees.entries()]
|
|
12
|
+
.filter(([b]) => b !== "main")
|
|
13
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
14
|
+
if (wts.length === 0)
|
|
15
|
+
return null;
|
|
16
|
+
return (_jsxs(_Fragment, { children: [_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.subtle, children: line }) }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.dim, children: "WORKTREES" }) }), _jsx(Box, { flexDirection: "column", marginTop: 1, children: wts.map(([branch, entry]) => {
|
|
17
|
+
let portLabel;
|
|
18
|
+
if ("ports" in entry) {
|
|
19
|
+
const vals = Object.values(entry.ports);
|
|
20
|
+
const first = vals[0];
|
|
21
|
+
portLabel =
|
|
22
|
+
vals.length > 1
|
|
23
|
+
? `:${first} +${vals.length - 1} more`
|
|
24
|
+
: `:${first}`;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
portLabel = `:${entry.port}`;
|
|
28
|
+
}
|
|
29
|
+
return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: palette.accent, children: branch.padEnd(14) }), _jsx(Text, { color: palette.muted, children: portLabel.padEnd(6) }), _jsx(Text, { color: palette.dim, children: `${branch}--*.${DOMAIN}` })] }, branch));
|
|
30
|
+
}) })] }));
|
|
31
|
+
})(), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.subtle, children: line }) }), _jsxs(Box, { justifyContent: "center", marginTop: 1, children: [_jsxs(Text, { color: palette.accent, bold: true, children: ["LISTENING :", PROXY_PORT] }), httpsEnabled && (_jsxs(_Fragment, { children: [_jsx(Text, { color: palette.subtle, children: " \u00B7 " }), _jsxs(Text, { color: palette.success, bold: true, children: ["TLS :", HTTPS_PORT] })] }))] }), _jsxs(Box, { justifyContent: "center", marginTop: 1, children: [_jsx(Text, { color: palette.muted, children: "Press " }), _jsx(Text, { color: palette.accent, bold: true, children: "Enter" }), _jsx(Text, { color: palette.muted, children: " to arm" })] }), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: palette.subtle, children: "/ filter \u00B7 j/k nav \u00B7 r replay \u00B7 y copy" }) })] }) }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { PROXY_PORT, HTTPS_PORT } from "../proxy/routes.js";
|
|
4
|
+
import { useWorktrees } from "../proxy/worktrees.js";
|
|
5
|
+
import { useStore } from "../store.js";
|
|
6
|
+
import { truncate, palette } from "../utils/format.js";
|
|
7
|
+
function Tag({ label, color }) {
|
|
8
|
+
return _jsx(Text, { color: color, bold: true, children: `[${label}]` });
|
|
9
|
+
}
|
|
10
|
+
export function StatusBar({ termSize, httpsEnabled }) {
|
|
11
|
+
const { followMode, events, errorsOnly, hideNextStatic, searchQuery, activeWsCount } = useStore();
|
|
12
|
+
const wts = useWorktrees();
|
|
13
|
+
const wtCount = [...wts.keys()].filter((k) => k !== "main").length;
|
|
14
|
+
const showFlags = termSize.cols >= 80;
|
|
15
|
+
const showSearch = !!searchQuery && termSize.cols >= 90;
|
|
16
|
+
const showHints = termSize.cols >= 120;
|
|
17
|
+
const query = searchQuery ? truncate(searchQuery, showHints ? 28 : 16) : "";
|
|
18
|
+
return (_jsxs(Box, { width: "100%", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: palette.brand, bold: true, children: ["\u25C9", " DEV-PROXY"] }), _jsx(Text, { color: palette.subtle, children: "\u2502" }), _jsx(Text, { color: palette.success, bold: true, children: "LIVE" }), _jsx(Text, { color: palette.subtle, children: "\u2502" }), _jsxs(Text, { color: palette.accent, bold: true, children: [":", PROXY_PORT] }), httpsEnabled && (_jsxs(_Fragment, { children: [_jsx(Text, { color: palette.subtle, children: "\u2502" }), _jsxs(Text, { color: palette.success, bold: true, children: ["TLS :", HTTPS_PORT] })] })), wtCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { color: palette.subtle, children: "\u2502" }), _jsx(Tag, { label: `WT ${wtCount}`, color: palette.accent })] }))] }), _jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: palette.dim, children: [events.length, " REQ"] }), activeWsCount > 0 && (_jsxs(Text, { color: palette.ws, bold: true, children: ["WS ", activeWsCount] })), showFlags && followMode && _jsx(Tag, { label: "FOL", color: palette.success }), showFlags && errorsOnly && _jsx(Tag, { label: "ERR", color: palette.error }), showFlags && hideNextStatic && _jsx(Tag, { label: "QUIET", color: palette.muted }), showSearch && _jsx(Tag, { label: `/${query}`, color: palette.accent }), showHints && _jsx(Text, { color: palette.muted, children: "J/K \u00B7 ENTER \u00B7 /" })] })] }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useStdin, useStdout } from "ink";
|
|
3
|
+
const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1006h";
|
|
4
|
+
const DISABLE_MOUSE = "\x1b[?1000l\x1b[?1006l";
|
|
5
|
+
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
|
|
6
|
+
const SGR_RE = /\x1b\[<(\d+);(\d+);(\d+)([mM])/g;
|
|
7
|
+
function parseSgrEvent(code, x, y, isDown) {
|
|
8
|
+
const isWheel = (code & 64) === 64;
|
|
9
|
+
if (isWheel) {
|
|
10
|
+
const direction = (code & 1) === 1 ? "down" : "up";
|
|
11
|
+
return { kind: "scroll", direction, x, y };
|
|
12
|
+
}
|
|
13
|
+
if (!isDown)
|
|
14
|
+
return null;
|
|
15
|
+
const button = code & 3;
|
|
16
|
+
if (button === 0)
|
|
17
|
+
return { kind: "down", button: "left", x, y };
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
export function useMouse(handler) {
|
|
21
|
+
const { stdout } = useStdout();
|
|
22
|
+
const { internal_eventEmitter } = useStdin();
|
|
23
|
+
const handlerRef = useRef(handler);
|
|
24
|
+
const bufferRef = useRef("");
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
handlerRef.current = handler;
|
|
27
|
+
}, [handler]);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
stdout.write(ENABLE_MOUSE);
|
|
30
|
+
const onInput = (data) => {
|
|
31
|
+
const chunk = typeof data === "string" ? data : data.toString("utf8");
|
|
32
|
+
bufferRef.current += chunk;
|
|
33
|
+
let match;
|
|
34
|
+
let lastIndex = 0;
|
|
35
|
+
SGR_RE.lastIndex = 0;
|
|
36
|
+
while ((match = SGR_RE.exec(bufferRef.current)) !== null) {
|
|
37
|
+
lastIndex = SGR_RE.lastIndex;
|
|
38
|
+
const code = Number(match[1]);
|
|
39
|
+
const x = Number(match[2]);
|
|
40
|
+
const y = Number(match[3]);
|
|
41
|
+
const isDown = match[4] === "M";
|
|
42
|
+
const event = parseSgrEvent(code, x, y, isDown);
|
|
43
|
+
if (event)
|
|
44
|
+
handlerRef.current(event);
|
|
45
|
+
}
|
|
46
|
+
if (lastIndex > 0) {
|
|
47
|
+
bufferRef.current = bufferRef.current.slice(lastIndex);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const prefixIndex = bufferRef.current.lastIndexOf("\x1b[<");
|
|
51
|
+
if (prefixIndex === -1)
|
|
52
|
+
bufferRef.current = "";
|
|
53
|
+
else if (prefixIndex > 0)
|
|
54
|
+
bufferRef.current = bufferRef.current.slice(prefixIndex);
|
|
55
|
+
}
|
|
56
|
+
if (bufferRef.current.length > 2000) {
|
|
57
|
+
bufferRef.current = bufferRef.current.slice(-200);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
internal_eventEmitter.on("input", onInput);
|
|
61
|
+
return () => {
|
|
62
|
+
internal_eventEmitter.removeListener("input", onInput);
|
|
63
|
+
stdout.write(DISABLE_MOUSE);
|
|
64
|
+
};
|
|
65
|
+
}, [stdout, internal_eventEmitter]);
|
|
66
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
process.title = "dev-proxy";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { createProxyServer, startProxyServer, destroyAgents } from "./proxy/server.js";
|
|
5
|
+
import { loadRegistry, stopRegistry } from "./proxy/worktrees.js";
|
|
6
|
+
import { pushHttp, pushWs } from "./store.js";
|
|
7
|
+
import { App } from "./components/app.js";
|
|
8
|
+
loadRegistry();
|
|
9
|
+
const { server, httpsServer, emitter } = createProxyServer();
|
|
10
|
+
let shuttingDown = false;
|
|
11
|
+
emitter.on("request", (event) => {
|
|
12
|
+
pushHttp(event);
|
|
13
|
+
});
|
|
14
|
+
emitter.on("request:complete", (event) => {
|
|
15
|
+
pushHttp(event);
|
|
16
|
+
});
|
|
17
|
+
emitter.on("request:error", (event) => {
|
|
18
|
+
pushHttp(event);
|
|
19
|
+
});
|
|
20
|
+
emitter.on("ws", (event) => {
|
|
21
|
+
pushWs(event);
|
|
22
|
+
});
|
|
23
|
+
try {
|
|
24
|
+
await startProxyServer(server, httpsServer);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
console.error(`[dev-proxy] Failed to start: ${err.message}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
// ── Alternate screen buffer (fullscreen) ─────────────────────
|
|
31
|
+
process.stdout.write("\x1b[?1049h\x1b[2J\x1b[H");
|
|
32
|
+
// ── Synchronized output wrapper ──────────────────────────────
|
|
33
|
+
// Buffers all stdout.write calls within the same event-loop tick and
|
|
34
|
+
// flushes them as a single atomic frame wrapped in DEC synchronized
|
|
35
|
+
// output markers (mode 2026). This prevents the terminal from painting
|
|
36
|
+
// intermediate states (cursor moves, partial line rewrites) which the
|
|
37
|
+
// user would perceive as flicker.
|
|
38
|
+
// Supported: iTerm2, kitty, WezTerm, Alacritty, Windows Terminal, etc.
|
|
39
|
+
// Unsupported terminals safely ignore the escape sequences.
|
|
40
|
+
const _raw = process.stdout.write.bind(process.stdout);
|
|
41
|
+
const MAX_BUF = 100;
|
|
42
|
+
let _buf = [];
|
|
43
|
+
let _pending = false;
|
|
44
|
+
let altScreenActive = true;
|
|
45
|
+
process.stdout.write = ((chunk, encodingOrCb, cb) => {
|
|
46
|
+
_buf.push(typeof chunk === "string"
|
|
47
|
+
? chunk
|
|
48
|
+
: Buffer.isBuffer(chunk)
|
|
49
|
+
? chunk.toString(typeof encodingOrCb === "string" ? encodingOrCb : "utf-8")
|
|
50
|
+
: String(chunk));
|
|
51
|
+
// Force early flush if buffer grows too large
|
|
52
|
+
if (_buf.length >= MAX_BUF) {
|
|
53
|
+
flushBufferedFrame();
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
if (!_pending) {
|
|
57
|
+
_pending = true;
|
|
58
|
+
setImmediate(() => {
|
|
59
|
+
try {
|
|
60
|
+
const frame = _buf.join("");
|
|
61
|
+
_buf = [];
|
|
62
|
+
if (frame)
|
|
63
|
+
_raw("\x1b[?2026h" + frame + "\x1b[?2026l");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// stdout closed or broken — drop the frame silently
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
_pending = false;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
74
|
+
if (callback)
|
|
75
|
+
callback();
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
/** Flush any buffered frame immediately via _raw (bypass setImmediate) */
|
|
79
|
+
function flushBufferedFrame() {
|
|
80
|
+
try {
|
|
81
|
+
if (_buf.length > 0) {
|
|
82
|
+
const frame = _buf.join("");
|
|
83
|
+
_buf = [];
|
|
84
|
+
if (frame)
|
|
85
|
+
_raw(frame);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* ignored: stdout may be closed during shutdown */
|
|
90
|
+
}
|
|
91
|
+
_pending = false;
|
|
92
|
+
}
|
|
93
|
+
const app = render(_jsx(App, { httpsEnabled: httpsServer !== null }), {
|
|
94
|
+
patchConsole: false,
|
|
95
|
+
});
|
|
96
|
+
// ── Graceful shutdown ────────────────────────────────────────
|
|
97
|
+
function restoreTerminal() {
|
|
98
|
+
if (!altScreenActive)
|
|
99
|
+
return;
|
|
100
|
+
altScreenActive = false;
|
|
101
|
+
_raw("\x1b[?1049l");
|
|
102
|
+
}
|
|
103
|
+
function shutdown(code = 0) {
|
|
104
|
+
if (shuttingDown)
|
|
105
|
+
return;
|
|
106
|
+
shuttingDown = true;
|
|
107
|
+
stopRegistry();
|
|
108
|
+
try {
|
|
109
|
+
app.unmount();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* ignored: best-effort cleanup */
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
server.close();
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
/* ignored: best-effort cleanup */
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
httpsServer?.close();
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* ignored: best-effort cleanup */
|
|
125
|
+
}
|
|
126
|
+
destroyAgents();
|
|
127
|
+
// Drain any buffered frame before leaving alternate screen
|
|
128
|
+
flushBufferedFrame();
|
|
129
|
+
restoreTerminal();
|
|
130
|
+
process.exit(code);
|
|
131
|
+
}
|
|
132
|
+
process.on("SIGINT", () => {
|
|
133
|
+
shutdown(0);
|
|
134
|
+
});
|
|
135
|
+
process.on("SIGTERM", () => {
|
|
136
|
+
shutdown(0);
|
|
137
|
+
});
|
|
138
|
+
process.on("SIGHUP", () => {
|
|
139
|
+
shutdown(0);
|
|
140
|
+
});
|
|
141
|
+
process.on("uncaughtException", (err, origin) => {
|
|
142
|
+
flushBufferedFrame();
|
|
143
|
+
restoreTerminal();
|
|
144
|
+
console.error(`[dev-proxy] Uncaught exception (${origin}): ${err.stack ?? err.message}`);
|
|
145
|
+
shutdown(1);
|
|
146
|
+
});
|
|
147
|
+
process.on("unhandledRejection", (reason) => {
|
|
148
|
+
const message = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
|
|
149
|
+
flushBufferedFrame();
|
|
150
|
+
restoreTerminal();
|
|
151
|
+
console.error(`[dev-proxy] Unhandled rejection: ${message}`);
|
|
152
|
+
shutdown(1);
|
|
153
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { execFileSync, execSync } from "node:child_process";
|
|
5
|
+
import { config, CONFIG_DIR } from "./config.js";
|
|
6
|
+
const CERTS_DIR = path.resolve(CONFIG_DIR, "certs");
|
|
7
|
+
const DEFAULT_CERT = path.join(CERTS_DIR, `${config.domain}+1.pem`);
|
|
8
|
+
const DEFAULT_KEY = path.join(CERTS_DIR, `${config.domain}+1-key.pem`);
|
|
9
|
+
const DOMAINS = [`*.${config.domain}`, config.domain];
|
|
10
|
+
function hasMkcert() {
|
|
11
|
+
try {
|
|
12
|
+
execSync("which mkcert", { stdio: "ignore" });
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// Expected: mkcert is simply not installed
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Resolves TLS certificate paths for the dev proxy.
|
|
22
|
+
*
|
|
23
|
+
* 1. If explicit paths are provided (from config), uses those.
|
|
24
|
+
* 2. Otherwise checks for certs at ~/.dev-proxy/certs/.
|
|
25
|
+
* 3. If missing, auto-generates using mkcert (installs local CA if needed).
|
|
26
|
+
* 4. If mkcert is not installed, logs instructions and returns null.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveCerts(configCertPath, configKeyPath) {
|
|
29
|
+
// Explicit config — use as-is
|
|
30
|
+
if (configCertPath && configKeyPath) {
|
|
31
|
+
if (fs.existsSync(configCertPath) && fs.existsSync(configKeyPath)) {
|
|
32
|
+
return { certPath: configCertPath, keyPath: configKeyPath };
|
|
33
|
+
}
|
|
34
|
+
console.error(`[dev-proxy] Configured cert/key not found:\n cert: ${configCertPath}\n key: ${configKeyPath}`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
// Default location — check if already generated
|
|
38
|
+
if (fs.existsSync(DEFAULT_CERT) && fs.existsSync(DEFAULT_KEY)) {
|
|
39
|
+
return { certPath: DEFAULT_CERT, keyPath: DEFAULT_KEY };
|
|
40
|
+
}
|
|
41
|
+
// Auto-generate with mkcert
|
|
42
|
+
if (!hasMkcert()) {
|
|
43
|
+
console.error("[dev-proxy] HTTPS disabled — mkcert not found.\n" +
|
|
44
|
+
" Install: brew install mkcert && mkcert -install");
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(CERTS_DIR, { recursive: true });
|
|
49
|
+
// Ensure local CA is installed (idempotent)
|
|
50
|
+
execFileSync("mkcert", ["-install"], { stdio: "ignore" });
|
|
51
|
+
// Generate wildcard cert (use execFileSync to avoid shell injection)
|
|
52
|
+
execFileSync("mkcert", ["-cert-file", DEFAULT_CERT, "-key-file", DEFAULT_KEY, ...DOMAINS], { stdio: "inherit" });
|
|
53
|
+
// Restrict cert file permissions to owner-only (0600)
|
|
54
|
+
try {
|
|
55
|
+
fs.chmodSync(DEFAULT_CERT, constants.S_IRUSR | constants.S_IWUSR);
|
|
56
|
+
fs.chmodSync(DEFAULT_KEY, constants.S_IRUSR | constants.S_IWUSR);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Non-fatal: best-effort permission tightening
|
|
60
|
+
}
|
|
61
|
+
console.warn("[dev-proxy] TLS certificates generated with mkcert");
|
|
62
|
+
return { certPath: DEFAULT_CERT, keyPath: DEFAULT_KEY };
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
console.error(`[dev-proxy] HTTPS disabled — mkcert failed: ${err.message}`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|