| 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 | */ |
| 13 | import * as ByteSize from "../ByteSize.ts" |
| 14 | import * as Context from "../Context.ts" |
| 15 | import * as Effect from "../Effect.ts" |
| 16 | import * as FileSystem from "../FileSystem.ts" |
| 17 | import { identity } from "../Function.ts" |
| 18 | import * as Layer from "../Layer.ts" |
| 19 | import * as Option from "../Option.ts" |
| 20 | import { badArgument, type PlatformError } from "../PlatformError.ts" |
| 21 | import * as Stream from "../Stream.ts" |
| 22 | import * as Etag from "./Etag.ts" |
| 23 | import * as Headers from "./Headers.ts" |
| 24 | import type * as Body from "./HttpBody.ts" |
| 25 | import * as Response from "./HttpServerResponse.ts" |
| 26 | import * as internal from "./internal/compression.ts" |
| 27 | import * 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 | */ |
| 36 | export 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 | */ |
| 64 | export 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 | |
| 148 | const 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 | |
| 153 | const 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 | |
| 164 | const 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 | */ |
| 189 | export 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 | |
| 255 | const 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 | */ |
| 264 | export 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 | */ |
| 278 | export 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 | */ |
| 300 | export 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 | */ |
| 323 | export 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 | */ |
| 340 | export 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 |