Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 11 additions & 9 deletions src/certs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ function checkUpdates(): void {
body += chunk.toString("utf8")
})
res.on("end", () => {
const currentVersion: unknown = JSON.parse(
fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8"),
)
const latestVersion: unknown = JSON.parse(body)
if (!isRelease(latestVersion)) return
const current = isVersioned(currentVersion) ? (currentVersion.version ?? "") : ""
if (current !== latestVersion.tag_name.replace("v", "")) {
console.warn("[https-localhost] New update available.")
}
try {
const currentVersion: unknown = JSON.parse(
fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8"),
)
const latestVersion: unknown = JSON.parse(body)
if (!isRelease(latestVersion)) return
const current = isVersioned(currentVersion) ? (currentVersion.version ?? "") : ""
if (current !== latestVersion.tag_name.replace("v", "")) {
console.warn("[https-localhost] New update available.")
}
} catch {}
})
})
.end()
Expand Down
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Server } from "node:http"
import https from "node:https"

import { getCerts } from "./certs.ts"
import { createProxyHandler } from "./proxy.ts"
import { createProxyHandler, createProxyUpgradeHandler } from "./proxy.ts"
import { createRouter } from "./router.ts"
import { createStaticHandler } from "./static.ts"

Expand Down Expand Up @@ -31,6 +31,9 @@ export function createServer({
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)
}
await new Promise<void>(resolve => {
app.server?.listen(port, resolve)
})
Expand All @@ -39,6 +42,7 @@ export function createServer({
},
async proxy(target, port = 443) {
router.setProxyHandler(createProxyHandler(target))
router.setProxyUpgradeHandler(createProxyUpgradeHandler(target))
console.info(`Proxying to ${target}`)
await app.listen(port)
return app
Expand Down
57 changes: 55 additions & 2 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ServerResponse,
} from "node:http"
import https from "node:https"
import type { Duplex } from "node:stream"

const HOP_BY_HOP_HEADERS: ReadonlySet<string> = new Set([
"connection",
Expand Down Expand Up @@ -36,10 +37,20 @@ export function createProxyHandler(target: string): RequestListener {
const port = url.port === "" ? (url.protocol === "https:" ? 443 : 80) : Number(url.port)

return function handleProxy(req: IncomingMessage, res: ServerResponse): void {
if (req.method === "OPTIONS") {
res.writeHead(204)
res.end()
return
}
const headers = filterHeaders(req.headers)
headers.host = url.host
if (req.socket.remoteAddress !== undefined)
headers["x-forwarded-for"] = req.socket.remoteAddress
if (req.socket.remoteAddress !== undefined) {
const forwardedFor = req.headers["x-forwarded-for"]
headers["x-forwarded-for"] =
forwardedFor === undefined
? req.socket.remoteAddress
: `${forwardedFor}, ${req.socket.remoteAddress}`
}
headers["x-forwarded-proto"] =
(req.socket as { encrypted?: boolean }).encrypted === true ? "https" : "http"
if (req.headers.host !== undefined) headers["x-forwarded-host"] = req.headers.host
Expand All @@ -55,6 +66,9 @@ export function createProxyHandler(target: string): RequestListener {
},
upstreamRes => {
res.writeHead(upstreamRes.statusCode ?? 502, filterHeaders(upstreamRes.headers))
upstreamRes.on("error", () => {
res.destroy()
})
upstreamRes.pipe(res)
},
)
Expand All @@ -72,3 +86,42 @@ export function createProxyHandler(target: string): RequestListener {
req.pipe(upstream)
}
}

export function createProxyUpgradeHandler(
target: string,
): (req: IncomingMessage, socket: Duplex, head: Buffer) => void {
const url = new URL(target)
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`Unsupported proxy protocol: ${url.protocol}. Use http or https.`)
}
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)
headers.host = url.host
const upstream = transport.request({
protocol: url.protocol,
hostname: url.hostname,
port,
path: req.url,
method: req.method,
headers,
})
upstream.on("upgrade", (upstreamRes, upstreamSocket, upstreamHead) => {
clientSocket.write(
`HTTP/1.1 ${upstreamRes.statusCode ?? 101} ${upstreamRes.statusMessage ?? "Switching Protocols"}\r\n`,
)
for (const [name, value] of Object.entries(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")
if (upstreamHead.length > 0) clientSocket.write(upstreamHead)
if (head.length > 0) upstreamSocket.write(head)
clientSocket.pipe(upstreamSocket).pipe(clientSocket)
})
upstream.on("error", () => clientSocket.destroy())
upstream.end()
}
}
23 changes: 22 additions & 1 deletion src/router.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { IncomingMessage, RequestListener, ServerResponse } from "node:http"
import type { Duplex } from "node:stream"

import { applyCors } from "./cors.ts"

export function createRouter(): {
handleRequest: RequestListener
setProxyHandler: (handler: RequestListener | undefined) => void
setProxyUpgradeHandler: (
handler: ((req: IncomingMessage, socket: Duplex, head: Buffer) => void) | undefined,
) => void
proxyUpgradeHandler?: (req: IncomingMessage, socket: Duplex, head: Buffer) => void
setStaticHandler: (handler: RequestListener | undefined) => void
} {
let proxyHandler: RequestListener | undefined
Expand All @@ -28,9 +33,25 @@ export function createRouter(): {
proxyHandler = handler
}

function setProxyUpgradeHandler(
handler: ((req: IncomingMessage, socket: Duplex, head: Buffer) => void) | undefined,
): void {
router.proxyUpgradeHandler = handler
}

function setStaticHandler(handler: RequestListener | undefined): void {
staticHandler = handler
}

return { handleRequest, setProxyHandler, setStaticHandler }
const router = {
handleRequest,
setProxyHandler,
setProxyUpgradeHandler,
proxyUpgradeHandler: undefined as
| ((req: IncomingMessage, socket: Duplex, head: Buffer) => void)
| undefined,
setStaticHandler,
}

return router
}
52 changes: 34 additions & 18 deletions src/static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ function mime(filePath: string): string {

function sanitize(staticPath: string, urlPath: string): string | null {
const base = path.resolve(staticPath)
const decoded = decodeURIComponent(urlPath.split("?")[0]?.split("#")[0] ?? "/")
let decoded: string
try {
decoded = decodeURIComponent(urlPath.split("?")[0]?.split("#")[0] ?? "/")
} catch {
return null
}
const resolved = path.resolve(base, `.${path.posix.normalize(`/${decoded}`)}`)
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) return null
return resolved
}

function parseRange(header: string, size: number): { start: number; end: number } | null {
const match = /^bytes=(\d*)-(\d*)$/u.exec(header.trim())
const firstRange = header.trim().split(",", 1)[0]
const match = /^bytes=(\d*)-(\d*)$/u.exec(firstRange ?? "")
if (match === null || (match[1] === "" && match[2] === "")) return null
if (match[1] === "") {
const n = Number(match[2])
Expand Down Expand Up @@ -72,14 +78,12 @@ function serveFile(
return
}
const ifModifiedSince = req.headers["if-modified-since"]
if (
ifNoneMatch !== undefined &&
ifModifiedSince !== undefined &&
stat.mtime <= new Date(ifModifiedSince)
) {
res.writeHead(304)
res.end()
return
if (ifModifiedSince !== undefined && !Number.isNaN(new Date(ifModifiedSince).getTime())) {
if (stat.mtime <= new Date(ifModifiedSince)) {
res.writeHead(304)
res.end()
return
}
}

const { size } = stat
Expand Down Expand Up @@ -110,21 +114,24 @@ export function createStaticHandler(staticPath: string): RequestListener {
return
}
if (req.method !== "GET" && req.method !== "HEAD") {
res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" })
res.end()
res.writeHead(405, {
Allow: "GET, HEAD, OPTIONS",
"Content-Type": "text/plain; charset=utf-8",
})
res.end("Method not allowed.")
return
}
const url = req.url ?? "/"
if (!url.startsWith("/") || url.startsWith("//")) {
res.writeHead(400)
res.end()
res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" })
res.end("Bad request.")
return
}
const [urlPath, query = ""] = url.split("?")
const filePath = sanitize(staticPath, urlPath ?? "/")
if (filePath === null) {
res.writeHead(403)
res.end()
res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" })
res.end("Forbidden.")
return
}
let target = filePath
Expand All @@ -145,13 +152,22 @@ export function createStaticHandler(staticPath: string): RequestListener {
serve404(staticPath, req, res)
return
}
try {
fs.statSync(target)
} catch {
serve404(staticPath, req, res)
return
}
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) {
res.writeHead(416, { "Content-Range": `bytes */${fs.statSync(target).size}` })
res.end()
res.writeHead(416, {
"Content-Range": `bytes */${fs.statSync(target).size}`,
"Content-Type": "text/plain; charset=utf-8",
})
res.end("Range not satisfiable.")
return
}
}
Expand Down
Loading