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 */
15import * as ByteSize from "../ByteSize.ts"
16import * as Data from "../Data.ts"
17import * as Effect from "../Effect.ts"
18import * as FileSystem from "../FileSystem.ts"
19import { format } from "../Formatter.ts"
20import * as Inspectable from "../Inspectable.ts"
21import * as Option from "../Option.ts"
22import * as PlatformError from "../PlatformError.ts"
23import * as Predicate from "../Predicate.ts"
24import * as Schema from "../Schema.ts"
25import type { ParseOptions } from "../SchemaAST.ts"
26import type { Issue } from "../SchemaIssue.ts"
27import * as SchemaParser from "../SchemaParser.ts"
28import type * as Stream_ from "../Stream.ts"
29import * as UrlParams from "./UrlParams.ts"
30
31const 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 */
40export 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 */
53export 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 */
61export 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
96const 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 */
105export 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 */
125export 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 */
138export type ErrorReason = {
139 readonly _tag: "JsonError"
140} | {
141 readonly _tag: "SchemaError"
142 readonly issue: Issue
143}
144
145abstract 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 */
167export 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 */
188export 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 */
197export 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 */
231export 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 */
250export 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 */
296export const uint8Array = (body: globalThis.Uint8Array, contentType?: string): Uint8Array =>
297 new Uint8Array(body, contentType ?? "application/octet-stream", body.length)
298
299const encoder = new TextEncoder()
300
301// Buffer encodes UTF-8 faster than TextEncoder when available.
302const 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
308const 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 */
323export 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 */
346export 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 */
360export 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 */
377export 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 */
396export 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 */
410export 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 */
438export 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 */
451export 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 */
464export type FormDataCoercible = string | number | boolean | globalThis.File | globalThis.Blob | null | undefined
465
466const 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 */
488export 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 */
509export 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 */
546export 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
552const 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
567const 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 */
612export 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 */
650export 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 )