# Blob server API

## Public imports

| Import | Use |
| --- | --- |
| `blob` from `@vite-hub/blob` | Read and write the Default Blob Store or named Blob Stores. |
| `detectContentType` from `vite-hub/blob/content-type` or `@vite-hub/blob/content-type` | Classify common image and PDF signatures before storage. |
| `ensureBlob` from `@vite-hub/blob` or `@vite-hub/blob/ensure` | Validate upload size and content type. |
| `hubBlob` from `@vite-hub/blob/vite` | Register Blob runtime configuration and Provider Output. |
| `resolveBlobViteConfig` from `@vite-hub/blob/vite` | Resolve Blob Vite runtime config manually. |
| `@vite-hub/blob/drivers/*` | Import provider-specific Blob Driver Modules. |

All Blob driver, object, list, put, store, and module types are exported from `@vite-hub/blob`.

Blob writes preserve the metadata you provide. `detectContentType()` checks common leading signatures, but it doesn't validate the complete file or prove that the file is safe.

## Use it at runtime

Use the `blob` Runtime Helper from server code.

```ts [server/api/files.post.ts]
import { blob } from '@vite-hub/blob'

export default defineEventHandler(async (event) => {
  const body = await readBody<{ path: string, text: string }>(event)

  const [error] = await blob.put(body.path, body.text, {
    contentType: 'text/plain',
    customMetadata: { source: 'api' },
  })
  if (error) throw error

  return { ok: true }
})
```

```ts [server/api/files/[...path].get.ts]
import { blob } from '@vite-hub/blob'

export default defineEventHandler(async (event) => {
  const path = getRouterParam(event, 'path')!
  const [error, object] = await blob.get(path)
  if (error) throw error

  if (!object) {
    throw createError({ statusCode: 404 })
  }

  return object
})
```

Use named Blob Stores when configuration defines multiple stores.

```ts [server/reports.ts]
import { blob } from '@vite-hub/blob'

export const reports = blob.store('reports')
```

## Runtime helper

`blob` implements `BlobStorage`.

Every async method returns `[error, value]`. Expected provider and storage failures are `ViteHubError` values with `BLOB_*` codes, so application code can apply HTTP, retry, logging, or best-effort policy without `try/catch`. Invalid arguments, unknown stores, and unsupported signing capabilities still throw because they indicate API or configuration misuse. Generated serving routes unwrap `blob.serve()` and pass its error to H3.

| Method | Description |
| --- | --- |
| `blob.put(pathname, body, options?)` | Stores text, bytes, streams, ArrayBuffers, or `Blob` objects. |
| `blob.get(pathname)` | Reads a `Blob` or returns `null`. |
| `blob.head(pathname)` | Reads object metadata. |
| `blob.list(options?)` | Lists objects with optional `prefix`, `limit`, `cursor`, and folded folders. |
| `blob.del(pathnames)` | Deletes one or more objects. |
| `blob.sign(pathname, options)` | Signs a short-lived `GET` or `PUT` request for one object. |
| `blob.serve(event, pathname)` | Serves an object stream through an H3 event. |
| `blob.handleUpload(event, options?)` | Stores the files of a `multipart/form-data` request. Read [Upload files](#upload-files). |
| `blob.createMultipartUpload(pathname, options?)` | Starts a multipart upload. Read [Multipart uploads](#multipart-uploads). |
| `blob.resumeMultipartUpload(pathname, uploadId)` | Continues a multipart upload in a later request. |
| `blob.handleMultipartUpload(event, options?)` | Serves the requests that the multipart client sends. |
| `blob.store(name)` | Selects a named Blob Store. |

Pass the cursor returned by `blob.list()` unchanged to the next list call on the same Blob Store. Keep `prefix` and `folded` unchanged. Netlify Blobs listings and folded files-sdk listings fail if they cannot decode the cursor.

## Write options

| Option | Type | Description |
| --- | --- | --- |
| `contentType` | `string` | Stored MIME type. |
| `contentLength` | `string` | Expected content length when the provider supports it. |
| `customMetadata` | `Record<string, string>` | Provider custom metadata. |
| `access` | `BlobPutOptions['access']` | Object access policy when the driver supports it. Values: `private`, `public`. |
| `addRandomSuffix` | `boolean` | Adds a random suffix when supported by the driver. |
| `prefix` | `string` | Provider path prefix when supported by the driver. |

## Upload files

`blob.handleUpload()` reads a `multipart/form-data` request and calls `put()`
for each file. The file name, without any directory part, is the pathname.
It returns `[error, objects]`. A request without files, with too many files, or
with a file that fails `ensure` throws an H3 400 error before anything is
stored.

```ts [server/api/files.post.ts]
import { blob } from '@vite-hub/blob'

export default defineEventHandler(async (event) => {
  // Authorize the request here. The route decides who may upload.
  const [error, objects] = await blob.handleUpload(event, {
    formKey: 'files',
    ensure: { maxSize: '8MB', types: ['image'] },
    put: { prefix: 'avatars', addRandomSuffix: true },
  })
  if (error) throw error
  return objects
})
```

| Option | Default | Description |
| --- | --- | --- |
| `formKey` | `"files"` | Form field that holds the files. |
| `multiple` | `true` | Set `false` to accept one file. |
| `ensure` | none | Options for [`ensureBlob()`](#ensureblobblob-options), checked for each file. |
| `put` | none | [Write options](#write-options) for each `put()`. |

In Vue, `useUpload()` from `@vite-hub/blob/vue` sends the form. The Nuxt module
auto-imports it when `blob` is enabled.

```vue [app/components/AvatarUpload.vue]
<script setup lang="ts">
const upload = useUpload('/api/files', { multiple: false })

async function onChange(event: Event) {
  const object = await upload(event.target as HTMLInputElement)
  console.log(object?.pathname)
}
</script>

<template>
  <input type="file" accept="image/*" @change="onChange">
</template>
```

`useUpload()` accepts a `File`, a `FileList`, an array of files, or an element
with `files`. It resolves with serialized objects, where `uploadedAt` is an ISO
string. Pass `headers` or `fetch` to add authentication.

## Multipart uploads

Use multipart uploads for files that are too large for one request. The client
splits the file into parts, and the server stores each part until `complete()`
joins them. The `fs`, `cloudflare-r2` (with the R2 binding), and `vercel-blob`
drivers support it. Other drivers throw `BLOB_R0030`.

Create one route with `action` and `pathname` params:

```ts [server/api/files/multipart/[action]/[...pathname].ts]
import { blob } from '@vite-hub/blob'

export default defineEventHandler(async (event) => {
  // Authorize the request here. The client chooses the pathname.
  const [error, result] = await blob.handleMultipartUpload(event)
  if (error) throw error
  return result
})
```

The route answers four requests:

| Action | Method | Request | Result |
| --- | --- | --- | --- |
| `create` | `POST` | JSON `{ contentType? }` | `{ action, pathname, uploadId }` |
| `upload` | `PUT` | `?uploadId=…&partNumber=…`, part bytes | `{ action, part }` |
| `complete` | `POST` | `?uploadId=…`, JSON `{ parts }` | `{ action, object }` |
| `abort` | `DELETE` | `?uploadId=…` | `{ action }` |

Pass `{ create: { contentType, prefix, addRandomSuffix } }` to set the create
options on the server. A `contentType` set here replaces the type that the
client sends.

In Vue, `useMultipartUpload()` sends the parts and tracks progress:

```vue [app/components/VideoUpload.vue]
<script setup lang="ts">
const uploadVideo = useMultipartUpload('/api/files/multipart', { concurrency: 2 })
const task = shallowRef<ReturnType<typeof uploadVideo>>()

async function onChange(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0]
  if (!file) return
  task.value = uploadVideo(file)
  const object = await task.value.completed
  console.log(object?.pathname)
}
</script>

<template>
  <input type="file" accept="video/*" @change="onChange">
  <progress v-if="task" :value="task.progress.value" max="100" />
</template>
```

`partSize` defaults to 10 MiB. Vercel Blob and R2 need at least 5 MiB for every
part except the last. `task.abort()` stops the upload and sends the `abort`
request; `completed` then resolves with `undefined`.

Server code can also drive an upload directly:

```ts [server/utils/copy-large-file.ts]
import { blob } from '@vite-hub/blob'

const [createError, upload] = await blob.createMultipartUpload('exports/report.csv')
if (createError) throw createError
const [partError, part] = await upload.uploadPart(1, firstChunk)
if (partError) throw partError
const [completeError, object] = await upload.complete([part])
if (completeError) throw completeError
```

| Driver | Upload state | Notes |
| --- | --- | --- |
| `fs` | Parts under `<base>/.vitehub/multipart/<uploadId>/` | `complete()` checks each part's etag. `abort()` deletes the parts. |
| `cloudflare-r2` | R2 multipart upload | Requires the R2 binding. The HTTP fallback has no multipart support. |
| `vercel-blob` | Vercel multipart upload | The upload ID encodes the Vercel key. Access always comes from the store config. `abort()` does nothing; Vercel discards unfinished uploads. |

The framework-neutral client is `@vite-hub/blob/client`, with `uploadFiles()`
and `createMultipartUploader()`. The Vue composables use it.

## Signed requests

Use `blob.sign()` when a client or provider needs short-lived direct access to one private object. The result contains the URL, HTTP method, and every header that must be sent with the request.

```ts [server/api/uploads/presign.post.ts]
import { blob } from '@vite-hub/blob'

const [sourceError, source] = await blob.sign('users/user/jobs/job/source.mp3', {
  method: 'GET',
  expiresIn: 6 * 60 * 60,
})
if (sourceError) throw sourceError

const [uploadError, upload] = await blob.sign('users/user/jobs/job/source.mp3', {
  method: 'PUT',
  expiresIn: 15 * 60,
  contentType: 'audio/mpeg',
  createOnly: true,
})
if (uploadError) throw uploadError
```

Send `upload.headers` unchanged with the `PUT` body. `contentType` binds the upload MIME type into the signed request. `createOnly` binds a provider condition that rejects the upload when the object already exists; drivers that cannot enforce it throw instead of silently allowing an overwrite.

Cloudflare R2 signs through its S3-compatible HTTP credentials, including when normal reads and writes use a Workers binding. A binding alone cannot mint a presigned URL, so configure `accountId`, `accessKeyId`, `secretAccessKey`, and `bucketName` through runtime environment values. [R2 presigned URLs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/) accept expiries from 1 second through 7 days, and the [S3 compatibility contract](https://developers.cloudflare.com/r2/api/s3/api/) supports `If-None-Match` on `PutObject`.

## `ensureBlob(blob, options)`

Use `ensureBlob()` at upload boundaries.

| Option | Type | Description |
| --- | --- | --- |
| `maxSize` | `BlobSize` | Rejects blobs larger than the limit. Examples: `4MB`, `128KB`, `1GB`. |
| `types` | `BlobType[]` | Allows exact MIME types or broad types such as `image`, `video`, `audio`, `pdf`, and `text`. |

## Production checks

Store content types and metadata at write time. Avoid guessing object type later from path names.

Blob can store Workspace data, but it doesn't provide a file tree to an Agent. Workspace handles file operations, rules, snapshots, and diffs.

Blob stores binary objects and small object metadata. Keep catalogs, indexes, permissions, search records, domain records, and richer metadata queries in KV, Database, or another NoSQL/catalog store next to Blob.
