| 1 | import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem" |
| 2 | import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform" |
| 3 | import * as NodePathLayer from "@effect/platform-node/NodePath" |
| 4 | import { assert, describe, it } from "@effect/vitest" |
| 5 | import { HttpRouter, HttpStaticServer } from "effect/http" |
| 6 | import * as Layer from "effect/Layer" |
| 7 | import * as Fs from "node:fs" |
| 8 | import { copyFile, cp, mkdtemp, rm, utimes, writeFile } from "node:fs/promises" |
| 9 | import { tmpdir } from "node:os" |
| 10 | import * as NodePath from "node:path" |
| 11 | import { fileURLToPath } from "node:url" |
| 12 | import { vi } from "vitest" |
| 13 | |
| 14 | vi.mock("node:fs", async (importOriginal) => { |
| 15 | const original = await importOriginal<typeof Fs>() |
| 16 | return { ...original, createReadStream: vi.fn(original.createReadStream) } |
| 17 | }) |
| 18 | |
| 19 | const fixturesRoot = fileURLToPath(new URL("./fixtures/http-static-server", import.meta.url)) |
| 20 | const fixturesOutsideFile = fileURLToPath(new URL("./fixtures/http-static-server-outside.txt", import.meta.url)) |
| 21 | |
| 22 | const staticFilesLayer = Layer.mergeAll( |
| 23 | NodePathLayer.layer, |
| 24 | NodeFileSystem.layer, |
| 25 | NodeHttpPlatform.layer |
| 26 | ) |
| 27 | |
| 28 | const 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 | |
| 40 | const 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 | |
| 66 | describe("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 | }) |