Palbase
Sign inGet started

Web SDK

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[] };
}
OptionTypeDefaultPurpose
fileBlob— (required)The bytes. A File from an input or a drop works directly.
filenamestringFile.name, else the literal 'file'The name on the file part. It reaches {filename} in the route's pathTemplate — sanitised server-side.
contentTypestringfile.typeRe-wraps the blob with this type. Wins over file.type on the wire and for the allowedTypes check.
fieldsRecord<string, string>Multipart form parts. Only a part named body reaches your handler.
onProgress({ sent, total }) => voidUpload progress in bytes. Passing it switches the transport to XMLHttpRequest.
signalAbortSignalCancels the upload.
headersRecord<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: total is 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.');
}
ConstraintFailure codeDetails
maxSize (bytes)file_too_largeCompared against file.size.
allowedTypes (exact MIME strings)file_type_not_allowedChecks 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). @Upload deliberately has no maxSize or allowedTypes of 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:

HeaderValue
apikeyYour Environment's publishable key — how the request is identified.
AuthorizationBearer <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-Infopalbe-web/8.0.0
X-Palbase-BundleThe page's window.location.origin, in a browser.
Idempotency-KeyA 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, no X-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-Key reaches storage and stops there — it is not forwarded to your backend. The exactly-once guarantee on a completion is keyed on the server-minted uploadId, 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-kind BackendError (decode_error).
  • Non-2xx throws a BackendError decoded 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

StatuscodeWhat happened
400bad_multipartThe body was not a multipart form.
400no_fileThe form carried no part named file.
400bad_bodyThe body part was not valid JSON. The stored object is deleted.
400not_an_upload_routeThe route exists but declares no @Upload.
401unauthorizedThe route requires a user and the credential is missing or invalid. Nothing was stored — this is decided before the body is read.
403forbiddenThe route requires a role this caller does not have.
404not_foundThe bucket the route names does not exist on this Environment.
413 / 415too_large / unsupported_typeThe bucket's size limit or MIME allowlist refused the write.
502completion_failedStorage could not reach your backend; the object does not survive.
503uploads_unwiredStorage 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.upload the same shape as its generic parameter.
  • Return absolute URLs. getPublicUrl(path) — and getPublicUrl(path, { variant: 'thumb' }) — is the one URL builder that prefixes the stack's public origin. A raw obj.variants entry and a createSignedUrl result are relative paths, and a browser on another origin cannot fetch them. See Storage.
  • The bucket must already exist on the Environment. palbase push refuses a bundle whose @Upload names a bucket the stack does not have.

Not on the web client

  • No bucket or object API. pb.storage, pb.buckets, pb.bucket, pb.objects and pb.files are 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 @Upload route, and reads objects through URLs your backend hands it.
  • No generated upload method. Codegen skips upload routes on web; call pb.upload with 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.
  • Direct Uploads@Upload, @UploadedObject(), the authorize-write-complete flow and its failure modes
  • Storage — buckets, renditions, and which URLs are absolute
  • Stack Settingspalbase storage add, where the size limit and MIME allowlist are set
  • Error Handling — the seven BackendError kinds 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