npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@velajs/storage

v1.34.0

Published

Edge-first, driver-based object/file storage for the Vela framework

Readme

@velajs/storage

Edge-first, driver-based object/file storage for the Vela framework.

Runs on Cloudflare Workers, Deno, Bun, Node 24+, and Vercel Edge — no AWS SDK, ever. S3/R2 requests are signed with aws4fetch (fetch + Web Crypto), so the whole default path passes Vela's edge-runtime-audit gate.

Why not just wrap files-sdk / storagesdk?

Both are great libraries, but their object-store adapters statically import the AWS SDK v3 (~500KB, breaks on Workers). @velajs/storage owns an edge-native StorageDriver contract and ships first-party drivers that sign with aws4fetch. You can still reach the wider ecosystem through the opt-in storageSdkDriver() bridge (@velajs/storage/storagesdk) on Node/Bun.

Install

pnpm add @velajs/storage

Quick start

import { StorageModule, StorageService } from '@velajs/storage';
import { s3Driver } from '@velajs/storage/drivers/s3';
import { ENV, Module } from '@velajs/vela';

@Module({
  imports: [
    StorageModule.registerAsync({
      inject: [ENV],
      useFactory: (env) => ({
        driver: s3Driver({
          endpoint: 'https://s3.us-east-1.amazonaws.com',
          region: 'us-east-1',
          bucket: 'my-bucket',
          credentials: { accessKeyId: env.AWS_KEY, secretAccessKey: env.AWS_SECRET },
        }),
      }),
    }),
  ],
})
class AppModule {}

ENV is the application's runtime environment. On Workers, createCloudflareWorker(AppModule) from @velajs/cloudflare seeds it, and wrangler types types AWS_KEY and AWS_SECRET from .dev.vars; elsewhere, pass VelaFactory.create(AppModule, { env }). An async registration names the tokens its factory's parameters receive in inject; a factory without parameters may omit it.

The factory returns the module options. name (the bucket, default 'default') and httpController are structural: they decide the provided tokens and the mounted routes, so registerAsync takes them next to the factory. The driver may be a function of the application's ENV, which builds it on the first storage operation of each application, so a static register serves every environment without reading a binding at boot. On Cloudflare Workers, name the R2 bucket binding with r2Storage from @velajs/cloudflare/storage:

import { r2Storage } from '@velajs/cloudflare/storage';

StorageModule.register({ driver: r2Storage({ binding: 'UPLOADS' }) });
// equivalent, by hand: StorageModule.register({ driver: (env) => r2Driver({ bucket: env.UPLOADS }) })

A missing or mistyped binding fails that first operation with a message naming the binding and the r2_buckets key of the Wrangler configuration. Each bucket name is one module instance; registering a name again with another driver, http block or other options fails bootstrap, so two features that each need a bucket give them distinct names (name: 'avatars').

Secure HTTP multipart uploads

Browser-direct multipart uploads use a stateless HMAC grant bound to the authenticated actor, object key, provider upload ID, exact byte size, part count, and expiry. Configure a stable secret of at least 32 bytes and return a server-derived actorId from the authorizer. A secret from the runtime environment comes through DI: the registerAsync factory returns it inside the runtime http policy.

StorageModule.registerAsync({
  inject: [ENV],
  useFactory: (env) => ({
    driver: r2Driver({ bucket: env.UPLOADS }),
    http: {
      multipartGrantSecret: env.STORAGE_MULTIPART_GRANT_SECRET,
      maxUploadSize: 100 * 1024 * 1024,
      maxMultipartParts: 1000,
      authorize: (_action, { ctx }) => ({ actorId: ctx.get('user').id }),
    },
  }),
  httpController: { path: '/api/storage' },
});

The factory runs once during application initialization. All HTTP policy, including authorization, download mode, size limits and http.multipartGrantSecret, resolves through DI. A secret shorter than 32 bytes rejects initialization; without a secret, multipart endpoints refuse every request with 403. A driver function runs on the first storage operation and retries construction after a failure.

Routes are opt-in: httpController: {} mounts /api/storage, and { path: '/uploads' } changes the mount. Omitting httpController or passing false mounts nothing, even if runtime http policy is provided. The old http.mountController, http.basePath and top-level multipartGrantSecret options are removed. The low-level createStorageController(path, serviceToken, optionsToken) reads all ResolvedHttpOptions from its injected options token.

The browser client sends the exact file size when creating an upload and echoes the returned grant for part signing, completion, and abort. Multipart data is completed into a reserved quarantine key, verified there, and only then promoted to the requested key. A mismatched, oversized, or unreadable result is never exposed at the requested key. HTTP downloads default to attachment; both /download redirects and /sign-download URLs bind an attachment Content-Disposition, proxy responses emit X-Content-Type-Options: nosniff, and HTML/SVG are never served inline.

The controller is marked SkipGuardPhases(['tenant', 'authorize']): the authorizer decides every action, so application-installed skippable tenant and authorization guards do not run on it, while global authentication and other global guards still do. Check tenant membership in the authorizer. The HTTP control plane's POST endpoints accept only application/json or +json bodies, because browsers send text/plain and form-encoded POSTs cross-site without a CORS preflight. Any other media type is refused with 415 before the authorizer runs, and a malformed or non-object body is a 400 invalid_request. The @velajs/storage/client browser client already sends JSON.

Drivers

| Driver | Import | Edge? | Presign | |---|---|---|---| | Memory (tests) | @velajs/storage/drivers/memory | ✅ | ❌ | | S3 / S3-compatible | @velajs/storage/drivers/s3 | ✅ | ✅ | | R2 (native binding) | @velajs/storage/drivers/r2, or r2Storage({ binding }) from @velajs/cloudflare/storage | ✅ | via hybrid | | R2 (HTTP + hybrid) | @velajs/storage/drivers/r2-http | ✅ | ✅ | | storagesdk bridge | @velajs/storage/storagesdk | Node/Bun only | depends on adapter |

Testing

There's no separate storage fake — the memory driver IS the fake. Build a disk in one line and assert against it with the helpers from @velajs/storage/testing:

import { createStorage } from '@velajs/storage';
import { memoryDriver } from '@velajs/storage/drivers/memory';
import { assertExists, assertMissing, assertCount } from '@velajs/storage/testing';

const storage = createStorage({ driver: memoryDriver() });
await storage.upload('avatars/1.png', bytes);

await assertExists(storage, 'avatars/1.png');
await assertMissing(storage, 'avatars/2.png');
await assertCount(storage, 'avatars/', 1); // or assertCount(storage, 1) for the whole disk

The helpers accept anything memory-backed — a Storage facade or an injected StorageService. For DI/integration tests, register the same driver instead: StorageModule.register({ driver: memoryDriver() }).

See the docs site for presigned uploads, the HTTP upload controller + browser client, middleware, multi-bucket, and the full capability matrix.

Deadlines and stream ownership

timeout bounds the wait for an operation; signal stops that wait when aborted. Both signal cooperative drivers, but native R2 binding I/O can still complete, including a write whose caller has already received Timeout or Aborted. Locally timed-out or cancelled operations are never retried, even with retries or the retry middleware enabled. A rejected write is not proof that no write occurred; reconcile its key before deciding whether to issue another mutation. Settled retryable provider errors retain the configured retry policy.

Controls end when the operation returns. A successfully delivered download body belongs to the caller: consume or cancel it explicitly. Abandoned downloads cancel a body if it arrives later; multipart upload cancellation stops scheduling parts and attempts to abort the upload. Cleanup cannot guarantee that an already-issued native operation was rolled back. head() and list() retain their 1.x lazy body readers; those later reads are separate from the original operation's deadline.

R2 range reads report the returned byte length, including ranges clipped at EOF, without buffering the stream. Invalid ranges fail before binding I/O.

Metadata and native binding types

Use stat(key) or listMetadata(options) when browsing metadata. They return plain StoredFileMetadata snapshots without body readers, while head() and list() keep their existing 1.x lazy readers. Fetch bytes explicitly through download(key, { signal, timeout }) when a later read needs operation controls. Metadata methods do not issue extra per-object GET or HEAD requests.

import { createStorage } from '@velajs/storage';
import { r2Driver } from '@velajs/storage/drivers/r2';

// env.FILES uses the R2Bucket type from your generated Workers environment.
const files = createStorage({
  driver: r2Driver({ bucket: env.FILES, includeMetadata: true }),
  prefix: 'uploads',
});
const meta = await files.stat('report.csv');
const page = await files.listMetadata({ delimiter: '/', limit: 100 });
if (page.hasMore) {
  const next = await files.listMetadata({ delimiter: '/', cursor: page.cursor });
}

includeMetadata: true is an R2 binding/hybrid driver option requesting HTTP and custom metadata in listings. Without it, R2 list results may omit custom metadata and use the fallback content type. Other drivers return the metadata already available from their listings. Metadata-rich pages can be shorter; follow hasMore and cursor, including when a page contains only folder prefixes.

files.raw retains the exact supplied native binding type. readonly() views, lazyDriver(), directly constructed StorageService instances, and built-in middleware composition preserve that type. Custom Middleware remains supported; its composition returns unknown raw types unless it implements the additive RawPreservingMiddleware contract. raw is the original handle and bypasses prefixes, readonly checks, middleware, and facade controls; use native keys there. Named module injection still exposes StorageService<unknown>; inject your typed ENV token when an injected consumer needs full native methods.

Storage on Cloudflare Workers

StorageModule from this package is the one file/object storage module; choose a driver through its independent import. On Workers, r2Storage({ binding }) from @velajs/cloudflare/storage drives it from the native R2 binding. Keep native env.CACHE, env.DB, and Durable Object storage for KV, SQL and per-object transactions; these are separate capabilities, not file-storage backends.

The native R2 binding cannot presign. Serve its objects through publicBaseUrl, through the authorized HTTP controller with httpController: {}, http: { download: 'proxy' }, or presign through the S3 or R2 HTTP/hybrid drivers.

@velajs/cloudflare no longer has a storage module of its own. An application that used its multi-disk StorageModule registers one StorageModule per former disk:

| Removed Cloudflare API | This package | | --- | --- | | disks with native buckets | One named StorageModule.register({ name, driver: r2Storage({ binding }) }) per disk | | Driver upload(body, path, { mimeType }) | upload(key, body, { contentType }) | | download(path).toStream() | (await download(key)).stream() | | Worker HMAC getPresignedUrl() and the GET /storage/:disk proxy route | publicBaseUrl, the authorized httpController: {}, http: { download: 'proxy' } controller, or provider-signed URLs from the S3 or R2 HTTP/hybrid drivers |

URLs issued by the removed proxy route stop working once it is gone.

Compression middleware requires a metadata-capable driver and valid vela-zip metadata on stored objects. Untagged objects are rejected. Encryption rejects invalid ciphertext; it has no plaintext passthrough mode.