Skip to content

Commit a14dd8e

Browse files
committed
refactor: replace express and cors with plain node http handlers
Drop express and cors dependencies. Static file serving (ETag, range requests, 404 fallback, directory redirect, MIME types via mrmime) now lives in src/static.ts, request routing in src/router.ts, CORS headers in src/cors.ts. Test fixtures moved to test/fixtures/. 🤖 Generated with [OpenCode](https://opencode.ai) (Smart-router)
1 parent 7c566fc commit a14dd8e

12 files changed

Lines changed: 332 additions & 755 deletions

File tree

package.json

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,10 @@
6868
"start": "node src/cli.ts"
6969
},
7070
"dependencies": {
71-
"cors": "^2.8.6",
72-
"express": "^5.2.1",
71+
"mrmime": "^2.0.1",
7372
"zod": "^4.5.4"
7473
},
7574
"devDependencies": {
76-
"@types/cors": "^2.8.19",
77-
"@types/express": "^5.0.6",
7875
"@types/node": "^26.4.1",
7976
"oxfmt": "^0.66.0",
8077
"oxlint": "^1.81.0",

pnpm-lock.yaml

Lines changed: 7 additions & 669 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cors.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { IncomingMessage, ServerResponse } from "node:http"
2+
3+
const CORS_HEADERS: Record<string, string> = {
4+
"Access-Control-Allow-Origin": "*",
5+
"Access-Control-Allow-Methods": "GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS",
6+
"Access-Control-Allow-Headers": "*",
7+
"Access-Control-Max-Age": "86400",
8+
}
9+
10+
function applyCors(req: IncomingMessage, res: ServerResponse): boolean {
11+
for (const [header, value] of Object.entries(CORS_HEADERS)) {
12+
res.setHeader(header, value)
13+
}
14+
if (req.method !== "OPTIONS") return false
15+
res.writeHead(204)
16+
res.end()
17+
return true
18+
}
19+
20+
export { applyCors }

src/index.ts

Lines changed: 39 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,65 @@
11
#!/usr/bin/env node
22

3-
import fs from "node:fs"
43
import http from "node:http"
4+
import type { Server } from "node:http"
55
import https from "node:https"
6-
import path from "node:path"
7-
8-
import cors from "cors"
9-
import express from "express"
10-
import type { Express, Request, Response } from "express"
116

127
import { getCerts } from "./certs.ts"
8+
import { createRouter, type RouteHandler } from "./router.ts"
9+
import { createStaticHandler } from "./static.ts"
1310

14-
export type HttpsLocalhostApp = Omit<Express, "listen"> & {
15-
server?: https.Server
16-
http?: http.Server
17-
listen: (port?: number) => Promise<https.Server>
11+
export type HttpsLocalhostApp = {
12+
server?: Server
13+
http?: Server
14+
get: (route: string, handler: RouteHandler) => void
15+
listen: (port?: number) => Promise<Server>
1816
redirect: (httpPort?: number, httpsPort?: number) => void
1917
serve: (staticPath?: string, port?: number) => void
2018
}
2119

22-
const createServer = ({
20+
export function createServer({
2321
domain = "localhost",
2422
certPath,
2523
reinstall,
2624
}: {
2725
domain?: string
2826
certPath?: string
2927
reinstall?: boolean
30-
} = {}): HttpsLocalhostApp => {
31-
const app = express() as unknown as HttpsLocalhostApp
32-
33-
app.use(cors())
34-
app.listen = async function (port = 443) {
35-
app.server = https
36-
.createServer(
37-
await getCerts({
38-
domain,
39-
certPath,
40-
reinstall,
41-
}),
42-
app as unknown as Express,
43-
)
44-
.listen(port)
45-
console.info("Server running on port " + port + ".")
46-
return app.server
47-
}
28+
} = {}): HttpsLocalhostApp {
29+
const router = createRouter()
4830

49-
app.redirect = function (httpPort = 80, httpsPort = 443) {
50-
app.http = http
51-
.createServer((req, res) => {
52-
const reqHost = req.headers.host ? req.headers.host.replace(":" + httpPort, "") : domain
53-
res.writeHead(301, {
54-
Location:
55-
"https://" + reqHost + (httpsPort !== 443 ? ":" + httpsPort : "") + (req.url || ""),
31+
const app: HttpsLocalhostApp = {
32+
get(route, handler) {
33+
router.get(route, handler)
34+
},
35+
listen: async function (port = 443) {
36+
app.server = https
37+
.createServer(await getCerts({ domain, certPath, reinstall }), router.handleRequest)
38+
.listen(port)
39+
console.info("Server running on port " + port + ".")
40+
return app.server
41+
},
42+
redirect: function (httpPort = 80, httpsPort = 443) {
43+
app.http = http
44+
.createServer((req, res) => {
45+
const reqHost = req.headers.host ? req.headers.host.replace(":" + httpPort, "") : domain
46+
res.writeHead(301, {
47+
Location:
48+
"https://" + reqHost + (httpsPort !== 443 ? ":" + httpsPort : "") + (req.url || ""),
49+
})
50+
res.end()
5651
})
57-
res.end()
58-
})
59-
.listen(httpPort)
60-
console.info("http to https redirection active.")
61-
}
62-
63-
app.serve = function (staticPath = process.cwd(), port = 443) {
64-
const p404 = staticPath + "/404.html"
65-
const index = staticPath + "/index.html"
66-
const fallback = fs.existsSync(p404)
67-
? { status: 404, content: fs.readFileSync(path.resolve(p404)) }
68-
: fs.existsSync(index)
69-
? { status: 200, content: fs.readFileSync(path.resolve(index)) }
70-
: undefined
71-
72-
app.use(express.static(staticPath))
73-
app.use((_req: Request, res: Response) => {
74-
if (fallback) {
75-
res.status(fallback.status).type("html").send(fallback.content)
76-
} else {
77-
res.status(404).send("Not found.")
78-
}
79-
})
80-
console.info("Serving static path: " + staticPath)
81-
void app.listen(port)
52+
.listen(httpPort)
53+
console.info("http to https redirection active.")
54+
},
55+
serve: function (staticPath = process.cwd(), port = 443) {
56+
router.setStaticHandler(createStaticHandler(staticPath))
57+
console.info("Serving static path: " + staticPath)
58+
void app.listen(port)
59+
},
8260
}
8361

8462
return app
8563
}
8664

8765
export default createServer
88-
export { createServer }

src/router.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { IncomingMessage, RequestListener, ServerResponse } from "node:http"
2+
3+
import { applyCors } from "./cors.ts"
4+
5+
export type RouteHandler = (req: IncomingMessage, res: ServerResponse) => void
6+
7+
export function createRouter(): {
8+
handleRequest: RequestListener
9+
get: (route: string, handler: RouteHandler) => void
10+
setStaticHandler: (handler: RequestListener | undefined) => void
11+
} {
12+
const routes: Array<{ route: string; handler: RouteHandler }> = []
13+
14+
let staticHandler: RequestListener | undefined
15+
16+
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
17+
if (applyCors(req, res)) return
18+
const route = routes.find(candidate => candidate.route === req.url?.split("?")[0])
19+
if (route && req.method === "GET") {
20+
route.handler(req, res)
21+
return
22+
}
23+
if (staticHandler) {
24+
staticHandler(req, res)
25+
return
26+
}
27+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" })
28+
res.end("Not found.")
29+
}
30+
31+
function get(route: string, handler: RouteHandler): void {
32+
routes.push({ route, handler })
33+
}
34+
35+
function setStaticHandler(handler: RequestListener | undefined): void {
36+
staticHandler = handler
37+
}
38+
39+
return { handleRequest, get, setStaticHandler }
40+
}

src/static.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import fs from "node:fs"
2+
import type { IncomingMessage, RequestListener, ServerResponse } from "node:http"
3+
import path from "node:path"
4+
5+
import { lookup } from "mrmime"
6+
7+
const DEFAULT_INDEX = "index.html"
8+
const DEFAULT_404 = "404.html"
9+
10+
function mime(filePath: string): string {
11+
const type = lookup(filePath)
12+
return type
13+
? type + (type.startsWith("text/") || type === "image/svg+xml" ? "; charset=utf-8" : "")
14+
: "application/octet-stream"
15+
}
16+
17+
function sanitize(staticPath: string, urlPath: string): string | null {
18+
const base = path.resolve(staticPath)
19+
const decoded = decodeURIComponent(urlPath.split("?")[0]?.split("#")[0] ?? "/")
20+
const resolved = path.resolve(base, "." + path.posix.normalize("/" + decoded))
21+
if (resolved !== base && !resolved.startsWith(base + path.sep)) return null
22+
return resolved
23+
}
24+
25+
function parseRange(header: string, size: number): { start: number; end: number } | null {
26+
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
27+
if (!match || (match[1] === "" && match[2] === "")) return null
28+
if (match[1] === "") {
29+
const n = Number(match[2])
30+
if (n === 0) return null
31+
return { start: Math.max(0, size - n), end: size - 1 }
32+
}
33+
const start = Number(match[1])
34+
const end = match[2] === "" ? size - 1 : Math.min(Number(match[2]), size - 1)
35+
if (start > end || start >= size) return null
36+
return { start, end }
37+
}
38+
39+
function serve404(staticPath: string, req: IncomingMessage, res: ServerResponse): void {
40+
try {
41+
const content = fs.readFileSync(path.join(staticPath, DEFAULT_404))
42+
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" })
43+
if (req.method === "HEAD") return void res.end()
44+
res.end(content)
45+
} catch {
46+
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" })
47+
res.end("Not found.")
48+
}
49+
}
50+
51+
function serveFile(
52+
target: string,
53+
req: IncomingMessage,
54+
res: ServerResponse,
55+
range: { start: number; end: number } | null,
56+
): void {
57+
const stat = fs.statSync(target)
58+
const etag = '"' + stat.size.toString(16) + "-" + stat.mtimeMs.toString(16) + '"'
59+
res.setHeader("ETag", etag)
60+
res.setHeader("Last-Modified", stat.mtime.toUTCString())
61+
res.setHeader("Accept-Ranges", "bytes")
62+
63+
const ifNoneMatch = req.headers["if-none-match"]
64+
if (
65+
ifNoneMatch &&
66+
ifNoneMatch.split(",").some(tag => tag.trim() === etag || tag.trim() === "W/" + etag)
67+
) {
68+
res.writeHead(304)
69+
res.end()
70+
return
71+
}
72+
const ifModifiedSince = req.headers["if-modified-since"]
73+
if (!ifNoneMatch && ifModifiedSince && stat.mtime <= new Date(ifModifiedSince)) {
74+
res.writeHead(304)
75+
res.end()
76+
return
77+
}
78+
79+
const size = stat.size
80+
const status = range ? 206 : 200
81+
const content = fs.readFileSync(target)
82+
const body = range ? content.subarray(range.start, range.end + 1) : content
83+
res.writeHead(status, {
84+
"Content-Type": mime(target),
85+
"Content-Length": range ? range.end - range.start + 1 : size,
86+
...(range ? { "Content-Range": `bytes ${range.start}-${range.end}/${size}` } : {}),
87+
})
88+
if (req.method === "HEAD") return void res.end()
89+
res.end(body)
90+
}
91+
92+
export function createStaticHandler(staticPath: string): RequestListener {
93+
return function handleStatic(req, res) {
94+
if (req.method === "OPTIONS") {
95+
res.writeHead(204)
96+
res.end()
97+
return
98+
}
99+
if (req.method !== "GET" && req.method !== "HEAD") {
100+
res.writeHead(405, { Allow: "GET, HEAD, OPTIONS" })
101+
res.end()
102+
return
103+
}
104+
const filePath = sanitize(staticPath, req.url ?? "/")
105+
if (filePath === null) {
106+
res.writeHead(403)
107+
res.end()
108+
return
109+
}
110+
let target = filePath
111+
try {
112+
let stat = fs.statSync(target)
113+
if (stat.isDirectory()) {
114+
if (!req.url?.endsWith("/")) {
115+
res.writeHead(301, { Location: encodeURI(req.url + "/") })
116+
res.end()
117+
return
118+
}
119+
target = path.join(target, DEFAULT_INDEX)
120+
}
121+
} catch {
122+
serve404(staticPath, req, res)
123+
return
124+
}
125+
let range: { start: number; end: number } | null = null
126+
const rangeHeader = req.headers.range
127+
if (rangeHeader) {
128+
range = parseRange(rangeHeader, fs.statSync(target).size)
129+
if (range === null) {
130+
res.writeHead(416, { "Content-Range": "bytes */" + fs.statSync(target).size })
131+
res.end()
132+
return
133+
}
134+
}
135+
serveFile(target, req, res, range)
136+
}
137+
}

test/cli.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ describe("cli", () => {
77
const cliPath = path.resolve("src/cli.ts")
88

99
it("CLI flags override environment", async () => {
10-
const testDir = path.resolve("test")
11-
const proc = spawn("node", [cliPath, "--port", "4448", testDir], {
10+
const fixtureDir = path.resolve("test/fixtures")
11+
const proc = spawn("node", [cliPath, "--port", "4448", fixtureDir], {
1212
env: { ...process.env, PORT: "4447" },
1313
stdio: ["ignore", "pipe", "pipe"],
1414
})
File renamed without changes.

test/fixtures/sub/index.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<html>
2+
<head>
3+
<title>Sub</title>
4+
</head>
5+
<body>
6+
<p>Subdirectory index.</p>
7+
</body>
8+
</html>

0 commit comments

Comments
 (0)