Skip to content
RELKIT
Esc
↑↓navigate↵open⌘Jpreview
On this page

File uploads

Accept bounded multipart files, validate media and size limits, store content, and test valid and invalid requests.

Accept multipart files by mapping request fields into your function’s file schemas. RELKIT maps multipart fields into the target function’s schemas, enforces request and file ceilings, and invokes the function only after validation. Uploads are currently buffered, so limits protect memory as well as domain policy.

Model the upload operation

The commerce example keeps storage behavior in a function and transport parsing in a route:

/** Validates multipart image files, writes them through the declared assets bucket and returns their uploaded names. */
import { defineFunction } from "@relkit/app/functions";
import { z } from "@relkit/app/schema";
import assets from "../buckets/assets.bucket.js";

const uploadedFile = z.file({
  maxBytes: 1024 * 1024 * 10,
  mediaTypes: ["image/jpeg", "image/png"],
});

const uploadAssets = defineFunction({
  input: z.object({
    label: z.string().min(1),
    primary: uploadedFile,
    attachments: z.array(uploadedFile),
  }),

  output: z.object({
    label: z.string(),
    files: z.array(z.string()),
  }),

  dependencies: { buckets: { assets } },

  handler: async ({ label, primary, attachments }, context) => {
    const files = [primary, ...attachments];

    // This demo uses filenames as keys; uploading the same name replaces the file.
    await Promise.all(
      files.map(async (file) =>
        context.buckets.assets.put(file.name, new Uint8Array(await file.arrayBuffer()), {
          contentType: file.type,
        }),
      ),
    );

    return { label, files: files.map(({ name }) => name) };
  },
});

export default uploadAssets;

z.file values provide the validated file name, media type, size, and bytes. The function declares bucket access, writes through the provider-neutral client, and returns ordinary JSON metadata. It does not receive a multipart parser or raw HTTP request.

/** Admits bounded multipart upload requests to the assets service's validated image handler. */
import { defineRoute } from "@relkit/app/routes";
import assets from "../../services/assets/service.js";

export const POST = defineRoute({
  target: assets.uploadAssets,
  accept: "multipart/form-data",
  maxBodyBytes: 10 * 1024 * 1024,
});

The route opts into multipart/form-data and sets a route-specific body ceiling. The function schema decides which fields are single files, repeated files, or text values. No explicit request is needed for this matching object shape; use http.multipart and http.multipartAll inside http.input when names differ.

To add this to your own app, follow Storage’s upload walkthrough for the bucket, public Assets service, provider configuration, and tests. Copying only the route does not create its dependencies.

Send a complete multipart request

Prepare representative files

Use two real, small PNG files named primary.png and detail.png in your terminal’s working directory. Keep their combined request below 1 MiB for the default commerce configuration. These commands assume the commerce app is running locally in test mode on port 4000, with its local bucket provider available. Adjust the port for your own app.

Upload every required field

curl -i -X POST http://localhost:4000/uploads \
  -F label=receipts \
  -F "primary=@primary.png;type=image/png" \
  -F "attachments=@detail.png;type=image/png"

Expect HTTP 200 and JSON listing primary.png and detail.png. Repeat attachments for more files. Let curl set the multipart Content-Type boundary; do not supply a bare Content-Type header yourself.

Prove invalid input is rejected

curl -i -X POST http://localhost:4000/uploads \
  -F label=receipts \
  -F "primary=@primary.png;type=text/plain" \
  -F "attachments=@detail.png;type=image/png"

Expect a bounded 422 response for the unsupported declared media type. The storage handler must not run. Also test missing required fields and duplicate scalar fields.

Set every relevant limit

Coordinate server.maxBodyBytes, the route’s maxBodyBytes, and individual z.file constraints. The framework checks Content-Length against the server ceiling before the route runs; the request mapper then enforces the route-specific ceiling, or the server/default limit when omitted. Raising only the route limit does not bypass an earlier global rejection.

The commerce server ceiling is 1 MiB, even though this route and each file schema declare 10 MiB. That is why the commands above use small files. A request ceiling includes multipart framing and all fields; accepting several maximum-size files requires a correspondingly larger total budget. Test oversized requests with and without Content-Length. See Responses for 413 versus mapping-validation failures.

For large media, streaming ingestion, resumable transfer, or direct-to-object-store uploads, use a purpose-built upload flow rather than raising a buffered route to unbounded sizes. A signed upload URL can keep large bytes off the application server while a later validated request records metadata.

Security checklist

  • Generate storage keys independently from untrusted file names when overwriting matters.
  • Treat reported media type as input, not proof of file content; the schema does not sniff file bytes.
  • Keep private buckets private and return signed URLs only after authorization.
  • Avoid logging file bytes, access tokens, or multipart bodies.
  • Test missing fields, repeated fields, zero-byte files, media mismatch, and both size ceilings.
Reference and source