1import { assert, describe, it } from "@effect/vitest"
2import * as ByteSize from "effect/ByteSize"
3import * as Effect from "effect/Effect"
4import * as FileSystem from "effect/FileSystem"
5import {
6 HttpEffect,
7 HttpPlatform,
8 HttpRouter,
9 HttpServerError,
10 HttpServerRequest,
11 HttpServerResponse,
12 HttpStaticServer
13} from "effect/http"
14import * as Layer from "effect/Layer"
15import * as Option from "effect/Option"
16import * as Path from "effect/Path"
17import * as PlatformError from "effect/PlatformError"
18
19const root = "/root"
20const filePath = `${root}/file.txt`
21const fileBody = "hello static"
22const lastModified = "Wed, 01 Jan 2025 00:00:00 GMT"
23
24const 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
33const 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
42const 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
59const stubCompression: HttpPlatform.Compression = {
60 algorithms: new Set(),
61 compressResponse: Effect.succeed
62}
63
64const 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
97const 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
137const 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
171describe("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})