Docs/Storage & Uploads/File Upload

Storage & Uploads

File Upload

File Upload — Storage & Uploads documentation for BCP Framework.

Last updated: 2026-08-29T01:11:41

File Upload

BCP Framework provides two server-only multipart upload paths through bcp/server:

  • the buffered FormData helpers introduced in 0.1.24,
  • direct multipart-to-storage streaming added in 0.1.26.

Use the buffered API for small forms and workflows that need normal FormData access. Use storeMultipartFile() when large files should flow directly from the request body into a StorageAdapter without materializing the complete file in application memory.

Buffered API route

TypeScript
import {
    parseMultipartFormData,
    requireUploadedFile,
    saveUploadedFile,
} from "bcp/server";

export async function POST(
    request: Request
) {
    const formData =
        await parseMultipartFormData(
            request,
            {
                maxBytes:
                    8 * 1024 * 1024,
            }
        );

    const file =
        requireUploadedFile(
            formData,
            "file",
            {
                maxBytes:
                    5 * 1024 * 1024,
                allowedTypes: [
                    "image/png",
                    "image/jpeg",
                    "image/webp",
                ],
                allowedExtensions: [
                    ".png",
                    ".jpg",
                    ".jpeg",
                    ".webp",
                ],
            }
        );

    const saved =
        await saveUploadedFile(
            file,
            {
                directory:
                    "./uploads",
            }
        );

    return Response.json({
        fileName:
            saved.fileName,
        size:
            saved.size,
        checksumSha256:
            saved.checksumSha256,
    });
}

Production streaming upload

BCP 0.1.26 adds storeMultipartFile():

TypeScript
import {
    createLocalStorage,
    storeMultipartFile,
} from "bcp/server";

const uploads =
    createLocalStorage({
        directory:
            "./uploads",
    });

export async function POST(
    request: Request
) {
    const stored =
        await storeMultipartFile(
            request,
            {
                storage:
                    uploads,
                fieldName:
                    "file",
                key:
                    "incoming/upload.bin",
                maxBytes:
                    100 * 1024 * 1024,
                constraints: {
                    maxBytes:
                        80 * 1024 * 1024,
                    allowedTypes: [
                        "application/octet-stream",
                    ],
                },
            }
        );

    return Response.json(
        stored
    );
}

The request body is parsed incrementally. The target file stream is handed to the selected StorageAdapter while multipart data is still arriving.

This path is intended for large uploads and shared/object storage. It avoids calling request.formData() for the uploaded file.

Streaming directly to S3-compatible storage

The same API works with createS3Storage():

TypeScript
import {
    createS3Storage,
    storeMultipartFile,
} from "bcp/server";

const uploads =
    createS3Storage({
        bucket:
            process.env.S3_BUCKET!,
        region:
            process.env.S3_REGION!,
        endpoint:
            process.env.S3_ENDPOINT,
        accessKeyId:
            process.env.S3_ACCESS_KEY_ID,
        secretAccessKey:
            process.env.S3_SECRET_ACCESS_KEY,
    });

export async function POST(
    request: Request
) {
    return Response.json(
        await storeMultipartFile(
            request,
            {
                storage:
                    uploads,
                fieldName:
                    "file",
                key: (
                    file
                ) =>
                    `documents/${crypto.randomUUID()}${file.originalName.endsWith(".pdf") ? ".pdf" : ""}`,
                constraints: {
                    maxBytes:
                        50 * 1024 * 1024,
                    allowedTypes: [
                        "application/pdf",
                    ],
                    allowedExtensions: [
                        ".pdf",
                    ],
                },
            }
        )
    );
}

The key option can be a fixed string or an async callback receiving:

TypeScript
{
    fieldName,
    originalName,
    encoding,
    contentType,
}

When key is omitted, BCP creates a UUID-based key and preserves a short sanitized extension.

Request body limit

BCP's security gateway enforces the framework-wide request body limit before the request reaches the application server.

The default is 1 MiB. Applications that accept larger uploads must explicitly raise it:

TypeScript
// bcp.config.ts
import {
    defineConfig,
} from "bcp/config";

export default defineConfig({
    server: {
        bodyLimit:
            100 * 1024 * 1024,
    },
});

The same setting can be supplied with BCP_BODY_LIMIT.

For buffered uploads, keep server.bodyLimit greater than or equal to the multipart limit used by parseMultipartFormData().

For streaming uploads, keep the infrastructure/framework body limit greater than or equal to storeMultipartFile({ maxBytes }). Streaming avoids full application-memory buffering, but it does not bypass reverse-proxy, framework or platform request-size limits.

Multipart request limits

Buffered parsing:

TypeScript
const formData =
    await parseMultipartFormData(
        request,
        {
            maxBytes:
                10 * 1024 * 1024,
        }
    );

Streaming parsing:

TypeScript
await storeMultipartFile(
    request,
    {
        storage,
        fieldName:
            "file",
        maxBytes:
            100 * 1024 * 1024,
    }
);

Both helpers:

  • require multipart/form-data,
  • reject a declared Content-Length above the configured request limit,
  • enforce the request limit while processing,
  • throw UploadError with HTTP-oriented status information on validation failures.

storeMultipartFile() additionally applies the file-level constraints.maxBytes while bytes are flowing into storage. Native streaming adapters can abort incomplete writes rather than keeping a partial object.

Reading buffered files

Use getUploadedFile() for optional files:

TypeScript
const avatar =
    getUploadedFile(
        formData,
        "avatar"
    );

if (!avatar) {
    // no file supplied
}

Use requireUploadedFile() when a file is mandatory:

TypeScript
const avatar =
    requireUploadedFile(
        formData,
        "avatar"
    );

A missing required file throws UploadError with code FILE_REQUIRED and status 400.

File constraints

Buffered and streaming uploads can validate:

TypeScript
{
    maxBytes: 5 * 1024 * 1024,
    allowedTypes: [
        "application/pdf",
    ],
    allowedExtensions: [
        ".pdf",
    ],
}

Supported checks are:

  • maximum file size,
  • MIME type allowlist,
  • extension allowlist.

MIME type and file extension are multipart metadata. They are useful validation signals but are not content-signature verification. Applications accepting security-sensitive formats should inspect the file content or magic bytes before trusting the file type.

Saving buffered files

TypeScript
const saved =
    await saveUploadedFile(
        file,
        {
            directory:
                "./uploads/documents",
        }
    );

When fileName is omitted, BCP generates a UUID-based storage name while preserving a short sanitized extension.

The result contains:

TypeScript
{
    originalName: string;
    fileName: string;
    path: string;
    type: string;
    size: number;
    checksumSha256: string;
}

Files are created with exclusive-write behavior by default. An existing destination produces FILE_EXISTS instead of silently overwriting data.

Use overwrite: true only when replacement is intentional.

Streaming result

storeMultipartFile() returns storage metadata together with multipart identity:

TypeScript
{
    key: string;
    size: number;
    contentType: string;
    lastModified: Date;
    etag: string;
    checksumSha256?: string;
    fieldName: string;
    originalName: string;
    encoding: string;
}

The checksum is optional because not every storage provider exposes a portable whole-object SHA-256 value for multipart uploads.

File-name safety

saveUploadedFile() sanitizes the final local file name and verifies that the resolved destination remains inside the configured directory.

You can use the same sanitizer independently:

TypeScript
import {
    sanitizeUploadFileName,
} from "bcp/server";

const safeName =
    sanitizeUploadFileName(
        userFileName
    );

Streaming storage uses logical storage keys instead of direct operating-system paths. Storage adapters validate their own key rules; local/S3 BCP adapters reject traversal segments.

Upload errors

TypeScript
import {
    UploadError,
} from "bcp/server";

try {
    // upload work
} catch (error) {
    if (
        error instanceof UploadError
    ) {
        return Response.json(
            {
                error: {
                    code:
                        error.code,
                    message:
                        error.message,
                },
            },
            {
                status:
                    error.status,
            }
        );
    }

    throw error;
}

Current error codes include:

text
INVALID_MULTIPART
UPLOAD_TOO_LARGE
FILE_REQUIRED
FILE_TOO_LARGE
FILE_TYPE_NOT_ALLOWED
FILE_EXTENSION_NOT_ALLOWED
INVALID_FILE_NAME
FILE_EXISTS

The streaming helper maps storage STREAM_TOO_LARGE and OBJECT_EXISTS failures into the corresponding upload-facing error codes.

Choosing buffered vs streaming

Prefer the buffered API when:

  • uploads are small,
  • the route needs several regular form fields/files as FormData,
  • application code needs random access to the parsed file before storage.

Prefer storeMultipartFile() when:

  • files can be large,
  • the file should be written directly to local/shared/object storage,
  • memory usage should stay bounded,
  • S3 multipart upload is required for streams with unknown final size.

The 0.1.26 streaming API intentionally targets one named file field per call. Multiple-file orchestration and richer streaming form-field APIs can be added later without changing the storage contract.