partial-content 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/LICENSE +21 -0
- package/README.md +601 -0
- package/SECURITY.md +92 -0
- package/dist/azure.d.ts +79 -0
- package/dist/azure.d.ts.map +1 -0
- package/dist/azure.js +251 -0
- package/dist/azure.js.map +1 -0
- package/dist/content-disposition.d.ts +74 -0
- package/dist/content-disposition.d.ts.map +1 -0
- package/dist/content-disposition.js +253 -0
- package/dist/content-disposition.js.map +1 -0
- package/dist/fs.d.ts +86 -0
- package/dist/fs.d.ts.map +1 -0
- package/dist/fs.js +375 -0
- package/dist/fs.js.map +1 -0
- package/dist/gcs.d.ts +72 -0
- package/dist/gcs.d.ts.map +1 -0
- package/dist/gcs.js +202 -0
- package/dist/gcs.js.map +1 -0
- package/dist/hono.d.ts +92 -0
- package/dist/hono.d.ts.map +1 -0
- package/dist/hono.js +61 -0
- package/dist/hono.js.map +1 -0
- package/dist/http.d.ts +70 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +281 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/kernel.d.ts +541 -0
- package/dist/kernel.d.ts.map +1 -0
- package/dist/kernel.js +1218 -0
- package/dist/kernel.js.map +1 -0
- package/dist/memory.d.ts +55 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +107 -0
- package/dist/memory.js.map +1 -0
- package/dist/mime.d.ts +49 -0
- package/dist/mime.d.ts.map +1 -0
- package/dist/mime.js +150 -0
- package/dist/mime.js.map +1 -0
- package/dist/node.d.ts +84 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +215 -0
- package/dist/node.js.map +1 -0
- package/dist/object-store.d.ts +472 -0
- package/dist/object-store.d.ts.map +1 -0
- package/dist/object-store.js +335 -0
- package/dist/object-store.js.map +1 -0
- package/dist/r2.d.ts +94 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +150 -0
- package/dist/r2.js.map +1 -0
- package/dist/s3.d.ts +49 -0
- package/dist/s3.d.ts.map +1 -0
- package/dist/s3.js +263 -0
- package/dist/s3.js.map +1 -0
- package/dist/web.d.ts +336 -0
- package/dist/web.d.ts.map +1 -0
- package/dist/web.js +1094 -0
- package/dist/web.js.map +1 -0
- package/docs/DESIGN.md +426 -0
- package/package.json +182 -0
- package/src/azure.ts +329 -0
- package/src/content-disposition.ts +300 -0
- package/src/fs.ts +469 -0
- package/src/gcs.ts +290 -0
- package/src/hono.ts +123 -0
- package/src/http.ts +351 -0
- package/src/index.ts +85 -0
- package/src/kernel.ts +1498 -0
- package/src/memory.ts +148 -0
- package/src/mime.ts +160 -0
- package/src/node.ts +261 -0
- package/src/object-store.ts +665 -0
- package/src/r2.ts +232 -0
- package/src/s3.ts +324 -0
- package/src/web.ts +1603 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 (2026-07-06)
|
|
4
|
+
|
|
5
|
+
Initial public release. Zero-dependency, ESM-only HTTP file-serving protocol layer for any storage backend.
|
|
6
|
+
|
|
7
|
+
### Kernel (`partial-content`)
|
|
8
|
+
- `evaluateConditionalRequest` / `evaluateConditionalWrite`: full RFC 7232/7233/9110 evaluation chain (412 > 304 > If-Range > Range) and write-side OCC.
|
|
9
|
+
- Range parsing with `multipart/byteranges` for multi-range requests, overlapping/adjacent coalescing, and range-amplification defense (`maxRanges`).
|
|
10
|
+
- ETag generation (strong content-hash, weak size+mtime, safe `undefined` fallback) with sub-second timestamp flooring.
|
|
11
|
+
- RFC 9530 `Repr-Digest` / `Content-Digest` with `Want-*` negotiation.
|
|
12
|
+
- `buildContentDisposition`: RFC 6266/8187 with CRLF-injection, path-traversal, and bidi-override hardening.
|
|
13
|
+
|
|
14
|
+
### Storage adapters
|
|
15
|
+
- `/s3` (AWS S3, R2 S3-mode, Hetzner, MinIO, Wasabi), `/r2` (native bindings), `/gcs`, `/azure`, `/fs`, `/http` (any range-capable origin), `/memory`.
|
|
16
|
+
- `/fs`: opt-in hot-object cache (nginx `open_file_cache` semantics): TTL revalidation, coherent metadata + small-body capture, LRU eviction under both an entry cap (`maxEntries`) and a body byte budget (`maxBytes`; `0` = metadata-only).
|
|
17
|
+
- Atomic pinned reads (TOCTOU elimination) via each backend's native conditional read; `authoritativeRange` single-round-trip fast path for media seeking.
|
|
18
|
+
- Backend failures map to truthful status: `404` (not found), `503` + `Retry-After` (transient throttle/overload, `StoreUnavailableError`; the `/azure` and `/http` adapters surface the backend's advised `Retry-After`), `502` (malformed upstream).
|
|
19
|
+
- `classifyStoreRead` + `StoreErrorClassifiers`: the shared error-classification primitive, exported for custom adapters; a throttle classifier may return `{ retryAfterSeconds }` to propagate a back-off hint.
|
|
20
|
+
- `guardStreamLength`: wrap a web `ReadableStream` so a graceful short read errors the body instead of under-running the committed `Content-Length`.
|
|
21
|
+
- `resolveServedRange`: parse a backend `Content-Range` into served bounds + honest total (the `bytes a-b/*` unknown-total sentinel maps to `undefined`), the shared primitive behind the S3/Azure/HTTP adapters.
|
|
22
|
+
|
|
23
|
+
### Runtimes
|
|
24
|
+
|
|
25
|
+
- Verified in CI on Node, Bun, and Deno (a runtime-agnostic smoke suite over the built package); Cloudflare Workers via the `/r2` adapter's Fetch-standard surface.
|
|
26
|
+
|
|
27
|
+
### Framework adapters
|
|
28
|
+
- `/web` (Fetch API: Next.js, SvelteKit, Remix, Workers, Bun, Deno), `/node` (Express, Fastify, Koa, raw http) with a bounded backpressure stall timeout, `/hono`.
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
- `nosniff`, `charset=utf-8` enforcement on textual types, `default-src 'none'` CSP on error responses, CORP, forced-`attachment` signed URLs, SSRF-safe redirect-error default on the http store.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Nordvec
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
# partial-content
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/partial-content)
|
|
4
|
+
[](https://www.npmjs.com/package/partial-content)
|
|
5
|
+
[](https://www.npmjs.com/package/partial-content)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
|
|
9
|
+
RFC-compliant HTTP file serving for any storage backend. Range requests, conditional caching, Content-Disposition, ETag generation, and complete storage adapters in one zero-dependency kernel.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
npm install partial-content
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### The problem
|
|
16
|
+
|
|
17
|
+
When your app proxies files from object storage, browsers expect your server to speak the full HTTP file-serving protocol: range requests for video seeking, conditional requests for cache validation, Content-Disposition for safe downloads. Today you need three or four packages to get there, and none of them handle the orchestration:
|
|
18
|
+
|
|
19
|
+
| | Range parsing | Conditional requests | Content-Disposition | Repr-Digest | Orchestration | Storage agnostic |
|
|
20
|
+
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
|
21
|
+
| **partial-content** | **Yes** | **Yes** (304, 412) | **Yes** (RFC 8187) | **Yes** (RFC 9530) | **Yes** | **Yes** |
|
|
22
|
+
| `range-parser` | Yes | -- | -- | -- | -- | Yes |
|
|
23
|
+
| `fresh` | -- | 304 only | -- | -- | -- | Yes |
|
|
24
|
+
| `content-disposition` | -- | -- | Yes | -- | -- | Yes |
|
|
25
|
+
| `send` | Yes | Yes | Yes | -- | Yes | **No** (local fs only) |
|
|
26
|
+
|
|
27
|
+
`partial-content` is the **protocol layer without the I/O layer**. You bring the bytes from wherever they live, or use a built-in storage adapter.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
Request
|
|
31
|
+
│
|
|
32
|
+
▼
|
|
33
|
+
evaluateConditionalRequest()
|
|
34
|
+
│
|
|
35
|
+
├──► 200 OK
|
|
36
|
+
├──► 206 Partial Content
|
|
37
|
+
├──► 304 Not Modified
|
|
38
|
+
├──► 412 Precondition Failed
|
|
39
|
+
└──► 416 Range Not Satisfiable
|
|
40
|
+
│
|
|
41
|
+
▼
|
|
42
|
+
Your storage backend
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Architecture
|
|
46
|
+
|
|
47
|
+
One package. Subpath exports. Install only the SDKs you use.
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
partial-content Zero-dep kernel (RFC 7232/7233/9110 evaluation)
|
|
51
|
+
partial-content/web Fetch API handler (Next.js, SvelteKit, Remix, Workers)
|
|
52
|
+
partial-content/s3 AWS S3, R2 (S3 mode), Hetzner, MinIO, Wasabi
|
|
53
|
+
partial-content/r2 Cloudflare R2 native bindings (no AWS SDK)
|
|
54
|
+
partial-content/gcs Google Cloud Storage
|
|
55
|
+
partial-content/azure Azure Blob Storage
|
|
56
|
+
partial-content/fs Local filesystem (Node.js)
|
|
57
|
+
partial-content/node Node.js http/Express/Fastify adapter
|
|
58
|
+
partial-content/hono Hono middleware
|
|
59
|
+
partial-content/http Any range-capable HTTP origin (Supabase, presigned URLs, CDNs)
|
|
60
|
+
partial-content/memory In-memory store (tests, demos, embedded assets)
|
|
61
|
+
partial-content/mime Curated zero-dep MIME lookup
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Cloud SDKs are **optional peer dependencies**:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# S3 users
|
|
68
|
+
npm install partial-content @aws-sdk/client-s3
|
|
69
|
+
# add @aws-sdk/s3-request-presigner only if you use createSignedUrl()
|
|
70
|
+
|
|
71
|
+
# GCS users
|
|
72
|
+
npm install partial-content @google-cloud/storage
|
|
73
|
+
|
|
74
|
+
# Azure users
|
|
75
|
+
npm install partial-content @azure/storage-blob
|
|
76
|
+
|
|
77
|
+
# Filesystem or kernel only (zero extra deps)
|
|
78
|
+
npm install partial-content
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Features
|
|
82
|
+
|
|
83
|
+
- **One call does everything**: `evaluateConditionalRequest()` handles the complete evaluation chain (412 > 304 > If-Range > Range) in correct order
|
|
84
|
+
- **Write-side OCC**: `evaluateConditionalWrite()` handles If-Match/If-None-Match for PUT/PATCH/DELETE with correct 412 semantics
|
|
85
|
+
- **RFC 9530 Repr-Digest**: End-to-end integrity verification via `sha-256=:<base64>:` header, with `Want-Repr-Digest` negotiation -- first-class support that `send`, `sirv`, and the framework static middlewares lack
|
|
86
|
+
- **Built-in storage adapters**: S3, R2 (native), GCS, Azure, local filesystem
|
|
87
|
+
- **Built-in framework adapters**: Fetch API (Next.js/SvelteKit/Remix/Workers), Node.js (Express/Fastify/Koa), Hono
|
|
88
|
+
- Range requests (206 Partial Content, 416 Range Not Satisfiable), including multi-range `multipart/byteranges` with overlapping/adjacent-range coalescing and range-amplification defense (`maxRanges`)
|
|
89
|
+
- Conditional requests (304 Not Modified, 412 Precondition Failed) with sub-second timestamp flooring
|
|
90
|
+
- ETag generation from storage metadata (strong for content hash, weak for size+mtime, safe undefined fallback)
|
|
91
|
+
- Content-Disposition with non-ASCII filename encoding, CRLF injection prevention, path traversal protection, and bidi spoofing defense
|
|
92
|
+
- Published `ObjectStore` interface for building custom storage adapters against a stable contract
|
|
93
|
+
- Pure functions, zero I/O, zero dependencies; the hot path constructs no fetch primitives, no stream machinery for small bodies, and re-parses no dates (validators derive once at stat time)
|
|
94
|
+
- ESM-only. Works across Node.js 20+, Bun, Deno, Cloudflare Workers, and edge runtimes
|
|
95
|
+
|
|
96
|
+
### Storage Adapters
|
|
97
|
+
|
|
98
|
+
| Adapter | Backends | Extra Dependencies |
|
|
99
|
+
|---------|----------|--------------------|
|
|
100
|
+
| `partial-content/s3` | AWS S3, R2 (S3 mode), Hetzner, MinIO, Backblaze, Wasabi | `@aws-sdk/client-s3` |
|
|
101
|
+
| `partial-content/r2` | Cloudflare R2 (native) | None (uses R2 bindings) |
|
|
102
|
+
| `partial-content/gcs` | Google Cloud Storage | `@google-cloud/storage` |
|
|
103
|
+
| `partial-content/azure` | Azure Blob Storage | `@azure/storage-blob` |
|
|
104
|
+
| `partial-content/fs` | Local filesystem | None (Node.js builtins) |
|
|
105
|
+
| `partial-content/http` | Supabase Storage, presigned URLs, CDNs, any range-capable HTTP origin | None (global fetch) |
|
|
106
|
+
| `partial-content/memory` | In-memory objects (tests, demos, embedded assets) | None |
|
|
107
|
+
|
|
108
|
+
### Framework Adapters
|
|
109
|
+
|
|
110
|
+
| Adapter | Works With |
|
|
111
|
+
|---------|------------|
|
|
112
|
+
| `partial-content/web` | Next.js, SvelteKit, Remix, Nuxt 3, SolidStart, Astro, Fresh (Deno), Elysia (Bun), Cloudflare Workers, Bun.serve, Deno.serve, and any Fetch API runtime |
|
|
113
|
+
| `partial-content/node` | Express, Fastify, Koa, NestJS, Angular SSR, raw `http.createServer` |
|
|
114
|
+
| `partial-content/hono` | Hono (all runtimes) |
|
|
115
|
+
| Kernel only | Anything. Pure functions, zero runtime assumptions. |
|
|
116
|
+
|
|
117
|
+
## Quick Start
|
|
118
|
+
|
|
119
|
+
### High-Level: Built-in Adapters
|
|
120
|
+
|
|
121
|
+
For most applications, combine a storage adapter with a framework adapter. The handler manages the full HTTP protocol (200, 206, 304, 412, 416, HEAD) automatically.
|
|
122
|
+
|
|
123
|
+
#### Next.js / SvelteKit / Remix (Fetch API)
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { serveObject } from "partial-content/web";
|
|
127
|
+
import { s3Store } from "partial-content/s3";
|
|
128
|
+
import { S3Client } from "@aws-sdk/client-s3";
|
|
129
|
+
|
|
130
|
+
const client = new S3Client({ region: "eu-central-1" });
|
|
131
|
+
const store = s3Store({ client, bucket: "documents" });
|
|
132
|
+
const handler = serveObject(store, { disposition: "inline" });
|
|
133
|
+
|
|
134
|
+
// Next.js App Router
|
|
135
|
+
export async function GET(req: Request, { params }: { params: { id: string } }) {
|
|
136
|
+
return handler(req, { key: params.id, mime: "application/pdf" });
|
|
137
|
+
}
|
|
138
|
+
export const HEAD = GET;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
#### Express / Node.js
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
import express from "express";
|
|
145
|
+
import { serveObject } from "partial-content/node";
|
|
146
|
+
import { fsStore } from "partial-content/fs";
|
|
147
|
+
|
|
148
|
+
const store = fsStore({ root: "/var/data/uploads" });
|
|
149
|
+
// Hot small files? Opt into the bounded TTL cache (see Benchmarks):
|
|
150
|
+
// fsStore({ root, cache: { ttlMs: 1000 } })
|
|
151
|
+
const app = express();
|
|
152
|
+
|
|
153
|
+
app.get("/files/:key", serveObject(store, {
|
|
154
|
+
key: (req) => req.params.key,
|
|
155
|
+
disposition: "inline",
|
|
156
|
+
}));
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
#### Hono
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
import { Hono } from "hono";
|
|
163
|
+
import { serveObject } from "partial-content/hono";
|
|
164
|
+
import { s3Store } from "partial-content/s3";
|
|
165
|
+
|
|
166
|
+
const store = s3Store({ client, bucket: "media" });
|
|
167
|
+
const app = new Hono();
|
|
168
|
+
|
|
169
|
+
app.get("/media/:key", serveObject(store, {
|
|
170
|
+
key: (c) => c.req.param("key"),
|
|
171
|
+
cacheControl: "public, max-age=31536000, immutable",
|
|
172
|
+
}));
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### Cloudflare Workers (R2 native, no AWS SDK)
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import { Hono } from "hono";
|
|
179
|
+
import { serveObject } from "partial-content/hono";
|
|
180
|
+
import { r2Store } from "partial-content/r2";
|
|
181
|
+
|
|
182
|
+
const app = new Hono<{ Bindings: { MY_BUCKET: R2Bucket } }>();
|
|
183
|
+
|
|
184
|
+
app.get("/files/:key", (c) => {
|
|
185
|
+
const store = r2Store({ bucket: c.env.MY_BUCKET });
|
|
186
|
+
return serveObject(store, { key: (c) => c.req.param("key") })(c);
|
|
187
|
+
});
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Low-Level: Kernel Only
|
|
191
|
+
|
|
192
|
+
For custom integrations, use the kernel primitives directly. You control the storage I/O, the kernel handles the protocol.
|
|
193
|
+
|
|
194
|
+
#### One call does everything
|
|
195
|
+
|
|
196
|
+
```typescript
|
|
197
|
+
import { evaluateConditionalRequest } from "partial-content";
|
|
198
|
+
|
|
199
|
+
const { status, headers, range } = evaluateConditionalRequest(
|
|
200
|
+
request.headers,
|
|
201
|
+
{
|
|
202
|
+
totalSize: fileSize,
|
|
203
|
+
contentType: "video/mp4",
|
|
204
|
+
etag: '"abc123"',
|
|
205
|
+
lastModified: "2025-06-28T12:00:00.000Z", // ISO 8601 normalized automatically
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
if (status === 304 || status === 412 || status === 416) {
|
|
210
|
+
return new Response(null, { status, headers });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// `range` is a kernel-validated ParsedRange -- pass it straight to your store.
|
|
214
|
+
const { stream } = range
|
|
215
|
+
? await store.getObject(key, { range }) // 206: only the requested slice
|
|
216
|
+
: await store.getObject(key); // 200: the whole object
|
|
217
|
+
|
|
218
|
+
return new Response(stream, { status, headers });
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
Request evaluateConditionalRequest()
|
|
223
|
+
│ │
|
|
224
|
+
│ If-Match / If-Unmodified-Since ├──► 412 Precondition Failed
|
|
225
|
+
│ If-None-Match / If-Modified-Since ├──► 304 Not Modified
|
|
226
|
+
│ If-Range + Range ├──► 416 Range Not Satisfiable
|
|
227
|
+
│ ├──► 206 Partial Content
|
|
228
|
+
│ └──► 200 OK
|
|
229
|
+
│
|
|
230
|
+
▼
|
|
231
|
+
Fetch bytes from storage (you control this)
|
|
232
|
+
│
|
|
233
|
+
▼
|
|
234
|
+
new Response(body, { status, headers })
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
#### Node.js / Express (kernel only)
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
import { fromNodeHeaders, evaluateConditionalRequest } from "partial-content";
|
|
241
|
+
|
|
242
|
+
app.get("/files/:key", (req, res) => {
|
|
243
|
+
const headers = fromNodeHeaders(req.headers);
|
|
244
|
+
const { status, headers: resHeaders, range } = evaluateConditionalRequest(
|
|
245
|
+
headers,
|
|
246
|
+
{ totalSize: fileSize, etag, lastModified, contentType },
|
|
247
|
+
);
|
|
248
|
+
res.writeHead(status, resHeaders);
|
|
249
|
+
// ...
|
|
250
|
+
});
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Content-Disposition
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
import { buildContentDisposition } from "partial-content";
|
|
257
|
+
|
|
258
|
+
buildContentDisposition("report.pdf");
|
|
259
|
+
// => 'attachment; filename=report.pdf'
|
|
260
|
+
|
|
261
|
+
buildContentDisposition("Årlig_Rapport.pdf");
|
|
262
|
+
// => 'attachment; filename="?rlig_Rapport.pdf"; filename*=UTF-8''%C3%85rlig_Rapport.pdf'
|
|
263
|
+
|
|
264
|
+
buildContentDisposition("slides.pdf", { type: "inline" });
|
|
265
|
+
// => 'inline; filename=slides.pdf'
|
|
266
|
+
|
|
267
|
+
// Handles untrusted input safely
|
|
268
|
+
buildContentDisposition("../../etc/passwd"); // Path traversal stripped
|
|
269
|
+
buildContentDisposition("evil\r\nX-Injected: yes"); // CRLF injection stripped
|
|
270
|
+
buildContentDisposition(null, { fallback: "export.csv" }); // Graceful fallback
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### RFC 9530 Repr-Digest (End-to-End Integrity)
|
|
274
|
+
|
|
275
|
+
Pass a SHA-256 digest from your storage backend for automatic `Repr-Digest` headers:
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const { status, headers, range } = evaluateConditionalRequest(
|
|
279
|
+
request.headers,
|
|
280
|
+
{
|
|
281
|
+
totalSize: fileSize,
|
|
282
|
+
etag: '"abc123"',
|
|
283
|
+
// S3: x-amz-checksum-sha256, GCS: x-goog-hash (sha256 component)
|
|
284
|
+
digest: "d2VsY29tZQ==", // raw base64 SHA-256
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
// Response headers include: Repr-Digest: sha-256=:d2VsY29tZQ==:
|
|
288
|
+
// Same digest on both 200 (full) and 206 (partial) -- covers the full representation
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Advanced: Manual Primitives
|
|
292
|
+
|
|
293
|
+
For full control over the evaluation chain:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
import {
|
|
297
|
+
parseRangeHeader,
|
|
298
|
+
buildRangeResponseHeaders,
|
|
299
|
+
isConditionalFresh,
|
|
300
|
+
isPreconditionFailure,
|
|
301
|
+
isRangeFresh,
|
|
302
|
+
build304Headers,
|
|
303
|
+
build412Headers,
|
|
304
|
+
build416Headers,
|
|
305
|
+
} from "partial-content";
|
|
306
|
+
|
|
307
|
+
// Step 1: Preconditions (If-Match / If-Unmodified-Since)
|
|
308
|
+
if (isPreconditionFailure(reqHeaders, etag, lastModified)) {
|
|
309
|
+
return new Response(null, build412Headers());
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Step 2: Freshness (If-None-Match / If-Modified-Since)
|
|
313
|
+
if (isConditionalFresh(reqHeaders, etag, lastModified)) {
|
|
314
|
+
return new Response(null, build304Headers(etag, lastModified));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Step 3: Range (If-Range + Range header)
|
|
318
|
+
const range = isRangeFresh(reqHeaders, etag, lastModified)
|
|
319
|
+
? parseRangeHeader(reqHeaders.get("range"), fileSize)
|
|
320
|
+
: null;
|
|
321
|
+
|
|
322
|
+
if (range === "unsatisfiable") {
|
|
323
|
+
return new Response(null, build416Headers(fileSize));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const { status, headers } = buildRangeResponseHeaders({
|
|
327
|
+
totalSize: fileSize, range, contentType, etag, lastModified,
|
|
328
|
+
digest: checksum, // RFC 9530 Repr-Digest
|
|
329
|
+
cacheControl: "private, no-cache",
|
|
330
|
+
});
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Real-world example: authorized proxy from object storage
|
|
334
|
+
|
|
335
|
+
A common pattern when serving private files from object storage: you proxy file requests through your own server so every request is authorized and audited, then stream the bytes with full range and conditional-request support.
|
|
336
|
+
|
|
337
|
+
Use this library when your application **must** proxy file requests (authorization, auditing, tenant isolation, or custom business logic) instead of redirecting clients to signed object-storage URLs:
|
|
338
|
+
|
|
339
|
+
```typescript
|
|
340
|
+
import { serveObject } from "partial-content/web";
|
|
341
|
+
import { s3Store } from "partial-content/s3";
|
|
342
|
+
|
|
343
|
+
const store = s3Store({ client, bucket: "private-documents" });
|
|
344
|
+
|
|
345
|
+
const handler = serveObject(store, {
|
|
346
|
+
disposition: "inline",
|
|
347
|
+
cacheControl: "private, no-cache",
|
|
348
|
+
// SOC 2 CC7.2 audit trail: bytes GRANTED, at header-commit time.
|
|
349
|
+
onServe: (event) => logger.info({ ...event }, "file.served"),
|
|
350
|
+
// Egress accounting / abandonment: bytes ACTUALLY transferred, once the
|
|
351
|
+
// body settles. `completed === false` means the client disconnected early.
|
|
352
|
+
onTransfer: (event) => meter.recordEgress(event.key, event.bytesTransferred),
|
|
353
|
+
onError: (err, ctx) => logger.error({ err, ...ctx }, "file.error"),
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
async function serveFile(request: Request, key: string) {
|
|
357
|
+
// 1. Authorize on your server. Access control lives in your application:
|
|
358
|
+
// deny here and not a single byte is read.
|
|
359
|
+
const file = await authorize(request, key); // your code
|
|
360
|
+
if (!file) return new Response("Not found", { status: 404 });
|
|
361
|
+
|
|
362
|
+
// 2. One call runs the full RFC 7232/7233 chain: 412, 304, If-Range, Range.
|
|
363
|
+
// The handler does HEAD, evaluates conditionals, streams bytes, and
|
|
364
|
+
// builds the correct 200/206/304/412/416 response automatically.
|
|
365
|
+
return handler(request, {
|
|
366
|
+
key: file.path,
|
|
367
|
+
mime: file.mimeType,
|
|
368
|
+
filename: file.filename,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
This is the path behind `<video>`/`<audio>` seeking and PDF.js progressive loading: the media element sends `Range` to **your** origin, you re-check access, and stream just that slice from storage. If you could hand the client a signed URL instead, the storage backend would speak this protocol for you and you wouldn't need a protocol layer -- see [Scope](docs/DESIGN.md#scope) for when this library earns its place.
|
|
374
|
+
|
|
375
|
+
## API Reference
|
|
376
|
+
|
|
377
|
+
### Kernel (`partial-content`)
|
|
378
|
+
|
|
379
|
+
**`evaluateConditionalRequest(reqHeaders, meta)`** - One-call handler for the full HTTP evaluation chain (GET/HEAD). Returns `{ status, headers, range }`.
|
|
380
|
+
|
|
381
|
+
**`evaluateConditionalWrite(reqHeaders, meta)`** - One-call handler for write requests (PUT/PATCH/DELETE). Returns `{ proceed: true }` or `{ proceed: false, status: 412, headers }`. The 412 response includes the current `ETag` when available, so the client can resync without a follow-up GET.
|
|
382
|
+
|
|
383
|
+
**`parseRangeHeader(rangeHeader, totalSize)`** - Returns `{ start, end }`, `"unsatisfiable"`, or `null`.
|
|
384
|
+
|
|
385
|
+
**`buildRangeResponseHeaders(opts)`** - Build 200 or 206 response headers.
|
|
386
|
+
|
|
387
|
+
**`parseContentRange(header)`** - Parse a `Content-Range` response header (e.g. `bytes 0-499/1000`). Returns `{ start, end, totalSize }` or `null`.
|
|
388
|
+
|
|
389
|
+
**`generateETag(source)`** - Derive an entity-tag from storage metadata. Returns a strong `"<hash>"` when a content digest is available, a weak `W/"<size>-<mtime>"` when only size and modification time are known, or `undefined` when there is insufficient metadata.
|
|
390
|
+
|
|
391
|
+
**`buildContentDisposition(filename, options?)`** - Security-hardened `Content-Disposition` header builder with CRLF injection prevention, path traversal protection, bidi override stripping, and RFC 8187 non-ASCII encoding.
|
|
392
|
+
|
|
393
|
+
**`fromNodeHeaders(headers)`** - Convert Node.js `IncomingHttpHeaders` to the `{ get(name) }` interface.
|
|
394
|
+
|
|
395
|
+
**`isConditionalFresh(reqHeaders, etag, lastModified)`** - `true` if not modified (304).
|
|
396
|
+
|
|
397
|
+
**`isPreconditionFailure(reqHeaders, etag, lastModified, exists?)`** - `true` if precondition failed (412). Pass `exists` when the resource's presence is known independently of its validators (e.g. `If-Match: *` upload guards).
|
|
398
|
+
|
|
399
|
+
**`isRangeFresh(reqHeaders, etag, lastModified)`** - `true` if If-Range passes (honor the range).
|
|
400
|
+
|
|
401
|
+
**`build304Headers(etag, lastModified, cacheControl?)`** - Build 304 headers.
|
|
402
|
+
|
|
403
|
+
**`build412Headers()`** - Build 412 headers.
|
|
404
|
+
|
|
405
|
+
**`build416Headers(totalSize)`** - Build 416 Range Not Satisfiable headers.
|
|
406
|
+
|
|
407
|
+
**`clientWantsDigest(reqHeaders)`** - RFC 9530 Section 4 negotiation: `true` when the client's `Want-Repr-Digest` / `Want-Content-Digest` headers accept `sha-256` (or are absent). The web adapter and orchestrator both honor this, so `Want-Repr-Digest: sha-256=0` suppresses digest emission everywhere.
|
|
408
|
+
|
|
409
|
+
### MIME Lookup (`partial-content/mime`)
|
|
410
|
+
|
|
411
|
+
**`lookupMime(filenameOrExt)`** - Curated, zero-dependency extension -> MIME lookup for documents, media, archives, fonts, and web assets. Case-insensitive, resolves the last dot segment (`archive.tar.gz` -> `application/gzip`), returns `undefined` for unknown types so the caller controls the fallback. `html` is deliberately absent: serving stored uploads as `text/html` is stored XSS, so that decision must be explicit at the call site.
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
import { lookupMime } from "partial-content/mime";
|
|
415
|
+
|
|
416
|
+
app.get("/files/:key", serveObject(store, {
|
|
417
|
+
key: (req) => req.params.key,
|
|
418
|
+
mime: (req) => lookupMime(req.params.key),
|
|
419
|
+
}));
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
### Universal HTTP Store (`partial-content/http`)
|
|
423
|
+
|
|
424
|
+
**`httpStore({ url, headers?, fetch?, redirect? })`** - Serve from ANY range-capable HTTP origin over plain `fetch`: Supabase Storage, presigned S3/GCS/Azure URLs, CDN origins, or another partial-content server. Pinned reads map to `If-Match` (origin 412 -> `ObjectChangedError`), `Repr-Digest` response headers are extracted, and requests are sent `Accept-Encoding: identity` and any response that still carries a non-identity `Content-Encoding` is refused, so transparent compression can never corrupt byte accounting. Redirects error by default (a hostile origin must not 3xx the store toward internal/metadata IPs); set `redirect: "follow"` for origins that legitimately redirect, paired with a validating `fetch` when keys are untrusted (see SECURITY.md).
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
import { httpStore } from "partial-content/http";
|
|
428
|
+
|
|
429
|
+
const store = httpStore({
|
|
430
|
+
url: (key) => `${SUPABASE_URL}/storage/v1/object/documents/${key}`,
|
|
431
|
+
headers: { Authorization: `Bearer ${serviceRoleKey}` },
|
|
432
|
+
});
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Memory Store (`partial-content/memory`)
|
|
436
|
+
|
|
437
|
+
**`memoryStore({ objects })`** - A spec-faithful in-memory store for consumer test suites, demos, and small embedded assets. Fabricates correct Content-Range values, honors `ifMatch` pinning (mutate the map to simulate overwrites and exercise retry logic), and streams zero-byte objects correctly.
|
|
438
|
+
|
|
439
|
+
### Web Adapter (`partial-content/web`)
|
|
440
|
+
|
|
441
|
+
**`serveObject(store, options?)`** - Create a Fetch API handler that serves files from an ObjectStore. Returns `(req: Request, ctx: ServeContext) => Promise<Response>`.
|
|
442
|
+
|
|
443
|
+
**`serveObjectRaw(store, options?)`** - The same engine returning `RawResponseParts` (`{ status, statusText, headers, body }`) instead of a `Response`, for server adapters that write to their runtime natively (the bundled node adapter uses it). Skips all fetch-primitive construction on the hot path.
|
|
444
|
+
|
|
445
|
+
Options: `disposition`, `cacheControl`, `immutable`, `securityHeaders`, `crossOriginResourcePolicy`, `timingAllowOrigin`, `timing`, `onTiming`, `onError`, `onServe`, `onTransfer`, `maxRanges`, `enforceCharset`, `fallbackFilename`.
|
|
446
|
+
|
|
447
|
+
### Node Adapter (`partial-content/node`)
|
|
448
|
+
|
|
449
|
+
**`serveObject(store, options)`** - Create a Node.js `(req, res) => Promise<void>` handler for Express, Fastify (compat), Koa, and raw `http.createServer`. Extends the web adapter options with `key` (required, extracts the storage key from `IncomingMessage`), `mime?`, and `filename?`.
|
|
450
|
+
|
|
451
|
+
**`writeStallTimeoutMs?`** (default `60000`) - Bounds how long the streaming pump waits for a single backpressure `drain` before treating the client as stalled and tearing the transfer down (cancel the storage read, destroy the response). A client that stops reading but holds its socket open would otherwise pin a backend storage connection indefinitely (a slow-read attack). Set to `0` to disable and rely on an upstream proxy / socket timeout instead. Only the raw-Node pump needs this; Fetch-runtime backpressure is the platform's own concern.
|
|
452
|
+
|
|
453
|
+
`ServeContext`: `key` (required), `mime?`, `filename?`, `cacheControl?` (per-request override of the handler-level value, e.g. `immutable` for content-addressed keys next to `private, no-cache` user uploads from the same handler).
|
|
454
|
+
|
|
455
|
+
`cacheControl` is emitted verbatim on 200/206/304, so any directive vocabulary your CDN or edge understands passes straight through: RFC 9111 `s-maxage` / `must-revalidate` / `proxy-revalidate` and the RFC 5861 resilience directives `stale-while-revalidate` and `stale-if-error`. The library does not synthesize or reorder directives (only appending `immutable` when the `immutable` option is set and it is not already present), so you keep full control of the response caching policy. `Vary` (e.g. `Vary: Accept-Encoding`) rides `securityHeaders` / `extraHeaders`.
|
|
456
|
+
|
|
457
|
+
### Storage Contract
|
|
458
|
+
|
|
459
|
+
**`ObjectStore`** (interface) - Read-only storage backend abstraction. Implementations provide `headObject(key, opts?)` for metadata and `getObject(key, opts?)` for streaming, where `opts` carries `range`, `signal`, `ifMatch` (pinned reads), and `pin` (an opaque token issued by `headObject` for stores whose version identifier is not the ETag; GCS uses it to stream a pinned generation without re-fetching metadata). Optional `createSignedUrl(key, opts)` for backends that cannot stream ranges through the origin. Optional `authoritativeRange: true` declares that ranged responses report the backend's ACTUAL served bounds (parsed Content-Range) -- the web adapter then serves plain range requests in a single round-trip with no validating HEAD (S3, Azure, R2, and http set it; video seeking and PDF.js chunking hit this path constantly).
|
|
460
|
+
|
|
461
|
+
**`ObjectMetadata`** (type) - HEAD response shape: `contentLength`, `etag?`, `lastModified?`, `digest?`.
|
|
462
|
+
|
|
463
|
+
**`ObjectStream`** (type) - GET response shape: `body` (a `ReadableStream`, or a plain `Uint8Array` when the adapter already holds the exact bytes -- consumers then skip stream machinery entirely), `contentLength`, `totalSize`, `range?` (the `{ start, end }` the backend ACTUALLY served; absent = full content), `etag?`, `lastModified?`, `digest?`.
|
|
464
|
+
|
|
465
|
+
**`classifyStoreRead(key, op, classifiers)`** - The ordered error-classification pipeline the built-in SDK adapters share, exported for custom adapter authors. Runs `op()` and maps its failure to the contract's error types in a fixed precedence: `notFound` -> `ObjectNotFoundError` (404), `changed` -> `ObjectChangedError` (412), `throttled` -> `StoreUnavailableError` (503), otherwise rethrow untouched. Supply one `StoreErrorClassifiers` set and reuse it for both `headObject` and `getObject` so the two paths cannot drift; predicates must be mutually exclusive on a given backend.
|
|
466
|
+
|
|
467
|
+
```typescript
|
|
468
|
+
import { classifyStoreRead, type StoreErrorClassifiers } from "partial-content";
|
|
469
|
+
|
|
470
|
+
const classifiers: StoreErrorClassifiers = {
|
|
471
|
+
notFound: (e) => (e as { statusCode?: number }).statusCode === 404,
|
|
472
|
+
changed: (e) => (e as { statusCode?: number }).statusCode === 412, // omit if the pin is an etag compare
|
|
473
|
+
throttled: (e) => (e as { statusCode?: number }).statusCode === 503,
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const meta = await classifyStoreRead(key, () => backend.head(key), classifiers);
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
**`StoreUnavailableError`** (class) - Throw from an adapter when the backend is transiently unavailable (throttled/overloaded after the adapter's own retries). Carries an optional `retryAfterSeconds` echoed as `Retry-After`. Distinct from a malformed-response `502`: this is the retryable `503` case.
|
|
480
|
+
|
|
481
|
+
## Design Decisions
|
|
482
|
+
|
|
483
|
+
**Multi-range is served as `multipart/byteranges`.** Overlapping and adjacent ranges are coalesced, and a range-amplification defense (`maxRanges`, default 50; plus a "ranges cover the whole file" check) degrades pathological requests to a full 200. The single-range fast path is untouched. See DESIGN.md for the framing and the eager-first-part re-validation.
|
|
484
|
+
|
|
485
|
+
**Weak ETag matching.** Storage providers (S3, R2, GCS) emit `W/` prefixes inconsistently. We strip `W/` for pragmatic matching to avoid false 412s.
|
|
486
|
+
|
|
487
|
+
**Sub-second timestamp flooring.** Storage backends return ISO-8601 with milliseconds. HTTP dates use whole seconds. All comparisons floor both sides to prevent permanent false-stale results.
|
|
488
|
+
|
|
489
|
+
**Atomic pinned reads (TOCTOU elimination).** After validating conditionals against HEAD metadata, the web adapter pins the GET to that exact representation via the store's native conditional read (S3 `IfMatch`, R2 `onlyIf.etagMatches`, Azure `conditions.ifMatch`, GCS generation pinning). If the object changes in the HEAD->GET window, the store throws `ObjectChangedError` and the request is re-validated once against the new state -- a stale `If-Range` then correctly yields a full 200 of the new bytes. For stores that cannot pin, a response-side guard remains: validators come from the GET response, the emitted 206 bounds come from the backend's actual Content-Range, and a missing Content-Range degrades to 200 (never a lying 206).
|
|
490
|
+
|
|
491
|
+
**Single-round-trip range serving.** Plain range requests (no conditionals, no `If-Range`) skip the HEAD entirely on `authoritativeRange` stores: one GET, with validators and bounds taken from the response itself -- inherently TOCTOU-atomic, and half the latency on media seeks. Backend-rejected ranges self-heal through the validating HEAD path, which emits the correct 416.
|
|
492
|
+
|
|
493
|
+
**Store failures map to the truthful status.** A missing object (the store throws `ObjectNotFoundError`, or an error with `status: 404`) becomes a `404`. A transiently unavailable backend -- throttling or overload after the adapter's own retries are exhausted -- is a distinct, retryable case: throw `StoreUnavailableError` (or an error with `status: 503`) and the web adapter emits `503 Service Unavailable`, echoing its optional `retryAfterSeconds` as a `Retry-After` header so clients and shared caches back off. Everything else -- a malformed response, an unparseable `Content-Range`, an empty body -- is a `502 Bad Gateway`. The bundled `/s3`, `/gcs`, `/azure`, and `/http` adapters classify `503 SlowDown` / `429 TooManyRequests` / SDK throttle signals into `StoreUnavailableError` automatically; the `/azure` and `/http` adapters additionally surface the backend's `Retry-After` when it sends one. Every error response carries `Cache-Control: no-store`, `nosniff`, and a `default-src 'none'` CSP; the `404`/`502`/`503` bodies also set `Accept-Ranges: none`, while a `416` keeps its RFC-mandated `Accept-Ranges: bytes` and `Content-Range`.
|
|
494
|
+
|
|
495
|
+
See [docs/DESIGN.md](docs/DESIGN.md) for full RFC deviation notes, response header matrix, and parsing details.
|
|
496
|
+
|
|
497
|
+
## Benchmarks
|
|
498
|
+
|
|
499
|
+
Representative throughput measured on Bun 1.3 (single core, 2M iterations). Benchmarks measure library overhead only -- they do not include network or storage latency.
|
|
500
|
+
|
|
501
|
+
| Function | Throughput |
|
|
502
|
+
|----------|-----------|
|
|
503
|
+
| `parseRangeHeader` | 10.1M ops/sec |
|
|
504
|
+
| `isPreconditionFailure` | 10.2M ops/sec |
|
|
505
|
+
| `isRangeFresh` | 13.9M ops/sec |
|
|
506
|
+
| `isConditionalFresh` | 4.7M ops/sec |
|
|
507
|
+
| `buildRangeResponseHeaders` | 4.2M ops/sec |
|
|
508
|
+
| `evaluateConditionalRequest` | 1.7M ops/sec |
|
|
509
|
+
| `buildContentDisposition` | 1.7M ops/sec |
|
|
510
|
+
|
|
511
|
+
### End-to-end vs `send` and `sirv`
|
|
512
|
+
|
|
513
|
+
Full HTTP serving (Node 24, `http.createServer`, loopback, 40 connections,
|
|
514
|
+
autocannon, identical fixtures; every cell correctness-verified before
|
|
515
|
+
timing; the load generator runs in a separate process so its parse cost
|
|
516
|
+
never throttles the server column). partial-content runs its shipped dist
|
|
517
|
+
through the node adapter + fsStore. Reproduce with `npm run bench`.
|
|
518
|
+
|
|
519
|
+
| Scenario | partial-content | + `cache` | send | sirv |
|
|
520
|
+
|---|---|---|---|---|
|
|
521
|
+
| GET 4 KB (200) | 9,210 req/s | **15,772 req/s** | 6,557 req/s | 7,162 req/s |
|
|
522
|
+
| GET 1 MB (200) | 118 req/s | 116 req/s | 117 req/s | 119 req/s |
|
|
523
|
+
| Range 64 KB of 1 MB (206) | 1,441 req/s | 1,413 req/s | 1,403 req/s | 1,443 req/s |
|
|
524
|
+
| Revalidation (304) | 11,716 req/s | **19,542 req/s** | 10,081 req/s | 23,774 req/s* |
|
|
525
|
+
|
|
526
|
+
The `cache` column is `fsStore({ root, cache: { ttlMs: 1000 } })`: an opt-in
|
|
527
|
+
hot-object cache with nginx `open_file_cache` semantics (TTL revalidation,
|
|
528
|
+
metadata + bytes captured atomically, bodies only at or below 128 KiB,
|
|
529
|
+
LRU-evicted under both an entry cap (`maxEntries`, default 1024) and a body
|
|
530
|
+
byte budget (`maxBytes`, default 64 MiB; `0` = metadata-only)). Hot small
|
|
531
|
+
files and their ranges serve from memory with zero syscalls. The trade is explicit: after an overwrite, responses (including
|
|
532
|
+
304 revalidations) can affirm the previous representation for up to
|
|
533
|
+
`ttlMs`; size it against acceptable staleness (see DESIGN.md).
|
|
534
|
+
|
|
535
|
+
\* sirv's 304 figure buys throughput with a different contract: at
|
|
536
|
+
`dev: false` it pre-renders complete header sets for a directory snapshot
|
|
537
|
+
taken at boot, so a file created after startup is a 404. In the
|
|
538
|
+
configuration that CAN see runtime-created files (`dev: true`), the same
|
|
539
|
+
run measures sirv at 4,957 req/s -- a quarter of the `cache` column. For a
|
|
540
|
+
fixed directory of immutable assets sirv's trade is exactly right; for
|
|
541
|
+
object storage (where uploads happen while the server runs) it is
|
|
542
|
+
disqualifying, which is why the boot-snapshot design stays a non-goal here.
|
|
543
|
+
On the rows where server code matters (4 KB bodies, revalidations), the
|
|
544
|
+
`cache` column is the fastest contender that can serve a file uploaded a
|
|
545
|
+
second ago; at 64 KB and above, all four columns converge on I/O parity.
|
|
546
|
+
|
|
547
|
+
Same code on Bun 1.3 (`Bun.serve`, same machine, same out-of-process
|
|
548
|
+
client -- the web adapter's Request/Response ARE the runtime's native
|
|
549
|
+
primitives there, no node bridge): GET 4 KB 9,418 req/s plain and
|
|
550
|
+
22,898 req/s with the cache; revalidation 12,538 plain and **38,090 req/s**
|
|
551
|
+
with the cache -- a TTL-revalidating cache serving runtime uploads,
|
|
552
|
+
outrunning even sirv's boot-frozen Node figure by 60%. `send` and `sirv`
|
|
553
|
+
are node-stream libraries and cannot ride `Bun.serve` natively. Reproduce:
|
|
554
|
+
`bun bench/bun-server.ts` (plain :18778, cache :18779) + autocannon.
|
|
555
|
+
|
|
556
|
+
Read this fairly in both directions:
|
|
557
|
+
|
|
558
|
+
- At payload sizes where file serving actually spends its time (>= 64 KB),
|
|
559
|
+
the contenders are at parity: I/O dominates.
|
|
560
|
+
- Small-body transfers and revalidations lead `send` and `sirv` even
|
|
561
|
+
without the cache, while doing strictly more per request: RFC 9530
|
|
562
|
+
digest negotiation, audit hooks, pinned-read plumbing, and storage
|
|
563
|
+
abstraction. Four things make that possible: transfers at or below
|
|
564
|
+
128 KiB take a single positional-read fast path in the fs store, small
|
|
565
|
+
bodies travel as plain bytes (no stream machinery), the node adapter
|
|
566
|
+
consumes `serveObjectRaw` (zero fetch primitives constructed on the Node
|
|
567
|
+
hot path), and validators are derived once at stat time instead of
|
|
568
|
+
re-parsing dates on every request.
|
|
569
|
+
- Correctness note found while building this benchmark: `send` honors a
|
|
570
|
+
request `Cache-Control: no-cache` during conditional evaluation, and
|
|
571
|
+
spec-compliant fetch clients (undici, browsers) auto-append exactly that
|
|
572
|
+
header to manually-conditional requests -- so `send` never answers 304 to
|
|
573
|
+
a fetch()-based revalidation. partial-content deliberately ignores request
|
|
574
|
+
cache directives here (matching Go stdlib and nginx; see DESIGN.md).
|
|
575
|
+
|
|
576
|
+
## Security & Compliance
|
|
577
|
+
|
|
578
|
+
The library surface maps directly to audit requirements for SOC 2 Type II, ISO 27001, and EU regulatory frameworks.
|
|
579
|
+
|
|
580
|
+
| Requirement | Standard | Feature |
|
|
581
|
+
|---|---|---|
|
|
582
|
+
| Integrity verification | RFC 9530, SOC 2 CC6.1 | `Repr-Digest` (SHA-256) on 200/206 responses whenever the backend supplies a representation digest (S3 checksummed uploads, `digest` in metadata) |
|
|
583
|
+
| Content integrity | RFC 9530 Section 2 | `Content-Digest` on 200 (content = full representation) |
|
|
584
|
+
| Digest negotiation | RFC 9530 Section 4 | `Want-Repr-Digest` / `Want-Content-Digest` parsing |
|
|
585
|
+
| Audit trail | SOC 2 CC7.2, ISO 27001 A.8.15 | `onServe` callback with structured audit events (bytes granted) |
|
|
586
|
+
| Egress accounting / abandonment | operational | `onTransfer` callback with true bytes transferred and a `completed` flag |
|
|
587
|
+
| Encoding-sniffing XSS prevention | OWASP | `charset=utf-8` enforcement on textual MIME types |
|
|
588
|
+
| MIME-sniffing prevention | OWASP, SOC 2 CC6.6 | `X-Content-Type-Options: nosniff` on every success + error response (`200`/`206`, the `404`/`502`/`503` bodies, and the bodyless `412`/`416` denials); `304`/`302` carry none |
|
|
589
|
+
| Header injection prevention | OWASP, CWE-113 | CRLF stripping in ETag, Last-Modified, filename |
|
|
590
|
+
| Content-Disposition hardening | RFC 6266, RFC 8187 | Bidi override stripping, path traversal prevention |
|
|
591
|
+
| Conditional request compliance | RFC 9110, RFC 7232 | Full precondition evaluation chain (412, 304, 416) |
|
|
592
|
+
| Retryable backend failures | RFC 9110 §15.6.4 / §10.2.3 | Transient store throttling/overload maps to `503 Service Unavailable` + `Retry-After` (`StoreUnavailableError`), distinct from `502` for malformed upstream responses |
|
|
593
|
+
| OCC for writes | RFC 9110 Section 13.1.2 | `evaluateConditionalWrite` (If-Match, If-None-Match) |
|
|
594
|
+
| Range request compliance | RFC 7233 / RFC 9110 §14 | Single- and multi-range (`multipart/byteranges`) serving with TOCTOU guards and range-amplification defense |
|
|
595
|
+
| Cross-origin resource policy | CORP | `Cross-Origin-Resource-Policy` header support |
|
|
596
|
+
| Performance observability | W3C Server-Timing | `Server-Timing` metrics with `onTiming` callback |
|
|
597
|
+
| Cache control | RFC 9111 / RFC 5861 | Verbatim `Cache-Control` passthrough (`s-maxage`, `must-revalidate`, `stale-while-revalidate`, `stale-if-error`); auto-`immutable` for content-addressed keys |
|
|
598
|
+
|
|
599
|
+
## License
|
|
600
|
+
|
|
601
|
+
MIT
|