1import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
2import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform"
3import * as NodePathLayer from "@effect/platform-node/NodePath"
4import { assert, describe, it } from "@effect/vitest"
5import { HttpRouter, HttpStaticServer } from "effect/http"
6import * as Layer from "effect/Layer"
7import * as Fs from "node:fs"
8import { copyFile, cp, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"
9import { tmpdir } from "node:os"
10import * as NodePath from "node:path"
11import { fileURLToPath } from "node:url"
12import { vi } from "vitest"
13
14vi.mock("node:fs", async (importOriginal) => {
15 const original = await importOriginal<typeof Fs>()
16 return { ...original, createReadStream: vi.fn(original.createReadStream) }
17})
18
19const fixturesRoot = fileURLToPath(new URL("./fixtures/http-static-server", import.meta.url))
20const fixturesOutsideFile = fileURLToPath(new URL("./fixtures/http-static-server-outside.txt", import.meta.url))
21
22const staticFilesLayer = Layer.mergeAll(
23 NodePathLayer.layer,
24 NodeFileSystem.layer,
25 NodeHttpPlatform.layer
26)
27
28const makeHandler = (
29 root: string,
30 options: Omit<Parameters<typeof HttpStaticServer.make>[0], "root"> = {}
31) =>
32 HttpRouter.toWebHandler(
33 HttpStaticServer.layer({
34 root,
35 ...options
36 }).pipe(Layer.provideMerge(staticFilesLayer)),
37 { disableLogger: true }
38 )
39
40const withStaticFiles = async (
41 run: (context: {
42 readonly handler: (request: Request) => Promise<Response>
43 readonly root: string
44 readonly outsideFile: string
45 }) => Promise<void>,
46 options: Omit<Parameters<typeof HttpStaticServer.make>[0], "root"> = {}
47) => {
48 const temporaryRoot = await mkdtemp(NodePath.join(tmpdir(), "effect-http-static-server-"))
49 const root = NodePath.join(temporaryRoot, "root")
50 const outsideFile = NodePath.join(temporaryRoot, NodePath.basename(fixturesOutsideFile))
51
52 await Promise.all([
53 cp(fixturesRoot, root, { recursive: true }),
54 copyFile(fixturesOutsideFile, outsideFile)
55 ])
56
57 const { handler, dispose } = makeHandler(root, options)
58 try {
59 await run({ handler, root, outsideFile })
60 } finally {
61 await dispose()
62 await rm(temporaryRoot, { recursive: true, force: true })
63 }
64}
65
66describe("HttpStaticServer", { concurrent: false }, () => {
67 it("serves files with expected content type and body", async () => {
68 await withStaticFiles(async ({ handler }) => {
69 const response = await handler(new Request("http://localhost/hello.txt"))
70 assert.strictEqual(response.status, 200)
71 assert.strictEqual(response.headers.get("content-type"), "text/plain; charset=utf-8")
72 assert.strictEqual((await response.text()).trimEnd(), "hello static file")
73 })
74 })
75
76 it("serves default index file for directory paths", async () => {
77 await withStaticFiles(async ({ handler }) => {
78 const response = await handler(new Request("http://localhost/guide"))
79 assert.strictEqual(response.status, 200)
80 assert.strictEqual(response.headers.get("content-type"), "text/html; charset=utf-8")
81 assert.strictEqual((await response.text()).trimEnd(), "<html><body>guide index</body></html>")
82 })
83 })
84
85 it("supports custom index file and disabled index fallback", async () => {
86 await withStaticFiles(async ({ handler }) => {
87 const response = await handler(new Request("http://localhost/custom"))
88 assert.strictEqual(response.status, 200)
89 assert.strictEqual((await response.text()).trimEnd(), "<html><body>custom home</body></html>")
90 }, { index: "home.html" })
91
92 await withStaticFiles(async ({ handler }) => {
93 const response = await handler(new Request("http://localhost/custom"))
94 assert.strictEqual(response.status, 404)
95 }, { index: undefined })
96 })
97
98 it("returns 304 for If-None-Match exact, weak, and wildcard", async () => {
99 await withStaticFiles(async ({ handler }) => {
100 const warmup = await handler(new Request("http://localhost/conditional.txt"))
101 const etag = warmup.headers.get("etag")
102 assert.notStrictEqual(etag, null)
103 if (etag === null) {
104 throw new Error("missing etag")
105 }
106 const weakComparable = etag.startsWith("W/") ? etag.slice(2) : `W/${etag}`
107
108 const exact = await handler(
109 new Request("http://localhost/conditional.txt", { headers: { "If-None-Match": etag } })
110 )
111 const weak = await handler(
112 new Request("http://localhost/conditional.txt", { headers: { "If-None-Match": weakComparable } })
113 )
114 const any = await handler(new Request("http://localhost/conditional.txt", { headers: { "If-None-Match": "*" } }))
115
116 assert.deepStrictEqual([exact.status, weak.status, any.status], [304, 304, 304])
117 assert.strictEqual(exact.headers.get("etag"), etag)
118 assert.strictEqual(exact.headers.get("cache-control"), "public, max-age=60")
119 assert.strictEqual(exact.headers.get("content-type"), null)
120 assert.strictEqual(exact.headers.get("content-length"), null)
121 }, { cacheControl: "public, max-age=60" })
122 })
123
124 it("returns 304 for If-Modified-Since and 200 when file changed", async () => {
125 await withStaticFiles(async ({ handler, root }) => {
126 const first = await handler(new Request("http://localhost/conditional.txt"))
127 const lastModified = first.headers.get("last-modified")
128 assert.notStrictEqual(lastModified, null)
129 if (lastModified === null) {
130 throw new Error("missing last-modified")
131 }
132
133 const notModified = await handler(
134 new Request("http://localhost/conditional.txt", {
135 headers: {
136 "If-Modified-Since": lastModified
137 }
138 })
139 )
140 assert.strictEqual(notModified.status, 304)
141
142 const conditionalFile = NodePath.join(root, "conditional.txt")
143 await writeFile(conditionalFile, "updated conditional body")
144 const updatedTime = new Date(Date.now() + 10_000)
145 await utimes(conditionalFile, updatedTime, updatedTime)
146
147 const modified = await handler(
148 new Request("http://localhost/conditional.txt", {
149 headers: {
150 "If-Modified-Since": lastModified
151 }
152 })
153 )
154 assert.strictEqual(modified.status, 200)
155 assert.strictEqual(await modified.text(), "updated conditional body")
156 })
157 })
158
159 it("honors Range only for a matching strong If-Range ETag", async () => {
160 await withStaticFiles(async ({ handler, root }) => {
161 const first = await handler(new Request("http://localhost/range.txt"))
162 const etag = first.headers.get("etag")!
163 assert.strictEqual(etag.startsWith("W/"), false)
164 const fullBody = await first.text()
165 vi.mocked(Fs.createReadStream).mockClear()
166
167 const matching = await handler(
168 new Request("http://localhost/range.txt", {
169 headers: { Range: "bytes=0-10", "If-Range": etag }
170 })
171 )
172 assert.strictEqual(matching.status, 206)
173 assert.strictEqual(matching.headers.get("content-range"), `bytes 0-10/${fullBody.length}`)
174 assert.strictEqual(await matching.text(), "0123456789a")
175 // Only the selected range acquires a source, not the discarded full response.
176 assert.deepStrictEqual(vi.mocked(Fs.createReadStream).mock.calls, [[NodePath.join(root, "range.txt"), {
177 start: 0,
178 end: 10
179 }]])
180
181 // Weak tags and Last-Modified dates never match: filesystem metadata
182 // cannot prove a date is a strong validator.
183 for (const ifRange of [`W/${etag}`, first.headers.get("last-modified")!]) {
184 const response = await handler(
185 new Request("http://localhost/range.txt", {
186 headers: { Range: "bytes=0-10", "If-Range": ifRange }
187 })
188 )
189 assert.strictEqual(response.status, 200)
190 assert.strictEqual(response.headers.get("content-range"), null)
191 assert.strictEqual(await response.text(), fullBody)
192 }
193 })
194 })
195
196 it.each(["bytes=10-", "bytes=100-200"])(
197 "returns the full changed file for a stale If-Range ETag with Range %s",
198 async (range) => {
199 await withStaticFiles(async ({ handler, root }) => {
200 const first = await handler(new Request("http://localhost/range.txt"))
201 const etag = first.headers.get("etag")!
202 await first.text()
203 const updatedBody = "NEW-NEW-NEW-NEW-NEW-NEW-"
204 await writeFile(NodePath.join(root, "range.txt"), updatedBody)
205 const resumed = await handler(
206 new Request("http://localhost/range.txt", {
207 headers: { Range: range, "If-Range": etag }
208 })
209 )
210 assert.strictEqual(resumed.status, 200)
211 assert.strictEqual(resumed.headers.get("content-range"), null)
212 assert.strictEqual(resumed.headers.get("content-length"), String(updatedBody.length))
213 assert.notStrictEqual(resumed.headers.get("etag"), etag)
214 assert.strictEqual(await resumed.text(), updatedBody)
215 })
216 }
217 )
218
219 it("handles range requests for valid, invalid, and malformed headers", async () => {
220 await withStaticFiles(async ({ handler }) => {
221 const full = await handler(new Request("http://localhost/range.txt"))
222 const etag = full.headers.get("etag")!
223 const fullBody = await full.text()
224 const fileSize = fullBody.length
225
226 const first = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=0-10" } }))
227 assert.strictEqual(first.status, 206)
228 assert.strictEqual(first.headers.get("content-range"), `bytes 0-10/${fileSize}`)
229 assert.strictEqual(await first.text(), "0123456789a")
230
231 const openEnded = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=5-" } }))
232 assert.strictEqual(openEnded.status, 206)
233 assert.strictEqual(openEnded.headers.get("content-range"), `bytes 5-${fileSize - 1}/${fileSize}`)
234 assert.strictEqual(await openEnded.text(), fullBody.slice(5))
235
236 const suffix = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=-10" } }))
237 assert.strictEqual(suffix.status, 206)
238 assert.strictEqual(suffix.headers.get("content-range"), `bytes ${fileSize - 10}-${fileSize - 1}/${fileSize}`)
239 assert.strictEqual(await suffix.text(), fullBody.slice(-10))
240
241 vi.mocked(Fs.createReadStream).mockClear()
242 const invalid = await handler(
243 new Request("http://localhost/range.txt", {
244 headers: { Range: "bytes=100-200", "If-Range": etag }
245 })
246 )
247 assert.strictEqual(invalid.status, 416)
248 assert.strictEqual(invalid.headers.get("content-range"), `bytes */${fileSize}`)
249 assert.strictEqual(await invalid.text(), "")
250 assert.deepStrictEqual(vi.mocked(Fs.createReadStream).mock.calls, [])
251
252 const malformed = await handler(new Request("http://localhost/range.txt", { headers: { Range: "bytes=abc" } }))
253 assert.strictEqual(malformed.status, 200)
254 assert.strictEqual(await malformed.text(), fullBody)
255 })
256 })
257
258 const unsafeRangeInteger = (BigInt(Number.MAX_SAFE_INTEGER) + BigInt(1)).toString()
259
260 it.each([
261 `bytes=${unsafeRangeInteger}-`,
262 `bytes=${unsafeRangeInteger}-${unsafeRangeInteger}`
263 ])("returns 416 for a range start above Number.MAX_SAFE_INTEGER: %s", async (range) => {
264 await withStaticFiles(async ({ handler }) => {
265 const fullBody = await handler(new Request("http://localhost/range.txt")).then((response) => response.text())
266 const response = await handler(
267 new Request("http://localhost/range.txt", { headers: { Range: range } })
268 )
269
270 assert.strictEqual(response.status, 416)
271 assert.strictEqual(response.headers.get("content-range"), `bytes */${fullBody.length}`)
272 assert.strictEqual(await response.text(), "")
273 })
274 })
275
276 it.each([
277 `bytes=0-${unsafeRangeInteger}`,
278 `bytes=-${unsafeRangeInteger}`
279 ])("returns the whole file as 206 for range ends and suffixes above Number.MAX_SAFE_INTEGER: %s", async (range) => {
280 await withStaticFiles(async ({ handler }) => {
281 const fullBody = await handler(new Request("http://localhost/range.txt")).then((response) => response.text())
282 const response = await handler(new Request("http://localhost/range.txt", { headers: { Range: range } }))
283
284 assert.strictEqual(response.status, 206)
285 assert.strictEqual(response.headers.get("content-range"), `bytes 0-${fullBody.length - 1}/${fullBody.length}`)
286 assert.strictEqual(response.headers.get("content-length"), String(fullBody.length))
287 assert.strictEqual(await response.text(), fullBody)
288 })
289 })
290
291 it("clamps a range end above Number.MAX_SAFE_INTEGER while preserving a nonzero start", async () => {
292 await withStaticFiles(async ({ handler }) => {
293 const fullBody = await handler(new Request("http://localhost/range.txt")).then((response) => response.text())
294 const response = await handler(
295 new Request("http://localhost/range.txt", { headers: { Range: `bytes=5-${unsafeRangeInteger}` } })
296 )
297
298 assert.strictEqual(response.status, 206)
299 assert.strictEqual(response.headers.get("content-range"), `bytes 5-${fullBody.length - 1}/${fullBody.length}`)
300 assert.strictEqual(response.headers.get("content-length"), String(fullBody.length - 5))
301 assert.strictEqual(await response.text(), fullBody.slice(5))
302 })
303 })
304
305 it("handles SPA fallback for html accept and missing routes", async () => {
306 await withStaticFiles(async ({ handler }) => {
307 const htmlFallback = await handler(new Request("http://localhost/missing", { headers: { accept: "text/html" } }))
308 assert.strictEqual(htmlFallback.status, 200)
309 assert.strictEqual((await htmlFallback.text()).trimEnd(), "<html><body>root index</body></html>")
310
311 const withExtension = await handler(
312 new Request("http://localhost/missing.js", { headers: { accept: "text/html" } })
313 )
314 assert.strictEqual(withExtension.status, 404)
315
316 const withoutHtmlAccept = await handler(
317 new Request("http://localhost/missing", { headers: { accept: "application/json" } })
318 )
319 assert.strictEqual(withoutHtmlAccept.status, 404)
320 }, { spa: true, index: "index.html" })
321 })
322
323 it("rejects directory traversal attempts", async () => {
324 await withStaticFiles(async ({ handler, outsideFile }) => {
325 const plainTraversal = await handler(new Request("http://localhost/../../../etc/passwd"))
326 const encodedTraversal = await handler(new Request("http://localhost/..%2F..%2Fetc%2Fpasswd"))
327 const encodedExistingParentFileTraversal = await handler(
328 new Request(`http://localhost/..%2F${encodeURIComponent(NodePath.basename(outsideFile))}`)
329 )
330
331 assert.deepStrictEqual([
332 plainTraversal.status,
333 encodedTraversal.status,
334 encodedExistingParentFileTraversal.status
335 ], [
336 404,
337 404,
338 404
339 ])
340 })
341 })
342
343 it("rejects null bytes and malformed uri encoding", async () => {
344 await withStaticFiles(async ({ handler }) => {
345 const nullByte = await handler(new Request("http://localhost/null%00byte.txt"))
346 const malformed = await handler(new Request("http://localhost/%E0%A4%A"))
347
348 assert.deepStrictEqual([nullByte.status, malformed.status], [404, 404])
349 })
350 })
351
352 it("applies custom mime types and cache-control", async () => {
353 await withStaticFiles(async ({ handler }) => {
354 const response = await handler(new Request("http://localhost/hello.txt"))
355 assert.strictEqual(response.status, 200)
356 assert.strictEqual(response.headers.get("content-type"), "application/x-custom-text")
357 assert.strictEqual(response.headers.get("cache-control"), "public, max-age=120")
358 }, {
359 cacheControl: "public, max-age=120",
360 mimeTypes: {
361 txt: "application/x-custom-text"
362 }
363 })
364 })
365
366 it("uses application/octet-stream for unknown extension and 404 for missing file", async () => {
367 await withStaticFiles(async ({ handler }) => {
368 const unknown = await handler(new Request("http://localhost/file.binx"))
369 assert.strictEqual(unknown.status, 200)
370 assert.strictEqual(unknown.headers.get("content-type"), "application/octet-stream")
371
372 const missing = await handler(new Request("http://localhost/does-not-exist.txt"))
373 assert.strictEqual(missing.status, 404)
374 })
375 })
376})