Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3c3c23d
fix: repair the release workflow
daquinoaldo Sep 16, 2026
8dcb658
fix: preserve upgrade headers when proxying WebSocket upgrades
daquinoaldo Sep 16, 2026
920144d
fix: stop hanging the client when an upstream refuses a WebSocket upg…
daquinoaldo Sep 16, 2026
ad6cf6c
fix: confine static file serving to the served root across symlinks
daquinoaldo Sep 16, 2026
d4a8389
fix: make the mkcert download robust against errors and a corrupt cache
daquinoaldo Sep 16, 2026
1aa5526
fix: drop headers listed in Connection when proxying
daquinoaldo Sep 16, 2026
38dc257
fix: reject listen errors and close the previous server on re-listen
daquinoaldo Sep 16, 2026
7780516
fix: use root-relative locations for directory redirects
daquinoaldo Sep 16, 2026
d0b0f91
fix: download the mkcert binary matching the CPU architecture
daquinoaldo Sep 16, 2026
028dd53
fix: throw instead of exiting the process on an unsupported platform
daquinoaldo Sep 16, 2026
9b857d0
fix: friendlier CLI errors for invalid input and startup failures
daquinoaldo Sep 16, 2026
b37f7d8
fix: strip only the port from the Host in http->https redirects
daquinoaldo Sep 16, 2026
1d4a615
fix: keep tsbuildinfo out of the npm tarball and export package.json
daquinoaldo Sep 16, 2026
d7cacf6
fix: make the build script Windows-compatible
daquinoaldo Sep 16, 2026
60d2be1
fix: serve multi-range requests as full representations
daquinoaldo Sep 16, 2026
40b2493
docs: add --help flag, install-mode note, and CORS warning
daquinoaldo Sep 16, 2026
0a42187
fix: list src/cli.ts directly in the knip entries
daquinoaldo Sep 16, 2026
d4c4efa
fix: drop the redundant knip entry for src/cli.ts
daquinoaldo Sep 16, 2026
d511d1a
fix: tear down the proxied tunnel when one peer disconnects
daquinoaldo Sep 16, 2026
0c900ea
test: cover the fixed proxy, static, and redirect behaviors
daquinoaldo Sep 16, 2026
e2355b5
fix: address CodeQL and GH security findings
daquinoaldo Sep 16, 2026
aa06e1b
test: make the nested fixture an actual HTML document
daquinoaldo Sep 16, 2026
d337602
docs: always install the module as a dev dependency and drop the CORS…
daquinoaldo Sep 16, 2026
5a0f31e
build: compile ESM and CJS with plain pnpm scripts like the monorepo
daquinoaldo Sep 16, 2026
2c4abc3
fix: retry synchronous ETXTBSY errors from mkcert startup
daquinoaldo Sep 16, 2026
117a973
fix: download mkcert to a temp file and rename it into place
daquinoaldo Sep 16, 2026
5ded146
fix: avoid ETXTBSY by creating the download file after HTTP validation
daquinoaldo Sep 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ node_modules
build
dist
*.tsbuildinfo
.cache

# Test-generated certificate directories
test/custom-folder/
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ Usage notes:
- `--cert-path <path>`: custom certificate directory (`CERT_PATH`)
- `--reinstall`: force certificate re-generation (`REINSTALL=true`)
- `--proxy <url>`: 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 dev dependency:

```sh
npm i -D https-localhost
Expand Down
20 changes: 16 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -56,11 +57,14 @@
"import": "./dist/esm/certs.js",
"require": "./dist/cjs/certs.js",
"default": "./dist/esm/certs.js"
}
},
"./package.json": "./package.json"
}
},
"scripts": {
"build": "rm -rf dist && tsc --project tsconfig.build.esm.json && tsc --project tsconfig.build.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
"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",
Expand Down Expand Up @@ -93,5 +97,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"
]
}
}
136 changes: 107 additions & 29 deletions src/certs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,46 +17,98 @@ 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`
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)
}
}

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<void> {
console.log("Downloading the mkcert executable...")
const file = fs.createWriteStream(destination)
// 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): void {
function get(currentUrl: string, redirectsLeft: number): void {
let file: fs.WriteStream | undefined
function fail(error: Error): void {
file?.destroy()
fs.rmSync(tempDestination, { 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 }))
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", reject)
output.on("error", fail)
})
.on("error", reject)
.on("error", fail)
}
get(url)
get(url, MAX_REDIRECTS)
})
}

Expand All @@ -78,16 +130,40 @@ 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
// 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. 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 => {
let child: ReturnType<typeof execFile>
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
}
resolve()
})
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ETXTBSY" && retriesLeft > 0) {
retry(retriesLeft - 1)
return
}
throw error
}
resolve()
})
child.on("error", () => {})
}
attempt(5)
})
}

Expand All @@ -104,7 +180,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")
}
Expand Down
55 changes: 37 additions & 18 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -82,4 +65,40 @@ process.on("uncaughtException", err => {
break
}
process.exit(1)
}

let env: ReturnType<typeof getEnv>
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()
Loading
Loading