File Storage

Unified file storage across Local, Amazon S3, Cloudflare R2, and an in-memory driver for tests, one API regardless of where files live.

Introduction

@tekir/drive exposes a Drive class that manages multiple named disks. Each disk maps to a DiskDriver implementation. You interact with the default disk directly, or call drive.use(diskName) to switch.

services.ts
import { service } from '@tekir/core'
import type { Drive } from '@tekir/drive'

export const drive = service<Drive>('drive')

Configuration

Create config/drive.ts and export a config object. The default field names the disk used when no disk is specified. Only configure the disks you need.

config/drive.ts
import env from '#env'
import type { DriveConfig } from '@tekir/drive'

export default {
  default: 'local',
  disks: {
    local: {
      driver: 'local',
      root: './storage/uploads',
      urlPrefix: '/uploads'
    },
    s3: {
      driver: 's3',
      bucket: env.S3_BUCKET,
      region: env.AWS_REGION,
      accessKeyId: env.AWS_ACCESS_KEY_ID,
      secretAccessKey: env.AWS_SECRET_ACCESS_KEY
    },
    r2: {
      driver: 'r2',
      bucket: env.R2_BUCKET,
      accountId: env.CF_ACCOUNT_ID,
      accessKeyId: env.R2_ACCESS_KEY_ID,
      secretAccessKey: env.R2_SECRET_ACCESS_KEY
    }
  }
} satisfies DriveConfig

Register DriveProvider in your kernel:

start/kernel.ts
import type { TekirApp } from '@tekir/core'
import { DriveProvider } from '@tekir/drive'

export default function({ app }: TekirApp) {
  app.registerAll([DriveProvider])
}

Basic Operations

put

Write content to a key (path). Accepts a Buffer, a string, or a ReadableStream. An optional options object lets you set contentType, visibility, and extra metadata.

import { drive } from '#services'

// Write a string
await drive.put('hello.txt', 'Hello, world!')

// Write a Buffer (e.g. from a multipart upload)
await drive.put('avatars/42.jpg', buffer, { contentType: 'image/jpeg' })

// Write a ReadableStream (e.g. piped from a fetch response)
const res = await fetch('https://example.com/large-file.bin')
await drive.put('files/large.bin', res.body!, { contentType: 'application/octet-stream' })

// Control visibility on S3/R2
await drive.put('public/logo.png', logoBuffer, {
  contentType: 'image/png',
  visibility: 'public'
})

get / getString / getStream

Read file contents as a Buffer, a UTF-8 string, or a ReadableStream. Streams are useful for proxying large files without buffering them in memory.

import { drive } from '#services'

// Read as a Buffer
const buf = await drive.get('avatars/42.jpg')

// Read as a UTF-8 string
const text = await drive.getString('hello.txt')

// Read as a ReadableStream (useful for proxying large files)
const stream = await drive.getStream('files/large.bin')

delete

Remove a file by key. No error is thrown if the file does not exist.

await drive.delete('avatars/42.jpg')
// No error is thrown if the file does not exist.

exists

Returns true if the file exists. Uses HEAD on S3/R2 for efficiency.

const exists = await drive.exists('avatars/42.jpg')

if (!exists) {
  return response.notFound('Avatar not found')
}

copy / move

copy duplicates a file while leaving the source intact. move copies then deletes the source, equivalent to a rename.

// Copy, source is preserved.
await drive.copy('avatars/42.jpg', 'avatars/42-backup.jpg')

// Move: source is deleted after the copy completes.
await drive.move('tmp/upload-abc.jpg', 'avatars/42.jpg')

list

List files under an optional prefix. The local driver walks the directory tree recursively. S3/R2 use the ListObjectsV2 API.

// List all files under a prefix (recursive on local disk).
const files = await drive.list('avatars/')
// ['avatars/1.jpg', 'avatars/2.jpg', ...]

// List everything on the disk
const all = await drive.list()

getMetadata

Retrieve size (bytes), lastModified (Date), contentType, and (on S3/R2) etag.

const meta = await drive.getMetadata('avatars/42.jpg')

console.log(meta.size)         // bytes
console.log(meta.lastModified) // Date
console.log(meta.contentType)  // 'image/jpeg'
console.log(meta.etag)         // present on S3/R2

Upload Validation

The storage drivers accept any key and content so the framework stays storage-agnostic, but untrusted uploads should be validated first. @tekir/drive exports assertValidUpload() for an extension allowlist and max-size check, and sanitizeFilename() to strip directory components and unsafe characters from a client-supplied name before it becomes part of a key.

import { assertValidUpload, sanitizeFilename } from '@tekir/drive'

// Validate untrusted input BEFORE writing it to a disk.
// Throws UploadValidationError if the extension or size is not allowed.
assertValidUpload('invoice.pdf', buffer, {
  allowedExtensions: ['pdf', 'png', 'jpg'],
  maxSize: 5 * 1024 * 1024  // 5 MB
})

// Strip directory components and unsafe characters from a client filename.
const safe = sanitizeFilename(file.clientName)  // 'My Report (1).pdf' → 'My_Report_1.pdf'
await drive.put(`docs/${safe}`, buffer)

The local driver can also enforce validation on every write. Add an upload block to the disk config with allowedExtensions and maxSize; any put() (including streamed writes) that violates the rules is rejected.

// config/drive.ts , validate every local put() automatically
export default {
  default: 'local',
  disks: {
    local: {
      driver: 'local',
      root: './storage/uploads',
      urlPrefix: '/uploads',
      upload: {
        allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'pdf'],
        maxSize: 8 * 1024 * 1024  // reject anything larger
      }
    }
  }
}

URLs

getUrl

Returns a public URL for the file. On local disk this is urlPrefix + key. On S3/R2 it is the full HTTPS URL. Synchronous method.

// Returns the public URL for a file.
// Local disk: '/uploads/avatars/42.jpg'
// S3: 'https://mybucket.s3.us-east-1.amazonaws.com/avatars/42.jpg'
const url = drive.getUrl('avatars/42.jpg')

return response.ok({ avatar: url })

getSignedUrl

Generate a time-limited pre-signed URL that grants read access without credentials. S3 and R2 use AWS Signature v4 via the Web Crypto API.

The local driver signs key:expires with HMAC-SHA256 using APP_KEY (or a constructor-passed secret). Calling getSignedUrl with no secret available throws so a misconfigured app never returns a forgeable token. Use localDriver.verifySignedUrl(key, token, expires) from your file-serving route to validate the signature in constant time and enforce the embedded expiry, or use serveDrive() below to enforce it for you.

// Generate a pre-signed URL valid for 15 minutes (S3/R2).
// Anyone with this URL can download the file: even without credentials.
const url = await drive.getSignedUrl('private/report.pdf', {
  expiresIn: 900 // seconds
})

return response.redirect(url)

Serving Private Files

Issuing a signed URL only protects a private file if the serving path actually checks the signature. serveDrive() returns a server.fallback-compatible handler that serves files from a LocalDriver and enforces the signature for you. With requireSignature (the default) any request to /uploads/<key> without a valid, unexpired token and expires for that exact key is rejected with 403, so a leaked direct path cannot be read. Set requireSignature: false only for genuinely public buckets.

import { serveDrive } from '@tekir/drive'
import { drive } from '#services'
import type { LocalDriver } from '@tekir/drive'

// Serve private local files through a signature-enforcing fallback.
const disk = drive.use('local') as LocalDriver

// requireSignature defaults to true: a request to /uploads/<key> WITHOUT a
// valid token+expires signature is rejected with 403. A leaked direct path
// can no longer be read.
server.fallback(serveDrive({ driver: disk, urlPrefix: '/uploads' }))

// Hand out access with a signed URL produced by the same driver:
const url = await disk.getSignedUrl('private/report.pdf', { expiresIn: 900 })

Disk Drivers

Local

Stores files on the filesystem under root. Directories are created automatically. getStreamuses Bun's native file API. Every operation is confined to root: keys are resolved against their real path, so traversal attempts and symlinks that point outside the root are rejected.

// config/drive.ts
export default {
  default: 'local',
  disks: {
    local: {
      driver: 'local',
      root: './storage/uploads',  // absolute or relative to cwd
      urlPrefix: '/uploads',     // prepended to getUrl() results
      upload: {                   // optional: validate every put()
        allowedExtensions: ['jpg', 'jpeg', 'png', 'webp', 'pdf'],
        maxSize: 8 * 1024 * 1024  // 8 MB
      }
    }
  }
}

S3

Signs requests with AWS Signature v4 using the Web Crypto API, no AWS SDK needed. Also compatible with S3-compatible services (MinIO, DigitalOcean Spaces) via endpoint and forcePathStyle.

// config/drive.ts
import env from '#env'

export default {
  default: 's3',
  disks: {
    s3: {
      driver: 's3',
      bucket: env.S3_BUCKET,
      region: env.AWS_REGION,
      accessKeyId: env.AWS_ACCESS_KEY_ID,
      secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
      // Optional: override endpoint (MinIO, DigitalOcean Spaces, etc.)
      // endpoint: 'https://nyc3.digitaloceanspaces.com'
      // forcePathStyle: true
    }
  }
}

Cloudflare R2

Uses the S3-compatible driver internally. Provide your accountId: the endpoint is derived automatically.

// config/drive.ts
import env from '#env'

export default {
  default: 'r2',
  disks: {
    r2: {
      driver: 'r2',
      bucket: env.R2_BUCKET,
      accountId: env.CF_ACCOUNT_ID,
      accessKeyId: env.R2_ACCESS_KEY_ID,
      secretAccessKey: env.R2_SECRET_ACCESS_KEY
    }
  }
}

// R2 uses the S3-compatible API internally: signed URLs and all operations
// work identically to the S3 driver.

Memory

Stores files in a Map. No disk I/O, no network. Use in tests or for ephemeral storage.

// config/drive.ts, useful for development/testing
export default {
  default: 'memory',
  disks: {
    memory: { driver: 'memory' }
  }
}

Switching Disks

drive.use(diskName) returns the DiskDriver for the named disk. All methods are available on the returned object. The Drive instance proxies all methods to the default disk for convenience.

import { drive } from '#services'

// Call drive.use(diskName) to get a DiskDriver for a specific disk.
await drive.use('s3').put('backups/db.sql', dump)

const url = await drive.use('r2').getSignedUrl('reports/q1.pdf', { expiresIn: 3600 })

const files = await drive.use('local').list('avatars/')

Testing with fake()

drive.fake(diskName?) replaces the named disk (or default) with an in-memory driver and returns a cleanup function. No real files are written.

import { drive } from '#services'
import { test, expect } from 'bun:test'

test('saves avatar to disk', async () => {
  // Replace the default disk with an in-memory driver.
  // Returns a cleanup function to restore the original disk.
  const restore = drive.fake()

  try {
    await uploadAvatar(42, fakeBuffer)

    // The memory driver confirms the file was written.
    const exists = await drive.exists('avatars/42.jpg')
    expect(exists).toBe(true)

    const content = await drive.get('avatars/42.jpg')
    expect(content).toEqual(fakeBuffer)
  } finally {
    restore()
  }
})

// Fake a specific disk by name
const restore = drive.fake('s3')