Uploads: signed direct upload and Func-generated files
Choosing between the two upload paths: browser-selected files go straight to the CDN through a signed upload, and Func-generated bytes go through ctx.assets.upload at 20 MiB per call. Why a base64 relay always fails, and how a file belongs in a table.
A site has two upload paths, and picking the wrong one runs straight into a size limit: files the user selects in the browser go through a signed CDN upload, and bytes generated inside Func go through ctx.assets.upload. Both return a URL you can use directly — what you store in a table is always the URL, never the file content.
Choose the path first
Browser files → signed upload
Avatars, attachments, and images the user selects or drops. The bytes go straight from the browser to the CDN and never pass through Func, so they are not bound by the Func execution timeout or result-size limit.
Func-generated files → ctx.assets.upload
Model-generated images, server-assembled PDFs, bytes fetched from a third party. The browser never had this content, so it can only leave through Func. One upload is capped at 20 MiB.
Base64-encoding a browser file and relaying it through
invoke()is the one genuinely wrong pattern here: base64 inflates the payload by about a third while consuming the Func input, execution time, and result-size budgets at once. Anything but a small file will fail.
Browser files: signed CDN upload
For a File or Blob selected or dropped in the browser, use talizen/assets:
import { uploadAsset } from 'talizen/assets'
const asset = await uploadAsset(file, {
onFileUploadProcess(fileName, progress) {
console.log(fileName, progress)
},
})
// asset.fileUrl === asset.url
uploadAsset() calls POST /api/asset/file/preupload to obtain a short-lived signed URL, uploads the file bytes directly from the browser with PUT to that CDN storage URL, then confirms the upload through POST /api/asset/file/ack. File bytes do not pass through Func, and matching content can reuse an existing object by hash.
Both File and Blob are accepted. For an unnamed Blob, call uploadAsset(blob, { fileName: 'avatar.webp' }). The result contains { fileUrl, url, fileName, mimeType, size, hash }, with identical URL fields. Signed upload works on both preview and published site domains.
Once the upload finishes, hand the URL to a Func to persist. The Func only ever handles strings:
import { invoke } from 'talizen/func'
const asset = await uploadAsset(file)
await invoke('profile.setAvatar', { url: asset.url, size: asset.size })
That URL arrives from the browser, so treat it as untrusted input: confirm it points at the platform CDN domain before storing it.
Files generated inside Func
const asset = ctx.assets.upload({
filename: 'report.pdf',
mimeType: 'application/pdf',
base64: input.base64,
})
await ctx.db.insert('reports', {
userId: ctx.auth.requireUser().id,
url: asset.url,
size: asset.size,
})
Use ctx.assets.upload() only for bytes generated inside Func that cannot originate in the browser. It synchronously returns { fileUrl, url, size }, with identical URL fields. Persist the URL and size only; do not store internal paths or large base64 payloads in tables or Func JSON. A single upload is limited to 20 MiB.
The typical case is moving bytes returned by a third party onto your own CDN so you do not depend on their temporary link:
export async function generate(input, ctx) {
const user = ctx.auth.requireUser()
const response = await fetch('https://api.example.com/v1/images', {
method: 'POST',
headers: { Authorization: 'Bearer ' + process.env.EXAMPLE_API_KEY },
body: JSON.stringify({ prompt: input.prompt }),
})
if (!response.ok) {
ctx.response.status(502)
throw new Error('upstream request failed')
}
const { image_base64 } = await response.json()
const asset = ctx.assets.upload({
filename: 'generated.png',
mimeType: 'image/png',
base64: image_base64,
})
ctx.db.insert('generations', { userId: user.id, url: asset.url, prompt: input.prompt })
return { url: asset.url }
}
Calls like this usually exceed the 5-second default, so the caller must raise its timeout explicitly — see Timeouts and streaming responses.
Storing files in a JSON table
Store the URL and metadata, not the file. Describe the field as a string with format: "uri" — table schemas do not support "format": "file":
{
"url": { "type": "string", "format": "uri", "contentMediaType": "image/*" },
"size": { "type": "number" },
"fileName": { "type": "string" }
}
For field and query rules, see JSON tables: definition, reads, and queries.
Acceptance checklist
- Browser-selected files go through
uploadAsset(), with no base64 relay throughinvoke(). ctx.assets.upload()is used only for Func-generated bytes, each under 20 MiB.- Tables store URLs and sizes — no base64, no internal storage paths.
- URLs supplied by the browser are validated before being persisted.
- Long-running generation calls set a matching
timeoutMsat the call site.
