Sign in

    moonpart

    Streaming-oriented multipart/form-data parser and encoder for MoonBit.

    multipart
    form-data
    http
    upload
    rfc7578
    Download zip
    Author
    Version
    0.1.0
    License
    Apache-2.0
    Last updated
    last month
    Downloads
    24

    No dependencies

    #MoonPart

    CI License

    MoonPart is a pure MoonBit multipart/form-data core library. It implements a small, strict RFC 7578-oriented parser and encoder for HTTP form bodies.

    #Installation

    After the 0.1.0 release is published to mooncakes.io:

    moon add eisem/moonpart@0.1.0

    Before publication, clone this repository and run the example and tests from the repository root.

    #Current status

    The library includes both a convenient text collector and StreamingParser, a byte-preserving event parser. StreamingParser::feed_bytes accepts arbitrary network chunks and emits PartBegin, PartData, PartEnd, and Finished events as soon as they are known. It keeps only a possible-boundary suffix, rather than retaining whole uploaded files.

    StreamingEncoder performs the inverse operation. It converts the same events into immediately writable Bytes chunks and passes PartData through without text conversion or whole-body buffering.

    StreamLimits bounds the complete wire body, header blocks, number of parts, and each part body. This is suitable for HTTP handlers that must reject hostile or accidental oversized uploads early.

    Each decoded Part retains headers and exposes name, filename, is_file, and content_type convenience accessors. Missing part Content-Type values use the RFC 7578 text/plain default.

    For uploaded files, prefer decode_bytes. It returns StreamEvent values and keeps every part body as Bytes, so arbitrary binary data is preserved:

    ///|
    test "read a binary file field" {
    let body = b"--bin\r\nContent-Disposition: form-data; name=\"file\"; filename=\"raw.bin\"\r\n\r\n\x00\xff\r\n--bin--\r\n"
    match @moonpart.decode_bytes("bin", body) {
    Ok(
    [
    @moonpart.StreamEvent::PartBegin(_),
    @moonpart.StreamEvent::PartData(data),
    ..,
    ]
    ) => assert_eq(data.length(), 2)
    _ => fail("invalid multipart body")
    }
    }

    #Example

    ///|
    test "parse one field" {
    let parser = @moonpart.Parser::new("MyBoundary")
    ignore(
    parser.feed(
    "--MyBoundary\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nMoonBit\r\n--MyBoundary--\r\n",
    ),
    )
    match parser.finish() {
    Ok(parts) => println(parts[0].body)
    Err(_) => println("invalid multipart body")
    }
    }

    #Streaming an upload

    ///|
    test "handle an upload incrementally" {
    let parser = @moonpart.StreamingParser::from_content_type(
    "multipart/form-data; boundary=upload",
    )
    guard parser is Ok(parser) else { fail("invalid content type") }
    let chunks = [
    b"--upload\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.bin\"\r\n\r\nabc",
    b"\r\n--upload--\r\n",
    ]
    for chunk in chunks {
    guard parser.feed_bytes(chunk) is Ok(events) else {
    fail("invalid multipart body")
    }
    for event in events {
    match event {
    @moonpart.StreamEvent::PartData(data) =>
    // Write data to a file, socket, or other application sink here.
    assert_true(data.length() > 0)
    _ => ()
    }
    }
    }
    assert_true(parser.finish() is Ok(_))
    }

    For small forms, feed the same events to FormDataCollector; it preserves the wire order and repeated field names while exposing text fields and raw file bytes. Its configurable per-part memory limit makes its buffering explicit.

    Submitted filenames are untrusted input. filename_basename removes Unix and Windows directory components, but applications should still generate their own server-side storage paths rather than writing directly to a submitted name.

    For large uploads, use StreamingFormCollector. Its callback receives FileSinkEvent::Begin, incremental Data(Bytes) chunks, and End. The collector retains text fields and file metadata only; applications can forward file chunks directly to a filesystem, object store, hash function, or network sink without constructing the complete file in memory.

    StreamingFormDecoder connects content-type boundary parsing, incremental body decoding, limits, field validation, and sink delivery behind one feed_bytes API for HTTP handlers.

    Use FieldSizeLimit when a specific field needs a stricter cap than the global text or file limit, for example a short title alongside a larger asset.

    #Streaming an encoded body

    Create StreamEvent values in wire order and write each returned chunk directly to an HTTP request body, file, or application sink:

    ///|
    test "encode binary data incrementally" {
    let encoder = @moonpart.StreamingEncoder::new("out")
    let chunks = []
    let events = [
    @moonpart.StreamEvent::PartBegin([
    @moonpart.Header::{
    name: "Content-Disposition",
    value: "form-data; name=\"file\"; filename=\"a.bin\"",
    },
    ]),
    @moonpart.StreamEvent::PartData(b"\x00\xff"),
    @moonpart.StreamEvent::PartEnd,
    @moonpart.StreamEvent::Finished,
    ]
    for event in events {
    guard encoder.push(event) is Ok(chunk) else {
    fail("invalid encoder event")
    }
    chunks.push(chunk)
    }
    assert_true(chunks[1] == b"\x00\xff")
    assert_true(chunks[3] == b"--out--\r\n")
    }

    StreamingEncoder::content_type returns the matching quoted Content-Type value. Invalid boundaries, unsafe headers, and out-of-order events are rejected; after an error the encoder remains failed.

    #Choosing an API

    Use caseAPIBody storage
    Small text-oriented formsParserComplete body as String
    Binary body decoded at oncedecode_bytesEvents contain raw Bytes
    Incremental protocol parsingStreamingParserPossible-boundary suffix only
    Small forms collected in memoryFormDataCollectorText and file bodies
    Large files sent to a sinkStreamingFormDecoderText fields and file metadata
    Incremental binary encodingStreamingEncoderCurrent header chunk only

    Run the complete streaming example with:

    moon run cmd/main

    #Scope and non-goals

    MoonPart owns multipart/form-data boundary parsing, strict part-header parsing, encoding, resource limits, form collection, and sink-oriented file delivery. It deliberately does not provide an HTTP server/client, asynchronous filesystem I/O, temporary-file management, MIME sniffing, antivirus scanning, or trusted storage paths. Integrations should supply request chunks and decide how emitted file bytes are persisted.

    The current release targets RFC 7578 form-data behavior. It is not a general recursive MIME message parser and does not decode content-transfer encodings.

    #Security

    All limits should be selected for the surrounding HTTP service. Submitted filenames, media types, headers, and text values remain untrusted. See SECURITY.md for the supported security model and vulnerability reporting process.

    #Development

    moon fmt moon info moon check --target all moon build --target all moon test --target all moon coverage analyze moon bench --target native moon run cmd/main

    Continuous integration checks formatting and generated interfaces, then builds and tests the Wasm, Wasm-GC, JavaScript, and native backends. It also runs MoonBit's coverage analyzer on every push and pull request.

    The implementation design is documented in docs/DESIGN.md. See CONTRIBUTING.md for the change workflow and CHANGELOG.md for release notes. The latest reproducible competition checklist is in docs/ACCEPTANCE.md.

    The implementation is original and takes RFC 7578 and RFC 2046 as behavioral references. It is licensed under Apache-2.0.

    MultipartError

    pub(all) suberror MultipartError {
    InvalidBoundary
    InvalidContentType
    BodyTooLarge(Int)
    MissingOpeningBoundary
    MissingHeaderTerminator
    InvalidHeader(String)
    DuplicateHeader(String)
    MissingContentDisposition
    InvalidContentDisposition
    MissingFieldName
    HeaderTooLarge(Int)
    HeaderCountExceeded(Int)
    PartCountExceeded(Int)
    PartTooLarge(Int)
    InvalidStreamEvent
    FieldTooLarge(Int)
    FileTooLarge(Int)
    FieldCountExceeded(Int)
    FileCountExceeded(Int)
    UnexpectedField(String)
    SinkFailure(String)
    MissingClosingBoundary
    UnexpectedBoundary
    } derive(
    Debug
    )

    Errors reported when a multipart body is malformed or exceeds its limit.

    EncoderState

    type EncoderState

    FieldSizeLimit

    pub(all) struct FieldSizeLimit {
    name : String
    max_size : Int
    } derive(Eq,
    Debug
    )

    A size override for one named form field. It applies to both text and file parts and can only make the corresponding global limit stricter.

    FileSinkEvent

    pub(all) enum FileSinkEvent {
    Begin(StreamedFile)
    Data(Bytes)
    End
    } derive(
    Debug
    )

    Events sent to an application-provided file sink. Each Begin is followed by zero or more Data events and exactly one End event.

    FormData

    pub(all) struct FormData {
    entries : Array[FormEntry]
    } derive(
    Debug
    )

    Collected multipart/form-data entries in original wire order.

    FormData::file

    fn FormData::file(self : FormData, name : String) -> UploadedFile?

    Returns the first uploaded file with the supplied field name.

    FormData::files

    fn FormData::files(self : FormData, name : String) -> Array[UploadedFile]

    Returns every uploaded file with the supplied field name in wire order.

    FormData::text

    fn FormData::text(self : FormData, name : String) -> String?

    Returns the first text value with the supplied field name.

    FormData::text_all

    fn FormData::text_all(self : FormData, name : String) -> Array[String]

    Returns every text value with the supplied field name in wire order.

    FormDataCollector

    pub struct FormDataCollector {
    entries : Array[FormEntry]
    buffer :
    Buffer

    limits : FormDataLimits
    allowed_fields : Array[String]?
    field_size_limits : Array[FieldSizeLimit]
    current_part : FormPartMetadata?
    current_size : Int
    field_count : Int
    file_count : Int
    finished : Bool
    }

    Event consumer that builds an in-memory FormData value from a StreamingParser. Use a future sink-based collector for very large files.

    FormDataCollector::finish

    Returns the final collected data once the Finished event has been consumed.

    FormDataCollector::new

    FormDataCollector::push

    fn FormDataCollector::push(self : FormDataCollector, event : StreamEvent) -> Result[Unit, MultipartError]

    Consumes one streaming event.

    FormDataCollector::with_allowed_fields

    fn FormDataCollector::with_allowed_fields(allowed_fields : Array[String], limits? : FormDataLimits) -> FormDataCollector

    Creates a collector that rejects any part whose form field name is not in allowed_fields. Repeated occurrences of an allowed name remain valid.

    FormDataCollector::with_field_size_limits

    fn FormDataCollector::with_field_size_limits(limits : FormDataLimits, field_size_limits : Array[FieldSizeLimit]) -> FormDataCollector

    Creates a collector with global limits plus stricter per-field size limits.

    FormDataCollector::with_file_size_limit

    fn FormDataCollector::with_file_size_limit(max_file_size : Int) -> FormDataCollector

    Creates an in-memory collector with a maximum size for one part body.

    FormDataCollector::with_limits

    Creates an in-memory collector with separate text-field and file limits.

    FormDataLimits

    pub(all) struct FormDataLimits {
    max_field_size : Int
    max_file_size : Int
    max_field_count : Int
    max_file_count : Int
    }

    Resource bounds for the in-memory form collector. Text and file limits are separate because applications commonly accept many small fields but only a small number of larger uploads.

    FormDataLimits::default

    FormEntry

    pub(all) enum FormEntry {
    Text(TextField)
    File(UploadedFile)
    } derive(
    Debug
    )

    A form entry in original wire order. Repeated names are intentionally kept.

    FormPartMetadata

    type FormPartMetadata

    Validated form-data metadata cached for the lifetime of one part.
    pub(all) struct Header {
    name : String
    value : String
    } derive(Eq,
    Debug
    )

    A header belonging to one multipart body part. Header names are preserved as received; use Part::header for case-insensitive lookup.

    Parser

    pub struct Parser {
    boundary : String
    max_body_size : Int
    max_part_count : Int
    max_header_size : Int
    max_header_count : Int
    buffer : String
    failed : MultipartError?
    }

    An incremental multipart collector. Chunks may be supplied at arbitrary boundaries; parsing happens on finish so a delimiter split across chunks is handled exactly like an unsplit delimiter.

    Parser::feed

    fn Parser::feed(self : Parser, chunk : String) -> Result[Unit, MultipartError]

    Adds a body chunk. Chunks can be split at any character boundary.

    Parser::finish

    fn Parser::finish(self : Parser) -> Result[Array[Part], MultipartError]

    Parses all supplied chunks and returns body parts in wire order.

    Parser::from_content_type

    fn Parser::from_content_type(content_type : String, max_body_size? : Int) -> Result[Parser, MultipartError]

    Creates a parser directly from an HTTP Content-Type value.

    Parser::new

    fn Parser::new(boundary : String, max_body_size? : Int) -> Parser

    Creates a parser for the supplied boundary. The boundary must be the raw value from the Content-Type parameter, without the leading --.

    Parser::with_limits

    fn Parser::with_limits(boundary : String, max_body_size? : Int, max_part_count? : Int, max_header_size? : Int, max_header_count? : Int) -> Parser

    Creates a parser with explicit resource limits. The parser rejects a body once any configured bound is exceeded.

    Part

    pub(all) struct Part {
    headers : Array[Header]
    body : String
    } derive(Eq,
    Debug
    )

    One decoded multipart body part. body is text in this first core API; the parser never interprets its contents.

    Part::content_type

    fn Part::content_type(self : Part) -> String

    Returns the part media type. RFC 7578 defaults an omitted Content-Type to text/plain for form fields.

    Part::disposition_parameter

    fn Part::disposition_parameter(self : Part, parameter : String) -> String?

    Returns a parameter from Content-Disposition, such as name or filename. Quoted parameter values are unquoted.

    Part::filename

    fn Part::filename(self : Part) -> String?

    Convenience accessor for the submitted file name, when present.

    Part::filename_basename

    fn Part::filename_basename(self : Part) -> String?

    Returns the submitted filename without directory components.

    Part::header

    fn Part::header(self : Part, name : String) -> String?

    Returns a header value using ASCII case-insensitive comparison.

    Part::is_file

    fn Part::is_file(self : Part) -> Bool

    Reports whether this part carries a submitted file name.

    Part::name

    fn Part::name(self : Part) -> String?

    Convenience accessor for the form field name.

    StreamEvent

    pub(all) enum StreamEvent {
    PartBegin(Array[Header])
    PartData(Bytes)
    PartEnd
    Finished
    } derive(
    Debug
    )

    Events emitted by the binary multipart decoder. Part bodies remain Bytes, so file contents are never decoded as text.

    StreamLimits

    pub(all) struct StreamLimits {
    max_body_size : Int
    max_part_count : Int
    max_header_size : Int
    max_header_count : Int
    max_part_size : Int
    }

    Resource bounds for the incremental decoder. They mirror the limits that production multipart readers use to prevent unbounded header or body buffering.

    StreamLimits::default

    fn StreamLimits::default() -> StreamLimits

    StreamState

    type StreamState derive(
    Debug
    )

    Internal state of an incremental binary multipart decoder.

    StreamedFile

    pub(all) struct StreamedFile {
    field_name : String
    filename : String
    content_type : String
    headers : Array[Header]
    } derive(Eq,
    Debug
    )

    Metadata for a streamed file part. File contents are delivered separately as FileSinkEvent::Data and are never retained by StreamingFormCollector.

    StreamedFile::filename_basename

    fn StreamedFile::filename_basename(self : StreamedFile) -> String

    Returns the submitted filename without Unix or Windows directory parts.

    StreamedFormData

    pub(all) struct StreamedFormData {
    fields : Array[TextField]
    files : Array[StreamedFile]
    } derive(
    Debug
    )

    Result of sink-based form collection. Text fields are retained in memory; files contain metadata only because their bytes have already reached the sink.

    StreamingEncoder

    pub struct StreamingEncoder {
    boundary : String
    state : EncoderState
    failed : MultipartError?
    }

    Incrementally converts multipart events into binary wire chunks. The encoder retains only its state and the boundary; returned chunks can be written directly to an HTTP body sink.

    StreamingEncoder::content_type

    fn StreamingEncoder::content_type(self : StreamingEncoder) -> String

    Returns a Content-Type value suitable for the encoded body.

    StreamingEncoder::from_content_type

    fn StreamingEncoder::from_content_type(content_type : String) -> Result[StreamingEncoder, MultipartError]

    Creates an encoder from an HTTP multipart/form-data Content-Type value.

    StreamingEncoder::new

    fn StreamingEncoder::new(boundary : String) -> StreamingEncoder

    Creates an encoder for a raw multipart boundary. Invalid boundaries are reported by the first push call.

    StreamingEncoder::push

    fn StreamingEncoder::push(self : StreamingEncoder, event : StreamEvent) -> Result[Bytes, MultipartError]

    Encodes one event. PartBegin emits a boundary and headers, PartData returns the original binary payload without text conversion, PartEnd emits the required CRLF, and Finished emits the closing boundary.

    Events must follow PartBegin, zero or more PartData, PartEnd, then another part or Finished. Any error is terminal.

    StreamingFormCollector

    pub struct StreamingFormCollector {
    fields : Array[TextField]
    files : Array[StreamedFile]
    text_buffer :
    Buffer

    limits : FormDataLimits
    allowed_fields : Array[String]
    field_size_limits : Array[FieldSizeLimit]
    sink : (FileSinkEvent) -> Result[Unit, MultipartError]
    current_part : FormPartMetadata?
    current_size : Int
    field_count : Int
    file_count : Int
    finished : Bool
    failed : MultipartError?
    }

    Consumes multipart events while forwarding file bytes to a callback. An empty allowed_fields list accepts every field name.

    StreamingFormCollector::finish

    Returns collected text fields and streamed file metadata after Finished.

    StreamingFormCollector::new

    fn StreamingFormCollector::new(sink : (FileSinkEvent) -> Result[Unit, MultipartError], limits? : FormDataLimits, allowed_fields? : Array[String], field_size_limits? : Array[FieldSizeLimit]) -> StreamingFormCollector

    StreamingFormCollector::push

    fn StreamingFormCollector::push(self : StreamingFormCollector, event : StreamEvent) -> Result[Unit, MultipartError]

    Consumes one event produced by StreamingParser.

    StreamingFormDecoder

    pub struct StreamingFormDecoder {
    parser : StreamingParser
    collector : StreamingFormCollector
    failed : MultipartError?
    }

    End-to-end multipart/form-data decoder that connects StreamingParser to a StreamingFormCollector. File bytes go directly to the supplied sink.

    StreamingFormDecoder::feed_bytes

    fn StreamingFormDecoder::feed_bytes(self : StreamingFormDecoder, chunk : Bytes) -> Result[Unit, MultipartError]

    Feeds an arbitrary network body chunk through the parser and file sink.

    StreamingFormDecoder::finish

    Validates the closing boundary and returns text fields plus file metadata.

    StreamingFormDecoder::from_content_type

    fn StreamingFormDecoder::from_content_type(content_type : String, sink : (FileSinkEvent) -> Result[Unit, MultipartError], stream_limits? : StreamLimits, form_limits? : FormDataLimits, allowed_fields? : Array[String], field_size_limits? : Array[FieldSizeLimit]) -> Result[StreamingFormDecoder, MultipartError]

    Creates a decoder directly from an HTTP Content-Type value.

    StreamingFormDecoder::new

    fn StreamingFormDecoder::new(boundary : String, sink : (FileSinkEvent) -> Result[Unit, MultipartError], stream_limits? : StreamLimits, form_limits? : FormDataLimits, allowed_fields? : Array[String], field_size_limits? : Array[FieldSizeLimit]) -> StreamingFormDecoder

    Creates a decoder from a raw multipart boundary.

    StreamingParser

    pub struct StreamingParser {
    boundary : String
    opening : Bytes
    marker : Bytes
    limits : StreamLimits
    pending : Bytes
    state : StreamState
    received_size : Int
    part_count : Int
    current_part_size : Int
    failed : MultipartError?
    }

    Incremental multipart decoder. feed_bytes emits completed protocol events as soon as they are known, retaining only a short possible-boundary suffix.

    StreamingParser::feed_bytes

    fn StreamingParser::feed_bytes(self : StreamingParser, chunk : Bytes) -> Result[Array[StreamEvent], MultipartError]

    Feeds one arbitrary body chunk and returns events now known to be complete.

    StreamingParser::finish

    Finishes the stream. A complete body must already have emitted Finished.

    StreamingParser::from_content_type

    fn StreamingParser::from_content_type(content_type : String, limits? : StreamLimits) -> Result[StreamingParser, MultipartError]

    Creates an incremental decoder from a multipart/form-data Content-Type value, including a quoted boundary parameter.

    StreamingParser::new

    fn StreamingParser::new(boundary : String) -> StreamingParser

    StreamingParser::with_limits

    fn StreamingParser::with_limits(boundary : String, limits : StreamLimits) -> StreamingParser

    Creates an incremental decoder with explicit bounds for the complete wire body, each header block, the part count, and each individual part body.

    TextField

    pub(all) struct TextField {
    name : String
    value : String
    } derive(Eq,
    Debug
    )

    A decoded text form field.

    UploadedFile

    pub(all) struct UploadedFile {
    field_name : String
    filename : String
    content_type : String
    data : Bytes
    } derive(
    Debug
    )

    A decoded uploaded file. data preserves the original file bytes.

    UploadedFile::filename_basename

    fn UploadedFile::filename_basename(self : UploadedFile) -> String

    Returns the submitted filename without Unix or Windows directory components. Generate a separate server-side storage path for the file.

    boundary_from_content_type

    fn boundary_from_content_type(content_type : String) -> Result[String, MultipartError]

    Extracts and validates the boundary parameter from a multipart/form-data Content-Type header. Quoted boundary values are accepted.

    decode_bytes

    fn decode_bytes(boundary : String, body : Bytes) -> Result[Array[StreamEvent], MultipartError]

    Decodes a complete multipart body into binary-preserving events. This is the one-shot companion to the upcoming incremental byte state machine.

    encode

    fn encode(boundary : String, parts : Array[Part]) -> String

    Encodes parts as a multipart body using the supplied boundary.

    filename_basename

    fn filename_basename(filename : String) -> String

    Returns the submitted filename with both Unix and Windows directory components removed. An empty string is returned for . and ..; callers should still generate their own storage path rather than trusting a name.

    Powered by MoonBit

    Site sourceReport issuePackagesBuild queueSkillsStatistics

    © 2026 mooncakes.io