2.0 beta — new releases can include breaking changes. Stable 1.x docs
dimah-s3v2.0.0-beta.0

File Proxy

Stream downloads through `GET /file` with hardening headers, byte ranges, and conditional requests.

In proxy mode, a download returns a URL on your API instead of a signed bucket URL. The browser fetches GET /file, and your server streams the object from S3:

lib/s3.ts
invoices: route({
  download: {
    mode: "proxy", 
    guard: async ({ key, locals }) => {
      if (!(await canReadInvoice(locals.userId, key))) {
        throw s3Error("FORBIDDEN");
      }
    },
  },
}),
const { url, mode, expiresIn } = await api.download({ route: "invoices", key });
// url: "/api/s3/file?route=invoices&key=…&disposition=attachment"
// mode: "proxy", expiresIn: undefined

Use it when objects must never be reachable by a bucket URL, when every read must pass your guards, or when the bucket is not reachable from the browser.


How it works

File proxy
  1. route.guardhook
  2. download.resolvemode: "proxy"
  3. download.guardhook
  4. GetObjectRange and conditionals
  5. Stream responseserver
  • The /file URL is not signed and does not expire. Every request to it runs the instance, route, and download guards again, so they must authorize from what a plain browser GET carries, usually a session cookie. An <img> or <a href> does not send an Authorization header.
  • The URL is relative to the API ({basePath}/file?…) unless baseURL is set. createS3Client resolves a root-relative URL against an absolute baseURL, so a cross-origin API still works.
  • /file serves only routes with download.mode: "proxy". On any other route it returns 404 FEATURE_DISABLED with params.feature set to "proxy", so the server never streams objects a route did not opt in for.
  • There is no s3.api method for /file. On the server, read the object with your S3Client.

Response headers

Every /file response, including 206, 304, 412, 416, and errors, carries:

HeaderValue
Content-Security-Policydefault-src 'none'; img-src 'self' data:; media-src 'self'; style-src 'unsafe-inline'; sandbox
X-Content-Type-Optionsnosniff
Cross-Origin-Resource-Policysame-origin
Cache-Controlprivate, no-cache, unless download.resolve returns cacheControl
Accept-Rangesbytes

A successful response also sends Content-Type, Content-Disposition, Content-Length, and ETag, Last-Modified, Content-Encoding, and Content-Language when S3 reports them.

The object comes from your API origin, so it must never run as a page there. The CSP sandboxes it, and nosniff stops the browser from guessing another type.

Inline display

Proxy mode uses the same allow-list as presign mode, minus application/pdf: the sandbox CSP stops the PDF viewer in Chromium. To show PDFs inline, use presign mode, or serve /file from a separate origin and list application/pdf in download.inlineTypes.

download: {
  mode: "proxy",
  inlineTypes: ["image/*", "video/mp4"], 
},

Caching

private, no-cache lets the browser keep a copy but revalidate it on each use. Return cacheControl from download.resolve to change it for successful responses:

download: {
  mode: "proxy",
  resolve: () => ({ cacheControl: "private, max-age=3600" }), 
},

Error responses always send private, no-cache, so a shared cache never keeps a refusal and serves it to someone else.


Byte ranges

/file forwards one byte range to S3, so video seeking and resumed downloads work:

Range headerResponse
bytes=0-1023, bytes=1024-, bytes=-500206 with Content-Range
Several ranges, or malformedIgnored. 200 with the whole object
Outside the object416 RANGE_NOT_SATISFIABLE with Content-Range: bytes */<size>

Conditional requests

HeaderResponse when it matches
If-None-Match, If-Modified-Since304 with no body, the headers above, and ETag
If-Match, If-Unmodified-Since412 PRECONDITION_FAILED when it fails
If-RangeThe range when the object is unchanged, else 200 with the whole object

Conditional headers are forwarded to GetObject. If-Range is checked against the returned ETag or Last-Modified.


Disconnects

/file passes the request's abort signal to GetObject. When the client goes away, the S3 read stops. The Node, Express, and Fastify adapters abort that signal when the connection closes, and stream the body to the response without buffering it. See Server Setup.

Frequently asked questions

On this page