@strav/storage 1.0.0-alpha.27

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/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # @strav/storage
2
+
3
+ Object storage / filesystem abstraction for Strav 1.0. Two drivers — local filesystem and S3-compatible (AWS / R2 / B2 / Tigris / MinIO via the `endpoint` option). Apps inject the abstract `Storage` token; the provider in the container picks the concrete driver. Same dependency shape as `@strav/cache` and `@strav/broadcast`.
4
+
5
+ ```ts
6
+ import { Storage } from '@strav/storage'
7
+
8
+ @inject()
9
+ class ReportsController {
10
+ constructor(private readonly storage: Storage) {}
11
+
12
+ async upload(req: Request): Promise<Response> {
13
+ const body = await req.arrayBuffer()
14
+ await this.storage.put(`reports/${ulid()}.pdf`, body, {
15
+ contentType: 'application/pdf',
16
+ visibility: 'private',
17
+ })
18
+ return new Response(null, { status: 201 })
19
+ }
20
+
21
+ async share(path: string): Promise<string> {
22
+ return this.storage.signedUrl(path, { expiresIn: 3600 })
23
+ }
24
+ }
25
+ ```
26
+
27
+ Canonical docs live in [`docs/storage/README.md`](../../docs/storage/README.md).
28
+
29
+ ## What ships
30
+
31
+ | Driver | Subpath | Notes |
32
+ |---|---|---|
33
+ | Local | `@strav/storage` (root) + `@strav/storage/local` | `Bun.file` + `Bun.write` + `node:fs/promises`. Single-node deployments + dev. |
34
+ | S3-compatible | `@strav/storage/s3` | Bun's built-in `S3Client` — no third-party Bun S3 client dep. Works with AWS S3, Cloudflare R2, Backblaze B2, Tigris, MinIO via the `endpoint` option. |
35
+
36
+ All paths funnel through a strict normalizer (no `../`, no absolute paths, no backslashes) so controller code stays portable between drivers. Visibility maps to S3 ACL ('public' → `public-read`, 'private' → `private`); on LocalStorage it's a hint only — apps serving uploads via a static handler get the "public" semantic for free.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@strav/storage",
3
+ "version": "1.0.0-alpha.27",
4
+ "description": "Strav storage layer — Storage abstraction + LocalStorage (Bun.file + node:fs) + S3Storage (Bun.S3Client, works with AWS / R2 / B2 / Tigris / MinIO via the endpoint option). Kernel-free; mirrors @strav/cache + @strav/broadcast.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./local": "./src/drivers/local/index.ts",
11
+ "./s3": "./src/drivers/s3/index.ts"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md"
16
+ ],
17
+ "engines": {
18
+ "bun": ">=1.3.14"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@strav/kernel": "1.0.0-alpha.27"
25
+ },
26
+ "peerDependencies": {
27
+ "@types/bun": ">=1.3.14"
28
+ },
29
+ "devDependencies": null
30
+ }
@@ -0,0 +1 @@
1
+ export { LocalStorage, type LocalStorageOptions } from './local_storage.ts'
@@ -0,0 +1,363 @@
1
+ /**
2
+ * `LocalStorage` — filesystem driver backed by `Bun.file` + `Bun.write`
3
+ * + `node:fs/promises`.
4
+ *
5
+ * Right driver for: dev, tests, single-node deployments with a real
6
+ * volume. Wrong driver for: serverless platforms with ephemeral disk
7
+ * (Fly Machines, Vercel, etc.), multi-node deployments where every
8
+ * node needs to see every file.
9
+ *
10
+ * `Bun.write` handles streams + Blobs + ArrayBuffers + strings
11
+ * natively. We surface a single `put` that takes any of them. Parent
12
+ * directories are created on demand — apps don't need to `mkdir`
13
+ * before `put`.
14
+ *
15
+ * Listing walks `fs.readdir({ withFileTypes: true })`. `recursive:
16
+ * true` traverses subdirectories; the default returns direct
17
+ * children only (matching the S3 driver's delimiter semantic).
18
+ *
19
+ * `visibility` is recorded but not enforced — POSIX mode bits don't
20
+ * map cleanly to "public vs private" across deployments. Apps that
21
+ * serve uploads via a static handler against `root` get the
22
+ * "public" semantic for free; signed-URL semantics live on the S3
23
+ * driver.
24
+ */
25
+
26
+ import type { Dirent } from 'node:fs'
27
+ import * as fs from 'node:fs/promises'
28
+ import { dirname, join, sep as nativeSep, normalize, posix } from 'node:path'
29
+ import { normalizePrefix } from '../../path.ts'
30
+ import { Storage } from '../../storage.ts'
31
+ import { StorageDriverError, StorageNotFoundError } from '../../storage_error.ts'
32
+ import type {
33
+ ListEntry,
34
+ ListOptions,
35
+ ListResult,
36
+ PutOptions,
37
+ SignedUrlOptions,
38
+ StorageStat,
39
+ StorageWriteable,
40
+ } from '../../types.ts'
41
+
42
+ export interface LocalStorageOptions {
43
+ /**
44
+ * Absolute root directory. All `put`/`get`/`delete` paths join with
45
+ * this. Created on demand if missing.
46
+ */
47
+ root: string
48
+ /**
49
+ * Base URL prepended by `publicUrl()`. Typically the URL your static
50
+ * handler serves `root` at — e.g. `'https://cdn.acme.com'` or
51
+ * `'http://localhost:3000/files'`. Unset → `publicUrl()` throws.
52
+ */
53
+ publicBase?: string
54
+ }
55
+
56
+ const DEFAULT_LIST_LIMIT = 100
57
+ const MAX_LIST_LIMIT = 1000
58
+
59
+ export class LocalStorage extends Storage {
60
+ private readonly root: string
61
+ private readonly publicBase: string | undefined
62
+
63
+ constructor(options: LocalStorageOptions) {
64
+ super()
65
+ this.root = options.root
66
+ this.publicBase = options.publicBase
67
+ }
68
+
69
+ // ─── Reads ────────────────────────────────────────────────────────────────
70
+
71
+ override async get(path: string): Promise<Uint8Array> {
72
+ const key = this._normalize(path)
73
+ const file = Bun.file(this.full(key))
74
+ if (!(await file.exists())) {
75
+ throw new StorageNotFoundError(`LocalStorage: no object at "${key}".`, {
76
+ context: { path: key },
77
+ })
78
+ }
79
+ return new Uint8Array(await file.arrayBuffer())
80
+ }
81
+
82
+ override async getString(path: string): Promise<string> {
83
+ const key = this._normalize(path)
84
+ const file = Bun.file(this.full(key))
85
+ if (!(await file.exists())) {
86
+ throw new StorageNotFoundError(`LocalStorage: no object at "${key}".`, {
87
+ context: { path: key },
88
+ })
89
+ }
90
+ return file.text()
91
+ }
92
+
93
+ override async getStream(path: string): Promise<ReadableStream<Uint8Array>> {
94
+ const key = this._normalize(path)
95
+ const file = Bun.file(this.full(key))
96
+ if (!(await file.exists())) {
97
+ throw new StorageNotFoundError(`LocalStorage: no object at "${key}".`, {
98
+ context: { path: key },
99
+ })
100
+ }
101
+ return file.stream()
102
+ }
103
+
104
+ // ─── Writes ───────────────────────────────────────────────────────────────
105
+
106
+ override async put(
107
+ path: string,
108
+ contents: StorageWriteable,
109
+ // PutOptions are ignored on FS — recorded in docs.
110
+ _options?: PutOptions,
111
+ ): Promise<void> {
112
+ const key = this._normalize(path)
113
+ const target = this.full(key)
114
+ await fs.mkdir(dirname(target), { recursive: true })
115
+ try {
116
+ // `Bun.write` accepts string / Uint8Array / ArrayBuffer / Blob
117
+ // directly. ReadableStream isn't supported there — wrap it in a
118
+ // Response first so we can let `Bun.write` consume the buffered
119
+ // body. Memory-bounded by the caller's stream chunks; for large
120
+ // uploads to FS this is fine.
121
+ if (contents instanceof ReadableStream) {
122
+ // Drain the stream into a Uint8Array before write. Bun.write
123
+ // doesn't accept ReadableStream directly. For very large
124
+ // payloads, callers should stream to S3 instead — the FS
125
+ // driver is for single-node deployments where in-memory
126
+ // buffering of an upload is acceptable.
127
+ const buffered = await new Response(contents).bytes()
128
+ await Bun.write(target, buffered)
129
+ } else {
130
+ await Bun.write(target, contents as Parameters<typeof Bun.write>[1])
131
+ }
132
+ } catch (cause) {
133
+ throw new StorageDriverError(`LocalStorage: write failed for "${key}".`, {
134
+ context: { path: key },
135
+ cause,
136
+ })
137
+ }
138
+ }
139
+
140
+ // ─── Metadata ─────────────────────────────────────────────────────────────
141
+
142
+ override async exists(path: string): Promise<boolean> {
143
+ const key = this._normalize(path)
144
+ try {
145
+ await fs.access(this.full(key))
146
+ return true
147
+ } catch {
148
+ return false
149
+ }
150
+ }
151
+
152
+ override async stat(path: string): Promise<StorageStat> {
153
+ const key = this._normalize(path)
154
+ let st: Awaited<ReturnType<typeof fs.stat>>
155
+ try {
156
+ st = await fs.stat(this.full(key))
157
+ } catch (cause) {
158
+ const code = (cause as NodeJS.ErrnoException).code
159
+ if (code === 'ENOENT') {
160
+ throw new StorageNotFoundError(`LocalStorage: no object at "${key}".`, {
161
+ context: { path: key },
162
+ })
163
+ }
164
+ throw new StorageDriverError(`LocalStorage: stat failed for "${key}".`, {
165
+ context: { path: key, code },
166
+ cause,
167
+ })
168
+ }
169
+ return {
170
+ size: Number(st.size),
171
+ lastModified: st.mtime,
172
+ }
173
+ }
174
+
175
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
176
+
177
+ override async delete(path: string): Promise<boolean> {
178
+ const key = this._normalize(path)
179
+ try {
180
+ await fs.unlink(this.full(key))
181
+ return true
182
+ } catch (cause) {
183
+ const code = (cause as NodeJS.ErrnoException).code
184
+ if (code === 'ENOENT') return false
185
+ throw new StorageDriverError(`LocalStorage: delete failed for "${key}".`, {
186
+ context: { path: key, code },
187
+ cause,
188
+ })
189
+ }
190
+ }
191
+
192
+ override async copy(from: string, to: string): Promise<void> {
193
+ const src = this._normalize(from)
194
+ const dst = this._normalize(to)
195
+ const target = this.full(dst)
196
+ await fs.mkdir(dirname(target), { recursive: true })
197
+ try {
198
+ await fs.copyFile(this.full(src), target)
199
+ } catch (cause) {
200
+ const code = (cause as NodeJS.ErrnoException).code
201
+ if (code === 'ENOENT') {
202
+ throw new StorageNotFoundError(`LocalStorage: source "${src}" does not exist.`, {
203
+ context: { from: src, to: dst },
204
+ })
205
+ }
206
+ throw new StorageDriverError(`LocalStorage: copy "${src}" → "${dst}" failed.`, {
207
+ context: { from: src, to: dst, code },
208
+ cause,
209
+ })
210
+ }
211
+ }
212
+
213
+ override async move(from: string, to: string): Promise<void> {
214
+ const src = this._normalize(from)
215
+ const dst = this._normalize(to)
216
+ const target = this.full(dst)
217
+ await fs.mkdir(dirname(target), { recursive: true })
218
+ try {
219
+ await fs.rename(this.full(src), target)
220
+ return
221
+ } catch (cause) {
222
+ const code = (cause as NodeJS.ErrnoException).code
223
+ if (code === 'ENOENT') {
224
+ throw new StorageNotFoundError(`LocalStorage: source "${src}" does not exist.`, {
225
+ context: { from: src, to: dst },
226
+ })
227
+ }
228
+ if (code === 'EXDEV') {
229
+ // Cross-volume — fall back to copy + delete.
230
+ await fs.copyFile(this.full(src), target)
231
+ await fs.unlink(this.full(src))
232
+ return
233
+ }
234
+ throw new StorageDriverError(`LocalStorage: move "${src}" → "${dst}" failed.`, {
235
+ context: { from: src, to: dst, code },
236
+ cause,
237
+ })
238
+ }
239
+ }
240
+
241
+ // ─── Listing ──────────────────────────────────────────────────────────────
242
+
243
+ override async list(options: ListOptions = {}): Promise<ListResult> {
244
+ const limit = Math.min(options.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT)
245
+ const recursive = options.recursive ?? false
246
+ const prefix = options.prefix !== undefined ? normalizePrefix(options.prefix) : ''
247
+ const after = options.after
248
+
249
+ // If the prefix is a directory path (`reports/2026/`), descend
250
+ // straight into it. Otherwise walk the root + filter — that's the
251
+ // path for partial-prefix matches (`prefix: 'reports/2026/jan-'`).
252
+ let base = this.root
253
+ let walkRelBase = ''
254
+ let filterPrefix = prefix
255
+ if (prefix !== '' && prefix.endsWith('/')) {
256
+ // Strip the trailing slash and see if it's a real directory.
257
+ const dirPath = prefix.slice(0, -1)
258
+ try {
259
+ const st = await fs.stat(join(this.root, dirPath))
260
+ if (st.isDirectory()) {
261
+ base = join(this.root, dirPath)
262
+ walkRelBase = dirPath
263
+ filterPrefix = ''
264
+ }
265
+ } catch {
266
+ // Not a directory — fall through to root-walk + filter.
267
+ }
268
+ }
269
+
270
+ const collected: ListEntry[] = []
271
+ await this.walk(base, walkRelBase, recursive, (relPath, entry) => {
272
+ if (filterPrefix !== '' && !relPath.startsWith(filterPrefix)) return false
273
+ if (after !== undefined && relPath <= after) return false
274
+ collected.push({
275
+ path: relPath,
276
+ ...(entry.isDirectory()
277
+ ? { isDirectory: true }
278
+ : {
279
+ size: Number(entry.size),
280
+ lastModified: entry.mtime,
281
+ isDirectory: false,
282
+ }),
283
+ } as ListEntry)
284
+ return collected.length < limit + 1 // gather one extra so we know there's a next page
285
+ })
286
+
287
+ const entries = collected.slice(0, limit)
288
+ const cursor = collected.length > limit ? (entries.at(-1)?.path ?? undefined) : undefined
289
+ return cursor !== undefined ? { entries, cursor } : { entries }
290
+ }
291
+
292
+ // ─── URLs ─────────────────────────────────────────────────────────────────
293
+
294
+ override publicUrl(path: string): string {
295
+ const key = this._normalize(path)
296
+ if (this.publicBase === undefined) {
297
+ throw new StorageDriverError(
298
+ `LocalStorage: publicUrl("${key}") needs a configured \`publicBase\` (the URL your static handler serves \`root\` at).`,
299
+ { context: { path: key } },
300
+ )
301
+ }
302
+ return `${this.publicBase.replace(/\/$/, '')}/${key}`
303
+ }
304
+
305
+ override async signedUrl(_path: string, _options: SignedUrlOptions): Promise<string> {
306
+ throw new StorageDriverError(
307
+ 'LocalStorage does not support signed URLs — there is no signing authority. Configure `publicBase` and serve via your static handler, or switch to the S3 driver for production.',
308
+ )
309
+ }
310
+
311
+ // ─── Internals ───────────────────────────────────────────────────────────
312
+
313
+ private full(key: string): string {
314
+ // Reassemble using the native separator (Windows compat) but
315
+ // collapse `..` defensively via `normalize` even though
316
+ // `normalizePath` already rejected them.
317
+ return normalize(join(this.root, key.split(posix.sep).join(nativeSep)))
318
+ }
319
+
320
+ private async walk(
321
+ dir: string,
322
+ relBase: string,
323
+ recursive: boolean,
324
+ visit: (
325
+ relPath: string,
326
+ entry: Awaited<ReturnType<typeof fs.stat>> & { isDirectory(): boolean },
327
+ ) => boolean,
328
+ ): Promise<boolean> {
329
+ let entries: Dirent[]
330
+ try {
331
+ entries = (await fs.readdir(dir, { withFileTypes: true })) as Dirent[]
332
+ } catch (cause) {
333
+ const code = (cause as NodeJS.ErrnoException).code
334
+ if (code === 'ENOENT') return true
335
+ throw new StorageDriverError(`LocalStorage: list walk failed at "${dir}".`, {
336
+ context: { dir, code },
337
+ cause,
338
+ })
339
+ }
340
+ entries.sort((a, b) => a.name.localeCompare(b.name))
341
+ for (const dirent of entries) {
342
+ const rel = relBase === '' ? dirent.name : `${relBase}/${dirent.name}`
343
+ if (dirent.isDirectory()) {
344
+ if (!recursive) {
345
+ // Emit the directory entry then skip its contents.
346
+ const cont = visit(rel, {
347
+ ...(await fs.stat(join(dir, dirent.name))),
348
+ isDirectory: () => true,
349
+ })
350
+ if (!cont) return false
351
+ continue
352
+ }
353
+ const cont = await this.walk(join(dir, dirent.name), rel, true, visit)
354
+ if (!cont) return false
355
+ continue
356
+ }
357
+ const st = await fs.stat(join(dir, dirent.name))
358
+ const cont = visit(rel, { ...st, isDirectory: () => false })
359
+ if (!cont) return false
360
+ }
361
+ return true
362
+ }
363
+ }
@@ -0,0 +1,2 @@
1
+ export { S3Storage, type S3StorageOptions } from './s3_storage.ts'
2
+ export { type S3StorageConfig, S3StorageProvider } from './s3_storage_provider.ts'
@@ -0,0 +1,345 @@
1
+ /**
2
+ * `S3Storage` — S3-compatible object storage driver backed by Bun's
3
+ * built-in `S3Client`.
4
+ *
5
+ * Works with AWS S3, Cloudflare R2, Backblaze B2, Tigris, MinIO, and
6
+ * anything else that speaks the S3 API. Region + bucket + credentials
7
+ * + endpoint (for non-AWS providers) configure the target. No
8
+ * third-party Bun S3 client dependency.
9
+ *
10
+ * Wire mapping:
11
+ *
12
+ * - `get` / `getStream` → `client.file(key).arrayBuffer()` /
13
+ * `client.file(key).stream()`.
14
+ * - `put` → `client.write(key, data, { type, cacheControl, … })`.
15
+ * Bun handles strings, Buffers, Blobs, Requests, Responses,
16
+ * ReadableStreams (wrapped) and multipart upload chunking.
17
+ * - `exists` / `stat` / `delete` → straight-through to the bucket
18
+ * methods of the same name.
19
+ * - `copy` — no native single-call copy in Bun's S3 surface; we
20
+ * stream the source to the destination via `client.write(toKey,
21
+ * client.file(fromKey))`.
22
+ * - `list` → `client.list({ prefix, startAfter, maxKeys, delimiter })`
23
+ * mapped to our `ListResult` shape. Cursor is the
24
+ * `nextContinuationToken`.
25
+ * - `publicUrl` → `publicBase + '/' + key`, throws when unset.
26
+ * - `signedUrl` → `client.presign(key, { expiresIn, method })`.
27
+ *
28
+ * ACL mapping for `put({ visibility })`:
29
+ *
30
+ * - `'public'` → `acl: 'public-read'`
31
+ * - `'private'` → `acl: 'private'` (also the default when omitted)
32
+ */
33
+
34
+ import { S3Client, type S3ListObjectsOptions, type S3Options } from 'bun'
35
+ import { normalizePrefix } from '../../path.ts'
36
+ import { Storage } from '../../storage.ts'
37
+ import { StorageDriverError, StorageNotFoundError } from '../../storage_error.ts'
38
+ import type {
39
+ ListEntry,
40
+ ListOptions,
41
+ ListResult,
42
+ PutOptions,
43
+ SignedUrlOptions,
44
+ StorageStat,
45
+ StorageWriteable,
46
+ } from '../../types.ts'
47
+
48
+ export interface S3StorageOptions {
49
+ accessKeyId: string
50
+ secretAccessKey: string
51
+ bucket: string
52
+ /**
53
+ * AWS region — used for the default endpoint and signing.
54
+ * Non-AWS providers (R2, MinIO, B2) ignore the region; set it
55
+ * anyway (`'auto'` works) for signing.
56
+ */
57
+ region?: string
58
+ /**
59
+ * Override the S3 endpoint. Required for non-AWS providers:
60
+ *
61
+ * - Cloudflare R2: `https://<account>.r2.cloudflarestorage.com`
62
+ * - Backblaze B2: `https://s3.<region>.backblazeb2.com`
63
+ * - Tigris: `https://t3.storage.dev`
64
+ * - MinIO: `http://localhost:9000` (dev / self-hosted)
65
+ */
66
+ endpoint?: string
67
+ /** Optional STS session token. */
68
+ sessionToken?: string
69
+ /** Force virtual-hosted-style addressing (vs path-style). Default backend's choice. */
70
+ virtualHostedStyle?: boolean
71
+ /**
72
+ * Public URL prefix returned by `publicUrl()`. Unset → `publicUrl()`
73
+ * throws (the bucket might be private; the framework doesn't try to
74
+ * guess). For AWS S3 buckets this is typically
75
+ * `https://<bucket>.s3.<region>.amazonaws.com`; for R2 it's the
76
+ * `r2.dev` URL or your custom domain.
77
+ */
78
+ publicBase?: string
79
+ /** Pre-constructed client for tests. */
80
+ client?: S3Client
81
+ }
82
+
83
+ const DEFAULT_LIST_LIMIT = 100
84
+ const MAX_LIST_LIMIT = 1000
85
+
86
+ export class S3Storage extends Storage {
87
+ private readonly client: S3Client
88
+ private readonly publicBase: string | undefined
89
+
90
+ constructor(options: S3StorageOptions) {
91
+ super()
92
+ this.publicBase = options.publicBase
93
+ if (options.client !== undefined) {
94
+ this.client = options.client
95
+ } else {
96
+ const clientOpts: S3Options = {
97
+ accessKeyId: options.accessKeyId,
98
+ secretAccessKey: options.secretAccessKey,
99
+ bucket: options.bucket,
100
+ }
101
+ if (options.region !== undefined) clientOpts.region = options.region
102
+ if (options.endpoint !== undefined) clientOpts.endpoint = options.endpoint
103
+ if (options.sessionToken !== undefined) clientOpts.sessionToken = options.sessionToken
104
+ if (options.virtualHostedStyle !== undefined) {
105
+ clientOpts.virtualHostedStyle = options.virtualHostedStyle
106
+ }
107
+ this.client = new S3Client(clientOpts)
108
+ }
109
+ }
110
+
111
+ // ─── Reads ────────────────────────────────────────────────────────────────
112
+
113
+ override async get(path: string): Promise<Uint8Array> {
114
+ const key = this._normalize(path)
115
+ const file = this.client.file(key)
116
+ try {
117
+ const buffer = await file.arrayBuffer()
118
+ return new Uint8Array(buffer)
119
+ } catch (cause) {
120
+ throw this.wrapNotFoundOrDriver(cause, key, 'get')
121
+ }
122
+ }
123
+
124
+ override async getString(path: string): Promise<string> {
125
+ const key = this._normalize(path)
126
+ try {
127
+ return await this.client.file(key).text()
128
+ } catch (cause) {
129
+ throw this.wrapNotFoundOrDriver(cause, key, 'getString')
130
+ }
131
+ }
132
+
133
+ override async getStream(path: string): Promise<ReadableStream<Uint8Array>> {
134
+ const key = this._normalize(path)
135
+ try {
136
+ // Bun's S3File supports `.stream()` natively.
137
+ return this.client.file(key).stream() as unknown as ReadableStream<Uint8Array>
138
+ } catch (cause) {
139
+ throw this.wrapNotFoundOrDriver(cause, key, 'getStream')
140
+ }
141
+ }
142
+
143
+ // ─── Writes ───────────────────────────────────────────────────────────────
144
+
145
+ override async put(
146
+ path: string,
147
+ contents: StorageWriteable,
148
+ options: PutOptions = {},
149
+ ): Promise<void> {
150
+ const key = this._normalize(path)
151
+ const writeOpts: S3Options = {}
152
+ if (options.contentType !== undefined) writeOpts.type = options.contentType
153
+ if (options.contentEncoding !== undefined) writeOpts.contentEncoding = options.contentEncoding
154
+ // PutOptions.cacheControl and .metadata aren't exposed on Bun's
155
+ // current S3Options surface — when Bun lands them, plumb here.
156
+ // Today they're silently dropped; documented in docs/storage/api.md.
157
+ writeOpts.acl = options.visibility === 'public' ? 'public-read' : 'private'
158
+ try {
159
+ const payload = await this.coerceToWriteable(contents)
160
+ await this.client.write(key, payload, writeOpts)
161
+ } catch (cause) {
162
+ throw new StorageDriverError(`S3Storage: write failed for "${key}".`, {
163
+ context: { path: key },
164
+ cause,
165
+ })
166
+ }
167
+ }
168
+
169
+ // ─── Metadata ─────────────────────────────────────────────────────────────
170
+
171
+ override async exists(path: string): Promise<boolean> {
172
+ const key = this._normalize(path)
173
+ try {
174
+ return await this.client.exists(key)
175
+ } catch (cause) {
176
+ throw new StorageDriverError(`S3Storage: exists failed for "${key}".`, {
177
+ context: { path: key },
178
+ cause,
179
+ })
180
+ }
181
+ }
182
+
183
+ override async stat(path: string): Promise<StorageStat> {
184
+ const key = this._normalize(path)
185
+ let st: Awaited<ReturnType<S3Client['stat']>>
186
+ try {
187
+ st = await this.client.stat(key)
188
+ } catch (cause) {
189
+ throw this.wrapNotFoundOrDriver(cause, key, 'stat')
190
+ }
191
+ const result: StorageStat = {
192
+ size: Number(st.size ?? 0),
193
+ lastModified: st.lastModified ? new Date(st.lastModified) : new Date(0),
194
+ }
195
+ if (st.type !== undefined) result.contentType = st.type
196
+ if (st.etag !== undefined) result.etag = st.etag
197
+ return result
198
+ }
199
+
200
+ // ─── Lifecycle ────────────────────────────────────────────────────────────
201
+
202
+ override async delete(path: string): Promise<boolean> {
203
+ const key = this._normalize(path)
204
+ // S3 doesn't tell us whether the key was actually there (DELETE is
205
+ // idempotent on S3). Check first to give the same semantics as
206
+ // LocalStorage.delete — true iff a real object went away.
207
+ const existed = await this.exists(key)
208
+ if (!existed) return false
209
+ try {
210
+ await this.client.delete(key)
211
+ return true
212
+ } catch (cause) {
213
+ throw new StorageDriverError(`S3Storage: delete failed for "${key}".`, {
214
+ context: { path: key },
215
+ cause,
216
+ })
217
+ }
218
+ }
219
+
220
+ override async copy(from: string, to: string): Promise<void> {
221
+ const src = this._normalize(from)
222
+ const dst = this._normalize(to)
223
+ if (!(await this.exists(src))) {
224
+ throw new StorageNotFoundError(`S3Storage: source "${src}" does not exist.`, {
225
+ context: { from: src, to: dst },
226
+ })
227
+ }
228
+ try {
229
+ // S3Client.write accepts an S3File — Bun streams source → dest
230
+ // via the bucket's own copy operation when possible.
231
+ await this.client.write(dst, this.client.file(src))
232
+ } catch (cause) {
233
+ throw new StorageDriverError(`S3Storage: copy "${src}" → "${dst}" failed.`, {
234
+ context: { from: src, to: dst },
235
+ cause,
236
+ })
237
+ }
238
+ }
239
+
240
+ // `move` falls back to the base implementation (copy + delete).
241
+ // Server-side rename isn't a single S3 op anyway — that's exactly
242
+ // what the base does.
243
+
244
+ // ─── Listing ──────────────────────────────────────────────────────────────
245
+
246
+ override async list(options: ListOptions = {}): Promise<ListResult> {
247
+ const limit = Math.min(options.limit ?? DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT)
248
+ const recursive = options.recursive ?? false
249
+ const prefix = options.prefix !== undefined ? normalizePrefix(options.prefix) : ''
250
+
251
+ const input: S3ListObjectsOptions = { maxKeys: limit }
252
+ if (prefix !== '') input.prefix = prefix
253
+ if (!recursive) input.delimiter = '/'
254
+ if (options.after !== undefined) {
255
+ // `after` is our cursor — interpret as Bun's continuationToken first
256
+ // since that's how we issue cursors. Apps that pass an
257
+ // arbitrary key value also work via `startAfter`.
258
+ input.continuationToken = options.after
259
+ }
260
+
261
+ let resp: Awaited<ReturnType<S3Client['list']>>
262
+ try {
263
+ resp = await this.client.list(input)
264
+ } catch (cause) {
265
+ throw new StorageDriverError('S3Storage: list failed.', {
266
+ context: { prefix, recursive },
267
+ cause,
268
+ })
269
+ }
270
+
271
+ const entries: ListEntry[] = []
272
+ for (const cp of resp.commonPrefixes ?? []) {
273
+ entries.push({ path: cp.prefix, isDirectory: true })
274
+ }
275
+ for (const obj of resp.contents ?? []) {
276
+ const entry: ListEntry = { path: obj.key }
277
+ if (obj.size !== undefined) entry.size = obj.size
278
+ if (obj.lastModified !== undefined) entry.lastModified = new Date(obj.lastModified)
279
+ entries.push(entry)
280
+ }
281
+
282
+ const result: ListResult = { entries }
283
+ if (resp.isTruncated && resp.nextContinuationToken !== undefined) {
284
+ result.cursor = resp.nextContinuationToken
285
+ }
286
+ return result
287
+ }
288
+
289
+ // ─── URLs ─────────────────────────────────────────────────────────────────
290
+
291
+ override publicUrl(path: string): string {
292
+ const key = this._normalize(path)
293
+ if (this.publicBase === undefined) {
294
+ throw new StorageDriverError(
295
+ `S3Storage: publicUrl("${key}") needs a configured \`publicBase\` (the URL your bucket is served at; e.g. https://<bucket>.s3.<region>.amazonaws.com for AWS, or your r2.dev / custom domain for R2).`,
296
+ { context: { path: key } },
297
+ )
298
+ }
299
+ return `${this.publicBase.replace(/\/$/, '')}/${key}`
300
+ }
301
+
302
+ override async signedUrl(path: string, options: SignedUrlOptions): Promise<string> {
303
+ const key = this._normalize(path)
304
+ try {
305
+ return this.client.presign(key, {
306
+ expiresIn: options.expiresIn,
307
+ method: options.method ?? 'GET',
308
+ })
309
+ } catch (cause) {
310
+ throw new StorageDriverError(`S3Storage: presign failed for "${key}".`, {
311
+ context: { path: key },
312
+ cause,
313
+ })
314
+ }
315
+ }
316
+
317
+ // ─── Internals ────────────────────────────────────────────────────────────
318
+
319
+ private wrapNotFoundOrDriver(cause: unknown, key: string, op: string): Error {
320
+ const message = (cause as { message?: string }).message ?? ''
321
+ const code = (cause as { code?: string }).code
322
+ // Bun surfaces missing keys as either NoSuchKey, 404, or "not
323
+ // found" depending on the backend. Match liberally.
324
+ if (code === 'NoSuchKey' || message.includes('404') || /not\s*found/i.test(message)) {
325
+ return new StorageNotFoundError(`S3Storage: no object at "${key}".`, {
326
+ context: { path: key, op },
327
+ })
328
+ }
329
+ return new StorageDriverError(`S3Storage: ${op} failed for "${key}".`, {
330
+ context: { path: key, op },
331
+ cause,
332
+ })
333
+ }
334
+
335
+ private async coerceToWriteable(
336
+ contents: StorageWriteable,
337
+ ): Promise<Parameters<S3Client['write']>[1]> {
338
+ if (contents instanceof ReadableStream) {
339
+ // Bun's S3 write accepts Response — wrap so multipart upload
340
+ // streams the stream chunk-by-chunk.
341
+ return new Response(contents)
342
+ }
343
+ return contents as Parameters<S3Client['write']>[1]
344
+ }
345
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `S3StorageProvider` — wires `S3Storage` under the `Storage` token.
3
+ * Apps register this INSTEAD OF `StorageProvider` to use an
4
+ * S3-compatible backplane (AWS / R2 / B2 / Tigris / MinIO).
5
+ */
6
+
7
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
8
+ import { Storage } from '../../storage.ts'
9
+ import { StorageConfigError } from '../../storage_error.ts'
10
+ import { S3Storage, type S3StorageOptions } from './s3_storage.ts'
11
+
12
+ export interface S3StorageConfig extends Omit<S3StorageOptions, 'client'> {
13
+ driver: 's3'
14
+ }
15
+
16
+ export class S3StorageProvider extends ServiceProvider {
17
+ override readonly name = 'storage'
18
+ override readonly dependencies = ['config']
19
+
20
+ override register(app: Application): void {
21
+ app.singleton(Storage, (c) => {
22
+ const cfg = c.resolve(ConfigRepository).get('storage') as S3StorageConfig | undefined
23
+ if (cfg === undefined || cfg.driver !== 's3') {
24
+ throw new StorageConfigError(
25
+ 'S3StorageProvider: `config.storage` must have `driver: "s3"`.',
26
+ )
27
+ }
28
+ if (!cfg.accessKeyId || !cfg.secretAccessKey || !cfg.bucket) {
29
+ throw new StorageConfigError(
30
+ 'S3StorageProvider: `accessKeyId`, `secretAccessKey`, and `bucket` are required.',
31
+ )
32
+ }
33
+ return new S3Storage({
34
+ accessKeyId: cfg.accessKeyId,
35
+ secretAccessKey: cfg.secretAccessKey,
36
+ bucket: cfg.bucket,
37
+ ...(cfg.region !== undefined ? { region: cfg.region } : {}),
38
+ ...(cfg.endpoint !== undefined ? { endpoint: cfg.endpoint } : {}),
39
+ ...(cfg.sessionToken !== undefined ? { sessionToken: cfg.sessionToken } : {}),
40
+ ...(cfg.virtualHostedStyle !== undefined
41
+ ? { virtualHostedStyle: cfg.virtualHostedStyle }
42
+ : {}),
43
+ ...(cfg.publicBase !== undefined ? { publicBase: cfg.publicBase } : {}),
44
+ })
45
+ })
46
+ }
47
+
48
+ override async boot(app: Application): Promise<void> {
49
+ app.resolve(Storage)
50
+ }
51
+
52
+ override async shutdown(app: Application): Promise<void> {
53
+ await app.resolve(Storage).close()
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ // Public API of @strav/storage.
2
+ //
3
+ // Root barrel exports the primitive — `Storage` base class + types +
4
+ // errors + the LocalStorage provider. Drivers ship under subpaths:
5
+ // - `@strav/storage/local` (re-exports for explicit construction)
6
+ // - `@strav/storage/s3` (S3-compatible: AWS / R2 / B2 / Tigris / MinIO)
7
+
8
+ export { LocalStorage, type LocalStorageOptions } from './drivers/local/local_storage.ts'
9
+ export { normalizePath, normalizePrefix } from './path.ts'
10
+ export { Storage } from './storage.ts'
11
+ export {
12
+ StorageConfigError,
13
+ StorageDriverError,
14
+ StorageError,
15
+ StorageNotFoundError,
16
+ StoragePathError,
17
+ } from './storage_error.ts'
18
+ export { type LocalStorageConfig, StorageProvider } from './storage_provider.ts'
19
+ export type {
20
+ ListEntry,
21
+ ListOptions,
22
+ ListResult,
23
+ PutOptions,
24
+ SignedUrlOptions,
25
+ StorageStat,
26
+ StorageWriteable,
27
+ } from './types.ts'
package/src/path.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Path normalization + safety check.
3
+ *
4
+ * All public `Storage` methods funnel input through `normalizePath()`
5
+ * before handing to the driver. The rules:
6
+ *
7
+ * - POSIX-style only. Backslashes are illegal (would let Windows
8
+ * callers smuggle path-segment confusion past S3 → FS portability).
9
+ * - No `..` segments anywhere — even `a/../b` is rejected (collapsing
10
+ * would conflate two distinct caller intents; rejecting forces
11
+ * callers to be explicit).
12
+ * - No absolute paths (leading `/`). The FS driver joins the input
13
+ * with its `root`; an absolute path would escape that root.
14
+ * - No empty segments (`a//b`), no `.` segments (`a/./b`) — both
15
+ * trip the parser on some backends; reject for cross-driver
16
+ * parity.
17
+ * - No control characters (< 0x20, 0x7F).
18
+ * - Leading + trailing whitespace trimmed; bare paths rejected.
19
+ *
20
+ * Throws `StoragePathError` on any rejection. The error includes the
21
+ * offending path so callers can log + fix at the source.
22
+ */
23
+
24
+ import { StoragePathError } from './storage_error.ts'
25
+
26
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching control chars is the point — we reject paths containing them
27
+ const CONTROL_CHAR = /[\x00-\x1f\x7f]/
28
+
29
+ /**
30
+ * Same rules as `normalizePath` but tolerates a trailing `/` —
31
+ * prefixes describe ranges of keys, not individual objects, and
32
+ * `reports/2026/` is the natural shape for "everything under 2026".
33
+ * The trailing slash is preserved on output so drivers can use the
34
+ * result verbatim in their backend's prefix-match logic.
35
+ *
36
+ * The empty string is allowed (return as-is — "everything from root").
37
+ */
38
+ export function normalizePrefix(input: string): string {
39
+ if (typeof input !== 'string') {
40
+ throw new StoragePathError(`Storage prefix must be a string; got: ${typeof input}`)
41
+ }
42
+ const trimmed = input.trim()
43
+ if (trimmed.length === 0) return ''
44
+ const hasTrailing = trimmed.endsWith('/')
45
+ const stripped = hasTrailing ? trimmed.slice(0, -1) : trimmed
46
+ if (stripped.length === 0) {
47
+ // Bare "/" — same problem as an absolute path.
48
+ throw new StoragePathError('Storage prefix "/" is not valid — use the empty string for root.')
49
+ }
50
+ const normalized = normalizePath(stripped)
51
+ return hasTrailing ? `${normalized}/` : normalized
52
+ }
53
+
54
+ export function normalizePath(input: string): string {
55
+ if (typeof input !== 'string') {
56
+ throw new StoragePathError(`Storage path must be a string; got: ${typeof input}`)
57
+ }
58
+ const trimmed = input.trim()
59
+ if (trimmed.length === 0) {
60
+ throw new StoragePathError('Storage path is empty.')
61
+ }
62
+ if (trimmed.includes('\\')) {
63
+ throw new StoragePathError(
64
+ `Storage path "${trimmed}" contains backslashes — use forward slashes only.`,
65
+ { context: { path: trimmed } },
66
+ )
67
+ }
68
+ if (trimmed.startsWith('/')) {
69
+ throw new StoragePathError(
70
+ `Storage path "${trimmed}" must be relative — leading "/" not allowed.`,
71
+ { context: { path: trimmed } },
72
+ )
73
+ }
74
+ if (CONTROL_CHAR.test(trimmed)) {
75
+ throw new StoragePathError(`Storage path "${trimmed}" contains a control character.`, {
76
+ context: { path: trimmed },
77
+ })
78
+ }
79
+ const segments = trimmed.split('/')
80
+ for (const segment of segments) {
81
+ if (segment.length === 0) {
82
+ throw new StoragePathError(
83
+ `Storage path "${trimmed}" has an empty segment (consecutive slashes).`,
84
+ { context: { path: trimmed } },
85
+ )
86
+ }
87
+ if (segment === '..') {
88
+ throw new StoragePathError(
89
+ `Storage path "${trimmed}" contains ".." — directory traversal not allowed.`,
90
+ { context: { path: trimmed } },
91
+ )
92
+ }
93
+ if (segment === '.') {
94
+ throw new StoragePathError(`Storage path "${trimmed}" contains "." segment — strip it.`, {
95
+ context: { path: trimmed },
96
+ })
97
+ }
98
+ }
99
+ return trimmed
100
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `Storage` — abstract base + container token.
3
+ *
4
+ * Non-`abstract` on purpose: serves as the `app.singleton(Storage,
5
+ * factory)` token (same trade-off as `Cache` / `Broadcaster`).
6
+ * Subclasses MUST override the primitives; the defaults throw to
7
+ * surface forgotten overrides during development.
8
+ *
9
+ * Driver primitives:
10
+ *
11
+ * - `get(path)` → `Uint8Array`
12
+ * - `put(path, contents, options?)`
13
+ * - `exists(path)` → `boolean`
14
+ * - `stat(path)` → `StorageStat`
15
+ * - `delete(path)` → `boolean` (true if a row was actually removed)
16
+ * - `copy(from, to)`
17
+ * - `list(options?)` → `ListResult`
18
+ * - `publicUrl(path)` → `string` (throws when `publicBase` unset)
19
+ * - `signedUrl(path, options)` → `string`
20
+ *
21
+ * The base provides:
22
+ *
23
+ * - `getString(path, encoding?)` — UTF-8 decoded `get`
24
+ * - `getStream(path)` — driver overrides to stream; the base
25
+ * synthesizes a one-chunk `ReadableStream` from `get` as a
26
+ * fallback so apps can always rely on the API
27
+ * - `move(from, to)` — `copy` then `delete`. Drivers override when
28
+ * the backend has a single-call equivalent (S3 `copyObject` +
29
+ * `deleteObject`, FS `rename`)
30
+ * - `close()` — default no-op
31
+ *
32
+ * All path arguments funnel through `normalizePath` (see `path.ts`)
33
+ * before reaching the driver. The base handles the normalization;
34
+ * driver overrides receive already-normalized strings.
35
+ */
36
+
37
+ import { normalizePath } from './path.ts'
38
+ import type {
39
+ ListOptions,
40
+ ListResult,
41
+ PutOptions,
42
+ SignedUrlOptions,
43
+ StorageStat,
44
+ StorageWriteable,
45
+ } from './types.ts'
46
+
47
+ export class Storage {
48
+ // ─── Primitives — subclass MUST override ─────────────────────────────────
49
+
50
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
51
+ get(path: string): Promise<Uint8Array> {
52
+ throw new Error('Storage.get must be overridden by the driver subclass.')
53
+ }
54
+ put(
55
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
56
+ path: string,
57
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
58
+ contents: StorageWriteable,
59
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
60
+ options?: PutOptions,
61
+ ): Promise<void> {
62
+ throw new Error('Storage.put must be overridden by the driver subclass.')
63
+ }
64
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
65
+ exists(path: string): Promise<boolean> {
66
+ throw new Error('Storage.exists must be overridden by the driver subclass.')
67
+ }
68
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
69
+ stat(path: string): Promise<StorageStat> {
70
+ throw new Error('Storage.stat must be overridden by the driver subclass.')
71
+ }
72
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
73
+ delete(path: string): Promise<boolean> {
74
+ throw new Error('Storage.delete must be overridden by the driver subclass.')
75
+ }
76
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
77
+ copy(from: string, to: string): Promise<void> {
78
+ throw new Error('Storage.copy must be overridden by the driver subclass.')
79
+ }
80
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
81
+ list(options?: ListOptions): Promise<ListResult> {
82
+ throw new Error('Storage.list must be overridden by the driver subclass.')
83
+ }
84
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
85
+ publicUrl(path: string): string {
86
+ throw new Error('Storage.publicUrl must be overridden by the driver subclass.')
87
+ }
88
+ // biome-ignore lint/correctness/noUnusedFunctionParameters: subclass contract
89
+ signedUrl(path: string, options: SignedUrlOptions): Promise<string> {
90
+ throw new Error('Storage.signedUrl must be overridden by the driver subclass.')
91
+ }
92
+
93
+ // ─── Base-class compositions ─────────────────────────────────────────────
94
+
95
+ /** UTF-8 decoded `get`. Drivers may override for efficiency. */
96
+ async getString(path: string): Promise<string> {
97
+ const bytes = await this.get(path)
98
+ return new TextDecoder().decode(bytes)
99
+ }
100
+
101
+ /**
102
+ * Stream a key's contents. The default implementation reads the
103
+ * whole object via `get` and emits a single chunk — drivers that
104
+ * have native streaming (LocalStorage via `Bun.file().stream()`,
105
+ * S3 via `S3File.stream()`) should override.
106
+ */
107
+ async getStream(path: string): Promise<ReadableStream<Uint8Array>> {
108
+ const bytes = await this.get(path)
109
+ return new ReadableStream<Uint8Array>({
110
+ start(controller) {
111
+ controller.enqueue(bytes)
112
+ controller.close()
113
+ },
114
+ })
115
+ }
116
+
117
+ /**
118
+ * `copy` then `delete`. Drivers with a server-side rename
119
+ * (LocalStorage via `fs.rename`) override; the base fallback works
120
+ * for every driver.
121
+ */
122
+ async move(from: string, to: string): Promise<void> {
123
+ await this.copy(from, to)
124
+ await this.delete(from)
125
+ }
126
+
127
+ /** Resource cleanup. Default no-op; S3Storage doesn't need teardown. */
128
+ async close(): Promise<void> {}
129
+
130
+ // ─── Internal helpers — drivers call into these ──────────────────────────
131
+
132
+ /**
133
+ * Validate + normalize a single path. Drivers wrap their entry
134
+ * points via `this._normalize(path)` so the rejection happens
135
+ * BEFORE any backend call — saves a round-trip on garbage input.
136
+ */
137
+ protected _normalize(path: string): string {
138
+ return normalizePath(path)
139
+ }
140
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Typed error hierarchy. Same shape as `@strav/cache` /
3
+ * `@strav/broadcast` — base `StorageError` extending `StravError`,
4
+ * narrower subclasses with stable `code`s apps branch on.
5
+ */
6
+
7
+ import { StravError } from '@strav/kernel'
8
+
9
+ interface StorageErrorOptions {
10
+ code?: string
11
+ status?: number
12
+ context?: Record<string, unknown>
13
+ cause?: unknown
14
+ }
15
+
16
+ export class StorageError extends StravError {
17
+ constructor(message: string, options: StorageErrorOptions = {}) {
18
+ super(
19
+ message,
20
+ { code: options.code ?? 'storage.error', status: options.status ?? 500 },
21
+ {
22
+ ...(options.context ? { context: options.context } : {}),
23
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
24
+ },
25
+ )
26
+ }
27
+ }
28
+
29
+ /** Provider boot — missing config, unreachable backend at construction. */
30
+ export class StorageConfigError extends StorageError {
31
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
32
+ super(message, {
33
+ code: 'storage.config',
34
+ status: 500,
35
+ ...(options.context ? { context: options.context } : {}),
36
+ })
37
+ }
38
+ }
39
+
40
+ /** Driver-side I/O failure (network, disk, signed-URL refused, etc.). */
41
+ export class StorageDriverError extends StorageError {
42
+ constructor(
43
+ message: string,
44
+ options: { context?: Record<string, unknown>; cause?: unknown } = {},
45
+ ) {
46
+ super(message, {
47
+ code: 'storage.driver',
48
+ status: 502,
49
+ ...(options.context ? { context: options.context } : {}),
50
+ ...(options.cause !== undefined ? { cause: options.cause } : {}),
51
+ })
52
+ }
53
+ }
54
+
55
+ /** `get` / `stat` / `copy` / `move` on a missing key. `delete` returns `false` instead. */
56
+ export class StorageNotFoundError extends StorageError {
57
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
58
+ super(message, {
59
+ code: 'storage.not_found',
60
+ status: 404,
61
+ ...(options.context ? { context: options.context } : {}),
62
+ })
63
+ }
64
+ }
65
+
66
+ /** Path normalization rejection — `../` traversal, absolute path, illegal chars. */
67
+ export class StoragePathError extends StorageError {
68
+ constructor(message: string, options: { context?: Record<string, unknown> } = {}) {
69
+ super(message, {
70
+ code: 'storage.path',
71
+ status: 400,
72
+ ...(options.context ? { context: options.context } : {}),
73
+ })
74
+ }
75
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * `StorageProvider` — wires `LocalStorage` under the `Storage` token
3
+ * by default.
4
+ *
5
+ * Apps that want the S3 backplane swap providers:
6
+ *
7
+ * import { StorageProvider } from '@strav/storage'
8
+ * import { S3StorageProvider } from '@strav/storage/s3'
9
+ *
10
+ * providers: [
11
+ * ...,
12
+ * new S3StorageProvider(), // instead of StorageProvider
13
+ * ]
14
+ *
15
+ * Both providers register under the same `Storage` token, so app code
16
+ * injecting `Storage` doesn't change between dev and prod.
17
+ *
18
+ * Eager singleton — config errors surface at boot rather than on the
19
+ * first `storage.put()` call.
20
+ */
21
+
22
+ import { type Application, ConfigRepository, ServiceProvider } from '@strav/kernel'
23
+ import { LocalStorage, type LocalStorageOptions } from './drivers/local/local_storage.ts'
24
+ import { Storage } from './storage.ts'
25
+ import { StorageConfigError } from './storage_error.ts'
26
+
27
+ export interface LocalStorageConfig extends LocalStorageOptions {
28
+ driver: 'local'
29
+ }
30
+
31
+ export class StorageProvider extends ServiceProvider {
32
+ override readonly name = 'storage'
33
+ override readonly dependencies = ['config']
34
+
35
+ override register(app: Application): void {
36
+ app.singleton(Storage, (c) => {
37
+ const cfg = c.resolve(ConfigRepository).get('storage') as LocalStorageConfig | undefined
38
+ if (cfg === undefined || cfg.driver !== 'local' || !cfg.root) {
39
+ throw new StorageConfigError(
40
+ 'StorageProvider: `config.storage.root` is required (set `config/storage.ts` with `{ driver: "local", root: "storage/uploads" }`).',
41
+ )
42
+ }
43
+ return new LocalStorage({
44
+ root: cfg.root,
45
+ ...(cfg.publicBase !== undefined ? { publicBase: cfg.publicBase } : {}),
46
+ })
47
+ })
48
+ }
49
+
50
+ override async boot(app: Application): Promise<void> {
51
+ app.resolve(Storage)
52
+ }
53
+
54
+ override async shutdown(app: Application): Promise<void> {
55
+ await app.resolve(Storage).close()
56
+ }
57
+ }
package/src/types.ts ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Public types for `@strav/storage`.
3
+ *
4
+ * Drivers implement a tight set of primitive ops + URL helpers; the
5
+ * abstract base composes higher-level helpers (`getString`,
6
+ * `getStream`, `move`) on top.
7
+ */
8
+
9
+ /**
10
+ * What `put()` accepts as a body. Strings are encoded as UTF-8.
11
+ * `ReadableStream` is preferred for large uploads — drivers stream
12
+ * the payload to the backend instead of buffering in memory.
13
+ */
14
+ export type StorageWriteable = string | Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>
15
+
16
+ /**
17
+ * What `stat()` returns about an object.
18
+ *
19
+ * `contentType` and `etag` are optional — drivers populate them when
20
+ * the backend supplies them (S3 always; LocalStorage doesn't track
21
+ * content-type, leaves it `undefined`).
22
+ */
23
+ export interface StorageStat {
24
+ /** Byte count. */
25
+ size: number
26
+ lastModified: Date
27
+ /** MIME type the object was stored under. Optional. */
28
+ contentType?: string
29
+ /** Provider-side strong hash, when available. */
30
+ etag?: string
31
+ }
32
+
33
+ export interface PutOptions {
34
+ /**
35
+ * MIME type. On S3 this rides on the `Content-Type` header and is
36
+ * returned from `stat()`. On LocalStorage it's ignored — the FS
37
+ * stores bytes, not media types.
38
+ */
39
+ contentType?: string
40
+ /** Sets the `Cache-Control` response header on S3 GETs. Ignored on FS. */
41
+ cacheControl?: string
42
+ /** Sets `Content-Encoding`. Ignored on FS. */
43
+ contentEncoding?: string
44
+ /**
45
+ * User-defined key/value metadata stored alongside the object.
46
+ * Round-trips on S3 (as `x-amz-meta-*`); ignored on FS.
47
+ */
48
+ metadata?: Record<string, string>
49
+ /**
50
+ * Object visibility. Default `'private'`.
51
+ *
52
+ * - `'private'` — the object is only accessible via signed URLs.
53
+ * S3 sets `acl: 'private'`; LocalStorage records this as a hint
54
+ * but enforces nothing (filesystem permissions don't map
55
+ * cleanly across deployments).
56
+ * - `'public'` — anyone can read the object. S3 sets
57
+ * `acl: 'public-read'`. LocalStorage assumes the configured
58
+ * `publicBase` is served by your static handler.
59
+ */
60
+ visibility?: 'private' | 'public'
61
+ }
62
+
63
+ export interface ListOptions {
64
+ /**
65
+ * Limit results to keys beginning with this prefix. POSIX-style
66
+ * (`reports/2026/`).
67
+ */
68
+ prefix?: string
69
+ /**
70
+ * Cursor from a previous `ListResult.cursor`. Resume listing past
71
+ * the last key returned.
72
+ */
73
+ after?: string
74
+ /**
75
+ * Max entries to return in one page. Drivers cap higher values:
76
+ * S3 maxes at 1000, the filesystem driver matches that ceiling.
77
+ * Default `100`.
78
+ */
79
+ limit?: number
80
+ /**
81
+ * When `true`, walks into subdirectories (FS) / treats every key
82
+ * as a flat namespace (S3 — keys with `/` already span "folders").
83
+ * Default `false`: FS returns only direct children; S3 honours the
84
+ * `Delimiter: '/'` semantic so subdirectory prefixes surface as
85
+ * `isDirectory: true` entries.
86
+ */
87
+ recursive?: boolean
88
+ }
89
+
90
+ export interface ListEntry {
91
+ /** Key relative to the storage root. POSIX-style. */
92
+ path: string
93
+ /** Byte count. Undefined for directory entries on FS. */
94
+ size?: number
95
+ lastModified?: Date
96
+ /**
97
+ * `true` for "common prefix" entries on S3 with delimiter
98
+ * semantics, and for FS directories. `false` / undefined for
99
+ * regular files.
100
+ */
101
+ isDirectory?: boolean
102
+ }
103
+
104
+ export interface ListResult {
105
+ entries: ListEntry[]
106
+ /** When set, pass back as `ListOptions.after` to fetch the next page. */
107
+ cursor?: string
108
+ }
109
+
110
+ export interface SignedUrlOptions {
111
+ /** Expiry in seconds. Required — pre-signed URLs without bounds are an anti-pattern. */
112
+ expiresIn: number
113
+ /** HTTP method the URL is signed for. Default `'GET'`. */
114
+ method?: 'GET' | 'PUT' | 'HEAD' | 'DELETE'
115
+ /**
116
+ * Override the response `Content-Type` when the URL is fetched.
117
+ * S3 sets the `response-content-type` query param; ignored on FS
118
+ * (which can't sign URLs anyway).
119
+ */
120
+ responseContentType?: string
121
+ }