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

@stowage/adapter-s3

v0.2.0

Published

stowage adapter for one bucket of AWS S3 or Cloudflare R2, speaking the S3 wire protocol without an SDK.

Downloads

307

Readme

@stowage/adapter-s3

A storage in one bucket of AWS S3 or Cloudflare R2, spoken to over the S3 wire protocol rather than through an SDK.

Install

npm install @stowage/adapter-s3

Example

A server signs a URL for one upload of the type and length the browser reported. presignPut resolves with the URL and the headers the upload sends beside the body, here content-type. The browser then calls it with a plain fetch, PUT and those headers, against AWS S3 or R2 alike.

import { fromEnv, s3Storage } from "@stowage/adapter-s3";

declare const process: { readonly env: Readonly<Record<string, string | undefined>> };

const aws = s3Storage({ bucket: "my-app-uploads", region: "eu-north-1", credentials: fromEnv });

const fromR2Env = () => ({
  accessKeyId: process.env.R2_ACCESS_KEY_ID ?? "",
  secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? "",
});

const r2 = s3Storage({
  bucket: "my-app-uploads",
  region: "auto",
  endpoint: "https://<account-id>.r2.cloudflarestorage.com",
  credentials: fromR2Env,
});

for (const storage of [aws, r2]) {
  const { url, headers } = await storage.presignPut("avatars/alice.png", {
    expiresIn: 300,
    contentType: "image/png",
    contentLength: 48_213,
  });

  console.log(url, headers);
}

The bucket has to allow UNSIGNED-PAYLOAD and carry a CORS rule for the uploading origin; stowage configures neither (flow 2).

Runtimes

Node 24 and later, Bun, Deno and workerd at the compatibility date 2026-09-01 without Node APIs. fromEnv reads the environment on workerd under nodejs_compat and on Deno under --allow-env. CI last ran green on Bun 1.4.2 and Deno 2.9.6.

The bundle measures 13.9 kB minified and gzipped, @stowage/core included.

Limits

  • keyBytesPreserved is not declared (spec 4.9). R2 normalizes a key to NFC, so two Unicode-equivalent keys name one object there and two on AWS S3.
  • delete sends at most one DeleteObjects per 1000 keys, plus at most one DELETE per key holding U+FFFE or U+FFFF, which XML carries neither raw nor as a reference (spec 7.1).
  • Where AWS S3 and R2 answer differently, the adapter is written to the stricter side (spec 7.2):

| Point | What holds | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | Listing order | None. A page holds at most 1000 keys | | userMetadata | 2 KB of encoded header bytes; keys handed back in lower case | | Single PUT | Up to 5 GB for a Uint8Array or string; a stream that fills more than one part goes as a multipart upload | | Object size ceiling | The provider's, answered with EntityTooLarge | | Content-Type | Always sent by put, application/octet-stream where none was given | | CompleteMultipartUpload | Judged by its body, which may carry an error under 200 | | Writes per key | R2 answers 429 above one write per second and key; the retry may recover a single collision, but does not guarantee it | | Incomplete multipart uploads | Removed by a lifecycle rule on AWS, after seven days by default on R2; stowage removes none | | Presigned URL host | The endpoint that signed it; on R2 the r2.cloudflarestorage.com endpoint and not a custom domain | | Response overrides on presignGet | Answered as the four response headers, on AWS and on R2 |

Notes

No package takes a connection URL (spec 7.3). A caller holding one splits it into the four options. credentials: fromEnv keeps the secret out of the URL; one that stays in it has to be percent-encoded, because a / in the secret makes new URL throw.

import { s3Storage, type S3Storage } from "@stowage/adapter-s3";

// s3://<access-key-id>:<secret-access-key>@<bucket>?region=<region>&endpoint=<endpoint>
export function s3StorageFromUrl(connection: string): S3Storage {
  const url = new URL(connection);
  const region = url.searchParams.get("region");

  // The message quotes nothing of the URL, which carries the secret.
  if (region === null) throw new Error("The connection URL names no region");

  return s3Storage({
    bucket: url.hostname,
    region,
    endpoint: url.searchParams.get("endpoint") ?? undefined,
    credentials: {
      accessKeyId: decodeURIComponent(url.username),
      secretAccessKey: decodeURIComponent(url.password),
    },
  });
}

put takes no Blob. A caller holding one passes its stream (spec 4.2):

import { fromEnv, s3Storage } from "@stowage/adapter-s3";

const storage = s3Storage({ bucket: "my-app-uploads", region: "eu-north-1", credentials: fromEnv });
const blob = new Blob(["region,revenue\n"], { type: "text/csv" });

await storage.put("reports/2026/q3.csv", blob.stream(), { contentType: blob.type });

stowage reports no progress, no upload id and no resume (spec 7.6). A caller who wants progress counts the bytes on their way into put:

import { fromEnv, s3Storage } from "@stowage/adapter-s3";

const storage = s3Storage({ bucket: "my-app-uploads", region: "eu-north-1", credentials: fromEnv });

function countBytes(report: (bytes: number) => void): TransformStream<Uint8Array, Uint8Array> {
  let bytes = 0;

  return new TransformStream({
    transform(chunk, controller) {
      bytes += chunk.byteLength;
      report(bytes);
      controller.enqueue(chunk);
    },
  });
}

const response = await fetch("https://example.com/video.mp4");

if (response.body === null) throw new Error("The response carries no body");

await storage.put("videos/intro.mp4", response.body.pipeThrough(countBytes(console.log)), {
  contentType: "video/mp4",
});

Specification

docs/spec.md at @stowage/[email protected] is the contract: a caller may rely on what it states and on nothing else this package happens to export. The terms it uses and the decisions behind it are at the same tag.

License

MIT