1/**
2 * Serves static files for Effect HTTP applications.
3 *
4 * `HttpStaticServer` turns request paths into file responses under a configured
5 * root directory. It can be used as an application value or mounted onto an
6 * `HttpRouter`, and it handles index files, optional single-page application
7 * fallback, MIME type headers, cache-control headers, byte ranges, and
8 * conditional `304 Not Modified` responses.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13import * as Effect from "../Effect.ts"
14import * as FileSystem from "../FileSystem.ts"
15import * as Layer from "../Layer.ts"
16import * as Path from "../Path.ts"
17import type { PlatformError } from "../PlatformError.ts"
18import * as HttpPlatform from "./HttpPlatform.ts"
19import * as HttpRouter from "./HttpRouter.ts"
20import * as HttpServerError from "./HttpServerError.ts"
21import * as HttpServerRequest from "./HttpServerRequest.ts"
22import * as HttpServerRespondable from "./HttpServerRespondable.ts"
23import * as HttpServerResponse from "./HttpServerResponse.ts"
24
25/**
26 * Creates an `HttpApp` that serves files from a directory.
27 *
28 * **Example** (Serving files from a directory)
29 *
30 * ```ts import.meta.vitest
31 * import { ByteSize, Effect, FileSystem, Layer, Path } from "effect"
32 * import {
33 * HttpEffect,
34 * HttpPlatform,
35 * HttpServerResponse,
36 * HttpStaticServer
37 * } from "effect/http"
38 *
39 * const TestFileSystem = FileSystem.layerNoop({
40 * stat: () =>
41 * Effect.succeed({
42 * type: "File",
43 * size: ByteSize.bytes(20)
44 * } as FileSystem.File.Info)
45 * })
46 * const TestHttpPlatform = Layer.succeed(
47 * HttpPlatform.HttpPlatform,
48 * HttpPlatform.HttpPlatform.of({
49 * platform: "web",
50 * fileResponse: (path) => Effect.succeed(HttpServerResponse.text(`Serving ${path}`)),
51 * fileWebResponse: () => Effect.die("unused")
52 * })
53 * )
54 * const TestServices = Layer.mergeAll(Path.layer, TestFileSystem, TestHttpPlatform)
55 *
56 * const program = Effect.gen(function*() {
57 * const app = yield* HttpStaticServer.make({ root: "/public" })
58 * const handler = HttpEffect.toWebHandler(app)
59 * const response = yield* Effect.promise(() => handler(new Request("http://localhost/guide.txt")))
60 * const body = yield* Effect.promise(() => response.text())
61 * return body
62 * }).pipe(Effect.provide(TestServices))
63 *
64 * await Effect.runPromise(program) // => "Serving /public/guide.txt"
65 * ```
66 *
67 * @stability unstable
68 * @category constructors
69 * @since 4.0.0
70 */
71export const make: (options: {
72 readonly root: string
73 readonly index?: string | undefined
74 readonly spa?: boolean | undefined
75 readonly cacheControl?: string | undefined
76 readonly mimeTypes?: Record<string, string> | undefined
77}) => Effect.Effect<
78 Effect.Effect<
79 HttpServerResponse.HttpServerResponse,
80 HttpServerError.HttpServerError,
81 HttpServerRequest.HttpServerRequest
82 >,
83 PlatformError,
84 FileSystem.FileSystem | Path.Path | HttpPlatform.HttpPlatform
85> = Effect.fnUntraced(function*(options) {
86 const fileSystem = yield* FileSystem.FileSystem
87 const path = yield* Path.Path
88 const platform = yield* HttpPlatform.HttpPlatform
89
90 const resolvedRoot = path.resolve(options.root)
91 const index = "index" in options ? options.index : "index.html"
92 const spa = options.spa === true
93 const cacheControl = options.cacheControl
94 const mimeTypes = {
95 ...defaultMimeTypes,
96 ...options.mimeTypes
97 }
98
99 const setFileHeaders = (
100 response: HttpServerResponse.HttpServerResponse,
101 filePath: string
102 ): HttpServerResponse.HttpServerResponse => {
103 let currentResponse = HttpServerResponse.setHeaders(response, {
104 "Content-Type": resolveMimeType(path, filePath, mimeTypes),
105 "Accept-Ranges": "bytes"
106 })
107 if (cacheControl !== undefined) {
108 currentResponse = HttpServerResponse.setHeader(currentResponse, "Cache-Control", cacheControl)
109 }
110 return currentResponse
111 }
112
113 const serveFile: (
114 request: HttpServerRequest.HttpServerRequest,
115 filePath: string,
116 fileSize?: bigint
117 ) => Effect.Effect<HttpServerResponse.HttpServerResponse, HttpServerError.HttpServerError> = Effect.fnUntraced(
118 function*(request, filePath, fileSize) {
119 const rangeHeader = request.method === "GET" ? request.headers["range"] : undefined
120 const shouldEvaluateConditionals = request.headers["if-none-match"] !== undefined ||
121 request.headers["if-modified-since"] !== undefined
122
123 let fullResponse: HttpServerResponse.HttpServerResponse | undefined
124 const getFullResponse = () =>
125 fullResponse === undefined
126 ? Effect.map(
127 handlePlatformError(request, platform.fileResponse(filePath)),
128 (response) => setFileHeaders(response, filePath)
129 )
130 : Effect.succeed(fullResponse)
131 if (shouldEvaluateConditionals) {
132 fullResponse = yield* getFullResponse()
133 const conditionalResponse = evaluateConditionalRequest(request, fullResponse)
134 if (conditionalResponse !== undefined) {
135 return conditionalResponse
136 }
137 }
138
139 if (rangeHeader === undefined) {
140 return yield* getFullResponse()
141 }
142
143 const ifRange = request.headers["if-range"]
144 if (ifRange !== undefined) {
145 fullResponse = yield* getFullResponse()
146 if (!matchesIfRange(ifRange, fullResponse.headers["etag"])) {
147 return fullResponse
148 }
149 }
150
151 const resolvedFileSize = fileSize ?? (yield* handlePlatformError(request, fileSystem.stat(filePath))).size
152 const parsedRange = parseRange(rangeHeader, resolvedFileSize)
153
154 if (parsedRange === undefined) {
155 return yield* getFullResponse()
156 }
157
158 if (parsedRange === "unsatisfiable") {
159 return HttpServerResponse.empty({
160 status: 416,
161 headers: {
162 "Content-Range": `bytes */${resolvedFileSize}`
163 }
164 })
165 }
166
167 let response = setFileHeaders(
168 yield* handlePlatformError(
169 request,
170 platform.fileResponse(filePath, {
171 status: 206,
172 offset: parsedRange.start,
173 bytesToRead: parsedRange.end - parsedRange.start + BigInt(1)
174 })
175 ),
176 filePath
177 )
178
179 response = HttpServerResponse.setHeader(
180 response,
181 "Content-Range",
182 `bytes ${parsedRange.start}-${parsedRange.end}/${resolvedFileSize}`
183 )
184
185 return response
186 }
187 )
188
189 // @effect-diagnostics-next-line returnEffectInGen:off
190 return HttpServerRequest.HttpServerRequest.use((request) => {
191 const resolvedPath = resolveFilePath(path, resolvedRoot, request.url)
192 if (resolvedPath === undefined) {
193 return Effect.fail(toRouteNotFoundError(request))
194 }
195
196 return Effect.matchEffect(fileSystem.stat(resolvedPath), {
197 onFailure: (error) =>
198 error.reason._tag === "NotFound" &&
199 spa && index !== undefined && path.extname(resolvedPath) === "" && acceptsHtml(request.headers["accept"])
200 ? serveFile(request, path.join(resolvedRoot, index))
201 : error.reason._tag === "NotFound"
202 ? Effect.fail(toRouteNotFoundError(request))
203 : Effect.fail(toInternalServerError(request, error)),
204 onSuccess(info) {
205 if (info.type === "File") {
206 return serveFile(request, resolvedPath, info.size)
207 }
208 if (info.type === "Directory" && index !== undefined) {
209 return serveFile(request, path.join(resolvedPath, index))
210 }
211 return Effect.fail(toRouteNotFoundError(request))
212 }
213 })
214 })
215})
216
217/**
218 * Creates a layer that mounts static files on an `HttpRouter`.
219 *
220 * **Example** (Mounting static files on a router)
221 *
222 * ```ts import.meta.vitest
223 * import { Layer } from "effect"
224 * import { HttpRouter, HttpServerResponse, HttpStaticServer } from "effect/http"
225 *
226 * const ApiLayer = HttpRouter.add("GET", "/health", HttpServerResponse.text("ok"))
227 *
228 * const StaticFilesLayer = HttpStaticServer.layer({
229 * root: "./public",
230 * prefix: "/static"
231 * })
232 *
233 * const AppLayer = Layer.mergeAll(ApiLayer, StaticFilesLayer)
234 * Layer.isLayer(AppLayer) // => true
235 * ```
236 *
237 * @stability unstable
238 * @category layers
239 * @since 4.0.0
240 */
241export const layer = (options: {
242 readonly root: string
243 readonly index?: string | undefined
244 readonly spa?: boolean | undefined
245 readonly cacheControl?: string | undefined
246 readonly mimeTypes?: Record<string, string> | undefined
247 readonly prefix?: string | undefined
248}): Layer.Layer<
249 never,
250 PlatformError,
251 HttpRouter.HttpRouter | FileSystem.FileSystem | Path.Path | HttpPlatform.HttpPlatform
252> =>
253 Layer.effectDiscard(Effect.gen(function*() {
254 const router = yield* HttpRouter.HttpRouter
255 const handler = (yield* make(options)).pipe(
256 Effect.catch(HttpServerRespondable.toResponse)
257 )
258 if (options.prefix !== undefined) {
259 yield* router.prefixed(options.prefix).add("GET", "/*", handler)
260 return
261 }
262 yield* router.add("GET", "/*", handler)
263 }))
264
265const defaultMimeTypes: Record<string, string> = {
266 html: "text/html; charset=utf-8",
267 htm: "text/html; charset=utf-8",
268 css: "text/css; charset=utf-8",
269 js: "text/javascript; charset=utf-8",
270 mjs: "text/javascript; charset=utf-8",
271 json: "application/json; charset=utf-8",
272 xml: "application/xml; charset=utf-8",
273 txt: "text/plain; charset=utf-8",
274 csv: "text/csv; charset=utf-8",
275 md: "text/markdown; charset=utf-8",
276 yaml: "text/yaml; charset=utf-8",
277 yml: "text/yaml; charset=utf-8",
278 png: "image/png",
279 jpg: "image/jpeg",
280 jpeg: "image/jpeg",
281 gif: "image/gif",
282 svg: "image/svg+xml; charset=utf-8",
283 ico: "image/x-icon",
284 webp: "image/webp",
285 avif: "image/avif",
286 woff: "font/woff",
287 woff2: "font/woff2",
288 ttf: "font/ttf",
289 otf: "font/otf",
290 eot: "application/vnd.ms-fontobject",
291 mp3: "audio/mpeg",
292 mp4: "video/mp4",
293 webm: "video/webm",
294 ogg: "audio/ogg",
295 wav: "audio/wav",
296 flac: "audio/flac",
297 aac: "audio/aac",
298 pdf: "application/pdf",
299 zip: "application/zip",
300 gz: "application/gzip",
301 wasm: "application/wasm",
302 map: "application/json",
303 webmanifest: "application/manifest+json"
304}
305
306const stripQueryString = (url: string): string => {
307 const queryIndex = url.indexOf("?")
308 return queryIndex === -1 ? url : url.slice(0, queryIndex)
309}
310
311const resolveMimeType = (path: Path.Path, filePath: string, mimeTypes: Record<string, string>): string => {
312 const extension = path.extname(filePath).toLowerCase()
313 if (extension.length <= 1) {
314 return "application/octet-stream"
315 }
316 return mimeTypes[extension.slice(1)] ?? "application/octet-stream"
317}
318
319const parseInteger = (value: string): bigint | undefined => {
320 if (!/^\d+$/.test(value)) {
321 return undefined
322 }
323 return BigInt(value)
324}
325
326const parseRange = (
327 header: string,
328 fileSize: bigint
329):
330 | { readonly start: bigint; readonly end: bigint }
331 | "unsatisfiable"
332 | undefined =>
333{
334 const value = header.trim()
335 if (!value.toLowerCase().startsWith("bytes=")) {
336 return undefined
337 }
338 const rangeValue = value.slice(6).trim()
339 if (rangeValue.length === 0 || rangeValue.includes(",")) {
340 return undefined
341 }
342 const separatorIndex = rangeValue.indexOf("-")
343 if (separatorIndex === -1) {
344 return undefined
345 }
346 const startPart = rangeValue.slice(0, separatorIndex).trim()
347 const endPart = rangeValue.slice(separatorIndex + 1).trim()
348 if (startPart === "" && endPart === "") {
349 return undefined
350 }
351 if (startPart === "") {
352 const suffixLength = parseInteger(endPart)
353 if (suffixLength === undefined) {
354 return undefined
355 }
356 if (suffixLength === BigInt(0) || fileSize === BigInt(0)) {
357 return "unsatisfiable"
358 }
359 return {
360 start: fileSize > suffixLength ? fileSize - suffixLength : BigInt(0),
361 end: fileSize - BigInt(1)
362 }
363 }
364 const start = parseInteger(startPart)
365 if (start === undefined) {
366 return undefined
367 }
368 if (endPart === "") {
369 if (start >= fileSize) {
370 return "unsatisfiable"
371 }
372 return {
373 start,
374 end: fileSize - BigInt(1)
375 }
376 }
377 const end = parseInteger(endPart)
378 if (end === undefined) {
379 return undefined
380 }
381 if (start > end || start >= fileSize) {
382 return "unsatisfiable"
383 }
384 return {
385 start,
386 end: end < fileSize ? end : fileSize - BigInt(1)
387 }
388}
389
390const resolveFilePath = (path: Path.Path, root: string, url: string): string | undefined => {
391 const urlPath = stripQueryString(url)
392 let decodedPath: string
393 try {
394 decodedPath = decodeURIComponent(urlPath)
395 } catch {
396 return undefined
397 }
398 if (decodedPath.includes("\u0000")) {
399 return undefined
400 }
401 const normalizedPath = path.normalize(decodedPath.startsWith("/") ? decodedPath.slice(1) : decodedPath)
402 if (normalizedPath === ".." || normalizedPath.startsWith(`..${path.sep}`)) {
403 return undefined
404 }
405 const resolvedPath = path.join(root, normalizedPath)
406 const rootPrefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`
407 if (resolvedPath !== root && !resolvedPath.startsWith(rootPrefix)) {
408 return undefined
409 }
410 return resolvedPath
411}
412
413const toRouteNotFoundError = (request: HttpServerRequest.HttpServerRequest) =>
414 new HttpServerError.HttpServerError({ reason: new HttpServerError.RouteNotFound({ request }) })
415
416const toInternalServerError = (request: HttpServerRequest.HttpServerRequest, cause: unknown) =>
417 new HttpServerError.HttpServerError({ reason: new HttpServerError.InternalError({ request, cause }) })
418
419const handlePlatformError = <A>(
420 request: HttpServerRequest.HttpServerRequest,
421 self: Effect.Effect<A, PlatformError>
422): Effect.Effect<A, HttpServerError.HttpServerError> =>
423 Effect.catchIf(
424 self,
425 (error): error is PlatformError => error.reason._tag === "NotFound",
426 () => Effect.fail(toRouteNotFoundError(request)),
427 (error) => Effect.fail(toInternalServerError(request, error))
428 )
429
430const acceptsHtml = (accept: string | undefined): boolean =>
431 accept !== undefined && accept.toLowerCase().includes("text/html")
432
433const stripWeakEtagPrefix = (value: string): string => {
434 const trimmed = value.trim()
435 return /^w\//i.test(trimmed) ? trimmed.slice(2) : trimmed
436}
437
438const matchesIfNoneMatch = (ifNoneMatch: string, etag: string | undefined): boolean => {
439 const normalizedEtag = etag === undefined ? undefined : stripWeakEtagPrefix(etag)
440 for (const candidate of ifNoneMatch.split(",")) {
441 const value = candidate.trim()
442 if (value === "") {
443 continue
444 }
445 if (value === "*") {
446 return true
447 }
448 if (normalizedEtag !== undefined && stripWeakEtagPrefix(value) === normalizedEtag) {
449 return true
450 }
451 }
452 return false
453}
454
455const isNotModifiedSince = (ifModifiedSince: string, lastModified: string | undefined): boolean => {
456 if (lastModified === undefined) {
457 return false
458 }
459 const ifModifiedSinceMs = Date.parse(ifModifiedSince)
460 if (Number.isNaN(ifModifiedSinceMs)) {
461 return false
462 }
463 const lastModifiedMs = Date.parse(lastModified)
464 if (Number.isNaN(lastModifiedMs)) {
465 return false
466 }
467 return lastModifiedMs <= ifModifiedSinceMs
468}
469
470// Only a strong entity-tag can match (RFC 9110 §13.1.5). Weak tags start
471// with `W/`, and Last-Modified dates never match: filesystem metadata cannot
472// prove the file did not change twice within the same second.
473const matchesIfRange = (ifRange: string, etag: string | undefined): boolean => {
474 const value = ifRange.trim()
475 return value.startsWith("\"") && value === etag
476}
477
478const notModifiedResponse = (
479 response: HttpServerResponse.HttpServerResponse
480): HttpServerResponse.HttpServerResponse => {
481 const headers: Record<string, string> = {}
482 const etag = response.headers["etag"]
483 if (etag !== undefined) {
484 headers["ETag"] = etag
485 }
486 const cacheControl = response.headers["cache-control"]
487 if (cacheControl !== undefined) {
488 headers["Cache-Control"] = cacheControl
489 }
490 const lastModified = response.headers["last-modified"]
491 if (lastModified !== undefined) {
492 headers["Last-Modified"] = lastModified
493 }
494 return HttpServerResponse.empty({
495 status: 304,
496 headers
497 })
498}
499
500const evaluateConditionalRequest = (
501 request: HttpServerRequest.HttpServerRequest,
502 response: HttpServerResponse.HttpServerResponse
503): HttpServerResponse.HttpServerResponse | undefined => {
504 const ifNoneMatch = request.headers["if-none-match"]
505 if (ifNoneMatch !== undefined) {
506 return matchesIfNoneMatch(ifNoneMatch, response.headers["etag"]) ? notModifiedResponse(response) : undefined
507 }
508 const ifModifiedSince = request.headers["if-modified-since"]
509 if (ifModifiedSince !== undefined && isNotModifiedSince(ifModifiedSince, response.headers["last-modified"])) {
510 return notModifiedResponse(response)
511 }
512 return undefined
513}