| 1 | /** |
| 2 | * Describes HTTP request and response bodies before they reach a platform |
| 3 | * adapter. |
| 4 | * |
| 5 | * `HttpBody` is the shared body representation used by the HTTP modules. Each |
| 6 | * variant stores the payload together with metadata that can be known before |
| 7 | * sending it, such as `contentType` and `contentLength`. This module includes |
| 8 | * body constructors for common payload shapes, support for schema-encoded JSON |
| 9 | * bodies, streaming and file-backed bodies, and the error type used when body |
| 10 | * construction fails. |
| 11 | * |
| 12 | * @stability unstable |
| 13 | * @since 4.0.0 |
| 14 | */ |
| 15 | import * as ByteSize from "../ByteSize.ts" |
| 16 | import * as Data from "../Data.ts" |
| 17 | import * as Effect from "../Effect.ts" |
| 18 | import * as FileSystem from "../FileSystem.ts" |
| 19 | import { format } from "../Formatter.ts" |
| 20 | import * as Inspectable from "../Inspectable.ts" |
| 21 | import * as Option from "../Option.ts" |
| 22 | import * as PlatformError from "../PlatformError.ts" |
| 23 | import * as Predicate from "../Predicate.ts" |
| 24 | import * as Schema from "../Schema.ts" |
| 25 | import type { ParseOptions } from "../SchemaAST.ts" |
| 26 | import type { Issue } from "../SchemaIssue.ts" |
| 27 | import * as SchemaParser from "../SchemaParser.ts" |
| 28 | import type * as Stream_ from "../Stream.ts" |
| 29 | import * as UrlParams from "./UrlParams.ts" |
| 30 | |
| 31 | const TypeId = "~effect/http/HttpBody" |
| 32 | |
| 33 | /** |
| 34 | * Returns `true` if the provided value is an `HttpBody`. |
| 35 | * |
| 36 | * @stability unstable |
| 37 | * @category guards |
| 38 | * @since 4.0.0 |
| 39 | */ |
| 40 | export const isHttpBody = (u: unknown): u is HttpBody => Predicate.hasProperty(u, TypeId) |
| 41 | |
| 42 | /** |
| 43 | * Represents an HTTP request body. |
| 44 | * |
| 45 | * **Details** |
| 46 | * |
| 47 | * Supported variants include empty bodies, raw bodies, byte arrays, `FormData`, and streams of bytes. |
| 48 | * |
| 49 | * @stability unstable |
| 50 | * @category models |
| 51 | * @since 4.0.0 |
| 52 | */ |
| 53 | export type HttpBody = Empty | Raw | Uint8Array | FormData | Stream |
| 54 | |
| 55 | /** |
| 56 | * Namespace containing type-level members associated with `HttpBody`. |
| 57 | * |
| 58 | * @stability unstable |
| 59 | * @since 4.0.0 |
| 60 | */ |
| 61 | export declare namespace HttpBody { |
| 62 | /** |
| 63 | * Common protocol implemented by all HTTP body variants. |
| 64 | * |
| 65 | * **Details** |
| 66 | * |
| 67 | * It carries the variant tag plus optional `contentType` and `contentLength` metadata. |
| 68 | * |
| 69 | * @stability unstable |
| 70 | * @category models |
| 71 | * @since 4.0.0 |
| 72 | */ |
| 73 | export interface Proto extends Inspectable.Inspectable { |
| 74 | readonly [TypeId]: typeof TypeId |
| 75 | readonly _tag: string |
| 76 | readonly contentType?: string | undefined |
| 77 | readonly contentLength?: number | undefined |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Minimal Web `File`-like shape used by HTTP helpers that need file metadata. |
| 82 | * |
| 83 | * @stability unstable |
| 84 | * @category models |
| 85 | * @since 4.0.0 |
| 86 | */ |
| 87 | export interface FileLike { |
| 88 | readonly name: string |
| 89 | readonly lastModified: number |
| 90 | readonly size: number |
| 91 | readonly stream: () => unknown |
| 92 | readonly type: string |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | const HttpBodyErrorTypeId = "~effect/http/HttpBody/HttpBodyError" |
| 97 | |
| 98 | /** |
| 99 | * Error produced while constructing an HTTP body from JSON or schema-encoded input. |
| 100 | * |
| 101 | * @stability unstable |
| 102 | * @category errors |
| 103 | * @since 4.0.0 |
| 104 | */ |
| 105 | export class HttpBodyError extends Data.TaggedError("HttpBodyError")<{ |
| 106 | readonly reason: ErrorReason |
| 107 | readonly cause?: unknown |
| 108 | }> { |
| 109 | /** |
| 110 | * Marks this value as an HTTP body error for runtime guards. |
| 111 | * |
| 112 | * @stability unstable |
| 113 | * @since 4.0.0 |
| 114 | */ |
| 115 | readonly [HttpBodyErrorTypeId] = HttpBodyErrorTypeId |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Checks whether a value is an `HttpBodyError`. |
| 120 | * |
| 121 | * @stability unstable |
| 122 | * @category guards |
| 123 | * @since 4.0.0 |
| 124 | */ |
| 125 | export const isHttpBodyError = (u: unknown): u is HttpBodyError => Predicate.hasProperty(u, HttpBodyErrorTypeId) |
| 126 | |
| 127 | /** |
| 128 | * Reason for an `HttpBodyError`. |
| 129 | * |
| 130 | * **Details** |
| 131 | * |
| 132 | * `JsonError` represents a `JSON.stringify` failure; `SchemaError` represents a schema encoding issue. |
| 133 | * |
| 134 | * @stability unstable |
| 135 | * @category errors |
| 136 | * @since 4.0.0 |
| 137 | */ |
| 138 | export type ErrorReason = { |
| 139 | readonly _tag: "JsonError" |
| 140 | } | { |
| 141 | readonly _tag: "SchemaError" |
| 142 | readonly issue: Issue |
| 143 | } |
| 144 | |
| 145 | abstract class Proto implements HttpBody.Proto { |
| 146 | readonly [TypeId]: typeof TypeId |
| 147 | abstract readonly _tag: string |
| 148 | constructor() { |
| 149 | this[TypeId] = TypeId |
| 150 | } |
| 151 | abstract toJSON(): unknown |
| 152 | [Inspectable.NodeInspectSymbol](): unknown { |
| 153 | return this.toJSON() |
| 154 | } |
| 155 | toString(): string { |
| 156 | return format(this, { ignoreToString: true }) |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | /** |
| 161 | * HTTP body variant representing the absence of request content. |
| 162 | * |
| 163 | * @stability unstable |
| 164 | * @category models |
| 165 | * @since 4.0.0 |
| 166 | */ |
| 167 | export class Empty extends Proto { |
| 168 | readonly _tag = "Empty" |
| 169 | toJSON(): unknown { |
| 170 | return { |
| 171 | _id: "effect/HttpBody", |
| 172 | _tag: "Empty" |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Provides the singleton empty HTTP body. |
| 179 | * |
| 180 | * **When to use** |
| 181 | * |
| 182 | * Use when you need an HTTP body value that represents no body content. |
| 183 | * |
| 184 | * @stability unstable |
| 185 | * @category constants |
| 186 | * @since 4.0.0 |
| 187 | */ |
| 188 | export const empty: Empty = new Empty() |
| 189 | |
| 190 | /** |
| 191 | * HTTP body variant containing an arbitrary runtime body value with optional content metadata. |
| 192 | * |
| 193 | * @stability unstable |
| 194 | * @category models |
| 195 | * @since 4.0.0 |
| 196 | */ |
| 197 | export class Raw extends Proto { |
| 198 | readonly _tag = "Raw" |
| 199 | readonly body: unknown |
| 200 | readonly contentType: string | undefined |
| 201 | readonly contentLength: number | undefined |
| 202 | |
| 203 | constructor( |
| 204 | body: unknown, |
| 205 | contentType: string | undefined, |
| 206 | contentLength: number | undefined |
| 207 | ) { |
| 208 | super() |
| 209 | this.body = body |
| 210 | this.contentType = contentType |
| 211 | this.contentLength = contentLength |
| 212 | } |
| 213 | toJSON(): unknown { |
| 214 | return { |
| 215 | _id: "effect/HttpBody", |
| 216 | _tag: "Raw", |
| 217 | body: this.body, |
| 218 | contentType: this.contentType, |
| 219 | contentLength: this.contentLength |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Creates a raw HTTP body from an arbitrary value and optional `contentType` and `contentLength` metadata. |
| 226 | * |
| 227 | * @stability unstable |
| 228 | * @category constructors |
| 229 | * @since 4.0.0 |
| 230 | */ |
| 231 | export const raw = ( |
| 232 | body: unknown, |
| 233 | options?: { |
| 234 | readonly contentType?: string | undefined |
| 235 | readonly contentLength?: number | undefined |
| 236 | } | undefined |
| 237 | ): Raw => new Raw(body, options?.contentType, options?.contentLength) |
| 238 | |
| 239 | /** |
| 240 | * HTTP body variant backed by a `Uint8Array`. |
| 241 | * |
| 242 | * **Details** |
| 243 | * |
| 244 | * It stores the bytes, content type, and byte length. |
| 245 | * |
| 246 | * @stability unstable |
| 247 | * @category models |
| 248 | * @since 4.0.0 |
| 249 | */ |
| 250 | export class Uint8Array extends Proto { |
| 251 | readonly _tag = "Uint8Array" |
| 252 | readonly contentType: string |
| 253 | readonly contentLength: number |
| 254 | /** Original text retained for adapters that can skip encoding. */ |
| 255 | readonly text: string | undefined |
| 256 | private _body: globalThis.Uint8Array | undefined |
| 257 | |
| 258 | constructor( |
| 259 | body: globalThis.Uint8Array | undefined, |
| 260 | contentType: string, |
| 261 | contentLength: number, |
| 262 | text?: string |
| 263 | ) { |
| 264 | super() |
| 265 | this._body = body |
| 266 | this.text = text |
| 267 | this.contentType = contentType |
| 268 | this.contentLength = contentLength |
| 269 | } |
| 270 | get body(): globalThis.Uint8Array { |
| 271 | return this._body ??= encodeText(this.text!) |
| 272 | } |
| 273 | toJSON(): unknown { |
| 274 | const toString = this.contentType.startsWith("text/") || this.contentType.endsWith("json") |
| 275 | return { |
| 276 | _id: "effect/HttpBody", |
| 277 | _tag: "Uint8Array", |
| 278 | body: toString ? new TextDecoder().decode(this.body) : `Uint8Array(${this.body.length})`, |
| 279 | contentType: this.contentType, |
| 280 | contentLength: this.contentLength |
| 281 | } |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | /** |
| 286 | * Creates a byte-array HTTP body. |
| 287 | * |
| 288 | * **Details** |
| 289 | * |
| 290 | * The content type defaults to `application/octet-stream`, and the content length is the byte array length. |
| 291 | * |
| 292 | * @stability unstable |
| 293 | * @category constructors |
| 294 | * @since 4.0.0 |
| 295 | */ |
| 296 | export const uint8Array = (body: globalThis.Uint8Array, contentType?: string): Uint8Array => |
| 297 | new Uint8Array(body, contentType ?? "application/octet-stream", body.length) |
| 298 | |
| 299 | const encoder = new TextEncoder() |
| 300 | |
| 301 | // Buffer encodes UTF-8 faster than TextEncoder when available. |
| 302 | const buffer = (globalThis as { |
| 303 | readonly Buffer?: { |
| 304 | readonly from: (body: string, encoding: "utf8") => globalThis.Uint8Array |
| 305 | readonly byteLength: (body: string, encoding: "utf8") => number |
| 306 | } |
| 307 | }).Buffer |
| 308 | const encodeText: (body: string) => globalThis.Uint8Array = buffer !== undefined |
| 309 | ? (body) => buffer.from(body, "utf8") |
| 310 | : (body) => encoder.encode(body) |
| 311 | |
| 312 | /** |
| 313 | * Creates a UTF-8 encoded text HTTP body. |
| 314 | * |
| 315 | * **Details** |
| 316 | * |
| 317 | * The content type defaults to `text/plain`. Text bodies are encoded lazily. |
| 318 | * |
| 319 | * @stability unstable |
| 320 | * @category constructors |
| 321 | * @since 4.0.0 |
| 322 | */ |
| 323 | export const text = (body: string, contentType?: string): Uint8Array => { |
| 324 | if (typeof body !== "string") { |
| 325 | // Preserve untyped callers that relied on TextEncoder coercion. |
| 326 | body = body === undefined ? "" : String(body) |
| 327 | } |
| 328 | if (buffer !== undefined) { |
| 329 | return new Uint8Array(undefined, contentType ?? "text/plain", buffer.byteLength(body, "utf8"), body) |
| 330 | } |
| 331 | const bytes = encoder.encode(body) |
| 332 | return new Uint8Array(bytes, contentType ?? "text/plain", bytes.length, body) |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Creates a JSON HTTP body using `JSON.stringify`, throwing if serialization fails. |
| 337 | * |
| 338 | * **Details** |
| 339 | * |
| 340 | * The content type defaults to `application/json`. |
| 341 | * |
| 342 | * @stability unstable |
| 343 | * @category constructors |
| 344 | * @since 4.0.0 |
| 345 | */ |
| 346 | export const jsonUnsafe = (body: unknown, contentType?: string): Uint8Array => |
| 347 | text(JSON.stringify(body), contentType ?? "application/json") |
| 348 | |
| 349 | /** |
| 350 | * Creates a JSON HTTP body in an `Effect`. |
| 351 | * |
| 352 | * **Details** |
| 353 | * |
| 354 | * `JSON.stringify` failures are captured as `HttpBodyError` values, and the content type defaults to `application/json`. |
| 355 | * |
| 356 | * @stability unstable |
| 357 | * @category constructors |
| 358 | * @since 4.0.0 |
| 359 | */ |
| 360 | export const json = (body: unknown, contentType?: string): Effect.Effect<Uint8Array, HttpBodyError> => |
| 361 | Effect.try({ |
| 362 | try: () => text(JSON.stringify(body), contentType ?? "application/json"), |
| 363 | catch: (cause) => new HttpBodyError({ reason: { _tag: "JsonError" }, cause }) |
| 364 | }) |
| 365 | |
| 366 | /** |
| 367 | * Creates a JSON body constructor that first encodes values with the schema's JSON codec. |
| 368 | * |
| 369 | * **Details** |
| 370 | * |
| 371 | * Schema encoding issues and JSON serialization failures are returned as `HttpBodyError` values. |
| 372 | * |
| 373 | * @stability unstable |
| 374 | * @category constructors |
| 375 | * @since 4.0.0 |
| 376 | */ |
| 377 | export const jsonSchema = <S extends Schema.Constraint>( |
| 378 | schema: S, |
| 379 | options?: ParseOptions | undefined |
| 380 | ) => { |
| 381 | const encode = SchemaParser.encodeUnknownEffect(Schema.toCodecJson(schema)) |
| 382 | return (body: S["Type"], contentType?: string): Effect.Effect<Uint8Array, HttpBodyError, S["EncodingServices"]> => |
| 383 | encode(body, options).pipe( |
| 384 | Effect.mapError((issue) => new HttpBodyError({ reason: { _tag: "SchemaError", issue }, cause: issue })), |
| 385 | Effect.flatMap((body) => json(body, contentType)) |
| 386 | ) |
| 387 | } |
| 388 | |
| 389 | /** |
| 390 | * Creates an `application/x-www-form-urlencoded` HTTP body from `UrlParams`. |
| 391 | * |
| 392 | * @stability unstable |
| 393 | * @category constructors |
| 394 | * @since 4.0.0 |
| 395 | */ |
| 396 | export const urlParams = (urlParams: UrlParams.Input, contentType?: string): Uint8Array => |
| 397 | text(UrlParams.toString(UrlParams.fromInput(urlParams)), contentType ?? "application/x-www-form-urlencoded") |
| 398 | |
| 399 | /** |
| 400 | * HTTP body variant backed by Web `FormData`. |
| 401 | * |
| 402 | * **Details** |
| 403 | * |
| 404 | * The content type and content length are left unset so the runtime can supply multipart boundaries. |
| 405 | * |
| 406 | * @stability unstable |
| 407 | * @category models |
| 408 | * @since 4.0.0 |
| 409 | */ |
| 410 | export class FormData extends Proto { |
| 411 | readonly _tag = "FormData" |
| 412 | readonly contentType = undefined |
| 413 | readonly contentLength = undefined |
| 414 | readonly formData: globalThis.FormData |
| 415 | |
| 416 | constructor( |
| 417 | formData: globalThis.FormData |
| 418 | ) { |
| 419 | super() |
| 420 | this.formData = formData |
| 421 | } |
| 422 | toJSON(): unknown { |
| 423 | return { |
| 424 | _id: "effect/HttpBody", |
| 425 | _tag: "FormData", |
| 426 | formData: this.formData |
| 427 | } |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | /** |
| 432 | * Wraps a Web `FormData` value as an HTTP body. |
| 433 | * |
| 434 | * @stability unstable |
| 435 | * @category constructors |
| 436 | * @since 4.0.0 |
| 437 | */ |
| 438 | export const formData = (body: globalThis.FormData): FormData => new FormData(body) |
| 439 | |
| 440 | /** |
| 441 | * Record input accepted by `formDataRecord`. |
| 442 | * |
| 443 | * **Details** |
| 444 | * |
| 445 | * Each field may be a single coercible value or an array of coercible values. |
| 446 | * |
| 447 | * @stability unstable |
| 448 | * @category models |
| 449 | * @since 4.0.0 |
| 450 | */ |
| 451 | export type FormDataInput = Record<string, FormDataCoercible | ReadonlyArray<FormDataCoercible>> |
| 452 | |
| 453 | /** |
| 454 | * Value that can be appended by `formDataRecord`. |
| 455 | * |
| 456 | * **Details** |
| 457 | * |
| 458 | * `File` and `Blob` values are appended directly, primitive values are converted to strings, and `null` or `undefined` values are skipped. |
| 459 | * |
| 460 | * @stability unstable |
| 461 | * @category models |
| 462 | * @since 4.0.0 |
| 463 | */ |
| 464 | export type FormDataCoercible = string | number | boolean | globalThis.File | globalThis.Blob | null | undefined |
| 465 | |
| 466 | const appendFormDataValue = (formData: globalThis.FormData, key: string, value: FormDataCoercible): void => { |
| 467 | if (value == null) { |
| 468 | return |
| 469 | } |
| 470 | if (typeof value === "object") { |
| 471 | formData.append(key, value) |
| 472 | return |
| 473 | } |
| 474 | formData.append(key, String(value)) |
| 475 | } |
| 476 | |
| 477 | /** |
| 478 | * Creates a `FormData` HTTP body from a record. |
| 479 | * |
| 480 | * **Details** |
| 481 | * |
| 482 | * Array fields append each item under the same key; primitive values are stringified, `File` and `Blob` values are appended directly, and nullish values are skipped. |
| 483 | * |
| 484 | * @stability unstable |
| 485 | * @category constructors |
| 486 | * @since 4.0.0 |
| 487 | */ |
| 488 | export const formDataRecord = (entries: FormDataInput): FormData => { |
| 489 | const data = new globalThis.FormData() |
| 490 | for (const [key, value] of Object.entries(entries)) { |
| 491 | if (Array.isArray(value)) { |
| 492 | for (const item of value) { |
| 493 | appendFormDataValue(data, key, item) |
| 494 | } |
| 495 | } else { |
| 496 | appendFormDataValue(data, key, value as FormDataCoercible) |
| 497 | } |
| 498 | } |
| 499 | return formData(data) |
| 500 | } |
| 501 | |
| 502 | /** |
| 503 | * HTTP body variant backed by a stream of `Uint8Array` chunks. |
| 504 | * |
| 505 | * @stability unstable |
| 506 | * @category models |
| 507 | * @since 4.0.0 |
| 508 | */ |
| 509 | export class Stream extends Proto { |
| 510 | readonly _tag = "Stream" |
| 511 | readonly stream: Stream_.Stream<globalThis.Uint8Array, unknown> |
| 512 | readonly contentType: string |
| 513 | readonly contentLength: number | undefined |
| 514 | |
| 515 | constructor( |
| 516 | stream: Stream_.Stream<globalThis.Uint8Array, unknown>, |
| 517 | contentType: string, |
| 518 | contentLength: number | undefined |
| 519 | ) { |
| 520 | super() |
| 521 | this.stream = stream |
| 522 | this.contentType = contentType |
| 523 | this.contentLength = contentLength |
| 524 | } |
| 525 | toJSON(): unknown { |
| 526 | return { |
| 527 | _id: "effect/HttpBody", |
| 528 | _tag: "Stream", |
| 529 | contentType: this.contentType, |
| 530 | contentLength: this.contentLength |
| 531 | } |
| 532 | } |
| 533 | } |
| 534 | |
| 535 | /** |
| 536 | * Creates a streaming HTTP body from a stream of byte chunks. |
| 537 | * |
| 538 | * **Details** |
| 539 | * |
| 540 | * The content type defaults to `application/octet-stream`; content length is optional. |
| 541 | * |
| 542 | * @stability unstable |
| 543 | * @category constructors |
| 544 | * @since 4.0.0 |
| 545 | */ |
| 546 | export const stream = ( |
| 547 | body: Stream_.Stream<globalThis.Uint8Array, unknown>, |
| 548 | contentType?: string, |
| 549 | contentLength?: number |
| 550 | ): Stream => new Stream(body, contentType ?? "application/octet-stream", contentLength) |
| 551 | |
| 552 | const fileRangeSize = ( |
| 553 | input: ByteSize.Input, |
| 554 | field: string, |
| 555 | method: string |
| 556 | ): Effect.Effect<ByteSize.ByteSize, PlatformError.PlatformError> => { |
| 557 | const size = ByteSize.fromInput(input) |
| 558 | return Option.isSome(size) |
| 559 | ? Effect.succeed(size.value) |
| 560 | : Effect.fail(PlatformError.badArgument({ |
| 561 | module: "HttpBody", |
| 562 | method, |
| 563 | description: `Invalid ${field}: ${input}` |
| 564 | })) |
| 565 | } |
| 566 | |
| 567 | const fileContentLength = Effect.fnUntraced(function*( |
| 568 | size: ByteSize.ByteSize, |
| 569 | method: string, |
| 570 | options?: { |
| 571 | readonly bytesToRead?: ByteSize.Input | undefined |
| 572 | readonly offset?: ByteSize.Input | undefined |
| 573 | } |
| 574 | ): Effect.fn.Return<number, PlatformError.PlatformError> { |
| 575 | const offset = options?.offset === undefined ? ByteSize.zero : yield* fileRangeSize(options.offset, "offset", method) |
| 576 | const bytesToRead = options?.bytesToRead === undefined |
| 577 | ? undefined |
| 578 | : yield* fileRangeSize(options.bytesToRead, "bytesToRead", method) |
| 579 | const available = offset >= size ? BigInt("0") : size - offset |
| 580 | const length = bytesToRead === undefined || bytesToRead > available ? available : bytesToRead |
| 581 | if (length > BigInt(Number.MAX_SAFE_INTEGER)) { |
| 582 | return yield* Effect.fail(PlatformError.badArgument({ |
| 583 | module: "HttpBody", |
| 584 | method, |
| 585 | description: `Content length exceeds Number.MAX_SAFE_INTEGER: ${length}` |
| 586 | })) |
| 587 | } |
| 588 | return Number(length) |
| 589 | }) |
| 590 | |
| 591 | /** |
| 592 | * Creates a streaming HTTP body for a file path. |
| 593 | * |
| 594 | * **Details** |
| 595 | * |
| 596 | * The effect requires `FileSystem`, stats the file to set the selected content length, and can fail with |
| 597 | * `PlatformError`. |
| 598 | * |
| 599 | * Range validation and file access are deferred until the effect runs. Numeric range inputs must be |
| 600 | * non-negative safe integers; bigint and byte-size strings can represent larger values. Malformed strings, |
| 601 | * negative values, and non-finite, fractional, or unsafe numbers fail with `PlatformError` / `BadArgument`. |
| 602 | * |
| 603 | * The selected length is calculated exactly with bigint arithmetic and clamped to the bytes available after |
| 604 | * `offset` (zero at or past EOF). Since `Stream.contentLength` is a number, a final length above |
| 605 | * `Number.MAX_SAFE_INTEGER` fails with `BadArgument`. Larger sizes, offsets, and byte counts are valid when |
| 606 | * the final clamped length is representable; the resulting Content-Length header preserves that exact length. |
| 607 | * |
| 608 | * @stability unstable |
| 609 | * @category constructors |
| 610 | * @since 4.0.0 |
| 611 | */ |
| 612 | export const file = ( |
| 613 | path: string, |
| 614 | options?: { |
| 615 | readonly bytesToRead?: ByteSize.Input | undefined |
| 616 | readonly chunkSize?: number | undefined |
| 617 | readonly offset?: ByteSize.Input | undefined |
| 618 | readonly contentType?: string | undefined |
| 619 | } |
| 620 | ): Effect.Effect<Stream, PlatformError.PlatformError, FileSystem.FileSystem> => |
| 621 | Effect.flatMap( |
| 622 | FileSystem.FileSystem, |
| 623 | (fs) => |
| 624 | Effect.flatMap(fs.stat(path), (info) => |
| 625 | Effect.map(fileContentLength(info.size, "file", options), (contentLength) => |
| 626 | stream( |
| 627 | fs.stream(path, options), |
| 628 | options?.contentType, |
| 629 | contentLength |
| 630 | ))) |
| 631 | ) |
| 632 | |
| 633 | /** |
| 634 | * Creates a streaming HTTP body for a file path using already-known file information. |
| 635 | * |
| 636 | * **Details** |
| 637 | * |
| 638 | * The effect requires `FileSystem`, uses the provided file size to determine the selected content length, and can |
| 639 | * fail with `PlatformError`. |
| 640 | * |
| 641 | * Like {@link file}, this constructor validates ranges lazily and calculates the EOF-clamped length with exact |
| 642 | * bigint arithmetic. Invalid range inputs and final lengths above `Number.MAX_SAFE_INTEGER` fail with |
| 643 | * `PlatformError` / `BadArgument`. Larger sizes, offsets, and byte counts remain valid when the final length |
| 644 | * is representable as a safe integer, preserving the exact Content-Length header. |
| 645 | * |
| 646 | * @stability unstable |
| 647 | * @category constructors |
| 648 | * @since 4.0.0 |
| 649 | */ |
| 650 | export const fileFromInfo = ( |
| 651 | path: string, |
| 652 | info: FileSystem.File.Info, |
| 653 | options?: { |
| 654 | readonly bytesToRead?: ByteSize.Input | undefined |
| 655 | readonly chunkSize?: number | undefined |
| 656 | readonly offset?: ByteSize.Input | undefined |
| 657 | readonly contentType?: string | undefined |
| 658 | } |
| 659 | ): Effect.Effect<Stream, PlatformError.PlatformError, FileSystem.FileSystem> => |
| 660 | Effect.flatMap( |
| 661 | FileSystem.FileSystem, |
| 662 | (fs) => |
| 663 | Effect.map(fileContentLength(info.size, "fileFromInfo", options), (contentLength) => |
| 664 | stream( |
| 665 | fs.stream(path, options), |
| 666 | options?.contentType, |
| 667 | contentLength |
| 668 | )) |
| 669 | ) |