Static Files & Multipart
Two related jobs live on this page: sending files down to clients (a static
asset directory, a one-off download) and receiving files from clients
(multipart/form-data uploads). All of the APIs live in the unstable HTTP
modules under effect/unstable/http.
Serving a static directory
Section titled “Serving a static directory”HttpStaticServer.layer mounts a directory onto your HttpRouter so every
GET under a URL prefix is served from disk. This is the common case.
import { Layer } from "effect"import { HttpRouter, HttpServerResponse, HttpStaticServer } from "effect/unstable/http"
// Your normal API routesconst ApiLayer = HttpRouter.add("GET", "/health", HttpServerResponse.text("ok"))
// Serve ./public under /static (GET /static/app.css -> ./public/app.css)const StaticLayer = HttpStaticServer.layer({ root: "./public", prefix: "/static", cacheControl: "public, max-age=3600"})
const AppLayer = Layer.mergeAll(ApiLayer, StaticLayer)HttpStaticServer.layer adds a GET route at prefix + "/*" (or "/*" at the
root when no prefix is given). It only handles GET — wire up other methods
yourself. The handler resolves the request path below root: the path is
URL-decoded and normalized, and requests that escape root via .., null
bytes, or invalid encoding fail as route-not-found.
Options
Section titled “Options”All layer / make options:
HttpStaticServer.layer({ root: "./public", // directory to serve (resolved to an absolute path) prefix: "/static", // URL prefix (layer only); routes mount at prefix + "/*" index: "index.html", // file served for directory requests (default "index.html") spa: true, // SPA fallback: serve `index` for extensionless HTML GETs cacheControl: "max-age=3600", // value for the Cache-Control header (optional) mimeTypes: { glb: "model/gltf-binary" } // extra/override extension -> Content-Type entries})index— the file served when the resolved path is a directory. Defaults to"index.html"; passindex: undefinedto disable directory serving.spa— whentrue, a request for a path with no extension whoseAcceptheader includestext/htmland that does not match a file falls back to the index file. This is the standard single-page-app routing pattern.mimeTypes— merged over a built-in table (html, css, js, json, png, svg, woff2, mp4, wasm, …). The extension key has no leading dot. Unknown extensions becomeapplication/octet-stream.
Built-in HTTP semantics
Section titled “Built-in HTTP semantics”The static handler does more than read file -> respond. For every served file
it sets Content-Type (from the extension), Accept-Ranges: bytes, and
Cache-Control if configured, and it honors:
- Conditional requests —
If-None-Match(ETag) andIf-Modified-Sincereturn304 Not Modifiedwith the validators echoed back. - Range requests — a
Range: bytes=...header produces a206 Partial Contentresponse with the rightContent-Range, or416when unsatisfiable.
These rely on file metadata supplied by the platform (HttpPlatform), which the
platform server layer provides automatically.
HttpStaticServer.make — the app value
Section titled “HttpStaticServer.make — the app value”When you need the handler as a value (to compose it manually, run conditionally,
or use outside a router), use make. It returns an Effect that yields an
HttpApp (an Effect that reads the HttpServerRequest and produces a
response).
import { Effect } from "effect"import { HttpServerRequest, HttpStaticServer } from "effect/unstable/http"
const program = Effect.gen(function* () { const staticApp = yield* HttpStaticServer.make({ root: "./public", spa: true })
// `staticApp` requires an HttpServerRequest in context; run it inside a handler // or provide a request to invoke it directly. return staticApp})
// `make` requires FileSystem | Path | HttpPlatform; it fails with PlatformError// while resolving the root, and the returned app fails with HttpServerError.layer is just make plus a router.add("GET", "/*", ...) with errors mapped
to responses, so prefer layer unless you need the raw value.
One-off file responses
Section titled “One-off file responses”To send a single file (a download, a generated report) without a static directory, build the response directly.
HttpServerResponse.file
Section titled “HttpServerResponse.file”Streams a filesystem path as the response body. Requires HttpPlatform, fails
with PlatformError, and supports status, headers, and byte-range options.
import { Effect } from "effect"import { HttpServerResponse } from "effect/unstable/http"
// Stream ./reports/q1.pdf as the response bodyconst download = HttpServerResponse.file("./reports/q1.pdf", { headers: { "Content-Disposition": 'attachment; filename="q1.pdf"' }})// => Effect<HttpServerResponse, PlatformError, HttpPlatform>
// Serve only bytes 0..1023 (e.g. a manual range implementation)const head = HttpServerResponse.file("./video.mp4", { offset: 0, bytesToRead: 1024 })HttpServerResponse.fileWeb
Section titled “HttpServerResponse.fileWeb”Same idea for a Web File-like value (anything with size / lastModified /
stream()), e.g. a File you received elsewhere. Requires HttpPlatform and
cannot fail.
import { Effect } from "effect"import { HttpServerResponse } from "effect/unstable/http"
declare const upload: File // a Web File
const respond = HttpServerResponse.fileWeb(upload)// => Effect<HttpServerResponse, never, HttpPlatform>HttpBody.file / HttpBody.fileFromInfo
Section titled “HttpBody.file / HttpBody.fileFromInfo”If you only need the streaming body of a file (to compose it yourself),
HttpBody.file produces an HttpBody.Stream and stats the file to set the
content length. fileFromInfo skips the extra stat when you already hold the
FileSystem.File.Info.
import { Effect } from "effect"import { HttpBody } from "effect/unstable/http"
const program = Effect.gen(function* () { const body = yield* HttpBody.file("./logo.png", { contentType: "image/png" }) // => HttpBody.Stream (requires FileSystem; fails with PlatformError) return body})For most responses prefer HttpServerResponse.file above, which sets the body
and requires HttpPlatform directly.
Etag formats HTTP entity-tag values and provides a Generator service that
derives tags from file metadata. The static server already does conditional
handling for you; reach for this module when you implement caching by hand.
Etag.Etag, Etag.Weak, Etag.Strong
Section titled “Etag.Etag, Etag.Weak, Etag.Strong”The model is a tagged union. A strong tag asserts byte-for-byte identity; a
weak tag is only a cache-revalidation hint. value is the raw tag without
quotes or the W/ prefix.
import type { Etag } from "effect/unstable/http"
const strong: Etag.Strong = { _tag: "Strong", value: "33a64df5" }const weak: Etag.Weak = { _tag: "Weak", value: "33a64df5" }Etag.toString
Section titled “Etag.toString”Formats an Etag as an HTTP header value, adding quotes and the W/ prefix.
import { Etag } from "effect/unstable/http"
Etag.toString({ _tag: "Strong", value: "abc" }) // => "\"abc\""Etag.toString({ _tag: "Weak", value: "abc" }) // => "W/\"abc\""Etag.Generator
Section titled “Etag.Generator”A Context.Service with fromFileInfo (derives a tag from FileSystem.File.Info)
and fromFileWeb (from a Web File-like value). Both return Effect<Etag>.
import { Effect } from "effect"import { Etag } from "effect/unstable/http"import { FileSystem } from "effect"
const tagFor = Effect.gen(function* () { const gen = yield* Etag.Generator const fs = yield* FileSystem.FileSystem const info = yield* fs.stat("./logo.png") const etag = yield* gen.fromFileInfo(info) return Etag.toString(etag) // => "\"<size>-<mtime>\"" (hex size + hex mtime)})Etag.layer / Etag.layerWeak
Section titled “Etag.layer / Etag.layerWeak”Provide the Generator. layer marks metadata-derived tags as strong;
layerWeak marks them weak. Use layerWeak when size + mtime are good cache
validators but not a byte-for-byte guarantee.
import { Etag } from "effect/unstable/http"
const StrongEtags = Etag.layer // Layer<Etag.Generator> — strong tagsconst WeakEtags = Etag.layerWeak // Layer<Etag.Generator> — weak tagsReceiving uploads: Multipart
Section titled “Receiving uploads: Multipart”multipart/form-data is how browsers submit forms with file inputs. The
Multipart module turns the incoming byte stream into typed Part values: text
inputs become Field values; file inputs become streamed File values that you
either read once or persist to scoped temp files.
The common case: schemaBodyMultipart
Section titled “The common case: schemaBodyMultipart”The high-level path. HttpServerRequest.schemaBodyMultipart persists every part
to a scoped temporary directory, then decodes the resulting record with a
Schema. Combine Schema.String fields with Multipart.SingleFileSchema /
Multipart.FilesSchema to validate the whole form at once.
import { Effect, Schema } from "effect"import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"import { Multipart } from "effect/unstable/http"
// Expect: a text field `title` and one uploaded file under `avatar`.const UploadForm = Schema.Struct({ title: Schema.String, avatar: Multipart.SingleFileSchema // decodes a 1-element file array to one PersistedFile})
const handler = Effect.gen(function* () { const form = yield* HttpServerRequest.schemaBodyMultipart(UploadForm) // form.title => string // form.avatar => Multipart.PersistedFile { key, name, contentType, path } return HttpServerResponse.text(`saved ${form.avatar.name} to ${form.avatar.path}`)})
// schemaBodyMultipart requires HttpServerRequest | Scope | FileSystem | Path// and fails with MultipartError | SchemaError.const UploadRoute = HttpRouter.add("POST", "/upload", handler)Streaming parts: request.multipartStream
Section titled “Streaming parts: request.multipartStream”For large uploads you may not want to buffer to disk. multipartStream emits
Part values as the parser reaches them, so you can pipe each File.content
straight to its destination.
import { Effect, Stream } from "effect"import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"import { Multipart } from "effect/unstable/http"
const stream = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest yield* Stream.runForEach(request.multipartStream, (part) => { if (Multipart.isField(part)) { return Effect.log(`field ${part.key} = ${part.value}`) } // part is a File: stream its bytes somewhere (each File.content is one-shot) return Effect.log(`file ${part.name} (${part.contentType})`) }) return HttpServerResponse.empty()})Multipart reference
Section titled “Multipart reference”Multipart.Part
Section titled “Multipart.Part”The union Field | File emitted by the parser. Every part shares a _tag and
the multipart TypeId brand.
import { Multipart } from "effect/unstable/http"
declare const part: Multipart.Partpart._tag // => "Field" | "File"Multipart.Field
Section titled “Multipart.Field”A text part with a decoded string value, plus key (field name) and
contentType.
import { Multipart } from "effect/unstable/http"
declare const field: Multipart.Fieldfield.key // => "title"field.value // => "Hello"field.contentType // => "text/plain"Multipart.File
Section titled “Multipart.File”A streamed upload. content is a one-shot Stream<Uint8Array, MultipartError>;
contentEffect collects the whole file into memory (use only for small files).
name is the client filename, key is the form field name.
import { Effect } from "effect"import { Multipart } from "effect/unstable/http"
declare const file: Multipart.Fileconst bytes = file.contentEffect // => Effect<Uint8Array, MultipartError> (buffers the file)// file.content => Stream<Uint8Array, MultipartError> (stream it instead)Multipart.PersistedFile
Section titled “Multipart.PersistedFile”A File that has been written to disk by persistence. path points at the
file while the request scope is open.
import { Multipart } from "effect/unstable/http"
declare const file: Multipart.PersistedFilefile.path // => "/tmp/.../avatar.png"file.name // => "avatar.png"file.contentType // => "image/png"Multipart.Persisted
Section titled “Multipart.Persisted”The record produced by persistence: field names map to a string, an array of
strings (repeated fields), or an array of PersistedFile.
import { Multipart } from "effect/unstable/http"
declare const data: Multipart.Persisted// data["title"] => string | string[]// data["avatar"] => PersistedFile[]Guards: isPart / isField / isFile / isPersistedFile
Section titled “Guards: isPart / isField / isFile / isPersistedFile”Narrow unknown (or a Part) to the concrete variant.
import { Multipart } from "effect/unstable/http"
declare const u: unknownMultipart.isPart(u) // => boolean (is it any multipart part?)Multipart.isField(u) // => boolean (is it a text Field?)Multipart.isFile(u) // => boolean (is it a streamed File?)Multipart.isPersistedFile(u) // => boolean (is it a PersistedFile?)Multipart.PersistedFileSchema
Section titled “Multipart.PersistedFileSchema”A Schema.declare for a single PersistedFile. Encodes to/decodes from
{ key, name, contentType, path }. Use it to embed a file in a larger struct.
import { Schema } from "effect"import { Multipart } from "effect/unstable/http"
const Form = Schema.Struct({ note: Schema.String, attachment: Multipart.PersistedFileSchema // one PersistedFile})Multipart.FilesSchema
Section titled “Multipart.FilesSchema”Schema.Array(PersistedFileSchema) — decodes a field that may carry many files
(e.g. <input type="file" multiple>).
import { Schema } from "effect"import { Multipart } from "effect/unstable/http"
const Gallery = Schema.Struct({ images: Multipart.FilesSchema // PersistedFile[]})Multipart.SingleFileSchema
Section titled “Multipart.SingleFileSchema”Requires exactly one file and decodes to a single PersistedFile (it checks the
array length is 1, then unwraps it).
import { Schema } from "effect"import { Multipart } from "effect/unstable/http"
const Form = Schema.Struct({ avatar: Multipart.SingleFileSchema // exactly one PersistedFile, unwrapped})Multipart.schemaPersisted
Section titled “Multipart.schemaPersisted”Builds a decoder (input, options?) => Effect<A, SchemaError, RD> for a
Persisted-shaped schema. This is the engine behind
HttpServerRequest.schemaBodyMultipart; use it directly when you already hold a
Persisted value.
import { Schema } from "effect"import { Multipart } from "effect/unstable/http"
const decode = Multipart.schemaPersisted( Schema.Struct({ name: Schema.String, file: Multipart.SingleFileSchema }))
declare const data: Multipart.Persistedconst result = decode(data) // => Effect<{ name: string; file: PersistedFile }, SchemaError>Multipart.schemaJson
Section titled “Multipart.schemaJson”Decodes a single field whose value is a JSON string. Useful when a form ships
structured data alongside files. Curried by field name or called with
(persisted, field).
import { Schema } from "effect"import { Multipart } from "effect/unstable/http"
const Meta = Schema.Struct({ tags: Schema.Array(Schema.String) })const parseMeta = Multipart.schemaJson(Meta)("meta")
declare const data: Multipart.Persistedconst meta = parseMeta(data) // => Effect<{ tags: string[] }, SchemaError> (parses data["meta"] as JSON)Multipart.toPersisted
Section titled “Multipart.toPersisted”Drains a stream of Part values into a Persisted record, writing each file to
a scoped temp directory. This is what request.multipart calls. Pass a custom
writeFile to control where files land.
import { Effect } from "effect"import { HttpServerRequest } from "effect/unstable/http"import { Multipart } from "effect/unstable/http"
const persist = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest const persisted = yield* Multipart.toPersisted(request.multipartStream) // => Persisted (requires FileSystem | Path | Scope, fails with MultipartError) return persisted})Multipart.makeChannel
Section titled “Multipart.makeChannel”The low-level parser: a Channel that consumes batches of Uint8Array request
chunks and emits batches of parsed Part values. makeChannel and
multipartStream are what you build everything else on; reach for it only for
custom transports.
import { Multipart } from "effect/unstable/http"
declare const headers: Record<string, string>const channel = Multipart.makeChannel(headers)// => Channel<NonEmptyReadonlyArray<Part>, MultipartError, void, NonEmptyReadonlyArray<Uint8Array>, ...>Multipart.collectUint8Array
Section titled “Multipart.collectUint8Array”Runs a channel of byte chunks and folds it into one Uint8Array. Materializes
the full content in memory — it backs File.contentEffect.
import { Effect } from "effect"import { Multipart } from "effect/unstable/http"
declare const file: Multipart.Fileconst all = file.contentEffect // built on collectUint8Array// => Effect<Uint8Array, MultipartError>Multipart.makeConfig
Section titled “Multipart.makeConfig”Reads the parser limits out of the current fiber context (the Max* references
below plus FieldMimeTypes) and produces the low-level config from request
headers. Mostly internal; useful if you wire up makeChannel yourself.
import { Multipart } from "effect/unstable/http"
declare const headers: Record<string, string>const config = Multipart.makeConfig(headers) // => Effect<MP.BaseConfig>Multipart limits
Section titled “Multipart limits”Parser limits are read from Context.References, so you tune them by providing a
context layer. The defaults: MaxFieldSize 10 MiB, MaxParts/MaxFileSize
unlimited, and FieldMimeTypes treats application/json parts as fields. When a
limit is exceeded the parser fails with the matching MultipartError reason.
Multipart.limitsServices
Section titled “Multipart.limitsServices”Builds a Context containing limit overrides; provide it to a route or app.
This is the ergonomic way to set limits.
import { Layer } from "effect"import { HttpRouter } from "effect/unstable/http"import { Multipart } from "effect/unstable/http"
const limits = Multipart.limitsServices({ maxParts: 20, maxFileSize: "5 MB", // FileSystem.SizeInput maxFieldSize: "1 MB", maxTotalSize: "25 MB", fieldMimeTypes: ["application/json", "text/plain"]})
const LimitsLayer = Layer.succeedContext(limits)// merge LimitsLayer into your HttpRouter app to apply these limitsMultipart.MaxFieldSize
Section titled “Multipart.MaxFieldSize”Context.Reference<FileSystem.SizeInput> for the largest text field value.
Default 10 MiB. Exceeding it fails with reason "FieldTooLarge".
import { Multipart } from "effect/unstable/http"
Multipart.MaxFieldSize // Context.Reference, default 10 MiBMultipart.MaxFileSize
Section titled “Multipart.MaxFileSize”Context.Reference<FileSystem.SizeInput | undefined> for the largest single
file. Default undefined (unlimited). Exceeding it fails with "FileTooLarge".
import { Multipart } from "effect/unstable/http"
Multipart.MaxFileSize // Context.Reference, default undefinedMultipart.MaxParts
Section titled “Multipart.MaxParts”Context.Reference<number | undefined> for the maximum number of parts. Default
undefined (unlimited). Exceeding it fails with "TooManyParts".
import { Multipart } from "effect/unstable/http"
Multipart.MaxParts // Context.Reference, default undefinedMultipart.FieldMimeTypes
Section titled “Multipart.FieldMimeTypes”Context.Reference<ReadonlyArray<string>> — MIME-type fragments whose parts are
parsed as text fields rather than files. Default ["application/json"].
import { Multipart } from "effect/unstable/http"
Multipart.FieldMimeTypes // Context.Reference, default ["application/json"]Multipart errors
Section titled “Multipart errors”Multipart.MultipartError
Section titled “Multipart.MultipartError”A Data.TaggedError("MultipartError") carrying a reason. Catch it with
Effect.catchTag("MultipartError", ...); its message is the reason tag.
import { Effect } from "effect"import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"import { Multipart } from "effect/unstable/http"
declare const UploadForm: any
const handler = HttpServerRequest.schemaBodyMultipart(UploadForm).pipe( Effect.catchTag("MultipartError", (error) => HttpServerResponse.text(`upload failed: ${error.reason._tag}`, { status: 413 }) ))Multipart.MultipartErrorReason
Section titled “Multipart.MultipartErrorReason”A Data.Error whose _tag is one of "FileTooLarge", "FieldTooLarge",
"BodyTooLarge", "TooManyParts", "InternalError", or "Parse". Inspect it
to map limit failures to specific status codes.
import { Multipart } from "effect/unstable/http"
declare const error: Multipart.MultipartErrorswitch (error.reason._tag) { case "FileTooLarge": /* 413 */ break case "TooManyParts": /* 413 */ break case "Parse": /* 400 */ break // "FieldTooLarge" | "BodyTooLarge" | "InternalError"}See also
Section titled “See also”- /platform/file-system/ — the
FileSystemservice used to stat/stream files. - /platform/path/ — the
Pathservice used to resolve paths safely.