portless 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -86,11 +86,34 @@ flowchart TD
86
86
 
87
87
  Apps are assigned a random port (4000-4999) via the `PORT` environment variable. Most frameworks (Next.js, Vite, etc.) respect this automatically.
88
88
 
89
+ ## HTTP/2 + HTTPS
90
+
91
+ Enable HTTP/2 for faster dev server page loads. Browsers limit HTTP/1.1 to 6 connections per host, which bottlenecks dev servers that serve many unbundled files (Vite, Nuxt, etc.). HTTP/2 multiplexes all requests over a single connection.
92
+
93
+ ```bash
94
+ # Start with HTTPS/2 -- generates certs and trusts them automatically
95
+ portless proxy start --https
96
+
97
+ # First run prompts for sudo once to add the CA to your system trust store.
98
+ # After that, no prompts. No browser warnings.
99
+
100
+ # Make it permanent (add to .bashrc / .zshrc)
101
+ export PORTLESS_HTTPS=1
102
+ portless proxy start # HTTPS by default now
103
+
104
+ # Use your own certs (e.g., from mkcert)
105
+ portless proxy start --cert ./cert.pem --key ./key.pem
106
+
107
+ # If you skipped sudo on first run, trust the CA later
108
+ sudo portless trust
109
+ ```
110
+
89
111
  ## Commands
90
112
 
91
113
  ```bash
92
114
  portless <name> <cmd> [args...] # Run app at http://<name>.localhost:1355
93
115
  portless list # Show active routes
116
+ portless trust # Add local CA to system trust store
94
117
 
95
118
  # Disable portless (run command directly)
96
119
  PORTLESS=0 pnpm dev # Bypasses proxy, uses default port
@@ -98,6 +121,7 @@ PORTLESS=0 pnpm dev # Bypasses proxy, uses default port
98
121
 
99
122
  # Proxy control
100
123
  portless proxy start # Start the proxy (port 1355, daemon)
124
+ portless proxy start --https # Start with HTTP/2 + TLS
101
125
  portless proxy start -p 80 # Start on port 80 (requires sudo)
102
126
  portless proxy start --foreground # Start in foreground (for debugging)
103
127
  portless proxy stop # Stop the proxy
@@ -105,10 +129,15 @@ portless proxy stop # Stop the proxy
105
129
  # Options
106
130
  -p, --port <number> # Port for the proxy (default: 1355)
107
131
  # Ports < 1024 require sudo
132
+ --https # Enable HTTP/2 + TLS with auto-generated certs
133
+ --cert <path> # Use a custom TLS certificate (implies --https)
134
+ --key <path> # Use a custom TLS private key (implies --https)
135
+ --no-tls # Disable HTTPS (overrides PORTLESS_HTTPS)
108
136
  --foreground # Run proxy in foreground instead of daemon
109
137
 
110
138
  # Environment variables
111
139
  PORTLESS_PORT=<number> # Override the default proxy port
140
+ PORTLESS_HTTPS=1 # Always enable HTTPS
112
141
  PORTLESS_STATE_DIR=<path> # Override the state directory
113
142
 
114
143
  # Info
@@ -5,8 +5,10 @@ function isErrnoException(err) {
5
5
  function escapeHtml(str) {
6
6
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
7
7
  }
8
- function formatUrl(hostname, proxyPort) {
9
- return proxyPort === 80 ? `http://${hostname}` : `http://${hostname}:${proxyPort}`;
8
+ function formatUrl(hostname, proxyPort, tls = false) {
9
+ const proto = tls ? "https" : "http";
10
+ const defaultPort = tls ? 443 : 80;
11
+ return proxyPort === defaultPort ? `${proto}://${hostname}` : `${proto}://${hostname}:${proxyPort}`;
10
12
  }
11
13
  function parseHostname(input) {
12
14
  let hostname = input.trim().replace(/^https?:\/\//, "").split("/")[0].toLowerCase();
@@ -30,24 +32,40 @@ function parseHostname(input) {
30
32
 
31
33
  // src/proxy.ts
32
34
  import * as http from "http";
35
+ import * as http2 from "http2";
36
+ import * as net from "net";
33
37
  var PORTLESS_HEADER = "X-Portless";
34
- function buildForwardedHeaders(req) {
38
+ var HOP_BY_HOP_HEADERS = /* @__PURE__ */ new Set([
39
+ "connection",
40
+ "keep-alive",
41
+ "proxy-connection",
42
+ "transfer-encoding",
43
+ "upgrade"
44
+ ]);
45
+ function getRequestHost(req) {
46
+ const authority = req.headers[":authority"];
47
+ if (typeof authority === "string" && authority) return authority;
48
+ return req.headers.host || "";
49
+ }
50
+ function buildForwardedHeaders(req, tls) {
35
51
  const headers = {};
36
52
  const remoteAddress = req.socket.remoteAddress || "127.0.0.1";
37
- const proto = "http";
38
- const hostHeader = req.headers.host || "";
53
+ const proto = tls ? "https" : "http";
54
+ const defaultPort = tls ? "443" : "80";
55
+ const hostHeader = getRequestHost(req);
39
56
  headers["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remoteAddress}` : remoteAddress;
40
57
  headers["x-forwarded-proto"] = req.headers["x-forwarded-proto"] || proto;
41
58
  headers["x-forwarded-host"] = req.headers["x-forwarded-host"] || hostHeader;
42
- headers["x-forwarded-port"] = req.headers["x-forwarded-port"] || hostHeader.split(":")[1] || "80";
59
+ headers["x-forwarded-port"] = req.headers["x-forwarded-port"] || hostHeader.split(":")[1] || defaultPort;
43
60
  return headers;
44
61
  }
45
62
  function createProxyServer(options) {
46
- const { getRoutes, proxyPort, onError = (msg) => console.error(msg) } = options;
63
+ const { getRoutes, proxyPort, onError = (msg) => console.error(msg), tls } = options;
64
+ const isTls = !!tls;
47
65
  const handleRequest = (req, res) => {
48
66
  res.setHeader(PORTLESS_HEADER, "1");
49
67
  const routes = getRoutes();
50
- const host = (req.headers.host || "").split(":")[0];
68
+ const host = getRequestHost(req).split(":")[0];
51
69
  if (!host) {
52
70
  res.writeHead(400, { "Content-Type": "text/plain" });
53
71
  res.end("Missing Host header");
@@ -66,7 +84,7 @@ function createProxyServer(options) {
66
84
  ${routes.length > 0 ? `
67
85
  <h2>Active apps:</h2>
68
86
  <ul>
69
- ${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort))}">${escapeHtml(r.hostname)}</a> - localhost:${escapeHtml(String(r.port))}</li>`).join("")}
87
+ ${routes.map((r) => `<li><a href="${escapeHtml(formatUrl(r.hostname, proxyPort, isTls))}">${escapeHtml(r.hostname)}</a> - localhost:${escapeHtml(String(r.port))}</li>`).join("")}
70
88
  </ul>
71
89
  ` : "<p><em>No apps running.</em></p>"}
72
90
  <p>Start an app with: <code>portless ${safeHost.replace(".localhost", "")} your-command</code></p>
@@ -75,11 +93,16 @@ function createProxyServer(options) {
75
93
  `);
76
94
  return;
77
95
  }
78
- const forwardedHeaders = buildForwardedHeaders(req);
96
+ const forwardedHeaders = buildForwardedHeaders(req, isTls);
79
97
  const proxyReqHeaders = { ...req.headers };
80
98
  for (const [key, value] of Object.entries(forwardedHeaders)) {
81
99
  proxyReqHeaders[key] = value;
82
100
  }
101
+ for (const key of Object.keys(proxyReqHeaders)) {
102
+ if (key.startsWith(":")) {
103
+ delete proxyReqHeaders[key];
104
+ }
105
+ }
83
106
  const proxyReq = http.request(
84
107
  {
85
108
  hostname: "127.0.0.1",
@@ -89,12 +112,18 @@ function createProxyServer(options) {
89
112
  headers: proxyReqHeaders
90
113
  },
91
114
  (proxyRes) => {
92
- res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
115
+ const responseHeaders = { ...proxyRes.headers };
116
+ if (isTls) {
117
+ for (const h of HOP_BY_HOP_HEADERS) {
118
+ delete responseHeaders[h];
119
+ }
120
+ }
121
+ res.writeHead(proxyRes.statusCode || 502, responseHeaders);
93
122
  proxyRes.pipe(res);
94
123
  }
95
124
  );
96
125
  proxyReq.on("error", (err) => {
97
- onError(`Proxy error for ${req.headers.host}: ${err.message}`);
126
+ onError(`Proxy error for ${getRequestHost(req)}: ${err.message}`);
98
127
  if (!res.headersSent) {
99
128
  const errWithCode = err;
100
129
  const message = errWithCode.code === "ECONNREFUSED" ? "Bad Gateway: the target app is not responding. It may have crashed." : "Bad Gateway: the target app may not be running.";
@@ -116,17 +145,22 @@ function createProxyServer(options) {
116
145
  };
117
146
  const handleUpgrade = (req, socket, head) => {
118
147
  const routes = getRoutes();
119
- const host = (req.headers.host || "").split(":")[0];
148
+ const host = getRequestHost(req).split(":")[0];
120
149
  const route = routes.find((r) => r.hostname === host);
121
150
  if (!route) {
122
151
  socket.destroy();
123
152
  return;
124
153
  }
125
- const forwardedHeaders = buildForwardedHeaders(req);
154
+ const forwardedHeaders = buildForwardedHeaders(req, isTls);
126
155
  const proxyReqHeaders = { ...req.headers };
127
156
  for (const [key, value] of Object.entries(forwardedHeaders)) {
128
157
  proxyReqHeaders[key] = value;
129
158
  }
159
+ for (const key of Object.keys(proxyReqHeaders)) {
160
+ if (key.startsWith(":")) {
161
+ delete proxyReqHeaders[key];
162
+ }
163
+ }
130
164
  const proxyReq = http.request({
131
165
  hostname: "127.0.0.1",
132
166
  port: route.port,
@@ -152,7 +186,7 @@ function createProxyServer(options) {
152
186
  socket.on("error", () => proxySocket.destroy());
153
187
  });
154
188
  proxyReq.on("error", (err) => {
155
- onError(`WebSocket proxy error for ${req.headers.host}: ${err.message}`);
189
+ onError(`WebSocket proxy error for ${getRequestHost(req)}: ${err.message}`);
156
190
  socket.destroy();
157
191
  });
158
192
  proxyReq.on("response", (res) => {
@@ -173,6 +207,44 @@ function createProxyServer(options) {
173
207
  }
174
208
  proxyReq.end();
175
209
  };
210
+ if (tls) {
211
+ const h2Server = http2.createSecureServer({
212
+ cert: tls.cert,
213
+ key: tls.key,
214
+ allowHTTP1: true,
215
+ ...tls.SNICallback ? { SNICallback: tls.SNICallback } : {}
216
+ });
217
+ h2Server.on("request", (req, res) => {
218
+ handleRequest(req, res);
219
+ });
220
+ h2Server.on("upgrade", (req, socket, head) => {
221
+ handleUpgrade(req, socket, head);
222
+ });
223
+ const plainServer = http.createServer(handleRequest);
224
+ plainServer.on("upgrade", handleUpgrade);
225
+ const wrapper = net.createServer((socket) => {
226
+ socket.once("readable", () => {
227
+ const buf = socket.read(1);
228
+ if (!buf) {
229
+ socket.destroy();
230
+ return;
231
+ }
232
+ socket.unshift(buf);
233
+ if (buf[0] === 22) {
234
+ h2Server.emit("connection", socket);
235
+ } else {
236
+ plainServer.emit("connection", socket);
237
+ }
238
+ });
239
+ });
240
+ const origClose = wrapper.close.bind(wrapper);
241
+ wrapper.close = function(cb) {
242
+ h2Server.close();
243
+ plainServer.close();
244
+ return origClose(cb);
245
+ };
246
+ return wrapper;
247
+ }
176
248
  const httpServer = http.createServer(handleRequest);
177
249
  httpServer.on("upgrade", handleUpgrade);
178
250
  return httpServer;
@@ -190,6 +262,7 @@ function isValidRoute(value) {
190
262
  return typeof value === "object" && value !== null && typeof value.hostname === "string" && typeof value.port === "number" && typeof value.pid === "number";
191
263
  }
192
264
  var RouteStore = class _RouteStore {
265
+ /** The state directory path. */
193
266
  dir;
194
267
  routesPath;
195
268
  lockPath;
package/dist/cli.js CHANGED
@@ -7,26 +7,401 @@ import {
7
7
  formatUrl,
8
8
  isErrnoException,
9
9
  parseHostname
10
- } from "./chunk-Y5OVKUR4.js";
10
+ } from "./chunk-VRBD6YAY.js";
11
11
 
12
12
  // src/cli.ts
13
13
  import chalk from "chalk";
14
- import * as fs2 from "fs";
15
- import * as path2 from "path";
14
+ import * as fs3 from "fs";
15
+ import * as path3 from "path";
16
16
  import { spawn as spawn2, spawnSync } from "child_process";
17
17
 
18
- // src/cli-utils.ts
18
+ // src/certs.ts
19
19
  import * as fs from "fs";
20
+ import * as path from "path";
21
+ import * as crypto from "crypto";
22
+ import * as tls from "tls";
23
+ import { execFile as execFileCb, execFileSync } from "child_process";
24
+ import { promisify } from "util";
25
+ var CA_VALIDITY_DAYS = 3650;
26
+ function fixOwnership(...paths) {
27
+ const uid = process.env.SUDO_UID;
28
+ const gid = process.env.SUDO_GID;
29
+ if (!uid || process.getuid?.() !== 0) return;
30
+ for (const p of paths) {
31
+ try {
32
+ fs.chownSync(p, parseInt(uid, 10), parseInt(gid || uid, 10));
33
+ } catch {
34
+ }
35
+ }
36
+ }
37
+ var SERVER_VALIDITY_DAYS = 365;
38
+ var EXPIRY_BUFFER_MS = 7 * 24 * 60 * 60 * 1e3;
39
+ var CA_COMMON_NAME = "portless Local CA";
40
+ var OPENSSL_TIMEOUT_MS = 15e3;
41
+ var CA_KEY_FILE = "ca-key.pem";
42
+ var CA_CERT_FILE = "ca.pem";
43
+ var SERVER_KEY_FILE = "server-key.pem";
44
+ var SERVER_CERT_FILE = "server.pem";
45
+ function fileExists(filePath) {
46
+ try {
47
+ fs.accessSync(filePath, fs.constants.R_OK);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+ function isCertValid(certPath) {
54
+ try {
55
+ const pem = fs.readFileSync(certPath, "utf-8");
56
+ const cert = new crypto.X509Certificate(pem);
57
+ const expiry = new Date(cert.validTo).getTime();
58
+ return Date.now() + EXPIRY_BUFFER_MS < expiry;
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+ function openssl(args, options) {
64
+ try {
65
+ return execFileSync("openssl", args, {
66
+ encoding: "utf-8",
67
+ timeout: OPENSSL_TIMEOUT_MS,
68
+ input: options?.input,
69
+ stdio: ["pipe", "pipe", "pipe"]
70
+ });
71
+ } catch (err) {
72
+ const message = err instanceof Error ? err.message : String(err);
73
+ throw new Error(
74
+ `openssl failed: ${message}
75
+
76
+ Make sure openssl is installed (ships with macOS and most Linux distributions).`
77
+ );
78
+ }
79
+ }
80
+ var execFileAsync = promisify(execFileCb);
81
+ async function opensslAsync(args) {
82
+ try {
83
+ const { stdout } = await execFileAsync("openssl", args, {
84
+ encoding: "utf-8",
85
+ timeout: OPENSSL_TIMEOUT_MS
86
+ });
87
+ return stdout;
88
+ } catch (err) {
89
+ const message = err instanceof Error ? err.message : String(err);
90
+ throw new Error(
91
+ `openssl failed: ${message}
92
+
93
+ Make sure openssl is installed (ships with macOS and most Linux distributions).`
94
+ );
95
+ }
96
+ }
97
+ function generateCA(stateDir) {
98
+ const keyPath = path.join(stateDir, CA_KEY_FILE);
99
+ const certPath = path.join(stateDir, CA_CERT_FILE);
100
+ openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", keyPath]);
101
+ openssl([
102
+ "req",
103
+ "-new",
104
+ "-x509",
105
+ "-key",
106
+ keyPath,
107
+ "-out",
108
+ certPath,
109
+ "-days",
110
+ CA_VALIDITY_DAYS.toString(),
111
+ "-subj",
112
+ `/CN=${CA_COMMON_NAME}`,
113
+ "-addext",
114
+ "basicConstraints=critical,CA:TRUE",
115
+ "-addext",
116
+ "keyUsage=critical,keyCertSign,cRLSign"
117
+ ]);
118
+ fs.chmodSync(keyPath, 384);
119
+ fs.chmodSync(certPath, 420);
120
+ fixOwnership(keyPath, certPath);
121
+ return { certPath, keyPath };
122
+ }
123
+ function generateServerCert(stateDir) {
124
+ const caKeyPath = path.join(stateDir, CA_KEY_FILE);
125
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
126
+ const serverKeyPath = path.join(stateDir, SERVER_KEY_FILE);
127
+ const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
128
+ const csrPath = path.join(stateDir, "server.csr");
129
+ const extPath = path.join(stateDir, "server-ext.cnf");
130
+ openssl(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", serverKeyPath]);
131
+ openssl(["req", "-new", "-key", serverKeyPath, "-out", csrPath, "-subj", "/CN=localhost"]);
132
+ fs.writeFileSync(
133
+ extPath,
134
+ [
135
+ "authorityKeyIdentifier=keyid,issuer",
136
+ "basicConstraints=CA:FALSE",
137
+ "keyUsage=digitalSignature,keyEncipherment",
138
+ "extendedKeyUsage=serverAuth",
139
+ "subjectAltName=DNS:localhost,DNS:*.localhost"
140
+ ].join("\n") + "\n"
141
+ );
142
+ openssl([
143
+ "x509",
144
+ "-req",
145
+ "-in",
146
+ csrPath,
147
+ "-CA",
148
+ caCertPath,
149
+ "-CAkey",
150
+ caKeyPath,
151
+ "-CAcreateserial",
152
+ "-out",
153
+ serverCertPath,
154
+ "-days",
155
+ SERVER_VALIDITY_DAYS.toString(),
156
+ "-extfile",
157
+ extPath
158
+ ]);
159
+ for (const tmp of [csrPath, extPath]) {
160
+ try {
161
+ fs.unlinkSync(tmp);
162
+ } catch {
163
+ }
164
+ }
165
+ fs.chmodSync(serverKeyPath, 384);
166
+ fs.chmodSync(serverCertPath, 420);
167
+ fixOwnership(serverKeyPath, serverCertPath);
168
+ return { certPath: serverCertPath, keyPath: serverKeyPath };
169
+ }
170
+ function ensureCerts(stateDir) {
171
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
172
+ const caKeyPath = path.join(stateDir, CA_KEY_FILE);
173
+ const serverCertPath = path.join(stateDir, SERVER_CERT_FILE);
174
+ let caGenerated = false;
175
+ if (!fileExists(caCertPath) || !fileExists(caKeyPath) || !isCertValid(caCertPath)) {
176
+ generateCA(stateDir);
177
+ caGenerated = true;
178
+ }
179
+ if (caGenerated || !fileExists(serverCertPath) || !isCertValid(serverCertPath)) {
180
+ generateServerCert(stateDir);
181
+ }
182
+ return {
183
+ certPath: serverCertPath,
184
+ keyPath: path.join(stateDir, SERVER_KEY_FILE),
185
+ caPath: caCertPath,
186
+ caGenerated
187
+ };
188
+ }
189
+ function isCATrusted(stateDir) {
190
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
191
+ if (!fileExists(caCertPath)) return false;
192
+ if (process.platform === "darwin") {
193
+ return isCATrustedMacOS(caCertPath);
194
+ } else if (process.platform === "linux") {
195
+ return isCATrustedLinux(stateDir);
196
+ }
197
+ return false;
198
+ }
199
+ function isCATrustedMacOS(caCertPath) {
200
+ try {
201
+ const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
202
+ for (const keychain of [loginKeychainPath(), "/Library/Keychains/System.keychain"]) {
203
+ try {
204
+ const result = execFileSync("security", ["find-certificate", "-a", "-Z", keychain], {
205
+ encoding: "utf-8",
206
+ timeout: 5e3,
207
+ stdio: ["pipe", "pipe", "pipe"]
208
+ });
209
+ if (result.toLowerCase().includes(fingerprint)) return true;
210
+ } catch {
211
+ }
212
+ }
213
+ return false;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+ function loginKeychainPath() {
219
+ try {
220
+ const result = execFileSync("security", ["default-keychain"], {
221
+ encoding: "utf-8",
222
+ timeout: 5e3
223
+ }).trim();
224
+ const match = result.match(/"(.+)"/);
225
+ if (match) return match[1];
226
+ } catch {
227
+ }
228
+ const home = process.env.HOME || `/Users/${process.env.USER || "unknown"}`;
229
+ return path.join(home, "Library", "Keychains", "login.keychain-db");
230
+ }
231
+ function isCATrustedLinux(stateDir) {
232
+ const systemCertPath = `/usr/local/share/ca-certificates/portless-ca.crt`;
233
+ if (!fileExists(systemCertPath)) return false;
234
+ try {
235
+ const ours = fs.readFileSync(path.join(stateDir, CA_CERT_FILE), "utf-8").trim();
236
+ const installed = fs.readFileSync(systemCertPath, "utf-8").trim();
237
+ return ours === installed;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+ var HOST_CERTS_DIR = "host-certs";
243
+ function sanitizeHostForFilename(hostname) {
244
+ return hostname.replace(/\./g, "_").replace(/[^a-z0-9_-]/gi, "");
245
+ }
246
+ async function generateHostCertAsync(stateDir, hostname) {
247
+ const caKeyPath = path.join(stateDir, CA_KEY_FILE);
248
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
249
+ const hostDir = path.join(stateDir, HOST_CERTS_DIR);
250
+ if (!fs.existsSync(hostDir)) {
251
+ await fs.promises.mkdir(hostDir, { recursive: true, mode: 493 });
252
+ fixOwnership(hostDir);
253
+ }
254
+ const safeName = sanitizeHostForFilename(hostname);
255
+ const keyPath = path.join(hostDir, `${safeName}-key.pem`);
256
+ const certPath = path.join(hostDir, `${safeName}.pem`);
257
+ const csrPath = path.join(hostDir, `${safeName}.csr`);
258
+ const extPath = path.join(hostDir, `${safeName}-ext.cnf`);
259
+ await opensslAsync(["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", keyPath]);
260
+ await opensslAsync(["req", "-new", "-key", keyPath, "-out", csrPath, "-subj", `/CN=${hostname}`]);
261
+ const sans = [`DNS:${hostname}`];
262
+ const parts = hostname.split(".");
263
+ if (parts.length >= 2) {
264
+ sans.push(`DNS:*.${parts.slice(1).join(".")}`);
265
+ }
266
+ await fs.promises.writeFile(
267
+ extPath,
268
+ [
269
+ "authorityKeyIdentifier=keyid,issuer",
270
+ "basicConstraints=CA:FALSE",
271
+ "keyUsage=digitalSignature,keyEncipherment",
272
+ "extendedKeyUsage=serverAuth",
273
+ `subjectAltName=${sans.join(",")}`
274
+ ].join("\n") + "\n"
275
+ );
276
+ await opensslAsync([
277
+ "x509",
278
+ "-req",
279
+ "-in",
280
+ csrPath,
281
+ "-CA",
282
+ caCertPath,
283
+ "-CAkey",
284
+ caKeyPath,
285
+ "-CAcreateserial",
286
+ "-out",
287
+ certPath,
288
+ "-days",
289
+ SERVER_VALIDITY_DAYS.toString(),
290
+ "-extfile",
291
+ extPath
292
+ ]);
293
+ for (const tmp of [csrPath, extPath]) {
294
+ try {
295
+ await fs.promises.unlink(tmp);
296
+ } catch {
297
+ }
298
+ }
299
+ await fs.promises.chmod(keyPath, 384);
300
+ await fs.promises.chmod(certPath, 420);
301
+ fixOwnership(keyPath, certPath);
302
+ return { certPath, keyPath };
303
+ }
304
+ function isSimpleLocalhostSubdomain(hostname) {
305
+ const parts = hostname.split(".");
306
+ return parts.length === 2 && parts[1] === "localhost";
307
+ }
308
+ function createSNICallback(stateDir, defaultCert, defaultKey) {
309
+ const cache = /* @__PURE__ */ new Map();
310
+ const pending = /* @__PURE__ */ new Map();
311
+ const defaultCtx = tls.createSecureContext({ cert: defaultCert, key: defaultKey });
312
+ return (servername, cb) => {
313
+ if (servername === "localhost" || isSimpleLocalhostSubdomain(servername)) {
314
+ cb(null, defaultCtx);
315
+ return;
316
+ }
317
+ if (cache.has(servername)) {
318
+ cb(null, cache.get(servername));
319
+ return;
320
+ }
321
+ const safeName = sanitizeHostForFilename(servername);
322
+ const hostDir = path.join(stateDir, HOST_CERTS_DIR);
323
+ const certPath = path.join(hostDir, `${safeName}.pem`);
324
+ const keyPath = path.join(hostDir, `${safeName}-key.pem`);
325
+ if (fileExists(certPath) && fileExists(keyPath) && isCertValid(certPath)) {
326
+ try {
327
+ const ctx = tls.createSecureContext({
328
+ cert: fs.readFileSync(certPath),
329
+ key: fs.readFileSync(keyPath)
330
+ });
331
+ cache.set(servername, ctx);
332
+ cb(null, ctx);
333
+ return;
334
+ } catch {
335
+ }
336
+ }
337
+ if (pending.has(servername)) {
338
+ pending.get(servername).then((ctx) => cb(null, ctx)).catch((err) => cb(err instanceof Error ? err : new Error(String(err))));
339
+ return;
340
+ }
341
+ const promise = generateHostCertAsync(stateDir, servername).then(async (generated) => {
342
+ const [cert, key] = await Promise.all([
343
+ fs.promises.readFile(generated.certPath),
344
+ fs.promises.readFile(generated.keyPath)
345
+ ]);
346
+ return tls.createSecureContext({ cert, key });
347
+ });
348
+ pending.set(servername, promise);
349
+ promise.then((ctx) => {
350
+ cache.set(servername, ctx);
351
+ pending.delete(servername);
352
+ cb(null, ctx);
353
+ }).catch((err) => {
354
+ pending.delete(servername);
355
+ cb(err instanceof Error ? err : new Error(String(err)));
356
+ });
357
+ };
358
+ }
359
+ function trustCA(stateDir) {
360
+ const caCertPath = path.join(stateDir, CA_CERT_FILE);
361
+ if (!fileExists(caCertPath)) {
362
+ return { trusted: false, error: "CA certificate not found. Run with --https first." };
363
+ }
364
+ try {
365
+ if (process.platform === "darwin") {
366
+ const keychain = loginKeychainPath();
367
+ execFileSync(
368
+ "security",
369
+ ["add-trusted-cert", "-r", "trustRoot", "-k", keychain, caCertPath],
370
+ { stdio: "pipe", timeout: 3e4 }
371
+ );
372
+ return { trusted: true };
373
+ } else if (process.platform === "linux") {
374
+ const dest = "/usr/local/share/ca-certificates/portless-ca.crt";
375
+ fs.copyFileSync(caCertPath, dest);
376
+ execFileSync("update-ca-certificates", [], { stdio: "pipe", timeout: 3e4 });
377
+ return { trusted: true };
378
+ }
379
+ return { trusted: false, error: `Unsupported platform: ${process.platform}` };
380
+ } catch (err) {
381
+ const message = err instanceof Error ? err.message : String(err);
382
+ if (message.includes("authorization") || message.includes("permission") || message.includes("EACCES")) {
383
+ return {
384
+ trusted: false,
385
+ error: "Permission denied. Try: sudo portless trust"
386
+ };
387
+ }
388
+ return { trusted: false, error: message };
389
+ }
390
+ }
391
+
392
+ // src/cli-utils.ts
393
+ import * as fs2 from "fs";
20
394
  import * as http from "http";
395
+ import * as https from "https";
21
396
  import * as net from "net";
22
397
  import * as os from "os";
23
- import * as path from "path";
398
+ import * as path2 from "path";
24
399
  import * as readline from "readline";
25
400
  import { execSync, spawn } from "child_process";
26
401
  var DEFAULT_PROXY_PORT = 1355;
27
402
  var PRIVILEGED_PORT_THRESHOLD = 1024;
28
403
  var SYSTEM_STATE_DIR = "/tmp/portless";
29
- var USER_STATE_DIR = path.join(os.homedir(), ".portless");
404
+ var USER_STATE_DIR = path2.join(os.homedir(), ".portless");
30
405
  var MIN_APP_PORT = 4e3;
31
406
  var MAX_APP_PORT = 4999;
32
407
  var RANDOM_PORT_ATTEMPTS = 50;
@@ -56,29 +431,59 @@ function resolveStateDir(port) {
56
431
  }
57
432
  function readPortFromDir(dir) {
58
433
  try {
59
- const raw = fs.readFileSync(path.join(dir, "proxy.port"), "utf-8").trim();
434
+ const raw = fs2.readFileSync(path2.join(dir, "proxy.port"), "utf-8").trim();
60
435
  const port = parseInt(raw, 10);
61
436
  return isNaN(port) ? null : port;
62
437
  } catch {
63
438
  return null;
64
439
  }
65
440
  }
441
+ var TLS_MARKER_FILE = "proxy.tls";
442
+ function readTlsMarker(dir) {
443
+ try {
444
+ return fs2.existsSync(path2.join(dir, TLS_MARKER_FILE));
445
+ } catch {
446
+ return false;
447
+ }
448
+ }
449
+ function writeTlsMarker(dir, enabled) {
450
+ const markerPath = path2.join(dir, TLS_MARKER_FILE);
451
+ if (enabled) {
452
+ fs2.writeFileSync(markerPath, "1", { mode: 420 });
453
+ } else {
454
+ try {
455
+ fs2.unlinkSync(markerPath);
456
+ } catch {
457
+ }
458
+ }
459
+ }
460
+ function isHttpsEnvEnabled() {
461
+ const val = process.env.PORTLESS_HTTPS;
462
+ return val === "1" || val === "true";
463
+ }
66
464
  async function discoverState() {
67
465
  if (process.env.PORTLESS_STATE_DIR) {
68
466
  const dir = process.env.PORTLESS_STATE_DIR;
69
467
  const port = readPortFromDir(dir) ?? getDefaultPort();
70
- return { dir, port };
468
+ const tls2 = readTlsMarker(dir);
469
+ return { dir, port, tls: tls2 };
71
470
  }
72
471
  const userPort = readPortFromDir(USER_STATE_DIR);
73
- if (userPort !== null && await isProxyRunning(userPort)) {
74
- return { dir: USER_STATE_DIR, port: userPort };
472
+ if (userPort !== null) {
473
+ const tls2 = readTlsMarker(USER_STATE_DIR);
474
+ if (await isProxyRunning(userPort, tls2)) {
475
+ return { dir: USER_STATE_DIR, port: userPort, tls: tls2 };
476
+ }
75
477
  }
76
478
  const systemPort = readPortFromDir(SYSTEM_STATE_DIR);
77
- if (systemPort !== null && await isProxyRunning(systemPort)) {
78
- return { dir: SYSTEM_STATE_DIR, port: systemPort };
479
+ if (systemPort !== null) {
480
+ const tls2 = readTlsMarker(SYSTEM_STATE_DIR);
481
+ if (await isProxyRunning(systemPort, tls2)) {
482
+ return { dir: SYSTEM_STATE_DIR, port: systemPort, tls: tls2 };
483
+ }
79
484
  }
80
485
  const defaultPort = getDefaultPort();
81
- return { dir: resolveStateDir(defaultPort), port: defaultPort };
486
+ return { dir: resolveStateDir(defaultPort), port: defaultPort, tls: false };
82
487
  }
83
488
  async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
84
489
  if (minPort > maxPort) {
@@ -106,15 +511,17 @@ async function findFreePort(minPort = MIN_APP_PORT, maxPort = MAX_APP_PORT) {
106
511
  }
107
512
  throw new Error(`No free port found in range ${minPort}-${maxPort}`);
108
513
  }
109
- function isProxyRunning(port) {
514
+ function isProxyRunning(port, tls2 = false) {
110
515
  return new Promise((resolve) => {
111
- const req = http.request(
516
+ const requestFn = tls2 ? https.request : http.request;
517
+ const req = requestFn(
112
518
  {
113
519
  hostname: "127.0.0.1",
114
520
  port,
115
521
  path: "/",
116
522
  method: "HEAD",
117
- timeout: SOCKET_TIMEOUT_MS
523
+ timeout: SOCKET_TIMEOUT_MS,
524
+ ...tls2 ? { rejectUnauthorized: false } : {}
118
525
  },
119
526
  (res) => {
120
527
  res.resume();
@@ -141,10 +548,10 @@ function findPidOnPort(port) {
141
548
  return null;
142
549
  }
143
550
  }
144
- async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS) {
551
+ async function waitForProxy(port, maxAttempts = WAIT_FOR_PROXY_MAX_ATTEMPTS, intervalMs = WAIT_FOR_PROXY_INTERVAL_MS, tls2 = false) {
145
552
  for (let i = 0; i < maxAttempts; i++) {
146
553
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
147
- if (await isProxyRunning(port)) {
554
+ if (await isProxyRunning(port, tls2)) {
148
555
  return true;
149
556
  }
150
557
  }
@@ -211,14 +618,15 @@ var DEBOUNCE_MS = 100;
211
618
  var POLL_INTERVAL_MS = 3e3;
212
619
  var EXIT_TIMEOUT_MS = 2e3;
213
620
  var SUDO_SPAWN_TIMEOUT_MS = 3e4;
214
- function startProxyServer(store, proxyPort) {
621
+ function startProxyServer(store, proxyPort, tlsOptions) {
215
622
  store.ensureDir();
623
+ const isTls = !!tlsOptions;
216
624
  const routesPath = store.getRoutesPath();
217
- if (!fs2.existsSync(routesPath)) {
218
- fs2.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
625
+ if (!fs3.existsSync(routesPath)) {
626
+ fs3.writeFileSync(routesPath, "[]", { mode: FILE_MODE });
219
627
  }
220
628
  try {
221
- fs2.chmodSync(routesPath, FILE_MODE);
629
+ fs3.chmodSync(routesPath, FILE_MODE);
222
630
  } catch {
223
631
  }
224
632
  let cachedRoutes = store.loadRoutes();
@@ -232,7 +640,7 @@ function startProxyServer(store, proxyPort) {
232
640
  }
233
641
  };
234
642
  try {
235
- watcher = fs2.watch(routesPath, () => {
643
+ watcher = fs3.watch(routesPath, () => {
236
644
  if (debounceTimer) clearTimeout(debounceTimer);
237
645
  debounceTimer = setTimeout(reloadRoutes, DEBOUNCE_MS);
238
646
  });
@@ -243,7 +651,8 @@ function startProxyServer(store, proxyPort) {
243
651
  const server = createProxyServer({
244
652
  getRoutes: () => cachedRoutes,
245
653
  proxyPort,
246
- onError: (msg) => console.error(chalk.red(msg))
654
+ onError: (msg) => console.error(chalk.red(msg)),
655
+ tls: tlsOptions
247
656
  });
248
657
  server.on("error", (err) => {
249
658
  if (err.code === "EADDRINUSE") {
@@ -264,9 +673,11 @@ function startProxyServer(store, proxyPort) {
264
673
  process.exit(1);
265
674
  });
266
675
  server.listen(proxyPort, () => {
267
- fs2.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
268
- fs2.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
269
- console.log(chalk.green(`HTTP proxy listening on port ${proxyPort}`));
676
+ fs3.writeFileSync(store.pidPath, process.pid.toString(), { mode: FILE_MODE });
677
+ fs3.writeFileSync(store.portFilePath, proxyPort.toString(), { mode: FILE_MODE });
678
+ writeTlsMarker(store.dir, isTls);
679
+ const proto = isTls ? "HTTPS/2" : "HTTP";
680
+ console.log(chalk.green(`${proto} proxy listening on port ${proxyPort}`));
270
681
  });
271
682
  let exiting = false;
272
683
  const cleanup = () => {
@@ -278,13 +689,14 @@ function startProxyServer(store, proxyPort) {
278
689
  watcher.close();
279
690
  }
280
691
  try {
281
- fs2.unlinkSync(store.pidPath);
692
+ fs3.unlinkSync(store.pidPath);
282
693
  } catch {
283
694
  }
284
695
  try {
285
- fs2.unlinkSync(store.portFilePath);
696
+ fs3.unlinkSync(store.portFilePath);
286
697
  } catch {
287
698
  }
699
+ writeTlsMarker(store.dir, false);
288
700
  server.close(() => process.exit(0));
289
701
  setTimeout(() => process.exit(0), EXIT_TIMEOUT_MS).unref();
290
702
  };
@@ -293,19 +705,19 @@ function startProxyServer(store, proxyPort) {
293
705
  console.log(chalk.cyan("\nProxy is running. Press Ctrl+C to stop.\n"));
294
706
  console.log(chalk.gray(`Routes file: ${store.getRoutesPath()}`));
295
707
  }
296
- async function stopProxy(store, proxyPort) {
708
+ async function stopProxy(store, proxyPort, tls2) {
297
709
  const pidPath = store.pidPath;
298
710
  const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
299
711
  const sudoHint = needsSudo ? "sudo " : "";
300
- if (!fs2.existsSync(pidPath)) {
301
- if (await isProxyRunning(proxyPort)) {
712
+ if (!fs3.existsSync(pidPath)) {
713
+ if (await isProxyRunning(proxyPort, tls2)) {
302
714
  console.log(chalk.yellow(`PID file is missing but port ${proxyPort} is still in use.`));
303
715
  const pid = findPidOnPort(proxyPort);
304
716
  if (pid !== null) {
305
717
  try {
306
718
  process.kill(pid, "SIGTERM");
307
719
  try {
308
- fs2.unlinkSync(store.portFilePath);
720
+ fs3.unlinkSync(store.portFilePath);
309
721
  } catch {
310
722
  }
311
723
  console.log(chalk.green(`Killed process ${pid}. Proxy stopped.`));
@@ -336,37 +748,37 @@ async function stopProxy(store, proxyPort) {
336
748
  return;
337
749
  }
338
750
  try {
339
- const pid = parseInt(fs2.readFileSync(pidPath, "utf-8"), 10);
751
+ const pid = parseInt(fs3.readFileSync(pidPath, "utf-8"), 10);
340
752
  if (isNaN(pid)) {
341
753
  console.error(chalk.red("Corrupted PID file. Removing it."));
342
- fs2.unlinkSync(pidPath);
754
+ fs3.unlinkSync(pidPath);
343
755
  return;
344
756
  }
345
757
  try {
346
758
  process.kill(pid, 0);
347
759
  } catch {
348
760
  console.log(chalk.yellow("Proxy process is no longer running. Cleaning up stale files."));
349
- fs2.unlinkSync(pidPath);
761
+ fs3.unlinkSync(pidPath);
350
762
  try {
351
- fs2.unlinkSync(store.portFilePath);
763
+ fs3.unlinkSync(store.portFilePath);
352
764
  } catch {
353
765
  }
354
766
  return;
355
767
  }
356
- if (!await isProxyRunning(proxyPort)) {
768
+ if (!await isProxyRunning(proxyPort, tls2)) {
357
769
  console.log(
358
770
  chalk.yellow(
359
771
  `PID file exists but port ${proxyPort} is not listening. The PID may have been recycled.`
360
772
  )
361
773
  );
362
774
  console.log(chalk.yellow("Removing stale PID file."));
363
- fs2.unlinkSync(pidPath);
775
+ fs3.unlinkSync(pidPath);
364
776
  return;
365
777
  }
366
778
  process.kill(pid, "SIGTERM");
367
- fs2.unlinkSync(pidPath);
779
+ fs3.unlinkSync(pidPath);
368
780
  try {
369
- fs2.unlinkSync(store.portFilePath);
781
+ fs3.unlinkSync(store.portFilePath);
370
782
  } catch {
371
783
  }
372
784
  console.log(chalk.green("Proxy stopped."));
@@ -383,7 +795,7 @@ async function stopProxy(store, proxyPort) {
383
795
  }
384
796
  }
385
797
  }
386
- function listRoutes(store, proxyPort) {
798
+ function listRoutes(store, proxyPort, tls2) {
387
799
  const routes = store.loadRoutes();
388
800
  if (routes.length === 0) {
389
801
  console.log(chalk.yellow("No active routes."));
@@ -392,23 +804,23 @@ function listRoutes(store, proxyPort) {
392
804
  }
393
805
  console.log(chalk.blue.bold("\nActive routes:\n"));
394
806
  for (const route of routes) {
395
- const url = formatUrl(route.hostname, proxyPort);
807
+ const url = formatUrl(route.hostname, proxyPort, tls2);
396
808
  console.log(
397
809
  ` ${chalk.cyan(url)} ${chalk.gray("->")} ${chalk.white(`localhost:${route.port}`)} ${chalk.gray(`(pid ${route.pid})`)}`
398
810
  );
399
811
  }
400
812
  console.log();
401
813
  }
402
- async function runApp(store, proxyPort, stateDir, name, commandArgs) {
814
+ async function runApp(store, proxyPort, stateDir, name, commandArgs, tls2) {
403
815
  const hostname = parseHostname(name);
404
- const appUrl = formatUrl(hostname, proxyPort);
405
816
  console.log(chalk.blue.bold(`
406
817
  portless
407
818
  `));
408
819
  console.log(chalk.gray(`-- ${hostname} (auto-resolves to 127.0.0.1)`));
409
- if (!await isProxyRunning(proxyPort)) {
820
+ if (!await isProxyRunning(proxyPort, tls2)) {
410
821
  const defaultPort = getDefaultPort();
411
822
  const needsSudo = defaultPort < PRIVILEGED_PORT_THRESHOLD;
823
+ const wantHttps = isHttpsEnvEnabled();
412
824
  if (needsSudo) {
413
825
  if (!process.stdin.isTTY) {
414
826
  console.error(chalk.red("Proxy is not running."));
@@ -429,7 +841,9 @@ portless
429
841
  return;
430
842
  }
431
843
  console.log(chalk.yellow("Starting proxy (requires sudo)..."));
432
- const result = spawnSync("sudo", [process.execPath, process.argv[1], "proxy", "start"], {
844
+ const startArgs = [process.execPath, process.argv[1], "proxy", "start"];
845
+ if (wantHttps) startArgs.push("--https");
846
+ const result = spawnSync("sudo", startArgs, {
433
847
  stdio: "inherit",
434
848
  timeout: SUDO_SPAWN_TIMEOUT_MS
435
849
  });
@@ -441,7 +855,9 @@ portless
441
855
  }
442
856
  } else {
443
857
  console.log(chalk.yellow("Starting proxy..."));
444
- const result = spawnSync(process.execPath, [process.argv[1], "proxy", "start"], {
858
+ const startArgs = [process.argv[1], "proxy", "start"];
859
+ if (wantHttps) startArgs.push("--https");
860
+ const result = spawnSync(process.execPath, startArgs, {
445
861
  stdio: "inherit",
446
862
  timeout: SUDO_SPAWN_TIMEOUT_MS
447
863
  });
@@ -452,16 +868,18 @@ portless
452
868
  process.exit(1);
453
869
  }
454
870
  }
455
- if (!await waitForProxy(defaultPort)) {
871
+ const autoTls = readTlsMarker(stateDir);
872
+ if (!await waitForProxy(defaultPort, void 0, void 0, autoTls)) {
456
873
  console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
457
- const logPath = path2.join(stateDir, "proxy.log");
874
+ const logPath = path3.join(stateDir, "proxy.log");
458
875
  console.error(chalk.blue("Try starting the proxy manually to see the error:"));
459
876
  console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start`));
460
- if (fs2.existsSync(logPath)) {
877
+ if (fs3.existsSync(logPath)) {
461
878
  console.error(chalk.gray(`Logs: ${logPath}`));
462
879
  }
463
880
  process.exit(1);
464
881
  }
882
+ tls2 = autoTls;
465
883
  console.log(chalk.green("Proxy started in background"));
466
884
  } else {
467
885
  console.log(chalk.gray("-- Proxy is running"));
@@ -469,8 +887,9 @@ portless
469
887
  const port = await findFreePort();
470
888
  console.log(chalk.green(`-- Using port ${port}`));
471
889
  store.addRoute(hostname, port, process.pid);
890
+ const finalUrl = formatUrl(hostname, proxyPort, tls2);
472
891
  console.log(chalk.cyan.bold(`
473
- -> ${appUrl}
892
+ -> ${finalUrl}
474
893
  `));
475
894
  console.log(chalk.gray(`Running: PORT=${port} ${commandArgs.join(" ")}
476
895
  `));
@@ -512,13 +931,16 @@ ${chalk.bold("Install:")}
512
931
 
513
932
  ${chalk.bold("Usage:")}
514
933
  ${chalk.cyan("portless proxy start")} Start the proxy (background daemon)
934
+ ${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS (auto-generates certs)
515
935
  ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
516
936
  ${chalk.cyan("portless proxy stop")} Stop the proxy
517
937
  ${chalk.cyan("portless <name> <cmd>")} Run your app through the proxy
518
938
  ${chalk.cyan("portless list")} Show active routes
939
+ ${chalk.cyan("portless trust")} Add local CA to system trust store
519
940
 
520
941
  ${chalk.bold("Examples:")}
521
942
  portless proxy start # Start proxy on port 1355
943
+ portless proxy start --https # Start with HTTPS/2 (faster page loads)
522
944
  portless myapp next dev # -> http://myapp.localhost:1355
523
945
  portless api.myapp pnpm start # -> http://api.myapp.localhost:1355
524
946
 
@@ -535,13 +957,23 @@ ${chalk.bold("How it works:")}
535
957
  3. Access via http://<name>.localhost:1355
536
958
  4. .localhost domains auto-resolve to 127.0.0.1
537
959
 
960
+ ${chalk.bold("HTTP/2 + HTTPS:")}
961
+ Use --https for HTTP/2 multiplexing (faster dev server page loads).
962
+ On first use, portless generates a local CA and adds it to your
963
+ system trust store. No browser warnings. No sudo required on macOS.
964
+
538
965
  ${chalk.bold("Options:")}
539
966
  -p, --port <number> Port for the proxy to listen on (default: 1355)
540
967
  Ports < 1024 require sudo
968
+ --https Enable HTTP/2 + TLS with auto-generated certs
969
+ --cert <path> Use a custom TLS certificate (implies --https)
970
+ --key <path> Use a custom TLS private key (implies --https)
971
+ --no-tls Disable HTTPS (overrides PORTLESS_HTTPS)
541
972
  --foreground Run proxy in foreground (for debugging)
542
973
 
543
974
  ${chalk.bold("Environment variables:")}
544
975
  PORTLESS_PORT=<number> Override the default proxy port (e.g. in .bashrc)
976
+ PORTLESS_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
545
977
  PORTLESS_STATE_DIR=<path> Override the state directory
546
978
  PORTLESS=0 | PORTLESS=skip Run command directly without proxy
547
979
 
@@ -552,24 +984,40 @@ ${chalk.bold("Skip portless:")}
552
984
  process.exit(0);
553
985
  }
554
986
  if (args[0] === "--version" || args[0] === "-v") {
555
- console.log("0.3.0");
987
+ console.log("0.4.0");
556
988
  process.exit(0);
557
989
  }
990
+ if (args[0] === "trust") {
991
+ const { dir: dir2 } = await discoverState();
992
+ const result = trustCA(dir2);
993
+ if (result.trusted) {
994
+ console.log(chalk.green("Local CA added to system trust store."));
995
+ console.log(chalk.gray("Browsers will now trust portless HTTPS certificates."));
996
+ } else {
997
+ console.error(chalk.red(`Failed to trust CA: ${result.error}`));
998
+ if (result.error?.includes("sudo")) {
999
+ console.error(chalk.blue("Run with sudo:"));
1000
+ console.error(chalk.cyan(" sudo portless trust"));
1001
+ }
1002
+ process.exit(1);
1003
+ }
1004
+ return;
1005
+ }
558
1006
  if (args[0] === "list") {
559
- const { dir: dir2, port: port2 } = await discoverState();
1007
+ const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
560
1008
  const store2 = new RouteStore(dir2, {
561
1009
  onWarning: (msg) => console.warn(chalk.yellow(msg))
562
1010
  });
563
- listRoutes(store2, port2);
1011
+ listRoutes(store2, port2, tls3);
564
1012
  return;
565
1013
  }
566
1014
  if (args[0] === "proxy") {
567
1015
  if (args[1] === "stop") {
568
- const { dir: dir2, port: port2 } = await discoverState();
1016
+ const { dir: dir2, port: port2, tls: tls3 } = await discoverState();
569
1017
  const store3 = new RouteStore(dir2, {
570
1018
  onWarning: (msg) => console.warn(chalk.yellow(msg))
571
1019
  });
572
- await stopProxy(store3, port2);
1020
+ await stopProxy(store3, port2, tls3);
573
1021
  return;
574
1022
  }
575
1023
  if (args[1] !== "start") {
@@ -577,6 +1025,7 @@ ${chalk.bold("Skip portless:")}
577
1025
  ${chalk.bold("Usage: portless proxy <command>")}
578
1026
 
579
1027
  ${chalk.cyan("portless proxy start")} Start the proxy (daemon)
1028
+ ${chalk.cyan("portless proxy start --https")} Start with HTTP/2 + TLS
580
1029
  ${chalk.cyan("portless proxy start --foreground")} Start in foreground (for debugging)
581
1030
  ${chalk.cyan("portless proxy start -p 80")} Start on port 80 (requires sudo)
582
1031
  ${chalk.cyan("portless proxy stop")} Stop the proxy
@@ -602,6 +1051,32 @@ ${chalk.bold("Usage: portless proxy <command>")}
602
1051
  process.exit(1);
603
1052
  }
604
1053
  }
1054
+ const hasNoTls = args.includes("--no-tls");
1055
+ const hasHttpsFlag = args.includes("--https");
1056
+ const wantHttps = !hasNoTls && (hasHttpsFlag || isHttpsEnvEnabled());
1057
+ let customCertPath = null;
1058
+ let customKeyPath = null;
1059
+ const certIdx = args.indexOf("--cert");
1060
+ if (certIdx !== -1) {
1061
+ customCertPath = args[certIdx + 1] || null;
1062
+ if (!customCertPath || customCertPath.startsWith("-")) {
1063
+ console.error(chalk.red("Error: --cert requires a file path."));
1064
+ process.exit(1);
1065
+ }
1066
+ }
1067
+ const keyIdx = args.indexOf("--key");
1068
+ if (keyIdx !== -1) {
1069
+ customKeyPath = args[keyIdx + 1] || null;
1070
+ if (!customKeyPath || customKeyPath.startsWith("-")) {
1071
+ console.error(chalk.red("Error: --key requires a file path."));
1072
+ process.exit(1);
1073
+ }
1074
+ }
1075
+ if (customCertPath && !customKeyPath || !customCertPath && customKeyPath) {
1076
+ console.error(chalk.red("Error: --cert and --key must be used together."));
1077
+ process.exit(1);
1078
+ }
1079
+ const useHttps = wantHttps || !!(customCertPath && customKeyPath);
605
1080
  const stateDir = resolveStateDir(proxyPort);
606
1081
  const store2 = new RouteStore(stateDir, {
607
1082
  onWarning: (msg) => console.warn(chalk.yellow(msg))
@@ -626,23 +1101,90 @@ ${chalk.bold("Usage: portless proxy <command>")}
626
1101
  console.error(chalk.cyan(" portless proxy start"));
627
1102
  process.exit(1);
628
1103
  }
1104
+ let tlsOptions;
1105
+ if (useHttps) {
1106
+ store2.ensureDir();
1107
+ if (customCertPath && customKeyPath) {
1108
+ try {
1109
+ const cert = fs3.readFileSync(customCertPath);
1110
+ const key = fs3.readFileSync(customKeyPath);
1111
+ const certStr = cert.toString("utf-8");
1112
+ const keyStr = key.toString("utf-8");
1113
+ if (!certStr.includes("-----BEGIN CERTIFICATE-----")) {
1114
+ console.error(chalk.red(`Error: ${customCertPath} is not a valid PEM certificate.`));
1115
+ console.error(chalk.gray("Expected a file starting with -----BEGIN CERTIFICATE-----"));
1116
+ process.exit(1);
1117
+ }
1118
+ if (!keyStr.match(/-----BEGIN [\w\s]*PRIVATE KEY-----/)) {
1119
+ console.error(chalk.red(`Error: ${customKeyPath} is not a valid PEM private key.`));
1120
+ console.error(
1121
+ chalk.gray("Expected a file starting with -----BEGIN ...PRIVATE KEY-----")
1122
+ );
1123
+ process.exit(1);
1124
+ }
1125
+ tlsOptions = { cert, key };
1126
+ } catch (err) {
1127
+ const message = err instanceof Error ? err.message : String(err);
1128
+ console.error(chalk.red(`Error reading certificate files: ${message}`));
1129
+ process.exit(1);
1130
+ }
1131
+ } else {
1132
+ console.log(chalk.gray("Ensuring TLS certificates..."));
1133
+ const certs = ensureCerts(stateDir);
1134
+ if (certs.caGenerated) {
1135
+ console.log(chalk.green("Generated local CA certificate."));
1136
+ }
1137
+ if (!isCATrusted(stateDir)) {
1138
+ console.log(chalk.yellow("Adding CA to system trust store..."));
1139
+ const trustResult = trustCA(stateDir);
1140
+ if (trustResult.trusted) {
1141
+ console.log(
1142
+ chalk.green("CA added to system trust store. Browsers will trust portless certs.")
1143
+ );
1144
+ } else {
1145
+ console.warn(chalk.yellow("Could not add CA to system trust store."));
1146
+ if (trustResult.error) {
1147
+ console.warn(chalk.gray(trustResult.error));
1148
+ }
1149
+ console.warn(
1150
+ chalk.yellow("Browsers will show certificate warnings. To fix this later, run:")
1151
+ );
1152
+ console.warn(chalk.cyan(" portless trust"));
1153
+ }
1154
+ }
1155
+ const cert = fs3.readFileSync(certs.certPath);
1156
+ const key = fs3.readFileSync(certs.keyPath);
1157
+ tlsOptions = {
1158
+ cert,
1159
+ key,
1160
+ SNICallback: createSNICallback(stateDir, cert, key)
1161
+ };
1162
+ }
1163
+ }
629
1164
  if (isForeground) {
630
1165
  console.log(chalk.blue.bold("\nportless proxy\n"));
631
- startProxyServer(store2, proxyPort);
1166
+ startProxyServer(store2, proxyPort, tlsOptions);
632
1167
  return;
633
1168
  }
634
1169
  store2.ensureDir();
635
- const logPath = path2.join(stateDir, "proxy.log");
636
- const logFd = fs2.openSync(logPath, "a");
1170
+ const logPath = path3.join(stateDir, "proxy.log");
1171
+ const logFd = fs3.openSync(logPath, "a");
637
1172
  try {
638
1173
  try {
639
- fs2.chmodSync(logPath, FILE_MODE);
1174
+ fs3.chmodSync(logPath, FILE_MODE);
640
1175
  } catch {
641
1176
  }
642
1177
  const daemonArgs = [process.argv[1], "proxy", "start", "--foreground"];
643
1178
  if (portFlagIndex !== -1) {
644
1179
  daemonArgs.push("--port", proxyPort.toString());
645
1180
  }
1181
+ if (useHttps) {
1182
+ if (customCertPath && customKeyPath) {
1183
+ daemonArgs.push("--cert", customCertPath, "--key", customKeyPath);
1184
+ } else {
1185
+ daemonArgs.push("--https");
1186
+ }
1187
+ }
646
1188
  const child = spawn2(process.execPath, daemonArgs, {
647
1189
  detached: true,
648
1190
  stdio: ["ignore", logFd, logFd],
@@ -650,19 +1192,20 @@ ${chalk.bold("Usage: portless proxy <command>")}
650
1192
  });
651
1193
  child.unref();
652
1194
  } finally {
653
- fs2.closeSync(logFd);
1195
+ fs3.closeSync(logFd);
654
1196
  }
655
- if (!await waitForProxy(proxyPort)) {
1197
+ if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
656
1198
  console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
657
1199
  console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
658
1200
  const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
659
1201
  console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
660
- if (fs2.existsSync(logPath)) {
1202
+ if (fs3.existsSync(logPath)) {
661
1203
  console.error(chalk.gray(`Logs: ${logPath}`));
662
1204
  }
663
1205
  process.exit(1);
664
1206
  }
665
- console.log(chalk.green(`Proxy started on port ${proxyPort}`));
1207
+ const proto = useHttps ? "HTTPS/2" : "HTTP";
1208
+ console.log(chalk.green(`${proto} proxy started on port ${proxyPort}`));
666
1209
  return;
667
1210
  }
668
1211
  const name = args[0];
@@ -675,11 +1218,11 @@ ${chalk.bold("Usage: portless proxy <command>")}
675
1218
  console.error(chalk.cyan(" portless myapp next dev"));
676
1219
  process.exit(1);
677
1220
  }
678
- const { dir, port } = await discoverState();
1221
+ const { dir, port, tls: tls2 } = await discoverState();
679
1222
  const store = new RouteStore(dir, {
680
1223
  onWarning: (msg) => console.warn(chalk.yellow(msg))
681
1224
  });
682
- await runApp(store, port, dir, name, commandArgs);
1225
+ await runApp(store, port, dir, name, commandArgs, tls2);
683
1226
  }
684
1227
  main().catch((err) => {
685
1228
  const message = err instanceof Error ? err.message : String(err);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import * as node_tls from 'node:tls';
1
2
  import * as http from 'node:http';
3
+ import * as net from 'node:net';
2
4
 
3
5
  /** Route info used by the proxy server to map hostnames to ports. */
4
6
  interface RouteInfo {
@@ -12,18 +14,31 @@ interface ProxyServerOptions {
12
14
  proxyPort: number;
13
15
  /** Optional error logger; defaults to console.error. */
14
16
  onError?: (message: string) => void;
17
+ /** When provided, enables HTTP/2 over TLS (HTTPS). */
18
+ tls?: {
19
+ cert: Buffer;
20
+ key: Buffer;
21
+ /** SNI callback for per-hostname certificate selection. */
22
+ SNICallback?: (servername: string, cb: (err: Error | null, ctx?: node_tls.SecureContext) => void) => void;
23
+ };
15
24
  }
16
25
 
17
26
  /** Response header used to identify a portless proxy (for health checks). */
18
27
  declare const PORTLESS_HEADER = "X-Portless";
28
+ /** Server type returned by createProxyServer (plain HTTP/1.1 or net.Server TLS wrapper). */
29
+ type ProxyServer = http.Server | net.Server;
19
30
  /**
20
31
  * Create an HTTP proxy server that routes requests based on the Host header.
21
32
  *
22
33
  * Uses Node's built-in http module for proxying (no external dependencies).
23
34
  * The `getRoutes` callback is invoked on every request so callers can provide
24
35
  * either a static list or a live-updating one.
36
+ *
37
+ * When `tls` is provided, creates an HTTP/2 secure server with HTTP/1.1
38
+ * fallback (`allowHTTP1: true`). This enables HTTP/2 multiplexing for
39
+ * browsers while keeping WebSocket upgrades working over HTTP/1.1.
25
40
  */
26
- declare function createProxyServer(options: ProxyServerOptions): http.Server;
41
+ declare function createProxyServer(options: ProxyServerOptions): ProxyServer;
27
42
 
28
43
  /** File permission mode for route and state files. */
29
44
  declare const FILE_MODE = 420;
@@ -37,7 +52,8 @@ interface RouteMapping extends RouteInfo {
37
52
  * Supports file locking and stale-route cleanup.
38
53
  */
39
54
  declare class RouteStore {
40
- private readonly dir;
55
+ /** The state directory path. */
56
+ readonly dir: string;
41
57
  private readonly routesPath;
42
58
  private readonly lockPath;
43
59
  readonly pidPath: string;
@@ -72,13 +88,14 @@ declare function isErrnoException(err: unknown): err is NodeJS.ErrnoException;
72
88
  */
73
89
  declare function escapeHtml(str: string): string;
74
90
  /**
75
- * Format a .localhost URL, including the port only when it is not 80 (standard HTTP).
91
+ * Format a .localhost URL. Omits the port when it matches the protocol default
92
+ * (80 for HTTP, 443 for HTTPS).
76
93
  */
77
- declare function formatUrl(hostname: string, proxyPort: number): string;
94
+ declare function formatUrl(hostname: string, proxyPort: number, tls?: boolean): string;
78
95
  /**
79
96
  * Parse and normalize a hostname input for use as a .localhost subdomain.
80
97
  * Strips protocol prefixes, validates characters, and appends .localhost if needed.
81
98
  */
82
99
  declare function parseHostname(input: string): string;
83
100
 
84
- export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServerOptions, type RouteInfo, type RouteMapping, RouteStore, createProxyServer, escapeHtml, formatUrl, isErrnoException, parseHostname };
101
+ export { DIR_MODE, FILE_MODE, PORTLESS_HEADER, type ProxyServer, type ProxyServerOptions, type RouteInfo, type RouteMapping, RouteStore, createProxyServer, escapeHtml, formatUrl, isErrnoException, parseHostname };
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  formatUrl,
9
9
  isErrnoException,
10
10
  parseHostname
11
- } from "./chunk-Y5OVKUR4.js";
11
+ } from "./chunk-VRBD6YAY.js";
12
12
  export {
13
13
  DIR_MODE,
14
14
  FILE_MODE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portless",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Replace port numbers with stable, named .localhost URLs. For humans and agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",