1/**
2 * Platform-specific support for serving files as HTTP server responses.
3 *
4 * `HttpPlatform` is the boundary between the portable HTTP response model and
5 * the runtime that knows how to stream bytes from the host platform. Server
6 * code uses this service when it needs to return local files, static assets,
7 * downloads, byte ranges, or Web `File`-like values without constructing the
8 * response body by hand.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13import * as ByteSize from "../ByteSize.ts"
14import * as Context from "../Context.ts"
15import * as Effect from "../Effect.ts"
16import * as FileSystem from "../FileSystem.ts"
17import { identity } from "../Function.ts"
18import * as Layer from "../Layer.ts"
19import * as Option from "../Option.ts"
20import { badArgument, type PlatformError } from "../PlatformError.ts"
21import * as Stream from "../Stream.ts"
22import * as Etag from "./Etag.ts"
23import * as Headers from "./Headers.ts"
24import type * as Body from "./HttpBody.ts"
25import * as Response from "./HttpServerResponse.ts"
26import * as internal from "./internal/compression.ts"
27import * as Mime from "./Mime.ts"
28
29/**
30 * Service for platform-specific HTTP response helpers, including file-backed server responses.
31 *
32 * @stability unstable
33 * @category services
34 * @since 4.0.0
35 */
36export class HttpPlatform extends Context.Service<HttpPlatform, {
37 readonly platform: "deno" | "node" | "bun" | "web"
38 readonly compression: Compression
39 readonly fileResponse: (
40 path: string,
41 options?: Response.Options.WithContentType & {
42 readonly bytesToRead?: ByteSize.Input | undefined
43 readonly chunkSize?: number | undefined
44 readonly offset?: ByteSize.Input | undefined
45 }
46 ) => Effect.Effect<Response.HttpServerResponse, PlatformError>
47 readonly fileWebResponse: (
48 file: Body.HttpBody.FileLike,
49 options?: Response.Options.WithContentType & {
50 readonly bytesToRead?: number | undefined
51 readonly chunkSize?: number | undefined
52 readonly offset?: number | undefined
53 }
54 ) => Effect.Effect<Response.HttpServerResponse>
55}>()("effect/http/HttpPlatform") {}
56
57/**
58 * Creates an `HttpPlatform` service from platform-specific file response constructors, using `FileSystem` and `Etag.Generator`.
59 *
60 * @stability unstable
61 * @category constructors
62 * @since 4.0.0
63 */
64export const make: (impl: {
65 readonly platform: "deno" | "node" | "bun" | "web"
66 readonly compression: Compression
67 readonly fileResponse: (
68 path: string,
69 status: number,
70 statusText: string | undefined,
71 headers: Headers.Headers,
72 start: number,
73 end: number | undefined,
74 contentLength: bigint
75 ) => Response.HttpServerResponse
76 readonly fileWebResponse: (
77 file: Body.HttpBody.FileLike,
78 status: number,
79 statusText: string | undefined,
80 headers: Headers.Headers,
81 options?: {
82 readonly bytesToRead?: number | undefined
83 readonly chunkSize?: number | undefined
84 readonly offset?: number | undefined
85 }
86 ) => Response.HttpServerResponse
87}) => Effect.Effect<
88 HttpPlatform["Service"],
89 never,
90 Etag.Generator | FileSystem.FileSystem
91> = Effect.fnUntraced(function*(impl) {
92 const fs = yield* FileSystem.FileSystem
93 const etagGen = yield* Etag.Generator
94
95 return HttpPlatform.of({
96 platform: impl.platform,
97 compression: internal.wrapCompression(impl.compression),
98 fileResponse: Effect.fnUntraced(function*(path, options) {
99 const info = yield* fs.stat(path)
100 const etag = yield* etagGen.fromFileInfo(info)
101 const requestedOffset = options?.offset === undefined
102 ? ByteSize.zero
103 : yield* fileResponseSize(options.offset, "offset")
104 const offset = requestedOffset > info.size ? info.size : requestedOffset
105 const available = info.size - offset
106 const bytesToRead = options?.bytesToRead !== undefined
107 ? yield* fileResponseSize(options.bytesToRead, "bytesToRead")
108 : undefined
109 const contentLength = bytesToRead === undefined || bytesToRead > available ? available : bytesToRead
110 const limit = bytesToRead === undefined ? undefined : offset + contentLength
111 const start = yield* fileResponseNumber(offset, "offset")
112 const end = limit === undefined ? undefined : yield* fileResponseNumber(limit, "end")
113 const headers = Headers.set(optionHeaders(options), "etag", Etag.toString(etag))
114 if (Option.isSome(info.mtime)) {
115 ;(headers as any)["last-modified"] = info.mtime.value.toUTCString()
116 }
117 return impl.fileResponse(
118 path,
119 options?.status ?? 200,
120 options?.statusText,
121 headers,
122 start,
123 end,
124 contentLength
125 )
126 }),
127 fileWebResponse(file, options) {
128 return Effect.map(etagGen.fromFileWeb(file), (etag) => {
129 const headers = Headers.merge(
130 optionHeaders(options),
131 Headers.fromRecordUnsafe({
132 etag: Etag.toString(etag),
133 "last-modified": new Date(file.lastModified).toUTCString()
134 })
135 )
136 return impl.fileWebResponse(
137 file,
138 options?.status ?? 200,
139 options?.statusText,
140 headers,
141 options
142 )
143 })
144 }
145 })
146})
147
148const optionHeaders = (options: Response.Options.WithContentType | undefined): Headers.Headers => {
149 const headers = options?.headers ? Headers.fromInput(options.headers) : Headers.empty
150 return options?.contentType ? Headers.set(headers, "content-type", options.contentType) : headers
151}
152
153const fileResponseSize = (input: ByteSize.Input, field: string): Effect.Effect<ByteSize.ByteSize, PlatformError> => {
154 const size = ByteSize.fromInput(input)
155 return Option.isSome(size)
156 ? Effect.succeed(size.value)
157 : Effect.fail(badArgument({
158 module: "HttpPlatform",
159 method: "fileResponse",
160 description: `Invalid ${field}: ${input}`
161 }))
162}
163
164const fileResponseNumber = (value: bigint, field: string): Effect.Effect<number, PlatformError> => {
165 const number = Number(value)
166 return Number.isSafeInteger(number)
167 ? Effect.succeed(number)
168 : Effect.fail(badArgument({
169 module: "HttpPlatform",
170 method: "fileResponse",
171 description: `${field} exceeds the safe integer range: ${value}`
172 }))
173}
174
175/**
176 * Provides the default `HttpPlatform` implementation for serving file paths and
177 * `File`-like values as streamed HTTP responses.
178 *
179 * **Details**
180 *
181 * The layer uses the `FileSystem` and weak ETag services to add file metadata
182 * headers such as `etag` and `last-modified`. Missing content types are inferred
183 * from the file extension.
184 *
185 * @stability unstable
186 * @category layers
187 * @since 4.0.0
188 */
189export const layer = Layer.effect(HttpPlatform)(
190 Effect.flatMap(FileSystem.FileSystem, (fs) =>
191 make({
192 platform: "web",
193 compression: internal.compressionWeb,
194 fileResponse(path, status, statusText, headers, start, end, contentLength) {
195 const length = Number(contentLength)
196 return Response.stream(
197 fs.stream(path, {
198 offset: start,
199 bytesToRead: end !== undefined ? end - start : undefined
200 }),
201 {
202 contentType: headers["content-type"] ?? mimeType(path),
203 // Omit unsafe numeric metadata so it cannot overwrite the exact header.
204 contentLength: Number.isSafeInteger(length) ? length : undefined,
205 headers: Headers.set(headers, "content-length", contentLength.toString()),
206 status,
207 statusText
208 }
209 )
210 },
211 fileWebResponse(file, status, statusText, headers, options) {
212 const offset = Math.min(Math.max(options?.offset ?? 0, 0), file.size)
213 const available = file.size - offset
214 const contentLength = options?.bytesToRead === undefined
215 ? available
216 : Math.min(Math.max(options.bytesToRead, 0), available)
217 const chunkSize = options?.chunkSize !== undefined ? Math.max(1, options.chunkSize) : Infinity
218 const end = offset + contentLength
219 const stream = end <= offset
220 ? Stream.empty
221 : Stream.fromReadableStream({
222 evaluate: () => file.stream() as ReadableStream<Uint8Array>,
223 onError: identity
224 }).pipe(
225 Stream.mapAccum(
226 () => 0,
227 (position, bytes) => {
228 const next = position + bytes.length
229 const start = Math.min(Math.max(offset - position, 0), bytes.length)
230 const stop = Math.min(Math.max(end - position, 0), bytes.length)
231 const chunks: Array<{ readonly bytes: Uint8Array; readonly done: boolean }> = []
232 for (let index = start; index < stop; index += chunkSize) {
233 chunks.push({
234 bytes: bytes.subarray(index, Math.min(index + chunkSize, stop)),
235 done: next >= end && index + chunkSize >= stop
236 })
237 }
238 return [next, chunks]
239 }
240 ),
241 Stream.takeUntil((chunk) => chunk.done),
242 Stream.map((chunk) => chunk.bytes)
243 )
244 return Response.stream(stream, {
245 contentType: headers["content-type"] ?? (file.type === "" ? mimeType(file.name) : file.type),
246 contentLength,
247 headers,
248 status,
249 statusText
250 })
251 }
252 }))
253).pipe(Layer.provide(Etag.layerWeak))
254
255const mimeType = (path: string): string => Option.getOrElse(Mime.getType(path), () => "application/octet-stream")
256
257/**
258 * Content codings that HTTP response compression can apply.
259 *
260 * @stability unstable
261 * @category compression
262 * @since 4.0.0
263 */
264export type CompressionAlgorithm = "gzip" | "deflate" | "br" | "zstd"
265
266/**
267 * Options passed to a platform when compressing a response body.
268 *
269 * **Details**
270 *
271 * The `level` scale depends on the algorithm. Platforms without a level knob,
272 * such as the Web `CompressionStream` implementation, ignore it.
273 *
274 * @stability unstable
275 * @category compression
276 * @since 4.0.0
277 */
278export interface CompressionOptions {
279 readonly level?: number | undefined
280}
281
282/**
283 * Platform primitive for HTTP response compression.
284 *
285 * **Details**
286 *
287 * `algorithms` advertises what the platform can encode; content negotiation
288 * happens in the shared `HttpMiddleware.compression` middleware.
289 *
290 * `compressResponse` is only called when compression is definitely happening —
291 * all skip logic runs in the shared middleware first. The platform owns the
292 * body transform and removes `Content-Length` when the compressed size is not
293 * known in advance. The `make` wrapper owns the `Content-Encoding` and `Vary`
294 * headers.
295 *
296 * @stability unstable
297 * @category compression
298 * @since 4.0.0
299 */
300export interface Compression {
301 readonly algorithms: ReadonlySet<CompressionAlgorithm>
302 readonly compressResponse: (
303 response: Response.HttpServerResponse,
304 algorithm: CompressionAlgorithm,
305 options?: CompressionOptions | undefined
306 ) => Effect.Effect<Response.HttpServerResponse>
307}
308
309/**
310 * Creates a compression body transform backed by the Web `CompressionStream`
311 * API, for use with `makeCompressionWeb`.
312 *
313 * **Details**
314 *
315 * The format string is passed through to the runtime, so runtime-specific
316 * formats such as Bun's `"brotli"` and `"zstd"` are usable. `CompressionStream`
317 * has no compression level knob, so `CompressionOptions.level` does not apply.
318 *
319 * @stability unstable
320 * @category compression
321 * @since 4.0.0
322 */
323export const compressionTransformWeb: (
324 format: string
325) => (stream: ReadableStream<Uint8Array>) => ReadableStream<Uint8Array> = internal.compressionTransformWeb
326
327/**
328 * Creates a `Compression` implementation from Web `ReadableStream`
329 * transforms.
330 *
331 * **Details**
332 *
333 * All supported bodies are transformed as streams. The `Content-Length`
334 * header is dropped in every case.
335 *
336 * @stability unstable
337 * @category compression
338 * @since 4.0.0
339 */
340export const makeCompressionWeb: (options: {
341 readonly algorithms: Iterable<CompressionAlgorithm>
342 readonly transform: (
343 algorithm: CompressionAlgorithm,
344 options?: CompressionOptions | undefined
345 ) => (stream: ReadableStream<Uint8Array>) => ReadableStream<Uint8Array>
346}) => Compression = internal.makeCompressionWeb