9remote 2.0.2 → 2.0.7

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/index.js DELETED
@@ -1,275 +0,0 @@
1
- /**
2
- * Server entry point - config-driven HTTP router + Socket.IO
3
- */
4
-
5
- import { createServer, request as httpRequest } from "http";
6
- import { exec, spawn } from "child_process";
7
- import { readFileSync, existsSync } from "fs";
8
- import { join, extname } from "path";
9
- import { fileURLToPath } from "url";
10
- import chalk from "chalk";
11
-
12
- import { createRouter, jsonOk, jsonErr } from "./lib/router.js";
13
- import { STEP, browserFetch, PERMISSION_POLL_MS } from "./lib/constants.js";
14
- import { setupSocketIO, getIO } from "./lib/socketio.js";
15
- import { setCorsHeaders, handlePreflight } from "./middleware/cors.js";
16
- import { createProxyServer, handleProxyRequest, startProxySession, endProxySession } from "./proxy/index.js";
17
- import { initializeTerminal } from "./features/terminal/terminalSocket.js";
18
- import { handleLocalSites } from "./api/localSites.js";
19
- import { verifyApiKeyCrc } from "./cli/utils/apiKey.js";
20
- import { isNewerVersion } from "./cli/utils/updateChecker.js";
21
-
22
- import {
23
- loadUiState, loadDesktopState, refreshPermissionsAsync, pushUiEvent, setRemoteAvailable,
24
- handleSseEvents, handleStateGet, handleStatePost,
25
- handleStop, handleStart, handleShutdown,
26
- handleConnections, handleDesktopToggle,
27
- handlePermissionsGet, handlePermissionsRequest,
28
- } from "./api/ui.js";
29
- import { handleOneTimeKey, handleRegenerate } from "./api/key.js";
30
- import { handleApprove, handleReject, handlePending, handleApproved, handleRemove, handleDisconnect, handleRejected, handleApproveRejected, handleClearRejected, handleGetAutoApprove, handleSetAutoApprove } from "./api/device.js";
31
- import { handleNotifyPost, handleNotifyGet } from "./api/notify.js";
32
-
33
- const __dirname = fileURLToPath(new URL(".", import.meta.url));
34
- const IS_DEV = process.env.NODE_ENV === "development";
35
- const VITE_PORT = 5173;
36
- const NPM_REGISTRY_URL = "https://registry.npmjs.org/9remote/latest";
37
-
38
- const UI_DIST = existsSync(join(__dirname, "ui", "dist"))
39
- ? join(__dirname, "ui", "dist")
40
- : join(__dirname, "ui");
41
-
42
- const MIME_TYPES = {
43
- ".html": "text/html", ".js": "application/javascript", ".css": "text/css",
44
- ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon",
45
- ".woff2": "font/woff2", ".woff": "font/woff",
46
- };
47
-
48
- // ── Static / Dev helpers ─────────────────────────────────────────
49
-
50
- function proxyToVite(req, res, fallbackFn) {
51
- const proxy = httpRequest(
52
- { hostname: "localhost", port: VITE_PORT, path: req.url, method: req.method, headers: req.headers },
53
- (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); }
54
- );
55
- proxy.on("error", () => fallbackFn ? fallbackFn() : (() => { res.writeHead(502); res.end("Vite dev server not ready"); })());
56
- req.pipe(proxy);
57
- }
58
-
59
- function serveStatic(res, filePath) {
60
- if (!existsSync(filePath)) return false;
61
- const mime = MIME_TYPES[extname(filePath)] || "application/octet-stream";
62
- res.setHeader("Content-Type", mime);
63
- res.writeHead(200);
64
- res.end(readFileSync(filePath));
65
- return true;
66
- }
67
-
68
- async function checkForUpdate(currentVersion) {
69
- try {
70
- const res = await browserFetch(NPM_REGISTRY_URL);
71
- if (!res.ok) return;
72
- const { version } = await res.json();
73
- if (version && isNewerVersion(currentVersion, version)) pushUiEvent("updateAvailable", { version });
74
- } catch { /* non-critical */ }
75
- }
76
-
77
- // ── Codespace handler ────────────────────────────────────────
78
-
79
- function handleCodespaceStop(req, res) {
80
- if (process.env.CODESPACES !== "true") { jsonErr(res, 400, "Not running on Codespaces"); return; }
81
- const name = process.env.CODESPACE_NAME;
82
- if (!name) { jsonErr(res, 400, "Codespace name not found"); return; }
83
- const io = getIO();
84
- if (io) io.emit("codespace:stopping");
85
- jsonOk(res, { success: true, message: "Stopping codespace..." });
86
- setTimeout(() => exec(`gh codespace stop -c ${name}`, { windowsHide: true }), 500);
87
- }
88
-
89
- // ── Proxy handlers ────────────────────────────────────────────
90
-
91
- let proxyServer;
92
-
93
- async function handleProxyStartEnd(req, res, { pathname }) {
94
- // Verify API key (required for tunnel access)
95
- const authHeader = req.headers.authorization;
96
- if (!authHeader || !authHeader.startsWith("Bearer ") || !verifyApiKeyCrc(authHeader.slice(7))) {
97
- jsonErr(res, 401, "Unauthorized");
98
- return;
99
- }
100
- const { parseJsonBody } = await import("./lib/router.js");
101
- const data = await parseJsonBody(req, res);
102
- if (!data) return;
103
- if (!data.port) { jsonErr(res, 400, "Port required"); return; }
104
- pathname.endsWith("start") ? startProxySession(data.port) : endProxySession(data.port);
105
- jsonOk(res, { success: true });
106
- }
107
-
108
- function handleProxy(req, res, { pathname, search }) {
109
- const match = pathname.match(/^\/proxy\/(\d+)(\/.*)?$/);
110
- if (match) {
111
- handleProxyRequest(proxyServer, req, res, match[1], match[2] || "/", search);
112
- } else {
113
- jsonErr(res, 404, "Not found");
114
- }
115
- }
116
-
117
- // ────────────────────────────────────────────────────────────────────────────
118
- // ROUTE TABLE — single source of truth for all HTTP endpoints
119
- // public: true = accessible via tunnel, false/omit = localhost-only
120
- // ────────────────────────────────────────────────────────────────────────────
121
-
122
- const ROUTES = [
123
- // Public routes (accessible via tunnel)
124
- { path: "/api/health", method: "GET", public: true, handler: (req, res) => jsonOk(res, { status: "ok", timestamp: Date.now() }) },
125
- { path: "/api/version", method: "GET", public: true, handler: (req, res) => {
126
- const version = typeof __CLI_VERSION__ !== "undefined"
127
- ? __CLI_VERSION__
128
- : JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
129
- jsonOk(res, { version });
130
- }},
131
- { path: "/api/notify", method: "POST", public: true, handler: handleNotifyPost },
132
- { path: "/api/notify", method: "GET", public: true, handler: handleNotifyGet },
133
- { path: "/proxy/*", method: "*", public: true, handler: handleProxy },
134
-
135
- // UI state & SSE (localhost-only)
136
- { path: "/api/ui/events", method: "GET", handler: handleSseEvents },
137
- { path: "/api/ui/state", method: "GET", handler: handleStateGet },
138
- { path: "/api/ui/state", method: "POST", handler: handleStatePost },
139
- { path: "/api/ui/stop", method: "POST", handler: handleStop },
140
- { path: "/api/ui/start", method: "POST", handler: handleStart },
141
- { path: "/api/ui/shutdown", method: "POST", handler: handleShutdown },
142
-
143
- // Key management (localhost-only)
144
- { path: "/api/key/one-time", method: "POST", handler: handleOneTimeKey },
145
- { path: "/api/key/regenerate", method: "POST", handler: handleRegenerate },
146
-
147
- // Device approval (localhost-only)
148
- { path: "/api/device/approve", method: "POST", handler: handleApprove },
149
- { path: "/api/device/reject", method: "POST", handler: handleReject },
150
- { path: "/api/device/pending", method: "GET", handler: handlePending },
151
- { path: "/api/device/approved", method: "GET", handler: handleApproved },
152
- { path: "/api/device/rejected", method: "GET", handler: handleRejected },
153
- { path: "/api/device/approve-rejected", method: "POST", handler: handleApproveRejected },
154
- { path: "/api/device/clear-rejected", method: "POST", handler: handleClearRejected },
155
- { path: "/api/device/remove", method: "POST", handler: handleRemove },
156
- { path: "/api/device/disconnect", method: "POST", handler: handleDisconnect },
157
- { path: "/api/device/auto-approve", method: "GET", handler: handleGetAutoApprove },
158
- { path: "/api/device/auto-approve", method: "POST", handler: handleSetAutoApprove },
159
-
160
- // System (localhost-only)
161
- { path: "/api/connections", method: "GET", handler: handleConnections },
162
- { path: "/api/permissions", method: "GET", handler: handlePermissionsGet },
163
- { path: "/api/permissions/request", method: "POST", handler: handlePermissionsRequest },
164
- { path: "/api/desktop/toggle", method: "POST", handler: handleDesktopToggle },
165
- { path: "/api/local-sites", method: "*", public: true, handler: handleLocalSites },
166
- { path: "/api/codespace/stop", method: "POST", handler: handleCodespaceStop },
167
-
168
- // Proxy session management (tunnel-accessible, requires API key)
169
- { path: "/api/proxy/start", method: "POST", public: true, handler: handleProxyStartEnd },
170
- { path: "/api/proxy/end", method: "POST", public: true, handler: handleProxyStartEnd },
171
- ];
172
-
173
- // ── Server bootstrap ───────────────────────────────────────────────────────
174
-
175
- const hostname = "localhost";
176
- const port = parseInt(process.env.PORT || "2208", 10);
177
-
178
- // Auto-start Vite dev server in dev mode
179
- let viteProcess = null;
180
- function startViteDev() {
181
- if (!IS_DEV) return;
182
- const viteConfigPath = join(__dirname, "vite.config.js");
183
- if (!existsSync(viteConfigPath)) return;
184
- const viteBin = existsSync(join(__dirname, "node_modules", ".bin", "vite"))
185
- ? join(__dirname, "node_modules", ".bin", "vite")
186
- : join(__dirname, "..", "node_modules", ".bin", "vite");
187
- viteProcess = spawn("node", [viteBin, "--config", viteConfigPath], {
188
- cwd: __dirname,
189
- stdio: "ignore",
190
- detached: false,
191
- });
192
- viteProcess.on("error", () => {});
193
- viteProcess.unref();
194
- }
195
-
196
- export async function startServer() {
197
- await initializeTerminal();
198
- proxyServer = createProxyServer();
199
- startViteDev();
200
-
201
- // Static file / Vite dev fallback (localhost-only)
202
- const staticFallback = (req, res, { pathname }) => {
203
- if (pathname.startsWith("/api/") || pathname.startsWith("/socket.io")) {
204
- jsonErr(res, 404, "Not found");
205
- return;
206
- }
207
- const isTunnel = !!req.headers["cf-connecting-ip"];
208
- const isLocal = req.socket.remoteAddress === "127.0.0.1" || req.socket.remoteAddress === "::1";
209
- if (isTunnel || !isLocal) { jsonErr(res, 403, "Forbidden"); return; }
210
- const serveStaticFiles = () => {
211
- const filePath = (pathname === "/" || pathname === "") ? join(UI_DIST, "index.html") : join(UI_DIST, pathname);
212
- if (serveStatic(res, filePath)) return;
213
- serveStatic(res, join(UI_DIST, "index.html"));
214
- };
215
- if (IS_DEV) { proxyToVite(req, res, serveStaticFiles); return; }
216
- serveStaticFiles();
217
- };
218
-
219
- const router = createRouter(ROUTES, { fallback: staticFallback });
220
-
221
- const server = createServer(async (req, res) => {
222
- setCorsHeaders(res);
223
- if (handlePreflight(req, res)) return;
224
- await router(req, res);
225
- });
226
-
227
- loadUiState();
228
- loadDesktopState();
229
- refreshPermissionsAsync();
230
- // macOS TCC has no change event — poll to detect permission revoke/grant
231
- if (process.platform === "darwin") {
232
- setInterval(refreshPermissionsAsync, PERMISSION_POLL_MS);
233
- }
234
-
235
- await setupSocketIO(server);
236
-
237
- server.listen(port, (err) => {
238
- if (err) throw err;
239
- const version = typeof __CLI_VERSION__ !== "undefined"
240
- ? __CLI_VERSION__
241
- : JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8")).version;
242
- checkForUpdate(version);
243
- });
244
-
245
- // Graceful shutdown
246
- let isShuttingDown = false;
247
- const gracefulShutdown = async (signal) => {
248
- if (isShuttingDown) return;
249
- isShuttingDown = true;
250
- console.log(chalk.yellow(`\n🛑 Received ${signal}, shutting down gracefully...`));
251
- const forceExit = setTimeout(() => { console.log(chalk.red("⚠️ Forced exit")); process.exit(1); }, 5000);
252
- try {
253
- server.close(() => console.log(chalk.gray("✓ HTTP server closed")));
254
- if (viteProcess) { try { viteProcess.kill(); } catch {} }
255
- const io = getIO();
256
- if (io) { io.emit("server:shutdown"); io.close(() => console.log(chalk.gray("✓ Socket.IO closed"))); }
257
- await new Promise((r) => setTimeout(r, 500));
258
- clearTimeout(forceExit);
259
- console.log(chalk.green("✅ Server stopped cleanly"));
260
- process.exit(0);
261
- } catch (error) {
262
- console.error(chalk.red("❌ Error during shutdown:"), error);
263
- clearTimeout(forceExit);
264
- process.exit(1);
265
- }
266
- };
267
-
268
- process.on("SIGINT", () => gracefulShutdown("SIGINT"));
269
- process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
270
- if (process.platform === "win32") process.on("SIGBREAK", () => gracefulShutdown("SIGBREAK"));
271
-
272
- return server;
273
- }
274
-
275
- startServer();
package/lib/constants.js DELETED
@@ -1,64 +0,0 @@
1
- import dns from "dns";
2
-
3
- // Bypass broken macOS system DNS resolver for long hostnames (e.g. trycloudflare).
4
- // Skip localhost/IP to avoid slow/hanging DNS resolve on Windows.
5
- const _originalLookup = dns.lookup;
6
- const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$|^::1$|^[0-9a-f:]+$/i;
7
- dns.lookup = (hostname, options, cb) => {
8
- if (typeof options === "function") { cb = options; options = {}; }
9
- if (hostname === "localhost" || IP_REGEX.test(hostname)) {
10
- return _originalLookup(hostname, options, cb);
11
- }
12
- dns.resolve4(hostname, (err, addrs) => {
13
- if (err) return _originalLookup(hostname, options, cb);
14
- if (options.all) {
15
- cb(null, addrs.map(a => ({ address: a, family: 4 })));
16
- } else {
17
- cb(null, addrs[0], 4);
18
- }
19
- });
20
- };
21
-
22
- /**
23
- * Shared constants for agent server + CLI
24
- */
25
-
26
- // Connection step states (UI progress tracking)
27
- export const STEP = {
28
- STOPPED: 0,
29
- PREPARING: 1,
30
- CONNECTING: 2,
31
- TUNNELING: 3,
32
- VERIFYING: 4,
33
- READY: 5,
34
- };
35
-
36
- // Debug flags — toggle diagnostic displays without touching logic
37
- export const DEBUG = {
38
- showTunnelUrlInMenu: false,
39
- };
40
-
41
- // macOS permission poll — TCC has no change event, so we poll periodically.
42
- // Fast cadence kicks in right after user clicks "Grant" so UI reflects the toggle quickly.
43
- export const PERMISSION_POLL_MS = 5000;
44
- export const PERMISSION_POLL_FAST_MS = 1000;
45
- export const PERMISSION_POLL_FAST_DURATION = 60000;
46
-
47
- // Browser-like headers to avoid CDN/firewall blocks
48
- const BROWSER_HEADERS = {
49
- "Accept": "application/json, text/plain, */*",
50
- "Accept-Language": "en-US,en;q=0.9",
51
- "Cache-Control": "no-cache",
52
- "Pragma": "no-cache",
53
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
54
- };
55
-
56
- /**
57
- * fetch() wrapper with browser-like headers for external requests
58
- */
59
- export function browserFetch(url, options = {}) {
60
- return fetch(url, {
61
- ...options,
62
- headers: { ...BROWSER_HEADERS, ...options.headers },
63
- });
64
- }
@@ -1,152 +0,0 @@
1
- /**
2
- * Device approval manager — persists approved deviceIds to ~/.9remote/approvedDevices.json
3
- */
4
-
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
6
- import { join } from "path";
7
- import { homedir } from "os";
8
-
9
- const STATE_DIR = join(homedir(), ".9remote");
10
- const DEVICES_FILE = join(STATE_DIR, "approvedDevices.json");
11
- const CONFIG_FILE = join(STATE_DIR, "config.json");
12
-
13
- // deviceId -> { approvedAt }
14
- let approvedDevices = new Map();
15
-
16
- // Auto-approve new device connections (default false). Persisted to config.json.
17
- let autoApprove = false;
18
-
19
- // Pending approval requests: socketId -> { deviceId, ip }
20
- const pendingApprovals = new Map();
21
-
22
- // Rejected devices (RAM only, cleared on restart): deviceId -> { ip, rejectedAt, socketId }
23
- const rejectedDevices = new Map();
24
-
25
- function ensureDir() {
26
- if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
27
- }
28
-
29
- export function loadApprovedDevices() {
30
- try {
31
- ensureDir();
32
- if (existsSync(DEVICES_FILE)) {
33
- const data = JSON.parse(readFileSync(DEVICES_FILE, "utf8"));
34
- // Migrate from old array format to new map format
35
- if (Array.isArray(data)) {
36
- approvedDevices = new Map(data.map((id) => [id, { approvedAt: null }]));
37
- } else {
38
- approvedDevices = new Map(Object.entries(data));
39
- }
40
- }
41
- } catch {
42
- approvedDevices = new Map();
43
- }
44
- }
45
-
46
- function saveApprovedDevices() {
47
- try {
48
- ensureDir();
49
- writeFileSync(DEVICES_FILE, JSON.stringify(Object.fromEntries(approvedDevices), null, 2));
50
- } catch {}
51
- }
52
-
53
- export function isDeviceApproved(deviceId) {
54
- if (!deviceId) return false;
55
- return approvedDevices.has(deviceId);
56
- }
57
-
58
- export function approveDevice(deviceId) {
59
- if (!deviceId) return;
60
- approvedDevices.set(deviceId, { approvedAt: new Date().toISOString() });
61
- saveApprovedDevices();
62
- }
63
-
64
- export function removeDevice(deviceId) {
65
- approvedDevices.delete(deviceId);
66
- saveApprovedDevices();
67
- }
68
-
69
- export function getApprovedDevices() {
70
- return [...approvedDevices.entries()].map(([id, meta]) => ({ deviceId: id, ...meta }));
71
- }
72
-
73
- // Pending approval queue
74
- export function addPendingApproval(socketId, data) {
75
- pendingApprovals.set(socketId, data);
76
- }
77
-
78
- export function getPendingApproval(socketId) {
79
- return pendingApprovals.get(socketId) || null;
80
- }
81
-
82
- export function removePendingApproval(socketId) {
83
- pendingApprovals.delete(socketId);
84
- }
85
-
86
- export function isDevicePending(deviceId) {
87
- if (!deviceId) return false;
88
- for (const data of pendingApprovals.values()) {
89
- if (data.deviceId === deviceId) return true;
90
- }
91
- return false;
92
- }
93
-
94
- export function getAllPendingApprovals() {
95
- return [...pendingApprovals.entries()].map(([socketId, data]) => ({ socketId, ...data }));
96
- }
97
-
98
- // Rejected devices (pending re-approval from Clients list)
99
- export function markDeviceRejected(deviceId, data) {
100
- if (!deviceId) return;
101
- rejectedDevices.set(deviceId, { ...data, rejectedAt: new Date().toISOString() });
102
- }
103
-
104
- export function isDeviceRejected(deviceId) {
105
- if (!deviceId) return false;
106
- return rejectedDevices.has(deviceId);
107
- }
108
-
109
- export function updateRejectedSocket(deviceId, socketId, ip) {
110
- const entry = rejectedDevices.get(deviceId);
111
- if (entry) rejectedDevices.set(deviceId, { ...entry, socketId, ip });
112
- }
113
-
114
- export function getRejectedDevices() {
115
- return [...rejectedDevices.entries()].map(([deviceId, meta]) => ({ deviceId, ...meta }));
116
- }
117
-
118
- export function clearRejectedDevice(deviceId) {
119
- rejectedDevices.delete(deviceId);
120
- }
121
-
122
- // ── Auto-approve setting (persisted) ────────────────────────────────
123
- function readConfig() {
124
- try {
125
- if (existsSync(CONFIG_FILE)) return JSON.parse(readFileSync(CONFIG_FILE, "utf8")) || {};
126
- } catch {}
127
- return {};
128
- }
129
-
130
- function writeConfig(cfg) {
131
- try {
132
- ensureDir();
133
- writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2));
134
- } catch {}
135
- }
136
-
137
- export function loadAutoApprove() {
138
- autoApprove = !!readConfig().autoApprove;
139
- return autoApprove;
140
- }
141
-
142
- export function isAutoApprove() {
143
- return autoApprove;
144
- }
145
-
146
- export function setAutoApprove(enabled) {
147
- autoApprove = !!enabled;
148
- const cfg = readConfig();
149
- cfg.autoApprove = autoApprove;
150
- writeConfig(cfg);
151
- return autoApprove;
152
- }
package/lib/router.js DELETED
@@ -1,134 +0,0 @@
1
- /**
2
- * Lightweight config-driven HTTP router
3
- * - Route table with path, method, handler
4
- * - Public route whitelist (bypass localhost check)
5
- * - Auto body parsing for POST/PUT
6
- */
7
-
8
- import { parse } from "url";
9
-
10
- function readBody(req) {
11
- return new Promise((resolve) => {
12
- let body = "";
13
- req.on("data", (chunk) => body += chunk);
14
- req.on("end", () => resolve(body));
15
- });
16
- }
17
-
18
- export function jsonOk(res, data = { ok: true }) {
19
- res.setHeader("Content-Type", "application/json");
20
- res.writeHead(200);
21
- res.end(JSON.stringify(data));
22
- }
23
-
24
- export function jsonErr(res, code, msg) {
25
- res.setHeader("Content-Type", "application/json");
26
- res.writeHead(code);
27
- res.end(JSON.stringify({ error: msg }));
28
- }
29
-
30
- /**
31
- * Parse JSON body with error handling, returns parsed object or null
32
- */
33
- export async function parseJsonBody(req, res) {
34
- try {
35
- const raw = await readBody(req);
36
- return JSON.parse(raw || "{}");
37
- } catch {
38
- jsonErr(res, 400, "Invalid JSON");
39
- return null;
40
- }
41
- }
42
-
43
- /**
44
- * Create router from route config
45
- * @param {Array<{path: string, method: string, handler: Function, public?: boolean}>} routes
46
- * @param {Object} options
47
- * @param {Function} options.fallback - Called when no route matches
48
- * @returns {Function} HTTP request handler (req, res) => void
49
- */
50
- export function createRouter(routes, { fallback } = {}) {
51
- // Pre-build lookup map: "METHOD:/path" → { handler, public }
52
- const exactMap = new Map();
53
- const prefixRoutes = [];
54
-
55
- for (const route of routes) {
56
- const methods = route.method === "*" ? ["GET", "POST", "PUT", "DELETE"] : [route.method];
57
- for (const m of methods) {
58
- if (route.path.endsWith("/*")) {
59
- prefixRoutes.push({ ...route, prefix: route.path.slice(0, -2), method: m });
60
- } else {
61
- exactMap.set(`${m}:${route.path}`, route);
62
- }
63
- }
64
- }
65
-
66
- // Collect public paths for fast localhost check
67
- const publicExact = new Set();
68
- const publicPrefixes = [];
69
- for (const route of routes) {
70
- if (!route.public) continue;
71
- if (route.path.endsWith("/*")) {
72
- publicPrefixes.push(route.path.slice(0, -2));
73
- } else {
74
- publicExact.add(route.path);
75
- }
76
- }
77
-
78
- function isPublic(pathname) {
79
- if (publicExact.has(pathname)) return true;
80
- return publicPrefixes.some((p) => pathname.startsWith(p));
81
- }
82
-
83
- return async (req, res) => {
84
- const parsedUrl = parse(req.url, true);
85
- const { pathname, search } = parsedUrl;
86
-
87
- // Localhost guard — block non-public routes from remote/tunnel
88
- const isTunnel = !!req.headers["cf-connecting-ip"];
89
- const isLocal = req.socket.remoteAddress === "127.0.0.1" || req.socket.remoteAddress === "::1";
90
- if (!isPublic(pathname) && (isTunnel || !isLocal)) {
91
- jsonErr(res, 403, "Forbidden");
92
- return;
93
- }
94
-
95
- // Exact match
96
- const key = `${req.method}:${pathname}`;
97
- const route = exactMap.get(key);
98
- if (route) {
99
- try {
100
- await route.handler(req, res, { pathname, query: parsedUrl.query, search });
101
- } catch (err) {
102
- console.error("Error:", req.url, err);
103
- jsonErr(res, 500, err.message);
104
- }
105
- return;
106
- }
107
-
108
- // Prefix match
109
- for (const pr of prefixRoutes) {
110
- if ((pr.method === req.method) && pathname.startsWith(pr.prefix)) {
111
- try {
112
- await pr.handler(req, res, { pathname, query: parsedUrl.query, search });
113
- } catch (err) {
114
- console.error("Error:", req.url, err);
115
- jsonErr(res, 500, err.message);
116
- }
117
- return;
118
- }
119
- }
120
-
121
- // Fallback (static files, etc.)
122
- if (fallback) {
123
- try {
124
- await fallback(req, res, { pathname, search });
125
- } catch (err) {
126
- console.error("Error:", req.url, err);
127
- jsonErr(res, 500, err.message);
128
- }
129
- return;
130
- }
131
-
132
- jsonErr(res, 404, "Not found");
133
- };
134
- }