File Operations
Upload Flow
Uploading a file is a two-step process:
- Your server calls
CreateUploadUrlto generate a presigned upload URL. - The client sends the file directly to the storage provider using that URL.
This pattern keeps file data off your application server and reduces bandwidth costs.
Complete upload example
After authenticating the caller and authorizing an avatar change, your server can
issue an upload URL like this. Derive uploadedBy from the authenticated session;
the fixed ID below is example data, not a value to accept from the client.
import {
CreateUploadUrl,
UploadValidationError,
} from "@antelopejs/interface-file-storage";
async function handleUploadRequest(file: {
name: string;
size: number;
type: string;
}) {
try {
const response = await CreateUploadUrl(
{
filename: file.name,
size: file.size,
mimetype: file.type,
path: "user-uploads/avatars",
metadata: { uploadedBy: "usr_abc123" },
},
{
maxSize: 5 * 1024 * 1024, // 5 MB
allowedMimetypes: ["image/jpeg", "image/png", "image/webp"],
},
);
// Return the presigned URL and headers to the client
return {
uploadUrl: response.uploadUrl,
resourceKey: response.resourceKey,
headers: response.headers,
expiresAt: response.expiresAt,
};
} catch (error) {
if (error instanceof UploadValidationError) {
throw new Error(`Upload rejected: ${error.message}`);
}
throw error;
}
}
The client then performs the upload using the presigned URL:
// Client-side upload using the response from the server
async function uploadFile(
file: File,
presignedData: { uploadUrl: string; headers: Record<string, string> },
) {
const response = await fetch(presignedData.uploadUrl, {
method: "PUT",
headers: presignedData.headers,
body: file,
});
if (!response.ok) {
throw new Error("Upload failed");
}
}
Important: The client must include all headers from the
headersfield in the PUT request. The presigned URL signature coversContent-TypeandContent-Length, so any modification to these values causes the request to be rejected.
Uploads use fresh keys and create-only acceptance by default. Replaying a URL must
not overwrite an accepted object. Concurrent or repeated attempts return an explicit
conflict response, such as HTTP 409 or a provider precondition failure; request a
fresh upload URL to upload again. Forward every returned header, including
If-None-Match when supplied, and allow those headers in the storage CORS policy.
The provider must enforce the condition, not merely return an advisory header.
This does not reserve a deleted key forever. A still-valid provider URL can recreate a deleted source; application admission must not reuse the original upload key. Staging expiration and source cleanup remain independent of business cancellation.
The UploadRequest Object
Use this reference when adding options to the upload example:
| Field | Type | Required | Description |
|---|---|---|---|
filename | string | Yes | Original filename |
size | number | Yes | Exact file size in bytes |
mimetype | string | Yes | MIME type, such as "image/jpeg" |
path | string | No | Folder prefix for the generated key |
metadata | Record<string, string> | No | Custom file metadata |
visibility | "public" | "private" | No | Override the storage default |
staging | boolean | No | Use temporary staging (default false); see promotion |
The UploadConstraints Object
Constraints validate the request before the provider generates a URL. Invalid
requests throw UploadValidationError. Request validation is not a content scan;
validate the final object before attaching it to an application record.
| Field | Type | Description |
|---|---|---|
maxSize | number | Maximum file size in bytes |
allowedMimetypes | string[] | Permitted MIME types |
The PresignedUploadResponse Object
| Field | Type | Description |
|---|---|---|
uploadUrl | string | URL for the client's PUT request |
resourceKey | string | Key to retain for later operations |
expiresAt | number | Upload URL expiration timestamp |
headers | Record<string, string> | Required PUT headers |
Staging Uploads and Promotion
Direct-to-storage uploads create a problem: a file uploaded via a presigned URL lands in storage immediately. If the user never confirms the form, nothing ends up referencing the key, leaving an orphaned object behind forever.
The staging flow solves this. Set staging: true on the upload request and the
generated resourceKey is placed under a reserved staging prefix
(STAGING_PREFIX, i.e. __staging__/), preserving the requested path after
the prefix:
import { CreateUploadUrl } from "@antelopejs/interface-file-storage";
const upload = await CreateUploadUrl({
filename: "draft.png",
size: 204800,
mimetype: "image/png",
path: "user-uploads/avatars",
visibility: "private",
staging: true,
});
console.log(upload.resourceKey); // e.g. "__staging__/user-uploads/avatars/<uuid>.png"
When staging expiration is configured, the backend expires abandoned objects using an S3 lifecycle rule or a local periodic sweep. The existing expiration settings remain unchanged; staging alone does not enable cleanup.
Staging key helpers
These public helpers operate on strings only. They do not check existence,
authorize access, upload, move or promote a file, and take no storage argument.
| Export | Behavior |
|---|---|
STAGING_PREFIX | The reserved prefix "__staging__/". |
isStagedKey(resourceKey: string): boolean | Tests whether the key begins with the prefix. |
toStagedKey(resourceKey: string): string | Adds the prefix unless it is already present. |
stripStagingPrefix(resourceKey: string): string | Removes one leading prefix; leaves a non-staged key unchanged. |
import {
isStagedKey,
toStagedKey,
stripStagingPrefix,
} from "@antelopejs/interface-file-storage";
const staged = toStagedKey("avatars/photo.png");
console.log(staged); // "__staging__/avatars/photo.png"
console.log(isStagedKey(staged)); // true
console.log(toStagedKey(staged)); // unchanged
console.log(stripStagingPrefix(staged)); // "avatars/photo.png"
console.log(stripStagingPrefix("avatars/photo.png")); // unchanged
Use PromoteFile below to publish a confirmed upload. Computing its final key
with stripStagingPrefix does not establish that promotion succeeded.
Promoting a confirmed file
Before admitting a file into the referencing record, call PromoteFile for its
staged key. The provider strips the staging prefix and publishes complete bytes,
metadata, and trusted source provenance at the canonical final key without
overwriting an existing destination. It preserves the selected visibility in the
same storage. Source cleanup follows verified final publication.
import { PromoteFile } from "@antelopejs/interface-file-storage";
const { resourceKey } = await PromoteFile(
"__staging__/user-uploads/avatars/<uuid>.png",
);
console.log(resourceKey); // "user-uploads/avatars/<uuid>.png"
PromoteFile is idempotent and safe to call repeatedly:
- Promoting a key that is not staged is a no-op and returns it unchanged.
- Promoting the same staged key twice returns the final key both times only when the destination is complete and has trusted provenance matching that source. This also supports retries after source cleanup.
- If the staged object no longer exists (for example it expired before being
promoted) and was not already promoted,
PromoteFilethrowsFileNotFoundErrorrather than returning a key that points to nothing. - A foreign destination or an incomplete destination that cannot be safely
repaired throws
FileConflictErrorwith codeFILE_CONFLICT. Mere existence, equal size, or upload-supplied metadata is not proof of a matching promotion. - Unknown provider outcomes throw. Retry the same key and storage to reconcile; never treat an error or an existence check as proof of success.
Provider and application responsibilities
Application authors must enforce the admission and access rules below. Backend
authors must also implement the provider promotion contract.
There is no fallback from PromoteFile to MoveFile plus FileExists.
Publish a complete object
If you implement a storage backend, follow the complete-publication requirements. Application authors can continue with admission and access below.
Admit the file into application data
Applications persist permanent source ownership and validate final metadata after promotion before committing the business admission. Preserve the same storage selection throughout upload, promotion, validation, reading, and cleanup; do not rebind it to another backend during the workflow.
Cancellation and receipts belong to application logic. Storage provides no cancellation tombstone or fence: delayed work can leave an orphan after cancellation. Applications must never attach that orphan to a cancelled admission.
Protect private files
For private workflows, the server must impose visibility: "private" through its
trusted upload authorization and prevent client overrides. Providers must enforce
private access for both staging and final bytes, not merely issue signed URLs over
publicly accessible storage.
Applications must also authorize metadata/read-URL routes against the business admission. Private storage alone does not prevent an overly permissive application route from issuing an unauthorized read capability. Previously issued read URLs can remain usable until expiration.
Moving an object
MoveFile remains an independent generic move with its existing overwrite and
missing-source behavior. It is not a substitute for no-clobber promotion and is
not used by PromoteFile.
import { MoveFile } from "@antelopejs/interface-file-storage";
await MoveFile("reports/draft.pdf", "reports/review.pdf");
Backend expiration of staged files
Each implementation is responsible for expiring abandoned staged objects:
- S3 (
@antelopejs/file-storage-s3) — setstagingExpirationDayson a storage config to apply a bucket lifecycle rule (scoped to the staging prefix) at bootstrap. If you prefer to manage infrastructure externally, leave it unset and provision an equivalent lifecycle rule via Terraform or the console. - Local (
@antelopejs/file-storage-local) — setstagingExpiration(in seconds) to enable a periodic sweep that deletes staged files older than that age. Leave it unset to keep staged files indefinitely (development only).
Download Flow
The CreateReadUrl function generates a URL to access a stored file.
The PresignedReadResponse Object
| Field | Type | Description |
|---|---|---|
url | string | URL to access the file |
expiresAt | number | undefined | Expiration timestamp (undefined for public files) |
Generate a download URL
import { CreateReadUrl } from "@antelopejs/interface-file-storage";
// Generate a read URL with 1-hour expiration (for private files)
const { url, expiresAt } = await CreateReadUrl(
"user-uploads/avatars/photo.jpg",
3600,
);
console.log(url); // Presigned or public URL
console.log(expiresAt); // Expiration timestamp, or undefined if public
Serve files in an API response
import { CreateReadUrl } from "@antelopejs/interface-file-storage";
async function getFileDownloadUrl(resourceKey: string) {
const { url, expiresAt } = await CreateReadUrl(resourceKey, 300); // 5 minutes
return {
downloadUrl: url,
expiresAt,
};
}
Delete a File
The DeleteFile function removes a file from storage.
import { DeleteFile } from "@antelopejs/interface-file-storage";
await DeleteFile("user-uploads/avatars/old-photo.jpg");
To delete a file from a specific storage bucket:
import { DeleteFile } from "@antelopejs/interface-file-storage";
await DeleteFile("backups/archive-2025.zip", "backup-bucket");
Check File Existence
The FileExists function returns true if the file exists, or false otherwise.
import { FileExists } from "@antelopejs/interface-file-storage";
const exists = await FileExists("user-uploads/avatars/photo.jpg");
if (!exists) {
console.log("File not found, using default avatar.");
}
File Metadata
The GetFileMetadata function retrieves detailed information about a stored file. It throws a FileNotFoundError if the file does not exist.
The FileMetadata Object
| Field | Type | Description |
|---|---|---|
resourceKey | string | Unique resource key |
filename | string | Original filename |
size | number | File size in bytes |
mimetype | string | MIME type |
lastModified | number | Last modified timestamp |
metadata | Record<string, string> | undefined | Optional custom metadata attached to the file |
Retrieve file metadata
import {
GetFileMetadata,
FileNotFoundError,
} from "@antelopejs/interface-file-storage";
try {
const meta = await GetFileMetadata("user-uploads/avatars/photo.jpg");
console.log(meta.filename); // "photo.jpg"
console.log(meta.size); // 204800
console.log(meta.mimetype); // "image/jpeg"
console.log(meta.lastModified); // 1711800000000
console.log(meta.metadata); // { uploadedBy: "usr_abc123" }
} catch (error) {
if (error instanceof FileNotFoundError) {
console.error("The requested file does not exist.");
}
}
Complete Workflow Example
Run this in a server-side test with a configured storage implementation and native
fetch. It uploads a small text report, reads its metadata, and deletes it. In a
browser flow, the PUT runs on the client instead; requesting a URL alone never
creates the object. A staged workflow also requires promotion and application
admission before issuing reads.
import {
CreateUploadUrl,
CreateReadUrl,
GetFileMetadata,
DeleteFile,
FileExists,
} from "@antelopejs/interface-file-storage";
const bytes = Buffer.from("Quarterly report\nRevenue: 12500 EUR\n", "utf8");
// 1. Generate a presigned upload URL for these exact bytes.
const upload = await CreateUploadUrl({
filename: "report.txt",
size: bytes.length,
mimetype: "text/plain",
path: "reports/2026",
visibility: "private",
});
// 2. Upload before trying to read the object.
const uploaded = await fetch(upload.uploadUrl, {
method: "PUT",
headers: upload.headers,
body: bytes,
});
if (!uploaded.ok) {
throw new Error(`Upload failed: ${uploaded.status}`);
}
// 3. After a successful upload, generate a read URL.
const read = await CreateReadUrl(upload.resourceKey, 3600);
console.log("Read URL expires at:", read.expiresAt);
// 4. Retrieve metadata.
const metadata = await GetFileMetadata(upload.resourceKey);
console.log("File size:", metadata.size, "bytes");
// 5. Check existence before deleting this disposable file.
const exists = await FileExists(upload.resourceKey);
if (exists) {
await DeleteFile(upload.resourceKey);
console.log("File deleted.");
}
Introduction
Let clients transfer bytes directly to storage while your server authorizes uploads and manages file references. @antelopejs/interface-file-storage defines the contract; an implementing module supplies the backend, URL signing, and storage configuration.
Provider Promotion Contract
This page is for storage backend authors. Application authors can start with file operations; their authorization, admission, and cancellation responsibilities remain in that guide.