File uploads
Let users upload files (avatars, attachments, generated images) to R2.
DeepSpace apps get a per-app R2 bucket out of the box. The useR2Files hook covers uploads, listings, deletions, and authenticated downloads - all proxied through the platform-worker so the app never holds R2 credentials directly.
This guide shows the common end-to-end flows. For the full method signatures and types, see the files reference.
How files are wired#
You don't add an R2 binding yourself. The starter worker already proxies /api/files/* to the platform-worker, which holds a shared bucket and namespaces keys per app (via the APP_NAME the worker forwards on each request). Every write is gated by the caller's signed JWT.
The client side is a single hook:
import { useR2Files } from 'deepspace'
Upload from a file input#
The most common case - an <input type="file"> or drag-drop event. Pass the resulting File to upload:
import { useR2Files } from 'deepspace'
function FileUploader() {
const { upload, isUploading } = useR2Files()
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const result = await upload(file, file.name)
if (result.success) {
console.log('uploaded:', result.key)
} else {
console.error(result.error)
}
}
return (
<input type="file" onChange={onFileChange} disabled={isUploading} />
)
}
Always check result.success before reading result.key - the upload may fail (network, auth). isUploading is true while a request is in flight.
Upload generated data (canvas, cropped image)#
When you have data as a Base64 string - for example, from <canvas>.toDataURL() - use uploadBase64. The display name is required so the file has an originalName for later downloads:
const { uploadBase64 } = useR2Files()
async function saveCanvasAsImage(canvas: HTMLCanvasElement) {
const dataUrl = canvas.toDataURL('image/png')
const base64 = dataUrl.split(',')[1]
const result = await uploadBase64(base64, 'drawing.png', 'image/png')
if (!result.success) console.error(result.error)
}
List and render a user's files#
list() is an async function - call it and store the result in state rather than reading a reactive array:
import { useState, useEffect } from 'react'
import { useR2Files, formatFileSize } from 'deepspace'
import type { R2FileInfo } from 'deepspace'
function Gallery() {
const { deleteFile, list, getUrl } = useR2Files()
const [files, setFiles] = useState<R2FileInfo[]>([])
async function refresh() {
setFiles(await list())
}
useEffect(() => { refresh() }, [])
return (
<div>
{files.map((f) => (
<div key={f.key}>
<img src={getUrl(f)} alt="" />
<p>{f.originalName ?? f.key} - {formatFileSize(f.size)}</p>
<button onClick={async () => { await deleteFile(f); refresh() }}>
Delete
</button>
</div>
))}
</div>
)
}
Re-call list() after mutations - there's no reactive cache. formatFileSize and isImageFile are display helpers exported from deepspace.
Authenticated downloads#
getUrl(fileOrKey) returns a plain URL with no auth token attached. It works for unauthenticated reads on deployed sites (the platform-worker resolves the app from APP_NAME and serves reads without a JWT), which is what you want for <img src>. For everything else, use downloadFile or readFile:
const { downloadFile, readFile } = useR2Files()
// Trigger a Save As… dialog. Uses originalName as the filename automatically.
const result = await downloadFile(file)
if (!result.success) console.error(result.error)
// Or read the bytes yourself - returns a Response you can .text(), .blob(), etc.
const resp = await readFile(file)
const text = await resp.text()
Both accept either an R2FileInfo from list() or a raw key string.
Storing metadata (MIME type, captions, tags)#
R2FileInfo carries key, size, uploaded, url, originalName, and uploadedBy - and nothing else. There's no mimeType field. For richer metadata, store a sidecar record in a collection:
// src/schemas/attachments-schema.ts
import type { CollectionSchema } from 'deepspace'
export const attachmentsSchema: CollectionSchema = {
name: 'attachments',
columns: [
{ name: 'fileKey', storage: 'text', interpretation: 'plain' },
{ name: 'mimeType', storage: 'text', interpretation: 'plain' },
{ name: 'caption', storage: 'text', interpretation: 'plain' },
],
permissions: {
member: { read: true, create: true, update: 'own', delete: 'own' },
},
}
Create the sidecar alongside the upload. The snippet assumes a RecordProvider higher in the tree that has registered the attachments collection - useMutations throws otherwise.
import { useR2Files, useMutations } from 'deepspace'
type Attachment = { fileKey: string; mimeType: string; caption: string }
const { upload } = useR2Files()
const { create } = useMutations<Attachment>('attachments')
async function uploadWithMeta(file: File) {
const result = await upload(file, file.name)
if (!result.success || !result.key) {
console.error(result.error)
return
}
await create({
fileKey: result.key,
mimeType: file.type,
caption: '',
})
}
Scoping and permissions#
useR2Files is always scoped to the current app - the platform derives the bucket prefix from APP_NAME. There's no per-user or cross-app scope exposed by the hook. If you need finer namespacing (per-room, per-project), encode it into the name argument at upload time or store it on a sidecar record.
Writes require a signed user JWT. Anyone who knows a file's key and hits your app's /api/files/<key> can read it; if a file should be private, gate access in your UI and use readFile/downloadFile rather than rendering its URL. There is no recycle bin - deleteFile is immediate and irreversible.
Local development#
For tests written before the first deploy, assert that uploads are dispatched (the function is called) rather than asserting on the round-trip. Or run npx deepspace dev --prod to point local UI at production workers.
Next steps#
- Files reference - full method signatures, return types, and the
R2FileInfoshape. - Custom bindings - declare a wholly separate R2 bucket with custom permissions.
- Data model - pair files with sidecar records for queryable metadata.