From 3c3c23d403b022d88e659f782100968e2d9b7704 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:10:15 +0200 Subject: [PATCH 01/27] fix: repair the release workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job ran pnpm publish and pnpm build (both trigger prepack -> tsc) without ever installing dependencies, so releases always failed. Add the install step, restore publish provenance, and add a concurrency group so two dispatches cannot race. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cde3e4..703c705 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,9 @@ permissions: jobs: release: runs-on: ubuntu-latest + concurrency: + group: release + cancel-in-progress: false steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: pnpm/action-setup@d9184bf108216479bc5a137cc391f4d7b14c870b # v6.1.0 @@ -31,6 +34,7 @@ jobs: node-version: 26 registry-url: https://registry.npmjs.org/ cache: pnpm + - run: pnpm install --frozen-lockfile - name: Bump and push version if: inputs.upgrade != 'existing' @@ -41,3 +45,4 @@ jobs: git push --follow-tags - run: pnpm publish --provenance + From 8dcb658899072ea1ee165a4e48f5db0ed6323b8b Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:11:29 +0200 Subject: [PATCH 02/27] fix: preserve upgrade headers when proxying WebSocket upgrades MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgrade handler filtered headers through the standard hop-by-hop filter, stripping Connection and Upgrade. Upstreams therefore received a downgraded plain request and never saw the upgrade, so WebSocket proxying silently never worked. Keep those two headers on the upgrade path only (RFC 9110 §7.8.1, RFC 6455 §4.2.1). 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/proxy.ts | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/proxy.ts b/src/proxy.ts index 9470707..7932472 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -19,10 +19,22 @@ const HOP_BY_HOP_HEADERS: ReadonlySet = new Set([ "upgrade", ]) -function filterHeaders(headers: IncomingMessage["headers"]): OutgoingHttpHeaders { +function filterHeaders( + headers: IncomingMessage["headers"], + { keepUpgradeHeaders = false }: { keepUpgradeHeaders?: boolean } = {}, +): OutgoingHttpHeaders { const filtered: OutgoingHttpHeaders = {} for (const [name, value] of Object.entries(headers)) { - if (value === undefined || HOP_BY_HOP_HEADERS.has(name.toLowerCase())) continue + if (value === undefined) continue + const lowerName = name.toLowerCase() + if (HOP_BY_HOP_HEADERS.has(lowerName)) { + // On the upgrade path Connection and Upgrade must be preserved (see + // createProxyUpgradeHandler); everything else stays hop-by-hop. + if (keepUpgradeHeaders && (lowerName === "connection" || lowerName === "upgrade")) { + filtered[name] = value + } + continue + } filtered[name] = value } return filtered @@ -97,7 +109,10 @@ export function createProxyUpgradeHandler( const transport = url.protocol === "https:" ? https : http const port = url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port) return function handleUpgrade(req, clientSocket, head): void { - const headers = filterHeaders(req.headers) + // An upgrade request is itself a protocol upgrade: the Connection and + // Upgrade headers must be forwarded verbatim or the upstream will never + // see the upgrade request (RFC 9110 §7.8.1 and RFC 6455 §4.2.1). + const headers = filterHeaders(req.headers, { keepUpgradeHeaders: true }) headers.host = url.host const upstream = transport.request({ protocol: url.protocol, From 920144da0ff8c51fe022ff34df80cf50efb1b6f4 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:11:43 +0200 Subject: [PATCH 03/27] fix: stop hanging the client when an upstream refuses a WebSocket upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upgrade handler only listened for upgrade and error events. When the upstream answered a non-101 response (e.g. 400/403), nobody consumed it and the client socket hung forever. Relay the upstream status and close both sockets. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/proxy.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/proxy.ts b/src/proxy.ts index 7932472..640f799 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -122,6 +122,21 @@ export function createProxyUpgradeHandler( method: req.method, headers, }) + // If the upstream answers without upgrading (e.g. 4xx/5xx), relay the + // status line and close both sockets: nobody else would consume the + // response and the client would hang forever otherwise. + upstream.on("response", upstreamRes => { + const statusLine = `HTTP/1.1 ${upstreamRes.statusCode ?? 502} ${upstreamRes.statusMessage ?? "Bad Gateway"}\r\n` + clientSocket.write(statusLine) + for (const [name, value] of Object.entries(filterHeaders(upstreamRes.headers))) { + if (value === undefined) continue + const values = Array.isArray(value) ? value : [value] + for (const item of values) clientSocket.write(`${name}: ${item}\r\n`) + } + clientSocket.write("\r\n") + clientSocket.end() + upstream.destroy() + }) upstream.on("upgrade", (upstreamRes, upstreamSocket, upstreamHead) => { clientSocket.write( `HTTP/1.1 ${upstreamRes.statusCode ?? 101} ${upstreamRes.statusMessage ?? "Switching Protocols"}\r\n`, From ad6cf6c172cb86d5ec8e8d09493c74f119aca25d Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:12:16 +0200 Subject: [PATCH 04/27] fix: confine static file serving to the served root across symlinks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The path check was purely lexical, so a symlink inside the served folder pointing outside it could expose arbitrary readable files. Resolve the real path of both root and target before serving and reject anything that escapes the root (403). 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/static.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/static.ts b/src/static.ts index 0a3e060..79fe80b 100644 --- a/src/static.ts +++ b/src/static.ts @@ -57,6 +57,21 @@ function serve404(staticPath: string, req: IncomingMessage, res: ServerResponse) } } +// The lexical sanitize() check does not catch symlinks pointing outside the +// static root. After resolving the real path, verify it still lives beneath +// the (real) root; otherwise attackers able to plant a symlink in the served +// tree can read arbitrary readable files (e.g. via an uploaded site folder). +function confine(root: string, target: string): string | null { + try { + const realRoot = fs.realpathSync(root) + const realTarget = fs.realpathSync(target) + if (realTarget !== realRoot && !realTarget.startsWith(`${realRoot}${path.sep}`)) return null + return realTarget + } catch { + return null + } +} + function serveFile( target: string, req: IncomingMessage, @@ -152,6 +167,13 @@ export function createStaticHandler(staticPath: string): RequestListener { serve404(staticPath, req, res) return } + const confined = confine(staticPath, target) + if (confined === null) { + res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }) + res.end("Forbidden.") + return + } + target = confined try { fs.statSync(target) } catch { From d4a838939d71189906086db1193aad7b80f19aeb Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:12:53 +0200 Subject: [PATCH 05/27] fix: make the mkcert download robust against errors and a corrupt cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The downloader followed only 302s, never checked the response status, and happily piped 4xx/5xx bodies (or redirect pages from 301/307/308) into the executable file. A truncated or empty cached binary then permanently broke startup with a cryptic exec error because the cache short-circuit never re-downloaded. Check the status code (follow 3xx with a redirect cap, reject anything that is not 200), clean up the partial file on failure, and validate the cached executable (non-empty and executable) before trusting it, re-downloading otherwise. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/certs.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/src/certs.ts b/src/certs.ts index 44fe968..2974d9a 100644 --- a/src/certs.ts +++ b/src/certs.ts @@ -34,29 +34,72 @@ function getExe(): string { } } +const MAX_REDIRECTS = 5 + +// mkcert release binaries are a few MB; a truncated or empty cache (e.g. an +// interrupted download or an error page written by older versions) must be +// re-downloaded instead of failing exec with a cryptic error forever. +const MIN_EXECUTABLE_SIZE = 1024 * 1024 + +function isValidCachedExecutable(exePath: string): boolean { + try { + const stat = fs.statSync(exePath) + return ( + stat.isFile() && + stat.size >= MIN_EXECUTABLE_SIZE && + fs.accessSync(exePath, fs.constants.X_OK) === undefined + ) + } catch { + return false + } +} + async function download(url: string, destination: string): Promise { console.log("Downloading the mkcert executable...") - const file = fs.createWriteStream(destination) return new Promise((resolve, reject) => { - function get(currentUrl: string): void { + function get(currentUrl: string, redirectsLeft: number): void { + const file = fs.createWriteStream(destination) + function fail(error: Error): void { + file.destroy() + fs.rmSync(destination, { force: true }) + reject(error) + } https .get(currentUrl, response => { - if (response.statusCode === 302 && response.headers.location !== undefined) { - get(response.headers.location) + const { statusCode } = response + const location = response.headers.location + if ( + statusCode !== undefined && + statusCode >= 300 && + statusCode < 400 && + location !== undefined + ) { + response.resume() + if (redirectsLeft <= 0) { + fail(new Error(`Too many redirects while downloading ${url}`)) + return + } + get(new URL(location, currentUrl).toString(), redirectsLeft - 1) + return + } + if (statusCode !== 200) { + // Never write an error page into the executable file. + response.resume() + fail(new Error(`Failed to download ${currentUrl} (HTTP ${statusCode ?? "unknown"})`)) return } response.pipe(file) file.on("finish", () => { file.close(err => { if (err === undefined || err === null) resolve() - else reject(new Error("Failed to close the certificate file", { cause: err })) + else fail(new Error("Failed to close the certificate file", { cause: err })) }) }) - file.on("error", reject) + file.on("error", fail) }) - .on("error", reject) + .on("error", fail) } - get(url) + get(url, MAX_REDIRECTS) }) } @@ -104,7 +147,9 @@ export async function generate({ const url = `https://github.com/FiloSottile/mkcert/releases/download/${MKCERT_VERSION}/` const exe = getExe() const exePath = path.join(appDataPath, exe) - if (!fs.existsSync(exePath)) { + const cached = isValidCachedExecutable(exePath) + if (!cached) { + if (fs.existsSync(exePath)) fs.rmSync(exePath, { force: true }) await download(url + exe, exePath) fs.chmodSync(exePath, "0755") } From 1aa55260176d5fd3ad7cd5c4a3ddb750ba258fe7 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:13:21 +0200 Subject: [PATCH 06/27] fix: drop headers listed in Connection when proxying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headers named by the Connection header are hop-by-hop per RFC 9110 §7.6.1 but were forwarded in both directions, leaking connection-scoped headers (and, from the upstream, response-scoped ones) end to end. Parse the Connection list and filter those names too, in both the request and the response path. The WebSocket upgrade path keeps Connection and Upgrade untouched. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/proxy.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/proxy.ts b/src/proxy.ts index 640f799..ec47b1f 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -19,15 +19,35 @@ const HOP_BY_HOP_HEADERS: ReadonlySet = new Set([ "upgrade", ]) +// Headers listed in Connection (RFC 9110 §7.6.1) are hop-by-hop too and +// must not be forwarded. +function connectionListedHeaders( + connection: IncomingMessage["headers"]["connection"], +): Set { + const listed = new Set() + const values = + connection === undefined ? [] : Array.isArray(connection) ? connection : [connection] + for (const value of values) { + for (const token of value.split(",")) { + const name = token.trim().toLowerCase() + if (name !== "") listed.add(name) + } + } + return listed +} + function filterHeaders( headers: IncomingMessage["headers"], { keepUpgradeHeaders = false }: { keepUpgradeHeaders?: boolean } = {}, ): OutgoingHttpHeaders { + const connectionListed = keepUpgradeHeaders + ? new Set() + : connectionListedHeaders(headers.connection) const filtered: OutgoingHttpHeaders = {} for (const [name, value] of Object.entries(headers)) { if (value === undefined) continue const lowerName = name.toLowerCase() - if (HOP_BY_HOP_HEADERS.has(lowerName)) { + if (HOP_BY_HOP_HEADERS.has(lowerName) || connectionListed.has(lowerName)) { // On the upgrade path Connection and Upgrade must be preserved (see // createProxyUpgradeHandler); everything else stays hop-by-hop. if (keepUpgradeHeaders && (lowerName === "connection" || lowerName === "upgrade")) { From 38dc25736989030ca418caf83ea9cefed5c3c3a7 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:13:54 +0200 Subject: [PATCH 07/27] fix: reject listen errors and close the previous server on re-listen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit listen() and redirect() always created a fresh server without closing a previously bound one and without any error listener: a second call on the same port emitted an uncaught EADDRINUSE and left the caller's promise pending forever, while a call on another port silently leaked the old server. Close the previous server when re-listening and reject with the listen error instead. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/index.ts | 70 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 20 deletions(-) diff --git a/src/index.ts b/src/index.ts index df87de1..05fabdc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,21 @@ import http from "node:http" -import type { Server } from "node:http" +import type { IncomingMessage, Server } from "node:http" import https from "node:https" +import type { Duplex } from "node:stream" import { getCerts } from "./certs.ts" import { createProxyHandler, createProxyUpgradeHandler } from "./proxy.ts" import { createRouter } from "./router.ts" import { createStaticHandler } from "./static.ts" +async function closeServer(server: Server): Promise { + server.closeAllConnections() + server.closeIdleConnections() + await new Promise(resolve => { + server.close(() => resolve()) + }) +} + export type HttpsLocalhostApp = { server?: Server http?: Server @@ -27,16 +36,36 @@ export function createServer({ } = {}): HttpsLocalhostApp { const router = createRouter() + // Listening errors (e.g. EADDRINUSE) must reject the caller's promise: + // without an error listener Node emits them uncaught and the promise + // never settles. + function listenWithEvents( + server: Server, + port: number, + onUpgrade?: (req: IncomingMessage, socket: Duplex, head: Buffer) => void, + ): Promise { + server.on("error", error => { + server.emit("listenError", error) + }) + if (onUpgrade !== undefined) server.on("upgrade", onUpgrade) + return new Promise((resolve, reject) => { + server.once("listenError", reject) + server.listen(port, () => { + server.removeListener("listenError", reject) + resolve(server) + }) + }) + } + const app: HttpsLocalhostApp = { async listen(port = 443) { - const certs = await getCerts({ domain, certPath, reinstall }) - app.server = https.createServer(certs, router.handleRequest) - if (router.proxyUpgradeHandler !== undefined) { - app.server.on("upgrade", router.proxyUpgradeHandler) + if (app.server?.listening === true) { + console.warn(`Server already listening, closing the previous one.`) + await closeServer(app.server) } - await new Promise(resolve => { - app.server?.listen(port, resolve) - }) + const certs = await getCerts({ domain, certPath, reinstall }) + const server = https.createServer(certs, router.handleRequest) + app.server = await listenWithEvents(server, port, router.proxyUpgradeHandler) console.info(`Server running on port ${port}.`) return app.server }, @@ -48,19 +77,20 @@ export function createServer({ return app }, async redirect(httpPort = 80, httpsPort = 443) { - await new Promise(resolve => { - app.http = http - .createServer((req, res) => { - const reqHost = req.headers.host ?? domain - res.writeHead(301, { - Location: `https://${reqHost.replace(`:${httpPort}`, "")}${ - httpsPort !== 443 ? `:${httpsPort}` : "" - }${req.url ?? ""}`, - }) - res.end() - }) - .listen(httpPort, resolve) + if (app.http?.listening === true) { + console.warn(`Redirect server already listening, closing the previous one.`) + await closeServer(app.http) + } + const server = http.createServer((req, res) => { + const reqHost = req.headers.host ?? domain + res.writeHead(301, { + Location: `https://${reqHost.replace(`:${httpPort}`, "")}${ + httpsPort !== 443 ? `:${httpsPort}` : "" + }${req.url ?? ""}`, + }) + res.end() }) + app.http = await listenWithEvents(server, httpPort) console.info("http to https redirection active.") return app }, From 7780516ccf21ee9e3ea56efc231a431ddc2526f7 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:14:09 +0200 Subject: [PATCH 08/27] fix: use root-relative locations for directory redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The directory redirect prefixed the full request path with './', so at depth > 1 the browser resolved it against the wrong base (e.g. /deep/a/b redirected to /deep/a/deep/a/b). Emit a root-relative location instead. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/static.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/static.ts b/src/static.ts index 79fe80b..659ef92 100644 --- a/src/static.ts +++ b/src/static.ts @@ -156,7 +156,7 @@ export function createStaticHandler(staticPath: string): RequestListener { if (urlPath?.endsWith("/") !== true) { const relativeUrlPath = urlPath?.replace(/^\//u, "") ?? "" res.writeHead(301, { - Location: encodeURI(`./${relativeUrlPath}/${query === "" ? "" : `?${query}`}`), + Location: encodeURI(`/${relativeUrlPath}/${query === "" ? "" : `?${query}`}`), }) res.end() return From d0b0f918f3b5d91251922bfdfffd673805f7383f Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:14:48 +0200 Subject: [PATCH 09/27] fix: download the mkcert binary matching the CPU architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit darwin always downloaded the amd64 binary (an arm64 release asset exists) and linux mapped both arm and arm64 to the armv7 binary, so first run failed on Apple Silicon without Rosetta and on arm64 Linux. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/certs.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/certs.ts b/src/certs.ts index 2974d9a..ca6ac7b 100644 --- a/src/certs.ts +++ b/src/certs.ts @@ -17,11 +17,11 @@ export type CertificatePair = { function getExe(): string { switch (process.platform) { case "darwin": + if (process.arch === "arm64") return `mkcert-${MKCERT_VERSION}-darwin-arm64` return `mkcert-${MKCERT_VERSION}-darwin-amd64` case "linux": - if (process.arch === "arm" || process.arch === "arm64") { - return `mkcert-${MKCERT_VERSION}-linux-arm` - } + if (process.arch === "arm64") return `mkcert-${MKCERT_VERSION}-linux-arm64` + if (process.arch === "arm") return `mkcert-${MKCERT_VERSION}-linux-arm` return `mkcert-${MKCERT_VERSION}-linux-amd64` case "win32": return `mkcert-${MKCERT_VERSION}-windows-amd64.exe` From 028dd53356ff78edf0711309f6e3f2dad0f225ca Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:14:58 +0200 Subject: [PATCH 10/27] fix: throw instead of exiting the process on an unsupported platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getExe() ran process.exit(0) on unsupported platforms. getCerts() calls it on every invocation, so any application importing the library on an unsupported platform was silently terminated with a success exit code. Throw a descriptive error and let the caller decide how to handle it. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/certs.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/certs.ts b/src/certs.ts index ca6ac7b..8486907 100644 --- a/src/certs.ts +++ b/src/certs.ts @@ -26,11 +26,10 @@ function getExe(): string { case "win32": return `mkcert-${MKCERT_VERSION}-windows-amd64.exe` default: - console.error( - "Cannot generate the localhost certificate on your " + - "platform. Please, consider contacting the developer if you can help.", + throw new Error( + "Cannot generate the localhost certificate on your platform " + + `(${process.platform}-${process.arch}). Please, consider contacting the developer if you can help.`, ) - process.exit(0) } } From 9b857d0de5594dfb51f3657668986f622d81ccd4 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:15:42 +0200 Subject: [PATCH 11/27] fix: friendlier CLI errors for invalid input and startup failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serve --port abc dumped a raw ZodError with a stack trace. Also, the start calls (proxy/serve/redirect) were fire-and-forget, so a rejection (e.g. a certificate download failure) died through Node's default unhandled-rejection path instead of the friendly EACCES/EADDRINUSE handler. Summarize validation issues as one line each and route both uncaught exceptions and unhandled rejections through the same handler. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/cli.ts | 55 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 7d6c2b5..4752513 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -42,24 +42,7 @@ if (proxy !== undefined && parsedArgs.positionals.length > 0) { process.exit(1) } -const env = getEnv({ - PORT: port, - HOST: host, - CERT_PATH: certPath, - REINSTALL: reinstall, - PROXY_TARGET: proxy, -}) - -const app = createServer({ - domain: env.HOST, - certPath: env.CERT_PATH, - reinstall: env.REINSTALL, -}) -if (env.PROXY_TARGET !== undefined) app.proxy(env.PROXY_TARGET, env.PORT) -else app.serve(staticFolder, env.PORT) -if (env.PORT === 443) app.redirect() - -process.on("uncaughtException", err => { +function friendlyError(err: unknown): void { const error = err as NodeJS.ErrnoException switch (error.code) { case "EACCES": @@ -82,4 +65,40 @@ process.on("uncaughtException", err => { break } process.exit(1) +} + +let env: ReturnType +try { + env = getEnv({ + PORT: port, + HOST: host, + CERT_PATH: certPath, + REINSTALL: reinstall, + PROXY_TARGET: proxy, + }) +} catch (err) { + const issues = + err !== null && typeof err === "object" && "issues" in err && Array.isArray(err.issues) + ? (err.issues as { path: (string | number | symbol)[]; message: string }[]) + : undefined + const summary = + issues === undefined + ? err instanceof Error + ? err.message + : String(err) + : issues.map(issue => `${issue.path.join(".")}: ${issue.message}`).join("\n") + console.error(`Invalid arguments or environment:\n${summary}`) + process.exit(1) +} + +process.on("unhandledRejection", friendlyError) +process.on("uncaughtException", friendlyError) + +const app = createServer({ + domain: env.HOST, + certPath: env.CERT_PATH, + reinstall: env.REINSTALL, }) +if (env.PROXY_TARGET !== undefined) app.proxy(env.PROXY_TARGET, env.PORT) +else app.serve(staticFolder, env.PORT) +if (env.PORT === 443) app.redirect() From b37f7d8c4659bdafb5d540820fa6d89a21cc5dcd Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:16:04 +0200 Subject: [PATCH 12/27] fix: strip only the port from the Host in http->https redirects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The redirect replaced the first ":" occurrence anywhere in the Host header, which could mangle hosts containing that substring and mishandled IPv6 literals. Parse the port with lastIndexOf and the bracket rule for IPv6 hosts instead. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/index.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 05fabdc..9fcaf2c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -83,10 +83,14 @@ export function createServer({ } const server = http.createServer((req, res) => { const reqHost = req.headers.host ?? domain + // Strip the http port from the Host without touching an unrelated + // occurrence (e.g. inside an IPv6 literal) and handle hosts without + // a port. + const colonIndex = reqHost.lastIndexOf(":") + const hasPort = colonIndex > reqHost.lastIndexOf("]") + const bareHost = hasPort ? reqHost.slice(0, colonIndex) : reqHost res.writeHead(301, { - Location: `https://${reqHost.replace(`:${httpPort}`, "")}${ - httpsPort !== 443 ? `:${httpsPort}` : "" - }${req.url ?? ""}`, + Location: `https://${bareHost}${httpsPort !== 443 ? `:${httpsPort}` : ""}${req.url ?? ""}`, }) res.end() }) From 1d4a615fcc547820dc017dff01af5acca42db728 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:16:39 +0200 Subject: [PATCH 13/27] fix: keep tsbuildinfo out of the npm tarball and export package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incremental build info files were written into dist/ and shipped in the npm package (they appeared in pnpm pack). Write them to .cache/tsc/ instead. Also expose ./package.json through the exports map so tooling that reads a package version keeps working. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- .gitignore | 1 + package.json | 6 ++++-- tsconfig.build.cjs.json | 1 + tsconfig.build.esm.json | 1 + tsconfig.json | 1 + 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 2cf5403..dcaea44 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules build dist *.tsbuildinfo +.cache # Test-generated certificate directories test/custom-folder/ diff --git a/package.json b/package.json index 34bcfab..59d2e23 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "types": "./src/certs.ts", "import": "./src/certs.ts", "default": "./src/certs.ts" - } + }, + "./package.json": "./package.json" }, "publishConfig": { "main": "dist/cjs/index.js", @@ -56,7 +57,8 @@ "import": "./dist/esm/certs.js", "require": "./dist/cjs/certs.js", "default": "./dist/esm/certs.js" - } + }, + "./package.json": "./package.json" } }, "scripts": { diff --git a/tsconfig.build.cjs.json b/tsconfig.build.cjs.json index ddbf37d..7258f04 100644 --- a/tsconfig.build.cjs.json +++ b/tsconfig.build.cjs.json @@ -9,6 +9,7 @@ "moduleResolution": "Bundler", "noEmit": false, "outDir": "dist/cjs", + "tsBuildInfoFile": ".cache/tsc/tsconfig.build.cjs.tsbuildinfo", "verbatimModuleSyntax": false }, "include": ["src/**/*.ts"] diff --git a/tsconfig.build.esm.json b/tsconfig.build.esm.json index c24b3ee..b4475e9 100644 --- a/tsconfig.build.esm.json +++ b/tsconfig.build.esm.json @@ -9,6 +9,7 @@ "moduleResolution": "Bundler", "noEmit": false, "outDir": "dist/esm", + "tsBuildInfoFile": ".cache/tsc/tsconfig.build.esm.tsbuildinfo", "verbatimModuleSyntax": true }, "include": ["src/**/*.ts"] diff --git a/tsconfig.json b/tsconfig.json index 9919f6c..601648f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "erasableSyntaxOnly": true, "forceConsistentCasingInFileNames": true, "incremental": true, + "tsBuildInfoFile": ".cache/tsc/tsconfig.tsbuildinfo", "isolatedModules": true, "lib": ["ES2023"], "module": "NodeNext", From d7cacf63574dfa0c480e809a7e3ac8925bbfedc9 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:17:04 +0200 Subject: [PATCH 14/27] fix: make the build script Windows-compatible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build used POSIX-only shell constructs (rm -rf, echo redirection) which fail under cmd.exe on Windows. Replace it with a small Node script performing the same steps portably. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- package.json | 15 +++++++++++---- scripts/build.mjs | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 scripts/build.mjs diff --git a/package.json b/package.json index 59d2e23..43c9a22 100644 --- a/package.json +++ b/package.json @@ -62,9 +62,8 @@ } }, "scripts": { - "build": "rm -rf dist && tsc --project tsconfig.build.esm.json && tsc --project tsconfig.build.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", - "fix": "oxfmt && oxlint --fix && knip && tsc --noEmit", - "lint": "oxfmt --check && oxlint && knip && tsc --noEmit", + "build": "node scripts/build.mjs", + "lint": "oxfmt && oxlint --fix && knip && tsc --noEmit", "test": "node --test-concurrency=1 --test test/*.test.ts", "prepack": "pnpm build", "preuninstall": "node dist/esm/certs-cli.js -u", @@ -95,5 +94,13 @@ "engines": { "node": ">=24" }, - "packageManager": "pnpm@12.3.4" + "packageManager": "pnpm@12.3.4", + "pkg": { + "scripts": "*.js", + "targets": [ + "node16-macos-x64", + "node16-linux-x64", + "node16-win-x64" + ] + } } diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..8940637 --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,21 @@ +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + +fs.rmSync(path.join(root, "dist"), { recursive: true, force: true }) +fs.rmSync(path.join(root, ".cache", "tsc"), { recursive: true, force: true }) + +const tsc = path.join(root, "node_modules", "typescript", "bin", "tsc") +for (const project of ["tsconfig.build.esm.json", "tsconfig.build.cjs.json"]) { + const { status } = spawnSync(process.execPath, [tsc, "--project", project], { + stdio: "inherit", + cwd: root, + }) + if (status !== 0) process.exit(status ?? 1) +} + +// The CJS build lives in a "type": "module" package; mark it as CommonJS. +fs.writeFileSync(path.join(root, "dist", "cjs", "package.json"), '{"type":"commonjs"}') From 60d2be160a703a4d4de801c5350f227df0655fe9 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:17:27 +0200 Subject: [PATCH 15/27] fix: serve multi-range requests as full representations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-range requests were answered with a 206 serving only the first range, silently dropping the rest. RFC 9110 §14.2 lets a server ignore Range, so answer unparseable-but-bytes-shaped and multi-range requests with 200 while keeping 416 for genuinely unsatisfiable single ranges. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/static.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/static.ts b/src/static.ts index 659ef92..7f143f3 100644 --- a/src/static.ts +++ b/src/static.ts @@ -28,8 +28,11 @@ function sanitize(staticPath: string, urlPath: string): string | null { } function parseRange(header: string, size: number): { start: number; end: number } | null { - const firstRange = header.trim().split(",", 1)[0] - const match = /^bytes=(\d*)-(\d*)$/u.exec(firstRange ?? "") + const trimmed = header.trim() + // Multi-range requests are answered with the full representation (RFC 9110 + // §14.2 allows a server to ignore Range). + if (trimmed.includes(",")) return null + const match = /^bytes=(\d*)-(\d*)$/u.exec(trimmed) if (match === null || (match[1] === "" && match[2] === "")) return null if (match[1] === "") { const n = Number(match[2]) @@ -183,8 +186,14 @@ export function createStaticHandler(staticPath: string): RequestListener { let range: { start: number; end: number } | null = null const rangeHeader = req.headers.range if (rangeHeader !== undefined) { - range = parseRange(rangeHeader, fs.statSync(target).size) - if (range === null) { + // null means ignore the header (serve 200), not an error: malformed or + // unsupported (e.g. multi-range) requests get the full representation. + const parsed = parseRange(rangeHeader, fs.statSync(target).size) + if ( + parsed === null && + rangeHeader.trim().startsWith("bytes=") && + !rangeHeader.includes(",") + ) { res.writeHead(416, { "Content-Range": `bytes */${fs.statSync(target).size}`, "Content-Type": "text/plain; charset=utf-8", @@ -192,6 +201,7 @@ export function createStaticHandler(staticPath: string): RequestListener { res.end("Range not satisfiable.") return } + range = parsed } serveFile(target, req, res, range) } From 40b24936acbb29b18d30ab0e22c84c47fba46166 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:17:44 +0200 Subject: [PATCH 16/27] docs: add --help flag, install-mode note, and CORS warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the -h/--help flag the CLI supports, clarify when to install the module as a dev dependency versus a runtime dependency, and warn that proxy mode serves every response with Access-Control-Allow-Origin: *, so any website the developer visits can read the proxied backend. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d94c81c..da999c3 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,12 @@ Usage notes: - `--cert-path `: custom certificate directory (`CERT_PATH`) - `--reinstall`: force certificate re-generation (`REINSTALL=true`) - `--proxy `: proxy all requests to the given http(s) URL (`PROXY_TARGET`) + - `-h, --help`: display help - Specifying a port number prevents HTTP to HTTPS redirect. ## Use as module -Install as a dependency: +Install as a dependency (`-D` when you only use it as a dev server, plain `npm i https-localhost` when your app depends on it at runtime): ```sh npm i -D https-localhost @@ -51,6 +52,9 @@ await app.serve(path) // serve static files await app.proxy("http://localhost:3000") ``` +> [!WARNING] +> Every response is sent with `Access-Control-Allow-Origin: *`. This is what makes cross-origin calls from your local apps work, but it also means any website you visit can read the responses of the proxied target while the proxy is running. Stop it when you are done and do not point it at anything sensitive. + Alternatively, you can use the certificates in your own server: ```js From 0a42187c028fb7b5dc1ecb92ee3a46553e9a2fc7 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:17:50 +0200 Subject: [PATCH 17/27] fix: list src/cli.ts directly in the knip entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI coverage relied on the bin heuristic; an explicit entry keeps it detected if the bin field ever changes. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- knip.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 6051eed..5fb1fca 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/certs-cli.ts", "test/**/*.test.ts"], + "entry": ["src/cli.ts", "src/certs-cli.ts", "test/**/*.test.ts"], "project": ["src/**/*.ts", "test/**/*.ts"] } From d4c4efa8151cd39b8c5111ade7d9ced5e89e8398 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:18:45 +0200 Subject: [PATCH 18/27] fix: drop the redundant knip entry for src/cli.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit knip already detects src/cli.ts through the bin field and warns that an explicit entry is redundant. Drop it and keep the configuration hint free. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- knip.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 5fb1fca..6051eed 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { "$schema": "https://unpkg.com/knip@6/schema.json", - "entry": ["src/cli.ts", "src/certs-cli.ts", "test/**/*.test.ts"], + "entry": ["src/certs-cli.ts", "test/**/*.test.ts"], "project": ["src/**/*.ts", "test/**/*.ts"] } From d511d1acf8aba30df8b2f2ed3c141e63b57c6b50 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:35:22 +0200 Subject: [PATCH 19/27] fix: tear down the proxied tunnel when one peer disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a WebSocket upgrade the client and upstream sockets were piped together with no error or close handling: a disconnect left both sides half-open forever, and a socket error on either side could crash the process with an unhandled error event. Destroy the other side on error or close. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/proxy.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/proxy.ts b/src/proxy.ts index ec47b1f..3a41249 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -169,6 +169,13 @@ export function createProxyUpgradeHandler( clientSocket.write("\r\n") if (upstreamHead.length > 0) clientSocket.write(upstreamHead) if (head.length > 0) upstreamSocket.write(head) + // When one side of the tunnel disconnects (or errors), tear down the + // other side too; without this, disconnected sockets linger and can + // crash the process on a socket error with no listener. + clientSocket.on("error", () => upstreamSocket.destroy()) + clientSocket.on("close", () => upstreamSocket.destroy()) + upstreamSocket.on("error", () => clientSocket.destroy()) + upstreamSocket.on("close", () => clientSocket.destroy()) clientSocket.pipe(upstreamSocket).pipe(clientSocket) }) upstream.on("error", () => clientSocket.destroy()) From 0c900ea08b1ee5be82f6fde5693f741fe07c41b4 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:35:22 +0200 Subject: [PATCH 20/27] test: cover the fixed proxy, static, and redirect behaviors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cover Connection-listed hop-by-hop filtering, the non-101 upgrade relay, WebSocket upgrade header forwarding with bidirectional data, root-relative directory redirects (including nested), the IPv6-safe http->https redirect, and the symlink confinement. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- test/fixtures/sub/deep/index.html | 1 + test/index.test.ts | 43 ++++++++- test/proxy.test.ts | 141 ++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 test/fixtures/sub/deep/index.html diff --git a/test/fixtures/sub/deep/index.html b/test/fixtures/sub/deep/index.html new file mode 100644 index 0000000..043681f --- /dev/null +++ b/test/fixtures/sub/deep/index.html @@ -0,0 +1 @@ +DEEP-INDEX diff --git a/test/index.test.ts b/test/index.test.ts index 4e26c73..3229085 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,5 +1,7 @@ import assert from "node:assert" import fs from "node:fs" +import os from "node:os" +import path from "node:path" import { afterEach, describe, it } from "node:test" import { generate, remove } from "../src/certs.ts" @@ -110,7 +112,7 @@ void describe("index (createServer)", { timeout: 300000 }, () => { const res = await makeRequest("/sub") assert.strictEqual(res.statusCode, 301) - assert.strictEqual(res.headers["location"], "./sub/") + assert.strictEqual(res.headers["location"], "/sub/") const index = await makeRequest("/sub/") assert.strictEqual(index.statusCode, 200) @@ -126,7 +128,35 @@ void describe("index (createServer)", { timeout: 300000 }, () => { const res = await makeRequest("/sub?a=1") assert.strictEqual(res.statusCode, 301) - assert.strictEqual(res.headers["location"], "./sub/?a=1") + assert.strictEqual(res.headers["location"], "/sub/?a=1") + }) + + void it("redirects nested directories to a resolvable location", async () => { + app = createServer() + await app.serve("test/fixtures", HTTPS_PORT) + + const res = await makeRequest("/sub/deep") + assert.strictEqual(res.statusCode, 301) + assert.strictEqual(res.headers["location"], "/sub/deep/") + + const index = await makeRequest(res.headers["location"] ?? "/") + assert.strictEqual(index.statusCode, 200) + }) + + void it("rejects symlinked paths escaping the served root", async () => { + app = createServer() + await app.serve("test/fixtures", HTTPS_PORT) + + const outsideFile = path.resolve(os.tmpdir(), "https-localhost-outside-secret.txt") + fs.writeFileSync(outsideFile, "TOPSECRET") + fs.symlinkSync(outsideFile, "test/fixtures/symlink-secret.txt") + try { + const res = await makeRequest("/symlink-secret.txt") + assert.strictEqual(res.statusCode, 403) + } finally { + fs.rmSync("test/fixtures/symlink-secret.txt") + fs.rmSync(outsideFile, { force: true }) + } }) void it("rejects protocol-relative and absolute request targets", async () => { @@ -190,6 +220,15 @@ void describe("index (createServer)", { timeout: 300000 }, () => { assert.strictEqual(res.headers["location"], `https://localhost:${HTTPS_PORT}/`) }) + void it("redirects http to https for an IPv6 host", async () => { + app = createServer() + await app.redirect(HTTP_PORT, HTTPS_PORT) + + const res = await makeRequest("/", false, HTTP_PORT, { host: "[::1]:8080" }) + assert.strictEqual(res.statusCode, 301) + assert.strictEqual(res.headers["location"], `https://[::1]:${HTTPS_PORT}/`) + }) + void it("doesn't install an uncaughtException handler when imported", async () => { const listenersBefore = process.listenerCount("uncaughtException") await import(`../src/index.ts?t=${Date.now()}`) diff --git a/test/proxy.test.ts b/test/proxy.test.ts index 801bd5c..7d30fe5 100644 --- a/test/proxy.test.ts +++ b/test/proxy.test.ts @@ -96,4 +96,145 @@ void describe("proxy", { timeout: 300000 }, () => { void it("throws for unsupported proxy protocols", async () => { await assert.rejects(() => app.proxy("ftp://localhost:3000", HTTPS_PORT)) }) + + void it("drops Connection-listed hop-by-hop headers", async () => { + let seen: Record = {} + upstream = http.createServer((req, res) => { + seen = req.headers + res.writeHead(200, { "x-resp-listed": "nope" }) + res.end("ok") + }) + await new Promise(resolve => { + upstream?.listen(0, () => resolve()) + }) + + app = createServer() + await app.proxy(`http://localhost:${(upstream.address() as { port: number }).port}`, HTTPS_PORT) + + const rootCA = getRootCA() + assert.ok(rootCA !== undefined) + const res = await new Promise<{ headers: Record }>( + (resolve, reject) => { + const req = https.request( + `https://localhost:${HTTPS_PORT}/`, + { + agent: false, + ca: [rootCA], + headers: { connection: "x-secret, keep-alive", "x-secret": "leak" }, + }, + upstreamRes => { + const headers = upstreamRes.headers + upstreamRes.resume() + upstreamRes.on("end", () => resolve({ headers })) + }, + ) + req.on("error", reject) + req.end() + }, + ) + + assert.strictEqual(seen["x-secret"], undefined, "x-secret must not reach the upstream") + assert.strictEqual(res.headers["x-resp-listed"], "nope") + }) + + void it("relays a non-101 response to a WebSocket upgrade instead of hanging", async () => { + upstream = http.createServer((_req, res) => { + res.writeHead(400) + res.end("no upgrades here") + }) + await new Promise(resolve => { + upstream?.listen(0, () => resolve()) + }) + + app = createServer() + await app.proxy(`http://localhost:${(upstream.address() as { port: number }).port}`, HTTPS_PORT) + + const rootCA = getRootCA() + assert.ok(rootCA !== undefined) + const result = await new Promise(resolve => { + const timer = setTimeout(() => resolve("hang"), 5000) + const req = https.request(`https://localhost:${HTTPS_PORT}/ws`, { + agent: false, + ca: [rootCA], + headers: { connection: "Upgrade", upgrade: "websocket" }, + }) + req.end() + req.on("upgrade", () => { + clearTimeout(timer) + resolve("upgraded") + }) + req.on("response", res => { + res.resume() + res.on("end", () => { + // The Node client keeps the socket half-open after a response to an + // upgrade request; destroy it or the test process never exits. + req.destroy() + clearTimeout(timer) + resolve(`response:${res.statusCode}`) + }) + }) + req.on("error", err => { + clearTimeout(timer) + resolve(`error:${(err as NodeJS.ErrnoException).code}`) + }) + }) + assert.notStrictEqual(result, "hang", "upgrade request hung on non-101 upstream") + assert.strictEqual(result, "response:400") + }) + + void it("proxies WebSocket upgrades with the upgrade headers intact", async () => { + let sawUpgradeHeader = false + upstream = http.createServer() + upstream.on("upgrade", (req, socket) => { + sawUpgradeHeader = req.headers.upgrade === "websocket" + socket.write( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n", + ) + socket.on("data", () => socket.end("hello-from-upstream")) + socket.on("close", () => socket.destroy()) + }) + await new Promise(resolve => { + upstream?.listen(0, () => resolve()) + }) + + app = createServer() + await app.proxy(`http://localhost:${(upstream.address() as { port: number }).port}`, HTTPS_PORT) + + const rootCA = getRootCA() + assert.ok(rootCA !== undefined) + const result = await new Promise<{ data: string; got101: boolean }>((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("upgrade timed out")), 5000) + const req = https.request(`https://localhost:${HTTPS_PORT}/ws`, { + agent: false, + ca: [rootCA], + headers: { connection: "Upgrade", upgrade: "websocket" }, + }) + req.end() + req.on("upgrade", (res, socket, head) => { + let data = head.toString() + socket.on("data", (chunk: Buffer) => { + data += chunk.toString() + socket.destroy() + }) + socket.on("close", () => { + clearTimeout(timer) + resolve({ data, got101: res.statusCode === 101 }) + }) + socket.on("error", err => { + clearTimeout(timer) + reject(err) + }) + // Ask the "server" to send something back: write a frame. + socket.write("ping") + }) + req.on("error", err => { + clearTimeout(timer) + reject(err) + }) + }) + + assert.ok(result.got101, "expected 101 Switching Protocols") + assert.ok(sawUpgradeHeader, "upstream must receive the Upgrade header") + assert.notStrictEqual(result.data, "", "some data must flow after the upgrade") + }) }) From e2355b57e311efdf5011a23ec49656612e6ce83a Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:00:40 +0200 Subject: [PATCH 21/27] fix: address CodeQL and GH security findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - proxy: validate every header name against the RFC 9110 token grammar before writing it to the outgoing headers object (remote property injection), and always write lowercased names. - static: validate the directory-redirect Location against a strict printable-ASCII same-site path shape before writing it (server-side URL redirect). - static: open the file first and fstat the descriptor so the metadata and the content served come from the same open file (file-system race / TOCTOU between statSync and readFileSync). - certs: retry execFile briefly on ETXTBSY, fixing the Ubuntu CI flake where the freshly downloaded mkcert binary is still held by the downloader fd. - test: create the outside-symlink target in a random mkdtemp directory instead of a predictable file in the shared temp dir (insecure temporary file). 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/certs.ts | 30 ++++++++++----- src/proxy.ts | 21 +++++++++-- src/static.ts | 94 +++++++++++++++++++++++++++------------------- test/index.test.ts | 7 +++- 4 files changed, 99 insertions(+), 53 deletions(-) diff --git a/src/certs.ts b/src/certs.ts index 8486907..a1f508e 100644 --- a/src/certs.ts +++ b/src/certs.ts @@ -120,16 +120,26 @@ async function runMkcert({ return new Promise((resolve, reject) => { console.log("Running mkcert to generate certificates...") - execFile(exePath, args, (error, stdout, stderr) => { - if (stdout.length > 0) console.log(stdout) - if (stderr.length > 0) console.error(stderr) - if (error !== null) { - console.error(error) - reject(new Error(`mkcert failed: ${error.message}`)) - return - } - resolve() - }) + // On Linux the freshly written executable may still be held by the + // downloader's fd for a moment (ETXTBSY); retry a few times before + // giving up. + const attempt = (retriesLeft: number): void => { + execFile(exePath, args, (error, stdout, stderr) => { + if (stdout.length > 0) console.log(stdout) + if (stderr.length > 0) console.error(stderr) + if (error !== null) { + if ((error as NodeJS.ErrnoException).code === "ETXTBSY" && retriesLeft > 0) { + setTimeout(() => attempt(retriesLeft - 1), 250) + return + } + console.error(error) + reject(new Error(`mkcert failed: ${error.message}`)) + return + } + resolve() + }) + } + attempt(5) }) } diff --git a/src/proxy.ts b/src/proxy.ts index 3a41249..8b77b1e 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -36,6 +36,16 @@ function connectionListedHeaders( return listed } +// A valid HTTP header name (RFC 9110 §5.1 token). Assigning an arbitrary +// string as a property name (e.g. "__proto__") on a plain object would be a +// prototype-pollution / property-injection hazard, so every header name is +// validated before it is written to the outgoing headers object. +const HEADER_NAME = /^[a-z0-9!#$%&'*+.^_`|~-]+$/u + +function isSafeHeaderName(name: string): boolean { + return HEADER_NAME.test(name) && !Object.prototype.hasOwnProperty.call(Object.prototype, name) +} + function filterHeaders( headers: IncomingMessage["headers"], { keepUpgradeHeaders = false }: { keepUpgradeHeaders?: boolean } = {}, @@ -47,15 +57,20 @@ function filterHeaders( for (const [name, value] of Object.entries(headers)) { if (value === undefined) continue const lowerName = name.toLowerCase() + if (!isSafeHeaderName(lowerName)) continue if (HOP_BY_HOP_HEADERS.has(lowerName) || connectionListed.has(lowerName)) { // On the upgrade path Connection and Upgrade must be preserved (see // createProxyUpgradeHandler); everything else stays hop-by-hop. - if (keepUpgradeHeaders && (lowerName === "connection" || lowerName === "upgrade")) { - filtered[name] = value + if ( + keepUpgradeHeaders && + (lowerName === "connection" || lowerName === "upgrade") && + typeof value === "string" + ) { + filtered[lowerName] = value } continue } - filtered[name] = value + filtered[lowerName] = value } return filtered } diff --git a/src/static.ts b/src/static.ts index 7f143f3..62aa2ff 100644 --- a/src/static.ts +++ b/src/static.ts @@ -81,47 +81,56 @@ function serveFile( res: ServerResponse, range: { start: number; end: number } | null, ): void { - const stat = fs.statSync(target) - const etag = `"${stat.size.toString(16)}-${stat.mtimeMs.toString(16)}"` - res.setHeader("ETag", etag) - res.setHeader("Last-Modified", stat.mtime.toUTCString()) - res.setHeader("Accept-Ranges", "bytes") + // Open first, then fstat the descriptor: stat and read refer to the same + // open file, so the metadata cannot drift from the content between the + // check and the read (TOCTOU). + const fd = fs.openSync(target, "r") + try { + const stat = fs.fstatSync(fd) + const etag = `"${stat.size.toString(16)}-${stat.mtimeMs.toString(16)}"` + res.setHeader("ETag", etag) + res.setHeader("Last-Modified", stat.mtime.toUTCString()) + res.setHeader("Accept-Ranges", "bytes") - const ifNoneMatch = req.headers["if-none-match"] - if ( - ifNoneMatch?.split(",").some(tag => tag.trim() === etag || tag.trim() === `W/${etag}`) === true - ) { - res.writeHead(304) - res.end() - return - } - const ifModifiedSince = req.headers["if-modified-since"] - if (ifModifiedSince !== undefined && !Number.isNaN(new Date(ifModifiedSince).getTime())) { - if (stat.mtime <= new Date(ifModifiedSince)) { + const ifNoneMatch = req.headers["if-none-match"] + if ( + ifNoneMatch?.split(",").some(tag => tag.trim() === etag || tag.trim() === `W/${etag}`) === + true + ) { res.writeHead(304) res.end() return } - } + const ifModifiedSince = req.headers["if-modified-since"] + if (ifModifiedSince !== undefined && !Number.isNaN(new Date(ifModifiedSince).getTime())) { + if (stat.mtime <= new Date(ifModifiedSince)) { + res.writeHead(304) + res.end() + return + } + } - const { size } = stat - const content = fs.readFileSync(target) - const body = range === null ? content : content.subarray(range.start, range.end + 1) - res.writeHead( - range === null ? 200 : 206, - Object.assign( - { - "Content-Type": mime(target), - "Content-Length": range === null ? size : range.end - range.start + 1, - }, - range === null ? {} : { "Content-Range": `bytes ${range.start}-${range.end}/${size}` }, - ), - ) - if (req.method === "HEAD") { - res.end() - return + const { size } = stat + const content = fs.readFileSync(fd) + const body = range === null ? content : content.subarray(range.start, range.end + 1) + res.writeHead( + range === null ? 200 : 206, + Object.assign( + { + "Content-Type": mime(target), + "Content-Length": range === null ? size : range.end - range.start + 1, + }, + range === null ? {} : { "Content-Range": `bytes ${range.start}-${range.end}/${size}` }, + ), + ) + if (req.method === "HEAD") { + res.end() + return + } + res.end(body) + } finally { + fs.closeSync(fd) } - res.end(body) } export function createStaticHandler(staticPath: string): RequestListener { @@ -157,10 +166,19 @@ export function createStaticHandler(staticPath: string): RequestListener { const stat = fs.statSync(target) if (stat.isDirectory()) { if (urlPath?.endsWith("/") !== true) { - const relativeUrlPath = urlPath?.replace(/^\//u, "") ?? "" - res.writeHead(301, { - Location: encodeURI(`/${relativeUrlPath}/${query === "" ? "" : `?${query}`}`), - }) + // Re-encode the user-controlled path and only redirect when the + // result matches a strict same-site location shape: printable ASCII + // (percent-encoded) characters only, single root, never + // protocol-relative. + const location = encodeURI( + `/${(urlPath ?? "/").replace(/^\//u, "")}/${query === "" ? "" : `?${query}`}`, + ) + if (!/^\/(?!\/)[\x20-\x7e]*$/u.test(location)) { + res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" }) + res.end("Bad request.") + return + } + res.writeHead(301, { Location: location }) res.end() return } diff --git a/test/index.test.ts b/test/index.test.ts index 3229085..c8c2904 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -147,7 +147,10 @@ void describe("index (createServer)", { timeout: 300000 }, () => { app = createServer() await app.serve("test/fixtures", HTTPS_PORT) - const outsideFile = path.resolve(os.tmpdir(), "https-localhost-outside-secret.txt") + // A random directory outside the served root; not a predictable file in + // the shared temp dir. + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "https-localhost-outside-")) + const outsideFile = path.join(outsideDir, "secret.txt") fs.writeFileSync(outsideFile, "TOPSECRET") fs.symlinkSync(outsideFile, "test/fixtures/symlink-secret.txt") try { @@ -155,7 +158,7 @@ void describe("index (createServer)", { timeout: 300000 }, () => { assert.strictEqual(res.statusCode, 403) } finally { fs.rmSync("test/fixtures/symlink-secret.txt") - fs.rmSync(outsideFile, { force: true }) + fs.rmSync(outsideDir, { recursive: true, force: true }) } }) From aa06e1b96c207ff962dfa0344cd1c6e3c10d065e Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:28:20 +0200 Subject: [PATCH 22/27] test: make the nested fixture an actual HTML document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- test/fixtures/sub/deep/index.html | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/fixtures/sub/deep/index.html b/test/fixtures/sub/deep/index.html index 043681f..f29645e 100644 --- a/test/fixtures/sub/deep/index.html +++ b/test/fixtures/sub/deep/index.html @@ -1 +1,8 @@ -DEEP-INDEX + + + Deep + + +

Deep index.

+ + From d33760254aa1959426b9bd1826bd080e001d46ad Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:28:20 +0200 Subject: [PATCH 23/27] docs: always install the module as a dev dependency and drop the CORS warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https-localhost is a dev server: it is always a dev dependency. Also remove the CORS warning that overstated the exposure of a localhost development tool. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index da999c3..d3ba20e 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Usage notes: ## Use as module -Install as a dependency (`-D` when you only use it as a dev server, plain `npm i https-localhost` when your app depends on it at runtime): +Install as a dev dependency: ```sh npm i -D https-localhost @@ -52,9 +52,6 @@ await app.serve(path) // serve static files await app.proxy("http://localhost:3000") ``` -> [!WARNING] -> Every response is sent with `Access-Control-Allow-Origin: *`. This is what makes cross-origin calls from your local apps work, but it also means any website you visit can read the responses of the proxied target while the proxy is running. Stop it when you are done and do not point it at anything sensitive. - Alternatively, you can use the certificates in your own server: ```js From 5a0f31e6aacb0b3b45f444a1bd181172f271406b Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:28:20 +0200 Subject: [PATCH 24/27] build: compile ESM and CJS with plain pnpm scripts like the monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the helper build script with the two-step script used by the Bending Spoons typescript monorepo libraries (pico, orion): one tsc project per module format, then write the dist package.json type marker. Reuses master's fix/lint scripts that the rebase had clobbered. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- .github/workflows/release.yml | 1 - package.json | 7 +++++-- scripts/build.mjs | 21 --------------------- 3 files changed, 5 insertions(+), 24 deletions(-) delete mode 100644 scripts/build.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 703c705..ef1534c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,4 +45,3 @@ jobs: git push --follow-tags - run: pnpm publish --provenance - diff --git a/package.json b/package.json index 43c9a22..46fc885 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,11 @@ } }, "scripts": { - "build": "node scripts/build.mjs", - "lint": "oxfmt && oxlint --fix && knip && tsc --noEmit", + "build": "pnpm run build:esm && pnpm run build:cjs", + "build:esm": "tsc --project tsconfig.build.esm.json && node -e \"fs.writeFileSync('dist/esm/package.json', JSON.stringify({ type: 'module' }))\"", + "build:cjs": "tsc --project tsconfig.build.cjs.json && node -e \"fs.writeFileSync('dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }))\"", + "fix": "oxfmt && oxlint --fix && knip && tsc --noEmit", + "lint": "oxfmt --check && oxlint && knip && tsc --noEmit", "test": "node --test-concurrency=1 --test test/*.test.ts", "prepack": "pnpm build", "preuninstall": "node dist/esm/certs-cli.js -u", diff --git a/scripts/build.mjs b/scripts/build.mjs deleted file mode 100644 index 8940637..0000000 --- a/scripts/build.mjs +++ /dev/null @@ -1,21 +0,0 @@ -import { spawnSync } from "node:child_process" -import fs from "node:fs" -import path from "node:path" -import { fileURLToPath } from "node:url" - -const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))) - -fs.rmSync(path.join(root, "dist"), { recursive: true, force: true }) -fs.rmSync(path.join(root, ".cache", "tsc"), { recursive: true, force: true }) - -const tsc = path.join(root, "node_modules", "typescript", "bin", "tsc") -for (const project of ["tsconfig.build.esm.json", "tsconfig.build.cjs.json"]) { - const { status } = spawnSync(process.execPath, [tsc, "--project", project], { - stdio: "inherit", - cwd: root, - }) - if (status !== 0) process.exit(status ?? 1) -} - -// The CJS build lives in a "type": "module" package; mark it as CommonJS. -fs.writeFileSync(path.join(root, "dist", "cjs", "package.json"), '{"type":"commonjs"}') From 2c4abc3ba0e075d9761ddba157e246c9487f242a Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:34:17 +0200 Subject: [PATCH 25/27] fix: retry synchronous ETXTBSY errors from mkcert startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu's execFile call can surface ETXTBSY while the freshly downloaded binary is still held by the downloader as a synchronous spawn error, which bypassed the callback-only retry and failed both Ubuntu test jobs. Handle both synchronous throws and callback errors with the same bounded retry. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/certs.ts | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/certs.ts b/src/certs.ts index a1f508e..81d2212 100644 --- a/src/certs.ts +++ b/src/certs.ts @@ -122,22 +122,36 @@ async function runMkcert({ console.log("Running mkcert to generate certificates...") // On Linux the freshly written executable may still be held by the // downloader's fd for a moment (ETXTBSY); retry a few times before - // giving up. + // giving up. The error can surface either synchronously from execFile + // or through the callback. + const retry = (retriesLeft: number): void => { + setTimeout(() => attempt(retriesLeft), 250) + } const attempt = (retriesLeft: number): void => { - execFile(exePath, args, (error, stdout, stderr) => { - if (stdout.length > 0) console.log(stdout) - if (stderr.length > 0) console.error(stderr) - if (error !== null) { - if ((error as NodeJS.ErrnoException).code === "ETXTBSY" && retriesLeft > 0) { - setTimeout(() => attempt(retriesLeft - 1), 250) + let child: ReturnType + try { + child = execFile(exePath, args, (error, stdout, stderr) => { + if (stdout.length > 0) console.log(stdout) + if (stderr.length > 0) console.error(stderr) + if (error !== null) { + if ((error as NodeJS.ErrnoException).code === "ETXTBSY" && retriesLeft > 0) { + retry(retriesLeft - 1) + return + } + console.error(error) + reject(new Error(`mkcert failed: ${error.message}`)) return } - console.error(error) - reject(new Error(`mkcert failed: ${error.message}`)) + resolve() + }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ETXTBSY" && retriesLeft > 0) { + retry(retriesLeft - 1) return } - resolve() - }) + throw error + } + child.on("error", () => {}) } attempt(5) }) From 117a973ccf979ee217b31c4fca8f9a4db446f869 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:37:03 +0200 Subject: [PATCH 26/27] fix: download mkcert to a temp file and rename it into place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ETXTBSY retries were not enough on the Ubuntu runners: the writer's file descriptor kept the executable busy well past the retry window. Download to a sibling temp file and atomically rename it to the final name, giving the executable a fresh inode no writer holds. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/certs.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/certs.ts b/src/certs.ts index 81d2212..ab83a55 100644 --- a/src/certs.ts +++ b/src/certs.ts @@ -55,12 +55,18 @@ function isValidCachedExecutable(exePath: string): boolean { async function download(url: string, destination: string): Promise { console.log("Downloading the mkcert executable...") + // Download to a temporary file and rename it into place: spawning a + // binary whose file descriptor is still open anywhere fails with ETXTBSY + // on Linux, and a rename gives the executable a fresh inode no writer + // holds. The temp file lives in the same directory so the rename is + // atomic. + const tempDestination = `${destination}.download-${process.pid}` return new Promise((resolve, reject) => { function get(currentUrl: string, redirectsLeft: number): void { - const file = fs.createWriteStream(destination) + const file = fs.createWriteStream(tempDestination) function fail(error: Error): void { file.destroy() - fs.rmSync(destination, { force: true }) + fs.rmSync(tempDestination, { force: true }) reject(error) } https @@ -90,8 +96,10 @@ async function download(url: string, destination: string): Promise { response.pipe(file) file.on("finish", () => { file.close(err => { - if (err === undefined || err === null) resolve() - else fail(new Error("Failed to close the certificate file", { cause: err })) + if (err === undefined || err === null) { + fs.renameSync(tempDestination, destination) + resolve() + } else fail(new Error("Failed to close the certificate file", { cause: err })) }) }) file.on("error", fail) From 5ded146b086216865157d604fdc196a7ec4d12b3 Mon Sep 17 00:00:00 2001 From: daquinoaldo <18645793+daquinoaldo@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:40:34 +0200 Subject: [PATCH 27/27] fix: avoid ETXTBSY by creating the download file after HTTP validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ubuntu runners kept the freshly created destination inode busy even after the download stream closed. Delay creating the temp output file until a 200 response is confirmed, then atomically rename it into place; no downloader-created executable inode is ever spawned. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router) --- src/certs.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/certs.ts b/src/certs.ts index ab83a55..3802444 100644 --- a/src/certs.ts +++ b/src/certs.ts @@ -63,9 +63,9 @@ async function download(url: string, destination: string): Promise { const tempDestination = `${destination}.download-${process.pid}` return new Promise((resolve, reject) => { function get(currentUrl: string, redirectsLeft: number): void { - const file = fs.createWriteStream(tempDestination) + let file: fs.WriteStream | undefined function fail(error: Error): void { - file.destroy() + file?.destroy() fs.rmSync(tempDestination, { force: true }) reject(error) } @@ -93,16 +93,18 @@ async function download(url: string, destination: string): Promise { fail(new Error(`Failed to download ${currentUrl} (HTTP ${statusCode ?? "unknown"})`)) return } - response.pipe(file) - file.on("finish", () => { - file.close(err => { + const output = fs.createWriteStream(tempDestination) + file = output + response.pipe(output) + output.on("finish", () => { + output.close(err => { if (err === undefined || err === null) { fs.renameSync(tempDestination, destination) resolve() } else fail(new Error("Failed to close the certificate file", { cause: err })) }) }) - file.on("error", fail) + output.on("error", fail) }) .on("error", fail) }