@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 +36 -0
- package/package.json +30 -0
- package/src/drivers/local/index.ts +1 -0
- package/src/drivers/local/local_storage.ts +363 -0
- package/src/drivers/s3/index.ts +2 -0
- package/src/drivers/s3/s3_storage.ts +345 -0
- package/src/drivers/s3/s3_storage_provider.ts +55 -0
- package/src/index.ts +27 -0
- package/src/path.ts +100 -0
- package/src/storage.ts +140 -0
- package/src/storage_error.ts +75 -0
- package/src/storage_provider.ts +57 -0
- package/src/types.ts +121 -0
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,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
|
+
}
|