Uploads
pb.upload(name, options) sends a file as one multipart/form-data POST and resolves with whatever your backend's @Upload handler returned. The bytes go to storage, not through your handler: storage asks your backend whether this caller may upload and where the object should land, writes it, renders the bucket's variants, calls your handler with the object's metadata, and hands your handler's own response back to the browser. There is no authorize call to make, no signed URL to PUT to and nothing to commit afterwards — but there is one thing this SDK will not do for you, and it is the first section below: pb.upload posts to the path you give it, verbatim.
Quick example
import { pb } from '@palbase/web';
type PostView = { id: string; url: string; caption: string };
const post = await pb.upload<PostView>('/posts/images', {
file, // a File from <input type="file">
fields: { body: JSON.stringify({ caption: 'sunset over the wing' }) },
onProgress: ({ sent, total }) => setPct(Math.round((sent / total) * 100)),
});
console.log(post.url);
That call reaches an @Upload('/images') declared on @Controller('/posts'), and post is that method's return value, decoded from JSON.
pb.upload is untyped — it is not part of the generated namespaces, so pass the response type as the generic parameter. Unlike the iOS SDK, palbe-gen emits no typed upload method: the x-palbase-upload extension in your contract is read by the native generator and ignored by the TypeScript one. On the web, pb.upload is the whole client surface for uploads.
You name your route, not the upload door
pb.upload takes your endpoint's route and puts it behind storage's door for you — the same shape as the iOS SDK.
pb.upload('/posts/images', …); // your @Upload route; the storage door is added for you
pb.upload('posts/images', …); // the same — a missing leading slash is added
Write the route your @Upload declares. Do not write the storage path yourself: an upload has to arrive through storage, which authorizes it against your route's @Upload declaration, writes the bytes, renders the variants and only then calls your handler. Posting a multipart body straight at your own route is refused with 401 and the message "This endpoint accepts uploads through storage, not directly" — the completion call is trusted only because storage signs it.
The route you pass is a name, not an instruction: storage forwards it to your backend, which reads that route's own @Upload declaration and answers with the bucket and the object key. The client never picks the bucket and never picks the path.
Your handler's input travels as one part named body
Storage reads exactly one part for your endpoint's payload — a part named body, containing JSON — and looks at no other. You do not have to serialise it yourself: whatever you put in fields is folded into that single part.
await pb.upload('/posts/images', {
file,
fields: { caption: 'sunset over the wing', albumId: 'alb_7' },
});
If you would rather own the envelope — a nested object, a pre-encoded string — pass it as body and it is sent verbatim:
await pb.upload('/posts/images', {
file,
fields: { body: JSON.stringify({ caption: 'sunset', tags: ['sky', 'wing'] }) },
});
A body part that is not valid JSON is refused with 400 bad_body, and the object that had already been written is deleted.
UploadOptions
interface UploadOptions {
file: Blob;
filename?: string;
contentType?: string;
fields?: Record<string, string>;
onProgress?: (progress: { sent: number; total: number }) => void;
signal?: AbortSignal;
headers?: Record<string, string>;
constraints?: { maxSize?: number; allowedTypes?: string[] };
}
| Option | Type | Default | Purpose |
|---|---|---|---|
file | Blob | — (required) | The bytes. A File from an input or a drop works directly. |
filename | string | File.name, else the literal 'file' | The name on the file part. It reaches {filename} in the route's pathTemplate — sanitised server-side. |
contentType | string | file.type | Re-wraps the blob with this type. Wins over file.type on the wire and for the allowedTypes check. |
fields | Record<string, string> | — | Multipart form parts. Only a part named body reaches your handler. |
onProgress | ({ sent, total }) => void | — | Upload progress in bytes. Passing it switches the transport to XMLHttpRequest. |
signal | AbortSignal | — | Cancels the upload. |
headers | Record<string, string> | — | Extra request headers, merged last — a pinned Authorization wins over the session's. |
constraints | { maxSize?, allowedTypes? } | — | Client-side pre-send checks. Not a security boundary — see below. |
The file part is always named file, and it is always sent last: every fields entry is appended first, then the blob.
Progress
fetch cannot report upload progress in any browser, so passing onProgress switches that one request to XMLHttpRequest. Without it, fetch is used. Everything else — headers, body, decoding, error mapping — is identical.
await pb.upload('/posts/images', {
file,
onProgress: ({ sent, total }) => console.log(`${sent}/${total} bytes`),
});
Note:
totalis always greater than zero when the callback fires — events whose length is not computable are skipped entirely, so you can divide without guarding. Progress covers the request body going up and nothing after it: when the bar reaches 100% the bytes are sent and storage is still writing the object, rendering variants and running your completion handler. On a large file that tail is real time. Show an indeterminate state after the bar fills rather than leaving it pinned at 100%.
Client-side constraints
constraints are checked before any network I/O — the promise rejects and nothing is sent:
import { pb, isBackendError } from '@palbase/web';
try {
await pb.upload('/posts/images', {
file,
constraints: { maxSize: 5 * 1024 * 1024, allowedTypes: ['image/png', 'image/jpeg'] },
});
} catch (e) {
if (isBackendError(e) && e.code === 'file_too_large') showToast('Max 5 MB.');
if (isBackendError(e) && e.code === 'file_type_not_allowed') showToast('PNG or JPEG only.');
}
| Constraint | Failure code | Details |
|---|---|---|
maxSize (bytes) | file_too_large | Compared against file.size. |
allowedTypes (exact MIME strings) | file_type_not_allowed | Checks the effective type — the contentType override if given, else file.type. An empty array means no check. |
Both are validation-kind BackendErrors carrying a single fields entry for file, so they slot into the same form-error rendering as a server-side field error.
Warning: These are a round-trip saver, not enforcement. The bucket owns the size limit and the MIME allowlist, and storage applies them at the write — before your handler runs, and with no way for a client to skip them. Set them where they are enforced:
palbase storage add post-images --max-size 5MB --mime image/jpeg,image/png(see Stack Settings).@Uploaddeliberately has nomaxSizeorallowedTypesof its own, so there is nothing for a per-route value to drift from.
Cancelling an upload
const controller = new AbortController();
const promise = pb.upload('/posts/images', { file, signal: controller.signal });
cancelButton.onclick = () => controller.abort();
try {
await promise;
} catch (e) {
if (isBackendError(e) && e.code === 'aborted') {
// the user cancelled — not worth reporting
}
}
An abort rejects with a network-kind BackendError whose code is aborted. A signal that is already aborted when you call pb.upload rejects immediately without opening a connection.
Aborting after the body is sent does not un-write the object: storage decides an upload's fate from your handler's answer, not from the client hanging up.
What goes on the wire
A multipart/form-data POST to <your Environment URL><name>, carrying:
| Header | Value |
|---|---|
apikey | Your Environment's publishable key — how the request is identified. |
Authorization | Bearer <access token> when a session exists. Both this and apikey are forwarded to your backend, which is how @User() names the uploader inside the completion handler. |
X-Client-Info | palbe-web/8.0.0 |
X-Palbase-Bundle | The page's window.location.origin, in a browser. |
Idempotency-Key | A generated UUID, unless you passed one in any casing. |
Anything in headers is merged over all of these, and your global config headers are included too.
What is deliberately absent on this path, because pb.upload builds its own request instead of going through the shared transport:
- No
Content-Type— the browser sets it, with the multipart boundary. Never set it yourself. - No
X-Platform, noX-Distinct-Id— those are shared-transport concerns. - No proof-of-work retry, no reactive 401-refresh-and-retry, and no perf trace. A single pre-flight refresh does happen: if the access token is already expired and a refresh token exists, the SDK refreshes before building the headers, and a terminal refusal (400, 401, 403) clears the session and lets the upload proceed unauthenticated rather than throwing.
- No retry of any kind. A dropped connection rejects with a
network-kind error (network_error); re-uploading is your decision.
Note: Your
Idempotency-Keyreaches storage and stops there — it is not forwarded to your backend. The exactly-once guarantee on a completion is keyed on the server-minteduploadId, so a retried completion returns the first answer rather than running your handler twice. That ledger is in-process and bounded, so a runtime restart can legitimately forget; make an expensive write idempotent. See Direct Uploads.
What comes back
The response body is decoded as JSON:
- 2xx resolves with the parsed body — which is your handler's return value, relayed by storage with your handler's own status and content type.
- An empty body resolves with
null. - 2xx with unparseable JSON throws a
decode-kindBackendError(decode_error). - Non-2xx throws a
BackendErrordecoded from the error envelope.
Kinds follow the same rules as every other call: 401 → 'unauthorized', 429 → 'rateLimited', and everything else — including 400, 403, 409, 413, 415, 502 and 503 — arrives as kind 'server' carrying the server's own code. Discriminate on err.code, and always with isBackendError(err) rather than instanceof.
Failures worth handling by name
| Status | code | What happened |
|---|---|---|
400 | bad_multipart | The body was not a multipart form. |
400 | no_file | The form carried no part named file. |
400 | bad_body | The body part was not valid JSON. The stored object is deleted. |
400 | not_an_upload_route | The route exists but declares no @Upload. |
401 | unauthorized | The route requires a user and the credential is missing or invalid. Nothing was stored — this is decided before the body is read. |
403 | forbidden | The route requires a role this caller does not have. |
404 | not_found | The bucket the route names does not exist on this Environment. |
413 / 415 | too_large / unsupported_type | The bucket's size limit or MIME allowlist refused the write. |
502 | completion_failed | Storage could not reach your backend; the object does not survive. |
503 | uploads_unwired | Storage has no backend wired to complete uploads against. |
Anything else is your own handler answering: a @Body validation failure, a 409 you threw, a domain error. Whatever status and body it returned reaches the browser unchanged — and if it was ≥ 400, the object was deleted on the way out.
try {
await pb.upload('/posts/images', { file, fields: { body: '{}' } });
} catch (e) {
if (!isBackendError(e)) throw e;
if (e.status === 413) showToast('That file is larger than this bucket allows.');
else if (e.code === 'unauthorized') router.push('/login');
else report(e.code, e.status, e.requestId);
}
The backend half
The route the example above posts to:
import { Controller, Upload, UploadedObject, Body, Storage, z } from "@palbase/backend";
const CreatePost = z.object({ caption: z.string().max(280) });
type CreatePost = z.infer<typeof CreatePost>;
const PostView = z.object({ id: z.string(), url: z.string(), caption: z.string() });
type PostView = z.infer<typeof PostView>;
@Controller("/posts")
export class PostsController {
@Upload("/images", { bucket: "post-images", pathTemplate: "{userId}/{uploadId}-{filename}" })
async createImage(
@UploadedObject() obj: UploadedObject,
@Body(CreatePost) body: CreatePost,
): Promise<PostView> {
// Runs once, after the object landed and its renditions were rendered.
return {
id: obj.uploadId,
url: Storage.bucket(obj.bucket).getPublicUrl(obj.path),
caption: body.caption,
};
}
}
Three things about that method decide what the browser sees:
- Its return type is the client's response type. Give
pb.uploadthe same shape as its generic parameter. - Return absolute URLs.
getPublicUrl(path)— andgetPublicUrl(path, { variant: 'thumb' })— is the one URL builder that prefixes the stack's public origin. A rawobj.variantsentry and acreateSignedUrlresult are relative paths, and a browser on another origin cannot fetch them. See Storage. - The bucket must already exist on the Environment.
palbase pushrefuses a bundle whose@Uploadnames a bucket the stack does not have.
Not on the web client
- No bucket or object API.
pb.storage,pb.buckets,pb.bucket,pb.objectsandpb.filesare all absent, and a test in the SDK fails if any of them appears — a client that could choose where bytes land would be making the backend's decision. A browser reaches storage through an@Uploadroute, and reads objects through URLs your backend hands it. - No generated upload method. Codegen skips upload routes on web; call
pb.uploadwith the full path. - No signed-URL PUT, no chunking, no resumable uploads, no multi-file part. One request, one file part, one answer.
- No client-chosen destination. The bucket and the object key come from the route's declaration and the caller's verified claims.
Related
- Direct Uploads —
@Upload,@UploadedObject(), the authorize-write-complete flow and its failure modes - Storage — buckets, renditions, and which URLs are absolute
- Stack Settings —
palbase storage add, where the size limit and MIME allowlist are set - Error Handling — the seven
BackendErrorkinds and how to discriminate them - Calling Your Backend — the shared request pipeline this path deliberately bypasses
- Uploads (iOS) — the same door from Swift, where the SDK prefixes the path for you