npm.io
1.1.0 • Published 19h ago

tus-server-r2

Licence
MIT
Version
1.1.0
Deps
0
Size
32 kB
Vulns
0
Weekly
0

tus-server-r2

npm license

TUS resumable upload protocol server for Cloudflare Workers + R2. Zero dependencies, no KV, no Durable Objects — just your R2 bucket.

Documentation & Setup Guide · Live Upload Example · npm · Changelog

Install

npm install tus-server-r2

Quickstart

wrangler.toml

name = "my-uploader"
main = "src/index.js"
compatibility_date = "2025-01-01"

[[r2_buckets]]
binding = "BUCKET"
bucket_name = "my-uploads"

src/index.js

import { createTusHandler } from 'tus-server-r2'

export default createTusHandler()
npx wrangler deploy

TUS endpoint: https://my-uploader.<account>.workers.dev

Options

createTusHandler({
  bucket,              // R2Bucket instance. Default: env.BUCKET
  statePrefix,         // R2 key prefix for upload state. Default: '__tus'
  uploadsPrefix,       // R2 key prefix for completed uploads. Default: 'uploads'
  maxSize,             // Max upload size in bytes. Default: unlimited
  uploadTTL,           // Incomplete upload TTL in ms. Default: 86400000 (24h)
  webhookUrl,          // POST to this URL on completion. Default: env.WEBHOOK_URL
  webhookBearerToken,  // Bearer token for webhook. Default: env.WEBHOOK_BEARER_TOKEN
  corsAllowOrigin,     // Allowed CORS origins, comma-separated. Default: env.CORS_ALLOW_ORIGIN or '*'
  onComplete,          // async (key, metadata, bucket) => void
  basePath,            // URL prefix if TUS is mounted at a sub-path. Default: ''
})

All options are optional. Called with no arguments, createTusHandler() reads env.BUCKET, env.WEBHOOK_URL, env.WEBHOOK_BEARER_TOKEN, and env.CORS_ALLOW_ORIGIN automatically.

CORS headers are always included on every response. The default Access-Control-Allow-Origin is *. Set corsAllowOrigin (or CORS_ALLOW_ORIGIN) to a comma-separated list to restrict to specific origins.

Storage Layout

__tus/{uuid}      — upload state JSON (deleted on completion or termination)
__tus/buf-{uuid}  — partial-part buffer (raw bytes, exists only during upload)
uploads/{uuid}    — completed file

Both prefixes are configurable via statePrefix and uploadsPrefix.

TUS Metadata → R2 Metadata

Upload-Metadata sent by the client is decoded and mapped to R2 on completion:

TUS key R2 field
type httpMetadata.contentType
filename httpMetadata.contentDisposition
other customMetadata[key]

Supported Extensions

Extension Description
creation POST to create upload before sending data
creation-with-upload Send first chunk in the POST body
creation-defer-length Omit Upload-Length at creation, provide later
termination DELETE to cancel upload and free resources
expiration Incomplete uploads expire after uploadTTL

Examples

Minimal standalone Worker
import { createTusHandler } from 'tus-server-r2'

export default createTusHandler()
Custom bucket binding
import { createTusHandler } from 'tus-server-r2'

export default {
  async fetch(request, env, ctx) {
    return createTusHandler({ bucket: env.MYUPLOADS }).fetch(request, env, ctx)
  }
}
With webhook notification

wrangler.toml:

[vars]
WEBHOOK_URL = "https://api.example.com/upload-complete"
WEBHOOK_BEARER_TOKEN = "secret-token"
import { createTusHandler } from 'tus-server-r2'

export default createTusHandler()
// webhook fires automatically on completion

Webhook payload:

{
  "key": "uploads/550e8400-e29b-41d4-a716-446655440000",
  "metadata": {
    "filename": "video.mp4",
    "type": "video/mp4"
  }
}
With onComplete hook
import { createTusHandler } from 'tus-server-r2'

export default createTusHandler({
  onComplete: async (key, metadata, bucket) => {
    // key = "uploads/{uuid}"
    // metadata = decoded TUS Upload-Metadata
    // bucket = R2Bucket — move, delete, or read the file
    console.log('Upload complete:', key, metadata)
  }
})
With auth

Authorization runs before TUS handling in the Worker fetch handler:

import { createTusHandler } from 'tus-server-r2'

const tus = createTusHandler()

export default {
  async fetch(request, env, ctx) {
    const token = request.headers.get('Authorization')?.replace('Bearer ', '')
    if (!token || token !== env.API_TOKEN) {
      return new Response('Unauthorized', { status: 401 })
    }
    return tus.fetch(request, env, ctx)
  }
}
Mounted at a sub-path (middleware)
import { createTusHandler } from 'tus-server-r2'

const tus = createTusHandler({ basePath: '/files' })

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url)
    if (url.pathname.startsWith('/files')) {
      return tus.fetch(request, env, ctx)
    }
    return new Response('Not found', { status: 404 })
  }
}
With custom prefixes
import { createTusHandler } from 'tus-server-r2'

export default createTusHandler({
  statePrefix: 'tus',
  uploadsPrefix: 'media',
})
// state at: tus/{uuid}
// files at: media/{uuid}
Expired upload cleanup (cron)

Add to wrangler.toml:

[triggers]
crons = ["0 * * * *"]
import { createTusHandler } from 'tus-server-r2'

const tus = createTusHandler()

export default {
  fetch: tus.fetch.bind(tus),

  async scheduled(event, env, ctx) {
    const bucket = env.BUCKET
    const prefix = '__tus/'
    let cursor
    do {
      const list = await bucket.list({ prefix, cursor })
      cursor = list.truncated ? list.cursor : undefined
      for (const obj of list.objects) {
        // Skip buffer objects — they're cleaned up alongside their state entry
        if (obj.key.includes('/buf-')) continue
        const state = JSON.parse(await (await bucket.get(obj.key)).text())
        if (Date.now() > state.expires) {
          const uuid = state.key.split('/').pop()
          if (state.uploadId) {
            await bucket.resumeMultipartUpload(state.key, state.uploadId).abort()
          }
          await bucket.delete(`${prefix}buf-${uuid}`)
          await bucket.delete(obj.key)
        }
      }
    } while (cursor)
  }
}

Client Setup (Uppy)

import Uppy from '@uppy/core'
import Tus from '@uppy/tus'

const uppy = new Uppy()
uppy.use(Tus, {
  endpoint: 'https://my-uploader.<account>.workers.dev',
  headers: {
    Authorization: 'Bearer my-token'
  }
})

CORS and Bot Protection

CORS headers are included on every response (see corsAllowOrigin in Options above).

If your Worker runs on a custom domain with Cloudflare Bot Fight Mode enabled, TUS clients may receive 403 responses. Create a WAF skip rule for your TUS path (Security → WAF → Custom rules → Action: Skip → Bot Fight Mode) or disable Bot Fight Mode for the zone.

On workers.dev the domain is Cloudflare-managed and cannot be configured. Two conditions trigger a 403: sending no User-Agent header, and Python's built-in urllib (Python-urllib/3.x). All other common HTTP client defaults pass through. Fix: set any non-empty User-Agent header:

# Python urllib
req = urllib.request.Request(url, headers={"User-Agent": "my-app/1.0"})
// Node.js fetch (omits User-Agent by default)
fetch(url, { headers: { "User-Agent": "my-app/1.0" } })
// C# HttpClient (omits User-Agent by default)
client.DefaultRequestHeaders.UserAgent.ParseAdd("my-app/1.0");

See the full per-client table in the docs.

Error Responses

Status Condition
400 Missing Upload-Length and Upload-Defer-Length
404 Upload not found
405 Method not allowed
409 Upload-Offset mismatch
410 Upload expired
412 Missing or wrong Tus-Resumable header
413 Upload exceeds maxSize
415 Wrong Content-Type on PATCH

License

MIT

Keywords