Palbase
Sign inGet started

Backend SDK

Direct Uploads

@Upload declares a route whose file bytes go straight to storage and never enter your process. It is the only inbound-file rail there is: your handler names a bucket and an object-key template, the client makes one multipart POST, and your method body then runs as the completion handler — after the object has landed and the bucket's renditions have been rendered — with whatever it returns handed back to the client verbatim. Use it whenever the bytes are the client's and your backend has no reason to hold them; a 1 GB video that streamed through your process to reach the same bucket cost you the bandwidth twice and bought nothing.

Quick example

// modules/posts/posts.controller.ts
import { Body, Controller, Storage, Upload, UploadedObject, 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: "posts", 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.
    // `obj` carries the metadata — never the bytes.
    return {
      id: obj.uploadId,
      url: Storage.bucket(obj.bucket).getPublicUrl(obj.path),
      caption: body.caption,
    };
  }
}

The client calls POST /v1/storage/upload/posts/images and gets this method's return value back. The size limit and MIME allowlist come from the post-images bucket on the Environment — palbase storage add post-images --public --max-size 5MB --mime image/jpeg,image/png,image/heic.

The flow

One call from the client's side. Three hops behind it:

client ──[ multipart: the file + your request body ]──► storage
storage ──[ may this caller upload here, and where does it go? ]──► your backend
storage ── writes the bytes, renders the bucket's declared renditions
storage ──[ the object's metadata + your request body ]──► your backend
your backend ── your method runs, returns its typed result
storage ──[ that result, verbatim ]──► client

The client makes one request and gets one answer: the upload's progress is that request's upload progress, and the response body is your handler's return value. There is no polling phase and no second call to correlate.

Note: There used to be a three-phase handshake here — authorize, a direct PUT to a signed URL, then complete — and the server never had a counterpart for it. If you are reading an older sample or an SDK doc-comment that still describes a signed URL the client writes to, it describes a protocol that does not run. One multipart POST is the whole client contract.

Why storage asks your backend first

Your route's @Upload declaration is TypeScript, in your deployed bundle — storage cannot know which bucket the route writes to, or whether this particular caller may write at all. So it asks, before it reads a single byte of the body:

  • A route that requires a user refuses an anonymous caller with 401, and nothing is stored. Deciding this after the upload would mean the bytes were written and every rendition rendered on the way to a refusal — once per attempt.
  • The path comes from verified claims, not from anything the client sent. {userId} is the caller the forwarded token names, verified by your own backend; a form field claiming to be somebody else reaches nothing.
  • A route with no @Upload is refused by name, so an operator reading storage's log learns which route was asked for.

The authorize call lands on /__palbase/upload/authorize, which the engine handles before ordinary routing — a project that declared that path as one of its own routes cannot shadow the mechanism that decides where uploads land.

On the wire

A generated SDK does this for you; this is what it sends.

POST /v1/storage/upload/<your route>       # e.g. /v1/storage/upload/posts/images
Authorization: Bearer <the caller's token>
Content-Type: multipart/form-data; boundary=…

file: <the bytes>                          # the part must be named `file`
body: {"caption":"from the beach"}         # your endpoint's own request body, as JSON

→ 200 <whatever your method returned>

The body part is the payload your @Body(...) schema validates. It travels beside the file so the client does not have to make a second call to say what the file is for, and storage reads only a part named body — loose form fields are not seen by your handler.

Both internal calls — the authorize pre-flight and the completion — carry x-palbase-upload-signature, compared in constant time against a shared secret. The runtime fetches that secret at boot from the storage module with its service-role key.

Warning: With PALBASE_UPLOAD_SECRET empty, your runtime refuses both internal calls — 401, "This endpoint is not callable directly" — and every @Upload route on the stack is dead. Storage relays that refusal to the client as 401 unauthorized, which reads exactly like a bad token, so check the runtime's boot log: it says upload secret unavailable … — @Upload routes will refuse. On a Palbase-hosted Environment the secret is wired for you; on a stack you host yourself it is a value you set on both processes.

Posting the multipart at your own route instead — POST /posts/images — is refused by the engine: 401, "This endpoint accepts uploads through storage, not directly". The completion call is only trusted because it is signed.

What happens if something fails

  • Your handler throws or returns a 4xx. The object is deleted, and your own status, body and content type are handed to the client anyway. Nothing recorded the object, so nobody could find it and nothing would clean it up.
  • Storage cannot reach your backend. 502 completion_failed, and the object does not survive a completion that never ran.
  • The completion is retried. Your handler runs once and the second call is answered with the first one's response, so a retry is invisible to the client — one uploaded photo does not become two posts.

Note: The exactly-once guarantee is bounded and in-process. The ledger that remembers answered completions holds 1024 upload ids, oldest evicted first, and it lives in the runtime's memory. A process restart legitimately forgets, and a completion retried after one would run your handler a second time. Make the write idempotent if a duplicate would be expensive.

@Upload

@Upload(subpath, config). Unlike every other method decorator, the second argument is required: config carries the upload settings plus the optional auth and rateLimit route options, which behave exactly as they do on @Get/@Post. UploadConfig itself is exactly two fields:

FieldTypeDescription
bucketPalbaseBucketNameThe target bucket. The type is the union palbase build renders from the Environment's buckets, so a bucket that does not exist is a compile error, not a deploy-time surprise.
pathTemplatestringThe server-side object-key template. The client never chooses the path.

Both are shape-checked at decoration time: a missing or empty bucket or pathTemplate throws while the module is being loaded, with the field named.

On the wire the authorize pre-flight is a POST, but that is an implementation detail — what marks a route as an upload route through the registry, the OpenAPI document and codegen is the presence of the upload configuration, never a special HTTP verb.

Note: @Upload deliberately takes no maxSize or allowedTypes. The bucket is the single source of truth for both, and storage enforces them at the write — the one guard a client cannot skip. A per-route cap here would let a route declare a tighter limit than its bucket that storage would not enforce. One bucket, one limit.

pathTemplate tokens

The path is rendered server-side from these tokens — the client never picks where the object lands:

TokenSource
{userId}The authenticated caller's id, from verified claims. An anonymous caller renders the literal anonymous.
{uploadId}A server-minted upload id, which is also the idempotency key.
{filename}The client-declared filename, sanitised.

A typical template:

"{userId}/{uploadId}-{filename}"

Every token is reduced to a single safe path segment before it is substituted: control characters are stripped, / and \ become -, runs of dots collapse, leading dots are removed, the result is truncated to 200 characters, and an empty result becomes file. That is what makes the template a boundary rather than a suggestion — a filename cannot climb out of the prefix the template put it in.

Bucket cross-check at push

The bucket must exist on the stack. palbase push reads the Environment's live bucket list from its Management API, compares it against every @Upload in the built bundle, and refuses before anything ships:

@Upload names 1 bucket(s) this stack does not have:
  posts.createImage → bucket "post-images"
It has: avatars, exports
Create it with `palbase storage add <name>` or fix the name — storage will not create it on demand

The previous version stays live. This is a push-time check against the running Environment, not a build-time one: palbase build does no bucket check at all, because there is no local declaration left for it to read.

That existence check is the only cross-check there is. Because @Upload carries no size or type of its own, there is nothing else that could drift.

@UploadedObject()

Inject the confirmed object into the completion handler with the @UploadedObject() parameter decorator. The bytes are not present — they went straight to storage; this is the metadata your handler persists.

FieldTypeDescription
uploadIdstringServer-minted id for this upload; also what makes the completion idempotent.
pathstringThe final object key, rendered from pathTemplate.
bucketstringThe bucket the object landed in.
sizenumberObject size in bytes, as stored.
contentTypestringMIME type sniffed from the bytes — not the type the client declared.
checksumstringSHA-256 of the stored bytes; the ETag derives from it.
width / heightnumber?Present for images: the original's decoded dimensions.
thumbhashstring?Present for images: a ~25-byte blurred placeholder to paint while the real one loads.
variantsRecord<string, string>The bucket's renditions, already rendered, by name.

The single exported name UploadedObject is both the decorator value (@UploadedObject()) and the type annotation (: UploadedObject), so one import covers both.

Warning: variants values are relative paths — /v1/files/<bucket>/<path>?variant=<name>. Only getPublicUrl(...) prefixes the stack's public origin. Hand a client Storage.bucket(obj.bucket).getPublicUrl(obj.path, { variant: "thumb" }) rather than a raw variants entry, or the URL is unusable off your own origin. See Storage.

Your @Body parameter sees your caller's own payload, not the envelope storage wrapped it in — the engine unwraps it before validation runs. @User() still names the person who uploaded, because the completion travels with the caller's own credential.

Limits and failure modes

The refusals a caller can actually hit, and where each one comes from:

StatusCodeCause
400bad_routeThe route segment is a traversal rather than a path.
400bad_multipartThe body is not a multipart form.
400no_fileThe form carries no part named file.
400bad_bodyThe body part is not valid JSON. The stored object is deleted.
400not_an_upload_route<METHOD> <path> does not declare @Upload.
401unauthorizedThe route requires a user and the forwarded credential is missing or invalid — or the upload secret is unset, see above.
403forbiddenThe route requires a role this caller does not have.
503uploads_unwiredStorage has no backend wired to complete uploads against.

Bucket-level refusals — 413 too_large, 415 unsupported_type, 409 already_exists — come from the write itself and are listed on Storage.

Two numbers are worth knowing before you size a feature. Storage parses the multipart form with a 32 MiB in-memory budget and spills the remainder to temporary files, so a large upload is a disk cost rather than a refusal — the actual size ceiling is the bucket's, and it answers 413. And the completion call to your backend has a 120 second timeout, after which storage treats the upload as failed and discards the object. A completion handler that does slow work — transcoding, calling a third party — should record the object and hand the work to a scheduled job instead.

If your handler needs the bytes

It cannot have them at completion time; that is the point of the design. There is no request-parsed file object either: @Req() injects the raw Web Request and nothing populates a req.file.

Reading them back with Storage.bucket(obj.bucket).download(obj.path) only works for images, which arrive as a Uint8Array, and for JSON, which arrives parsed. For every other content type download returns a UTF-8-decoded string, not the bytes — so a PDF, a HEIC or a zip is already corrupted before your handler sees it, and no error is raised. Scanning, re-encoding or extracting text from those cannot be done this way; see Downloading for exactly what comes back.

What works for the rest: record the object at completion and do the work outside the handler — a scheduled job, or a consumer that fetches a signed URL and gets the bytes straight from storage, unmangled.

  • Storage — buckets, the bucket client, URLs and renditions
  • Stack Settingspalbase storage add, which is how the bucket comes to exist
  • Web: Uploads — the browser client side
  • iOS: Uploads — the iOS client side
  • Event Hooksfile.uploaded, for objects that do not arrive through a route