@tridha643/hestia 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ import * as node_tls from 'node:tls';
2
+ import * as http from 'node:http';
3
+ import * as net from 'node:net';
4
+
5
+ /** Route info used by the proxy server to map hostnames to ports. */
6
+ interface RouteInfo {
7
+ hostname: string;
8
+ port: number;
9
+ }
10
+ interface ProxyServerOptions {
11
+ /** Called on each request to get the current route table. */
12
+ getRoutes: () => RouteInfo[];
13
+ /** The port the proxy is listening on (used to build correct URLs). */
14
+ proxyPort: number;
15
+ /** TLD suffix used for hostnames (default: "localhost"). */
16
+ tld?: string;
17
+ /** All TLD suffixes used for hostnames. The first one is used for examples. */
18
+ tlds?: string[];
19
+ /**
20
+ * When true, only exact hostname matches are used. Unregistered subdomain
21
+ * prefixes return 404 instead of falling back to the base service.
22
+ * Defaults to true.
23
+ */
24
+ strict?: boolean;
25
+ /** Optional error logger; defaults to console.error. */
26
+ onError?: (message: string) => void;
27
+ /** When provided, enables HTTP/2 over TLS (HTTPS). */
28
+ tls?: {
29
+ cert: Buffer;
30
+ key: Buffer;
31
+ /** CA certificate to include in the chain so clients can verify the leaf. */
32
+ ca?: Buffer;
33
+ /** SNI callback for per-hostname certificate selection. */
34
+ SNICallback?: (servername: string, cb: (err: Error | null, ctx?: node_tls.SecureContext) => void) => void;
35
+ };
36
+ }
37
+
38
+ /** Response header used to identify a portless proxy (for health checks). */
39
+ declare const PORTLESS_HEADER = "X-Portless";
40
+ /** Server type returned by createProxyServer (plain HTTP/1.1 or net.Server TLS wrapper). */
41
+ type ProxyServer = http.Server | net.Server;
42
+ /**
43
+ * Create an HTTP proxy server that routes requests based on the Host header.
44
+ *
45
+ * Uses Node's built-in http module for proxying (no external dependencies).
46
+ * The `getRoutes` callback is invoked on every request so callers can provide
47
+ * either a static list or a live-updating one.
48
+ *
49
+ * When `tls` is provided, creates an HTTP/2 secure server with HTTP/1.1
50
+ * fallback (`allowHTTP1: true`). This enables HTTP/2 multiplexing for
51
+ * browsers while keeping WebSocket upgrades working over HTTP/1.1.
52
+ */
53
+ declare function createProxyServer(options: ProxyServerOptions): ProxyServer;
54
+ /**
55
+ * Create a minimal HTTP server that 302-redirects every request to HTTPS.
56
+ * Meant to run on port 80 alongside an HTTPS proxy on port 443.
57
+ */
58
+ declare function createHttpRedirectServer(httpsPort: number): http.Server;
59
+
60
+ /** File permission mode for route and state files. */
61
+ declare const FILE_MODE = 420;
62
+ /** Directory permission mode for the state directory. */
63
+ declare const DIR_MODE = 493;
64
+ interface RouteMapping extends RouteInfo {
65
+ pid: number;
66
+ tailscaleUrl?: string;
67
+ tailscaleHttpsPort?: number;
68
+ tailscaleFunnel?: boolean;
69
+ ngrokUrl?: string;
70
+ ngrokPid?: number;
71
+ }
72
+ type RouteMetadataPatch = {
73
+ tailscaleUrl?: string | null;
74
+ tailscaleHttpsPort?: number | null;
75
+ tailscaleFunnel?: boolean | null;
76
+ ngrokUrl?: string | null;
77
+ ngrokPid?: number | null;
78
+ };
79
+ /**
80
+ * Thrown when a route is already registered by a live process and --force was
81
+ * not specified. With --force, the existing process is killed instead.
82
+ */
83
+ declare class RouteConflictError extends Error {
84
+ readonly hostname: string;
85
+ readonly existingPid: number;
86
+ constructor(hostname: string, existingPid: number);
87
+ }
88
+ /**
89
+ * Manages route mappings stored as a JSON file on disk.
90
+ * Supports file locking and stale-route cleanup.
91
+ */
92
+ declare class RouteStore {
93
+ /** The state directory path. */
94
+ readonly dir: string;
95
+ private readonly routesPath;
96
+ private readonly lockPath;
97
+ readonly pidPath: string;
98
+ readonly portFilePath: string;
99
+ private readonly onWarning;
100
+ constructor(dir: string, options?: {
101
+ onWarning?: (message: string) => void;
102
+ });
103
+ ensureDir(): void;
104
+ getRoutesPath(): string;
105
+ private static readonly sleepBuffer;
106
+ private syncSleep;
107
+ private acquireLock;
108
+ private releaseLock;
109
+ private isProcessAlive;
110
+ /**
111
+ * Load routes from disk, filtering out stale entries whose owning process
112
+ * is no longer alive. Stale-route cleanup is only persisted when the caller
113
+ * already holds the lock (i.e. inside addRoute/removeRoute) to avoid
114
+ * unprotected concurrent writes.
115
+ */
116
+ loadRoutes(persistCleanup?: boolean): RouteMapping[];
117
+ private saveRoutes;
118
+ /**
119
+ * Register a route. When `force` is true and the hostname is already claimed
120
+ * by another live process, that process is sent SIGTERM before the route is
121
+ * replaced. Returns the PID of the killed process (if any) so the caller can
122
+ * log it.
123
+ */
124
+ addRoute(hostname: string, port: number, pid: number, force?: boolean): number | undefined;
125
+ /**
126
+ * Load all routes from disk without filtering out dead PIDs. Used by
127
+ * `portless prune` to discover stale entries whose owning CLI is gone
128
+ * but whose dev server may still be holding a port.
129
+ */
130
+ loadRoutesRaw(): RouteMapping[];
131
+ /**
132
+ * Remove all route entries whose owning process is dead and persist the
133
+ * result. Returns the removed stale entries so the caller can act on them.
134
+ */
135
+ pruneStaleRoutes(): RouteMapping[];
136
+ /**
137
+ * Update metadata on an existing route entry. Only provided fields are
138
+ * merged; the route must already exist (matched by hostname).
139
+ */
140
+ updateRoute(hostname: string, fields: RouteMetadataPatch): void;
141
+ /**
142
+ * Remove a route by hostname. When `ownerPid` is provided, the entry is
143
+ * only removed while it is still owned by that pid. Exit cleanups must
144
+ * pass their own pid: after a `--force` takeover the killed process would
145
+ * otherwise deregister the route the new owner just registered.
146
+ */
147
+ removeRoute(hostname: string, ownerPid?: number): void;
148
+ }
149
+
150
+ /**
151
+ * When running under sudo, fix file ownership so the real user can
152
+ * read/write the file later without sudo. No-op on Windows or when not
153
+ * running as root.
154
+ */
155
+ declare function fixOwnership(...paths: string[]): void;
156
+ /** Type guard for Node.js system errors with an error code. */
157
+ declare function isErrnoException(err: unknown): err is NodeJS.ErrnoException;
158
+ /** Return whether a process exists, treating permission denial as alive. */
159
+ declare function isProcessAlive(pid: number): boolean;
160
+ /**
161
+ * Escape HTML special characters to prevent XSS.
162
+ */
163
+ declare function escapeHtml(str: string): string;
164
+ /**
165
+ * Format a URL for the given hostname. Omits the port when it matches the
166
+ * protocol default (80 for HTTP, 443 for HTTPS).
167
+ */
168
+ declare function formatUrl(hostname: string, proxyPort: number, tls?: boolean): string;
169
+ /**
170
+ * Parse and normalize a hostname input for use as a subdomain of the
171
+ * configured TLD. Strips protocol prefixes, validates characters, and
172
+ * appends the TLD suffix if needed.
173
+ */
174
+ declare function parseHostname(input: string, tld?: string): string;
175
+ /**
176
+ * Parse a hostname input for every configured TLD. If the input already ends
177
+ * with one of those TLDs, use the stripped base name for the full set.
178
+ */
179
+ declare function parseHostnames(input: string, tlds?: readonly string[]): string[];
180
+
181
+ /**
182
+ * Extract the portless-managed block from /etc/hosts content.
183
+ * Returns the lines between the markers (exclusive), or an empty array
184
+ * if no managed block exists.
185
+ */
186
+ declare function extractManagedBlock(content: string): string[];
187
+ /**
188
+ * Remove the portless-managed block from /etc/hosts content and return
189
+ * the cleaned content with trailing newlines normalized.
190
+ */
191
+ declare function removeBlock(content: string): string;
192
+ /**
193
+ * Build a portless-managed block for the given hostnames.
194
+ */
195
+ declare function buildBlock(hostnames: string[]): string;
196
+ /**
197
+ * Whether the proxy should write route hostnames to the hosts file.
198
+ * Disabled only when `PORTLESS_SYNC_HOSTS` is `0` or `false` (opt-out).
199
+ */
200
+ declare function shouldAutoSyncHosts(syncVal: string | undefined): boolean;
201
+ /**
202
+ * Sync /etc/hosts to include entries for all given hostnames.
203
+ * Replaces any existing portless-managed block. Requires root access.
204
+ * Returns true on success, false on failure.
205
+ */
206
+ declare function syncHostsFile(hostnames: string[]): boolean;
207
+ /**
208
+ * Remove the portless-managed block from /etc/hosts.
209
+ * Returns true on success, false on failure.
210
+ */
211
+ declare function cleanHostsFile(): boolean;
212
+ /**
213
+ * Return the current portless-managed hostnames from /etc/hosts.
214
+ */
215
+ declare function getManagedHostnames(): string[];
216
+ /**
217
+ * Check whether a hostname resolves to 127.0.0.1 via the system DNS resolver.
218
+ * Returns true if resolution works, false otherwise.
219
+ */
220
+ declare function checkHostResolution(hostname: string): Promise<boolean>;
221
+
222
+ export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServer, type ProxyServerOptions, RouteConflictError, type RouteInfo, type RouteMapping, RouteStore, buildBlock, checkHostResolution, cleanHostsFile, createHttpRedirectServer, createProxyServer, escapeHtml, extractManagedBlock, fixOwnership, formatUrl, getManagedHostnames, isErrnoException, isProcessAlive, parseHostname, parseHostnames, removeBlock, shouldAutoSyncHosts, syncHostsFile };
@@ -0,0 +1,48 @@
1
+ import {
2
+ DIR_MODE,
3
+ FILE_MODE,
4
+ PORTLESS_HEADER,
5
+ RouteConflictError,
6
+ RouteStore,
7
+ buildBlock,
8
+ checkHostResolution,
9
+ cleanHostsFile,
10
+ createHttpRedirectServer,
11
+ createProxyServer,
12
+ escapeHtml,
13
+ extractManagedBlock,
14
+ fixOwnership,
15
+ formatUrl,
16
+ getManagedHostnames,
17
+ isErrnoException,
18
+ isProcessAlive,
19
+ parseHostname,
20
+ parseHostnames,
21
+ removeBlock,
22
+ shouldAutoSyncHosts,
23
+ syncHostsFile
24
+ } from "./chunk-SD2PIWJU.js";
25
+ export {
26
+ DIR_MODE,
27
+ FILE_MODE,
28
+ PORTLESS_HEADER,
29
+ RouteConflictError,
30
+ RouteStore,
31
+ buildBlock,
32
+ checkHostResolution,
33
+ cleanHostsFile,
34
+ createHttpRedirectServer,
35
+ createProxyServer,
36
+ escapeHtml,
37
+ extractManagedBlock,
38
+ fixOwnership,
39
+ formatUrl,
40
+ getManagedHostnames,
41
+ isErrnoException,
42
+ isProcessAlive,
43
+ parseHostname,
44
+ parseHostnames,
45
+ removeBlock,
46
+ shouldAutoSyncHosts,
47
+ syncHostsFile
48
+ };
@@ -0,0 +1,177 @@
1
+ diff --git a/dist/cli.js b/dist/cli.js
2
+ --- a/dist/cli.js
3
+ +++ b/dist/cli.js
4
+ @@ -2867,7 +2867,12 @@
5
+ var SYSTEMD_SERVICE = "portless.service";
6
+ var WINDOWS_TASK_NAME = "Portless Proxy";
7
+ var INTERNAL_ELEVATED_ENV = "PORTLESS_INTERNAL_SERVICE_ELEVATED";
8
+ -var SERVICE_ENV_KEYS = /* @__PURE__ */ new Set(["PORTLESS_SYNC_HOSTS"]);
9
+ +var SERVICE_ENV_KEYS = /* @__PURE__ */ new Set([
10
+ + "PORTLESS_SYNC_HOSTS",
11
+ + "HESTIA_PORTLESS_ROUTES_PATH",
12
+ + "HESTIA_PORTLESS_ROOT_STATE",
13
+ + "HESTIA_PORTLESS_ROUTES_UID"
14
+ +]);
15
+ function normalizeTlds(tlds) {
16
+ return [...new Set(tlds.length > 0 ? tlds : [DEFAULT_TLD])];
17
+ }
18
+ @@ -4067,9 +4072,86 @@
19
+ } catch {
20
+ }
21
+ fixOwnership(routesPath);
22
+ - let cachedRoutes = store.loadRoutes();
23
+ + const externalRoutesPath = process.env.HESTIA_PORTLESS_ROUTES_PATH;
24
+ + const loadExternalRoutes = () => {
25
+ + if (!externalRoutesPath) return [];
26
+ + let fd;
27
+ + try {
28
+ + fd = fs9.openSync(
29
+ + externalRoutesPath,
30
+ + fs9.constants.O_RDONLY | (fs9.constants.O_NOFOLLOW ?? 0)
31
+ + );
32
+ + const stat = fs9.fstatSync(fd);
33
+ + const expectedUid = Number(process.env.HESTIA_PORTLESS_ROUTES_UID);
34
+ + if (!stat.isFile() || stat.size > 1024 * 1024 || !Number.isInteger(expectedUid) || stat.uid !== expectedUid) {
35
+ + return [];
36
+ + }
37
+ + const parsed = JSON.parse(fs9.readFileSync(fd, "utf8"));
38
+ + if (!Array.isArray(parsed)) return [];
39
+ + return parsed.filter((route) =>
40
+ + typeof route === "object" && route !== null &&
41
+ + typeof route.hostname === "string" &&
42
+ + route.hostname.endsWith(".localhost") && route.hostname.length <= 253 &&
43
+ + route.hostname.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)) &&
44
+ + Number.isInteger(route.port) && route.port > 0 && route.port <= 65535 &&
45
+ + Number.isInteger(route.pid) && route.pid > 0 &&
46
+ + typeof route.startTime === "string" && route.startTime.length > 0 && route.startTime.length <= 128
47
+ + );
48
+ + } catch {
49
+ + return [];
50
+ + } finally {
51
+ + if (fd !== void 0) fs9.closeSync(fd);
52
+ + }
53
+ + };
54
+ + const loadAllRoutes = () => {
55
+ + const externalRoutes = loadExternalRoutes();
56
+ + const externalHostnames = new Set(externalRoutes.map((route) => route.hostname));
57
+ + return [
58
+ + ...store.loadRoutes().filter((route) => !externalHostnames.has(route.hostname)),
59
+ + ...externalRoutes
60
+ + ];
61
+ + };
62
+ + let cachedRoutes = loadAllRoutes();
63
+ + let liveRouteIdentities = /* @__PURE__ */ new Set();
64
+ + const routeIdentityKey = (route) => `${route.pid}\0${route.startTime}`;
65
+ + const refreshRouteIdentities = () => {
66
+ + const guardedRoutes = cachedRoutes.filter((route) => typeof route.startTime === "string");
67
+ + if (guardedRoutes.length === 0) {
68
+ + liveRouteIdentities = /* @__PURE__ */ new Set();
69
+ + return;
70
+ + }
71
+ + try {
72
+ + const pids = [...new Set(guardedRoutes.map((route) => route.pid))];
73
+ + const output = execFileSync("ps", ["-p", pids.join(","), "-o", "pid=,lstart="], {
74
+ + encoding: "utf8",
75
+ + timeout: 1e3,
76
+ + env: { ...process.env, LC_ALL: "C", LANG: "C" }
77
+ + });
78
+ + const identities = /* @__PURE__ */ new Set();
79
+ + for (const line of output.split("\n")) {
80
+ + const match = line.match(/^\s*(\d+)\s+(.+)$/);
81
+ + if (match) identities.add(`${Number(match[1])}\0${match[2].trim()}`);
82
+ + }
83
+ + liveRouteIdentities = identities;
84
+ + } catch {
85
+ + liveRouteIdentities = /* @__PURE__ */ new Set();
86
+ + }
87
+ + };
88
+ + const routeProcessIsAlive = (route) => {
89
+ + if (route.pid === 0) return true;
90
+ + try {
91
+ + process.kill(route.pid, 0);
92
+ + } catch {
93
+ + return false;
94
+ + }
95
+ + return typeof route.startTime !== "string" || liveRouteIdentities.has(routeIdentityKey(route));
96
+ + };
97
+ + refreshRouteIdentities();
98
+ + const identityRefreshInterval = setInterval(refreshRouteIdentities, 500);
99
+ + identityRefreshInterval.unref();
100
+ let debounceTimer = null;
101
+ let watcher = null;
102
+ + let externalWatcher = null;
103
+ let pollingInterval = null;
104
+ const autoSyncHosts = shouldAutoSyncHosts(process.env.PORTLESS_SYNC_HOSTS);
105
+ const onMdnsError = (msg) => console.warn(chalk.yellow(msg));
106
+ @@ -4098,7 +4180,8 @@
107
+ const reloadRoutes = () => {
108
+ try {
109
+ const previousRoutes = new Map(cachedRoutes.map((r) => [r.hostname, r.port]));
110
+ - cachedRoutes = store.loadRoutes();
111
+ + cachedRoutes = loadAllRoutes();
112
+ + refreshRouteIdentities();
113
+ if (autoSyncHosts) {
114
+ syncHostsFile(cachedRoutes.map((r) => r.hostname));
115
+ }
116
+ @@ -4131,12 +4214,24 @@
117
+ console.warn(colors_default.yellow("fs.watch unavailable; falling back to polling for route changes"));
118
+ pollingInterval = setInterval(reloadRoutes, POLL_INTERVAL_MS);
119
+ }
120
+ + if (externalRoutesPath) {
121
+ + try {
122
+ + const externalBasename = path9.basename(externalRoutesPath);
123
+ + externalWatcher = fs9.watch(path9.dirname(externalRoutesPath), (_event, filename) => {
124
+ + if (filename && filename.toString() !== externalBasename) return;
125
+ + if (debounceTimer) clearTimeout(debounceTimer);
126
+ + debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
127
+ + });
128
+ + } catch {
129
+ + if (!pollingInterval) pollingInterval = setInterval(reloadRoutes, POLL_INTERVAL_MS);
130
+ + }
131
+ + }
132
+ if (autoSyncHosts) {
133
+ syncHostsFile(cachedRoutes.map((r) => r.hostname));
134
+ }
135
+ publishCachedRoutes();
136
+ const server = createProxyServer({
137
+ - getRoutes: () => cachedRoutes,
138
+ + getRoutes: () => cachedRoutes.filter(routeProcessIsAlive),
139
+ proxyPort,
140
+ tld,
141
+ tlds,
142
+ @@ -4171,9 +4266,9 @@
143
+ redirectServer.on("error", () => {
144
+ redirectServer = null;
145
+ });
146
+ - redirectServer.listen(80);
147
+ + redirectServer.listen(80, "127.0.0.1");
148
+ }
149
+ - server.listen(proxyPort, () => {
150
+ + server.listen(proxyPort, "127.0.0.1", () => {
151
+ fs9.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
152
+ fs9.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
153
+ writeTlsMarker(store.dir, isTls);
154
+ @@ -4215,10 +4310,12 @@
155
+ exiting = true;
156
+ if (debounceTimer) clearTimeout(debounceTimer);
157
+ if (pollingInterval) clearInterval(pollingInterval);
158
+ + clearInterval(identityRefreshInterval);
159
+ if (lanMonitor) lanMonitor.stop();
160
+ if (watcher) {
161
+ watcher.close();
162
+ }
163
+ + if (externalWatcher) externalWatcher.close();
164
+ if (activeLanIp) cleanupAll();
165
+ if (redirectServer) {
166
+ redirectServer.close();
167
+ diff --git a/dist/chunk-SD2PIWJU.js b/dist/chunk-SD2PIWJU.js
168
+ --- a/dist/chunk-SD2PIWJU.js
169
+ +++ b/dist/chunk-SD2PIWJU.js
170
+ @@ -2,6 +2,7 @@
171
+ import * as fs from "fs";
172
+ function fixOwnership(...paths) {
173
+ if (process.platform === "win32") return;
174
+ + if (process.env.HESTIA_PORTLESS_ROOT_STATE === "1") return;
175
+ const uid = process.env.SUDO_UID;
176
+ const gid = process.env.SUDO_GID;
177
+ if (!uid || process.getuid?.() !== 0) return;
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "portless",
3
+ "version": "0.15.1",
4
+ "description": "Replace port numbers with stable, named .localhost URLs. For humans and agents.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "bin": {
15
+ "portless": "./dist/cli.js"
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "engines": {
21
+ "node": ">=24"
22
+ },
23
+ "os": [
24
+ "darwin",
25
+ "linux",
26
+ "win32"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "dev": "tsup --watch",
31
+ "lint": "eslint src/",
32
+ "lint:fix": "eslint src/ --fix",
33
+ "prepublishOnly": "cp ../../README.md . && pnpm build",
34
+ "test": "vitest run",
35
+ "test:coverage": "vitest run --coverage",
36
+ "test:watch": "vitest",
37
+ "type-check": "tsc --noEmit"
38
+ },
39
+ "keywords": [
40
+ "local",
41
+ "development",
42
+ "proxy",
43
+ "localhost"
44
+ ],
45
+ "author": "Vercel Labs",
46
+ "license": "Apache-2.0",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/vercel-labs/portless.git"
50
+ },
51
+ "homepage": "https://portless.sh",
52
+ "bugs": {
53
+ "url": "https://github.com/vercel-labs/portless/issues"
54
+ },
55
+ "devDependencies": {
56
+ "@types/node": "^24.12.4",
57
+ "@vitest/coverage-v8": "^4.0.18",
58
+ "eslint": "^9.39.2",
59
+ "tsup": "^8.0.1",
60
+ "typescript": "^5.3.3",
61
+ "vitest": "^4.0.18"
62
+ }
63
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "package": "portless",
3
+ "version": "0.15.1",
4
+ "patchSha256": "9ca94ca771ce502769a2c7cbe3bb4fe8d54dc847ff9f7e3e2b34abf728618690"
5
+ }