| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import * as ByteSize from "effect/ByteSize" |
| 3 | import * as Effect from "effect/Effect" |
| 4 | import * as FileSystem from "effect/FileSystem" |
| 5 | import { |
| 6 | HttpEffect, |
| 7 | HttpPlatform, |
| 8 | HttpRouter, |
| 9 | HttpServerError, |
| 10 | HttpServerRequest, |
| 11 | HttpServerResponse, |
| 12 | HttpStaticServer |
| 13 | } from "effect/http" |
| 14 | import * as Layer from "effect/Layer" |
| 15 | import * as Option from "effect/Option" |
| 16 | import * as Path from "effect/Path" |
| 17 | import * as PlatformError from "effect/PlatformError" |
| 18 | |
| 19 | const root = "/root" |
| 20 | const filePath = `${root}/file.txt` |
| 21 | const fileBody = "hello static" |
| 22 | const lastModified = "Wed, 01 Jan 2025 00:00:00 GMT" |
| 23 | |
| 24 | const notFoundError = (path: string) => |
| 25 | PlatformError.systemError({ |
| 26 | _tag: "NotFound", |
| 27 | module: "FileSystem", |
| 28 | method: "stat", |
| 29 | description: "No such file or directory", |
| 30 | pathOrDescriptor: path |
| 31 | }) |
| 32 | |
| 33 | const permissionDeniedError = (path: string) => |
| 34 | PlatformError.systemError({ |
| 35 | _tag: "PermissionDenied", |
| 36 | module: "FileSystem", |
| 37 | method: "stat", |
| 38 | description: "Operation not permitted", |
| 39 | pathOrDescriptor: path |
| 40 | }) |
| 41 | |
| 42 | const fileInfo: FileSystem.File.Info = { |
| 43 | type: "File", |
| 44 | mtime: Option.some(new Date(lastModified)), |
| 45 | atime: Option.none(), |
| 46 | birthtime: Option.none(), |
| 47 | dev: 0, |
| 48 | ino: Option.none(), |
| 49 | mode: 0, |
| 50 | nlink: Option.none(), |
| 51 | uid: Option.none(), |
| 52 | gid: Option.none(), |
| 53 | rdev: Option.none(), |
| 54 | size: ByteSize.bytes(fileBody.length), |
| 55 | blksize: Option.none(), |
| 56 | blocks: Option.none() |
| 57 | } |
| 58 | |
| 59 | const stubCompression: HttpPlatform.Compression = { |
| 60 | algorithms: new Set(), |
| 61 | compressResponse: Effect.succeed |
| 62 | } |
| 63 | |
| 64 | const makeHandler = async () => { |
| 65 | const fileSystem = FileSystem.makeNoop({ |
| 66 | stat: (path) => path === filePath ? Effect.succeed(fileInfo) : Effect.fail(notFoundError(path)) |
| 67 | }) |
| 68 | |
| 69 | const httpPlatform = HttpPlatform.HttpPlatform.of({ |
| 70 | platform: "node", |
| 71 | compression: stubCompression, |
| 72 | fileResponse: (_path, options) => |
| 73 | Effect.succeed(HttpServerResponse.text(fileBody, { |
| 74 | status: options?.status, |
| 75 | headers: { |
| 76 | ETag: "\"etag-value\"", |
| 77 | "Last-Modified": lastModified |
| 78 | } |
| 79 | })), |
| 80 | fileWebResponse: () => Effect.die("not implemented") |
| 81 | }) |
| 82 | |
| 83 | const app = await Effect.runPromise( |
| 84 | HttpStaticServer.make({ |
| 85 | root, |
| 86 | cacheControl: "public, max-age=60" |
| 87 | }).pipe( |
| 88 | Effect.provide(Path.layer), |
| 89 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 90 | Effect.provideService(HttpPlatform.HttpPlatform, httpPlatform) |
| 91 | ) |
| 92 | ) |
| 93 | |
| 94 | return HttpEffect.toWebHandler(app) |
| 95 | } |
| 96 | |
| 97 | const makeFailingApp = async (options: { |
| 98 | readonly statError?: PlatformError.PlatformError |
| 99 | readonly fileResponseError?: PlatformError.PlatformError |
| 100 | }) => { |
| 101 | const fileSystem = FileSystem.makeNoop({ |
| 102 | stat: (path) => { |
| 103 | if (options.statError !== undefined) { |
| 104 | return Effect.fail(options.statError) |
| 105 | } |
| 106 | return path === filePath ? Effect.succeed(fileInfo) : Effect.fail(notFoundError(path)) |
| 107 | } |
| 108 | }) |
| 109 | |
| 110 | const httpPlatform = HttpPlatform.HttpPlatform.of({ |
| 111 | platform: "node", |
| 112 | compression: stubCompression, |
| 113 | fileResponse: (_path, fileOptions) => { |
| 114 | if (options.fileResponseError !== undefined) { |
| 115 | return Effect.fail(options.fileResponseError) |
| 116 | } |
| 117 | return Effect.succeed(HttpServerResponse.text(fileBody, { |
| 118 | status: fileOptions?.status, |
| 119 | headers: { |
| 120 | ETag: "\"etag-value\"", |
| 121 | "Last-Modified": lastModified |
| 122 | } |
| 123 | })) |
| 124 | }, |
| 125 | fileWebResponse: () => Effect.die("not implemented") |
| 126 | }) |
| 127 | |
| 128 | return Effect.runPromise( |
| 129 | HttpStaticServer.make({ root }).pipe( |
| 130 | Effect.provide(Path.layer), |
| 131 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 132 | Effect.provideService(HttpPlatform.HttpPlatform, httpPlatform) |
| 133 | ) |
| 134 | ) |
| 135 | } |
| 136 | |
| 137 | const makeLayerHandler = (options: { |
| 138 | readonly statError?: PlatformError.PlatformError |
| 139 | readonly fileResponseError?: PlatformError.PlatformError |
| 140 | }) => { |
| 141 | const fileSystem = FileSystem.makeNoop({ |
| 142 | stat: (path) => { |
| 143 | if (options.statError !== undefined) { |
| 144 | return Effect.fail(options.statError) |
| 145 | } |
| 146 | return path === filePath ? Effect.succeed(fileInfo) : Effect.fail(notFoundError(path)) |
| 147 | } |
| 148 | }) |
| 149 | const httpPlatform = HttpPlatform.HttpPlatform.of({ |
| 150 | platform: "node", |
| 151 | compression: stubCompression, |
| 152 | fileResponse: () => |
| 153 | options.fileResponseError !== undefined |
| 154 | ? Effect.fail(options.fileResponseError) |
| 155 | : Effect.succeed(HttpServerResponse.text(fileBody)), |
| 156 | fileWebResponse: () => Effect.die("not implemented") |
| 157 | }) |
| 158 | |
| 159 | const dependencies = Layer.mergeAll( |
| 160 | Path.layer, |
| 161 | Layer.succeed(FileSystem.FileSystem, fileSystem), |
| 162 | Layer.succeed(HttpPlatform.HttpPlatform, httpPlatform) |
| 163 | ) |
| 164 | |
| 165 | return HttpRouter.toWebHandler( |
| 166 | HttpStaticServer.layer({ root }).pipe(Layer.provideMerge(dependencies)), |
| 167 | { disableLogger: true } |
| 168 | ) |
| 169 | } |
| 170 | |
| 171 | describe("HttpStaticServer", () => { |
| 172 | it("304 with If-None-Match exact, weak, and wildcard", async () => { |
| 173 | const handler = await makeHandler() |
| 174 | const warmupResponse = await handler(new Request("http://localhost/file.txt")) |
| 175 | const etag = warmupResponse.headers.get("etag") |
| 176 | assert.strictEqual(etag, "\"etag-value\"") |
| 177 | if (etag === null) { |
| 178 | throw new Error("expected ETag header") |
| 179 | } |
| 180 | |
| 181 | const exact = await handler(new Request("http://localhost/file.txt", { headers: { "If-None-Match": etag } })) |
| 182 | const weak = await handler(new Request("http://localhost/file.txt", { headers: { "If-None-Match": `W/${etag}` } })) |
| 183 | const list = await handler( |
| 184 | new Request("http://localhost/file.txt", { headers: { "If-None-Match": `"other", W/${etag}` } }) |
| 185 | ) |
| 186 | const any = await handler(new Request("http://localhost/file.txt", { headers: { "If-None-Match": "*" } })) |
| 187 | |
| 188 | assert.deepStrictEqual([exact.status, weak.status, list.status, any.status], [304, 304, 304, 304]) |
| 189 | assert.strictEqual(exact.headers.get("etag"), "\"etag-value\"") |
| 190 | assert.strictEqual(exact.headers.get("cache-control"), "public, max-age=60") |
| 191 | assert.strictEqual(exact.headers.get("last-modified"), lastModified) |
| 192 | assert.strictEqual(exact.headers.get("content-type"), null) |
| 193 | assert.strictEqual(exact.headers.get("content-length"), null) |
| 194 | }) |
| 195 | |
| 196 | it("304 with If-Modified-Since and no If-None-Match", async () => { |
| 197 | const handler = await makeHandler() |
| 198 | |
| 199 | const notModified = await handler( |
| 200 | new Request("http://localhost/file.txt", { |
| 201 | headers: { |
| 202 | "If-Modified-Since": lastModified |
| 203 | } |
| 204 | }) |
| 205 | ) |
| 206 | const modified = await handler( |
| 207 | new Request("http://localhost/file.txt", { |
| 208 | headers: { |
| 209 | "If-Modified-Since": "Tue, 31 Dec 2024 23:59:59 GMT" |
| 210 | } |
| 211 | }) |
| 212 | ) |
| 213 | const invalidDate = await handler( |
| 214 | new Request("http://localhost/file.txt", { |
| 215 | headers: { |
| 216 | "If-Modified-Since": "not-a-date" |
| 217 | } |
| 218 | }) |
| 219 | ) |
| 220 | |
| 221 | assert.strictEqual(notModified.status, 304) |
| 222 | assert.strictEqual(modified.status, 200) |
| 223 | assert.strictEqual(invalidDate.status, 200) |
| 224 | }) |
| 225 | |
| 226 | it("If-None-Match takes precedence over If-Modified-Since", async () => { |
| 227 | const handler = await makeHandler() |
| 228 | |
| 229 | const response = await handler( |
| 230 | new Request("http://localhost/file.txt", { |
| 231 | headers: { |
| 232 | "If-None-Match": "\"different\"", |
| 233 | "If-Modified-Since": "Thu, 02 Jan 2025 00:00:00 GMT" |
| 234 | } |
| 235 | }) |
| 236 | ) |
| 237 | |
| 238 | assert.strictEqual(response.status, 200) |
| 239 | }) |
| 240 | |
| 241 | it("matched If-None-Match takes precedence over Range", async () => { |
| 242 | const handler = await makeHandler() |
| 243 | |
| 244 | const response = await handler( |
| 245 | new Request("http://localhost/file.txt", { |
| 246 | headers: { |
| 247 | "If-None-Match": "\"etag-value\"", |
| 248 | "If-Range": "\"stale\"", |
| 249 | Range: "bytes=1000-1001" |
| 250 | } |
| 251 | }) |
| 252 | ) |
| 253 | |
| 254 | assert.strictEqual(response.status, 304) |
| 255 | assert.strictEqual(response.headers.get("etag"), "\"etag-value\"") |
| 256 | assert.strictEqual(response.headers.get("cache-control"), "public, max-age=60") |
| 257 | assert.strictEqual(response.headers.get("last-modified"), lastModified) |
| 258 | assert.strictEqual(response.headers.get("content-range"), null) |
| 259 | assert.strictEqual(response.headers.get("accept-ranges"), null) |
| 260 | assert.strictEqual(response.headers.get("content-type"), null) |
| 261 | assert.strictEqual(response.headers.get("content-length"), null) |
| 262 | }) |
| 263 | |
| 264 | it("wraps missing routes as HttpServerError RouteNotFound", async () => { |
| 265 | const app = await makeFailingApp({}) |
| 266 | const error = await Effect.runPromise( |
| 267 | app.pipe( |
| 268 | Effect.provideService( |
| 269 | HttpServerRequest.HttpServerRequest, |
| 270 | HttpServerRequest.fromWeb(new Request("http://localhost/missing.txt")) |
| 271 | ), |
| 272 | Effect.flip |
| 273 | ) |
| 274 | ) |
| 275 | |
| 276 | assert.instanceOf(error, HttpServerError.HttpServerError) |
| 277 | assert.strictEqual(error.reason._tag, "RouteNotFound") |
| 278 | }) |
| 279 | |
| 280 | it("maps non-NotFound platform failures to HttpServerError InternalError", async () => { |
| 281 | const statError = permissionDeniedError(filePath) |
| 282 | const app = await makeFailingApp({ statError }) |
| 283 | const error = await Effect.runPromise( |
| 284 | app.pipe( |
| 285 | Effect.provideService( |
| 286 | HttpServerRequest.HttpServerRequest, |
| 287 | HttpServerRequest.fromWeb(new Request("http://localhost/file.txt")) |
| 288 | ), |
| 289 | Effect.flip |
| 290 | ) |
| 291 | ) |
| 292 | |
| 293 | assert.instanceOf(error, HttpServerError.HttpServerError) |
| 294 | assert.strictEqual(error.reason._tag, "InternalError") |
| 295 | assert.strictEqual(error.reason.cause, statError) |
| 296 | }) |
| 297 | |
| 298 | it("layer renders InternalError responses as 500", async () => { |
| 299 | const { handler, dispose } = makeLayerHandler({ |
| 300 | fileResponseError: permissionDeniedError(filePath) |
| 301 | }) |
| 302 | try { |
| 303 | const response = await handler(new Request("http://localhost/file.txt")) |
| 304 | assert.strictEqual(response.status, 500) |
| 305 | } finally { |
| 306 | await dispose() |
| 307 | } |
| 308 | }) |
| 309 | }) |