Skip to content

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.

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 routes
const 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.

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"; pass index: undefined to disable directory serving.
  • spa — when true, a request for a path with no extension whose Accept header includes text/html and 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 become application/octet-stream.

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 requestsIf-None-Match (ETag) and If-Modified-Since return 304 Not Modified with the validators echoed back.
  • Range requests — a Range: bytes=... header produces a 206 Partial Content response with the right Content-Range, or 416 when unsatisfiable.

These rely on file metadata supplied by the platform (HttpPlatform), which the platform server layer provides automatically.

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.

To send a single file (a download, a generated report) without a static directory, build the response directly.

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 body
const 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 })

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>

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.

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" }

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\""

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)
})

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 tags
const WeakEtags = Etag.layerWeak // Layer<Etag.Generator> — weak tags

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 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)

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()
})

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.Part
part._tag // => "Field" | "File"

A text part with a decoded string value, plus key (field name) and contentType.

import { Multipart } from "effect/unstable/http"
declare const field: Multipart.Field
field.key // => "title"
field.value // => "Hello"
field.contentType // => "text/plain"

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.File
const bytes = file.contentEffect // => Effect<Uint8Array, MultipartError> (buffers the file)
// file.content => Stream<Uint8Array, MultipartError> (stream it instead)

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.PersistedFile
file.path // => "/tmp/.../avatar.png"
file.name // => "avatar.png"
file.contentType // => "image/png"

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: unknown
Multipart.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?)

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
})

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[]
})

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
})

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.Persisted
const result = decode(data) // => Effect<{ name: string; file: PersistedFile }, SchemaError>

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.Persisted
const meta = parseMeta(data) // => Effect<{ tags: string[] }, SchemaError> (parses data["meta"] as JSON)

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
})

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>, ...>

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.File
const all = file.contentEffect // built on collectUint8Array
// => Effect<Uint8Array, MultipartError>

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>

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.

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 limits

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 MiB

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 undefined

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 undefined

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"]

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 })
)
)

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.MultipartError
switch (error.reason._tag) {
case "FileTooLarge": /* 413 */ break
case "TooManyParts": /* 413 */ break
case "Parse": /* 400 */ break
// "FieldTooLarge" | "BodyTooLarge" | "InternalError"
}