@shaferllc/keel 0.66.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +167 -0
- package/README.md +30 -1
- package/bin/keel-mcp.mjs +9 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +2 -2
- package/dist/core/cache.d.ts +82 -5
- package/dist/core/cache.js +181 -23
- package/dist/core/cli/stubs.d.ts +12 -0
- package/dist/core/cli/stubs.js +120 -0
- package/dist/core/events.d.ts +129 -5
- package/dist/core/events.js +165 -7
- package/dist/core/health.d.ts +141 -0
- package/dist/core/health.js +226 -0
- package/dist/core/helpers.d.ts +9 -3
- package/dist/core/helpers.js +11 -3
- package/dist/core/index.d.ts +15 -10
- package/dist/core/index.js +7 -4
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +82 -4
- package/dist/core/logger.js +141 -23
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +304 -14
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +287 -7
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/docs/ai-manifest.json +2472 -0
- package/docs/ai.md +128 -0
- package/docs/architecture.md +331 -0
- package/docs/authentication.md +453 -0
- package/docs/authorization.md +167 -0
- package/docs/broadcasting.md +137 -0
- package/docs/broker.md +500 -0
- package/docs/cache.md +558 -0
- package/docs/configuration.md +311 -0
- package/docs/console.md +356 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +530 -0
- package/docs/debugging.md +129 -0
- package/docs/decorators.md +127 -0
- package/docs/errors.md +395 -0
- package/docs/events.md +496 -0
- package/docs/examples/architecture-app.ts +27 -0
- package/docs/examples/authentication.ts +61 -0
- package/docs/examples/authorization.ts +79 -0
- package/docs/examples/broadcasting.ts +60 -0
- package/docs/examples/broker-cache-validate.ts +34 -0
- package/docs/examples/broker-fault-tolerance.ts +29 -0
- package/docs/examples/broker-middleware.ts +27 -0
- package/docs/examples/broker.ts +203 -0
- package/docs/examples/cache.ts +222 -0
- package/docs/examples/configuration.ts +81 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +118 -0
- package/docs/examples/debugging.ts +41 -0
- package/docs/examples/decorators.ts +40 -0
- package/docs/examples/errors.ts +121 -0
- package/docs/examples/events.ts +204 -0
- package/docs/examples/factories.ts +84 -0
- package/docs/examples/hashing.ts +71 -0
- package/docs/examples/health.ts +94 -0
- package/docs/examples/helpers.ts +171 -0
- package/docs/examples/hooks.ts +54 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +92 -0
- package/docs/examples/mail.ts +160 -0
- package/docs/examples/middleware.ts +119 -0
- package/docs/examples/migrations.ts +126 -0
- package/docs/examples/models.ts +239 -0
- package/docs/examples/notification.ts +124 -0
- package/docs/examples/providers.ts +123 -0
- package/docs/examples/queues.ts +254 -0
- package/docs/examples/rate-limiting.ts +42 -0
- package/docs/examples/redis.ts +99 -0
- package/docs/examples/request-response.ts +197 -0
- package/docs/examples/routing.ts +186 -0
- package/docs/examples/scheduling.ts +62 -0
- package/docs/examples/sessions.ts +102 -0
- package/docs/examples/static-files.ts +63 -0
- package/docs/examples/storage.ts +132 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +66 -0
- package/docs/examples/transformer.ts +141 -0
- package/docs/examples/transformers.ts +49 -0
- package/docs/examples/url-builder.ts +86 -0
- package/docs/examples/validation.ts +102 -0
- package/docs/examples/views.tsx +62 -0
- package/docs/examples/vite.ts +106 -0
- package/docs/factories.md +166 -0
- package/docs/getting-started.md +290 -0
- package/docs/hashing.md +259 -0
- package/docs/health.md +225 -0
- package/docs/helpers.md +347 -0
- package/docs/hono.md +186 -0
- package/docs/hooks.md +118 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +290 -0
- package/docs/mail.md +678 -0
- package/docs/middleware.md +425 -0
- package/docs/migrations.md +476 -0
- package/docs/models.md +810 -0
- package/docs/notifications.md +474 -0
- package/docs/providers.md +363 -0
- package/docs/queues.md +679 -0
- package/docs/rate-limiting.md +155 -0
- package/docs/redis.md +178 -0
- package/docs/request-response.md +953 -0
- package/docs/routing.md +804 -0
- package/docs/scheduling.md +110 -0
- package/docs/security.md +85 -0
- package/docs/sessions.md +354 -0
- package/docs/social-auth.md +174 -0
- package/docs/static-files.md +211 -0
- package/docs/storage.md +450 -0
- package/docs/templates.md +315 -0
- package/docs/testing.md +125 -0
- package/docs/transformers.md +381 -0
- package/docs/url-builder.md +295 -0
- package/docs/validation.md +288 -0
- package/docs/views.md +267 -0
- package/docs/vite.md +434 -0
- package/llms-full.txt +17694 -0
- package/llms.txt +116 -0
- package/package.json +26 -7
package/docs/storage.md
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
# Storage
|
|
2
|
+
|
|
3
|
+
File storage over a pluggable **disk** — like the database and mail layers, the
|
|
4
|
+
core imports no filesystem or SDK, so it runs on Node and the edge. Point a disk
|
|
5
|
+
at the local filesystem, S3, or a Cloudflare R2 binding; `MemoryDisk` is the
|
|
6
|
+
built-in default for tests.
|
|
7
|
+
|
|
8
|
+
## Using it
|
|
9
|
+
|
|
10
|
+
Register a disk once (in a service provider), then reach it anywhere with
|
|
11
|
+
`storage()`:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { storage, setDisk, MemoryDisk } from "@shaferllc/keel/core";
|
|
15
|
+
|
|
16
|
+
setDisk(new MemoryDisk()); // swap for a local / S3 / R2 disk in production
|
|
17
|
+
|
|
18
|
+
await storage().put("avatars/1.png", bytes); // string | Uint8Array | ArrayBuffer
|
|
19
|
+
const bytes = await storage().get("avatars/1.png"); // Uint8Array | null
|
|
20
|
+
const text = await storage().getText("notes/todo.md"); // string | null
|
|
21
|
+
await storage().exists("avatars/1.png"); // boolean
|
|
22
|
+
await storage().delete("avatars/1.png");
|
|
23
|
+
const files = await storage().list("avatars/"); // paths under a prefix
|
|
24
|
+
const url = storage().url("avatars/1.png"); // a public URL for the object
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The default disk is a `MemoryDisk`, so `storage()` works out of the box in tests.
|
|
28
|
+
|
|
29
|
+
## Writing files
|
|
30
|
+
|
|
31
|
+
The **content type is inferred from the extension**, so a `.png` lands in your
|
|
32
|
+
bucket as `image/png` rather than `application/octet-stream` — which is the
|
|
33
|
+
difference between a browser rendering the file and downloading it.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
await storage().put("avatars/1.png", bytes); // stored as image/png
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Pass `WriteOptions` to set it yourself, along with the rest of the object's
|
|
40
|
+
metadata:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
await storage().put("exports/report.csv", csv, {
|
|
44
|
+
contentType: "text/csv",
|
|
45
|
+
cacheControl: "public, max-age=3600",
|
|
46
|
+
visibility: "private", // needs a signed URL to read
|
|
47
|
+
metadata: { uploadedBy: "42" }, // arbitrary user metadata
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A disk that can't express one of these ignores it.
|
|
52
|
+
|
|
53
|
+
## Inspecting, copying, moving
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
const meta = await storage().metadata("avatars/1.png");
|
|
57
|
+
// { size, contentType, cacheControl, visibility, lastModified, metadata }
|
|
58
|
+
|
|
59
|
+
const size = await storage().size("avatars/1.png"); // number | null
|
|
60
|
+
|
|
61
|
+
await storage().copy("avatars/1.png", "avatars/1-backup.png");
|
|
62
|
+
await storage().move("tmp/upload.png", "avatars/2.png");
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`copy` and `move` use the backend's server-side operation when the disk provides
|
|
66
|
+
one, and fall back to read-then-write otherwise. `metadata` falls back to reading
|
|
67
|
+
the file and measuring it.
|
|
68
|
+
|
|
69
|
+
## Signed URLs
|
|
70
|
+
|
|
71
|
+
`url()` is the *public* URL. For a private file, hand out a **temporary** one
|
|
72
|
+
instead:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const url = await storage().signedUrl("invoices/42.pdf", { expiresIn: 300 });
|
|
76
|
+
// → /storage/invoices/42.pdf?expires=1752278400&signature=a3f1…
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
How it's signed depends on the disk:
|
|
80
|
+
|
|
81
|
+
- **A disk with backend presigning** (S3, R2, GCS — see the recipes below) returns
|
|
82
|
+
the backend's own presigned URL. The file is served straight from the bucket;
|
|
83
|
+
your app isn't in the path at all.
|
|
84
|
+
- **Any other disk** gets a URL signed with `config('app.key')`, pointing at your
|
|
85
|
+
app. Serve those with `serveStorage({ signed: true })`.
|
|
86
|
+
|
|
87
|
+
Either way the URL expires, and tampering with the path invalidates it.
|
|
88
|
+
|
|
89
|
+
### Serving files from a disk
|
|
90
|
+
|
|
91
|
+
`serveStorage()` is the middleware that makes app-signed URLs real — it serves a
|
|
92
|
+
disk's files over HTTP, verifying the signature when you ask it to. Requests that
|
|
93
|
+
don't match `basePath`, or that name a file the disk doesn't have, fall through
|
|
94
|
+
to your routes.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// in a service provider's boot(), or your HTTP kernel's constructor
|
|
98
|
+
const kernel = this.app.make(HttpKernel);
|
|
99
|
+
kernel.use(serveStorage()); // public files under /storage
|
|
100
|
+
kernel.use(serveStorage({ basePath: "/private", signed: true }));
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
In `signed` mode an unsigned or expired request gets a **403**. Files are sent
|
|
104
|
+
with their stored content type, an `ETag` (so conditional requests get a 304),
|
|
105
|
+
and their `Cache-Control`.
|
|
106
|
+
|
|
107
|
+
The signature covers the **path and query, not the host** — so the same signed URL
|
|
108
|
+
stays valid behind a CDN hostname, and you can't move a signature onto a different
|
|
109
|
+
file.
|
|
110
|
+
|
|
111
|
+
> **The disk's `url()` prefix and `basePath` must agree.** `signedUrl()` signs the
|
|
112
|
+
> path the *disk* reports, so if the disk hands out `/storage/…` while
|
|
113
|
+
> `serveStorage` listens on `/private`, every signed URL will 403. Give the disk
|
|
114
|
+
> the matching base — `new MemoryDisk("/private")`, or `localDisk("./storage",
|
|
115
|
+
> "/private")` — or keep both on the default `/storage`.
|
|
116
|
+
|
|
117
|
+
## Direct browser uploads
|
|
118
|
+
|
|
119
|
+
Proxying a large upload through your app is exactly what you don't want on the
|
|
120
|
+
edge — a 50 MB video shouldn't stream through a Worker. A **signed upload URL** lets
|
|
121
|
+
the browser `PUT` the file straight to the bucket:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
// server
|
|
125
|
+
const url = await storage("r2").signedUploadUrl("uploads/clip.mp4", {
|
|
126
|
+
expiresIn: 600,
|
|
127
|
+
contentType: "video/mp4",
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// browser
|
|
133
|
+
await fetch(url, {
|
|
134
|
+
method: "PUT",
|
|
135
|
+
body: file,
|
|
136
|
+
headers: { "Content-Type": "video/mp4" },
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Only the storage backend can accept such a write, so this needs a disk that
|
|
141
|
+
implements `signedUploadUrl` — there is no generic fallback, and calling it on one
|
|
142
|
+
that doesn't (the memory disk, say) throws a clear error rather than quietly
|
|
143
|
+
handing you a URL that won't work.
|
|
144
|
+
|
|
145
|
+
## Testing
|
|
146
|
+
|
|
147
|
+
`fakeDisk()` swaps a disk for an in-memory one, so tests never touch a real
|
|
148
|
+
bucket, and gives you assertions over what was written. `restoreDisk()` puts the
|
|
149
|
+
real one back.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { fakeDisk, restoreDisk } from "@shaferllc/keel/core";
|
|
153
|
+
|
|
154
|
+
const disk = fakeDisk(); // or fakeDisk("r2") for a named disk
|
|
155
|
+
|
|
156
|
+
await request.post("/avatars", form);
|
|
157
|
+
|
|
158
|
+
await disk.assertExists("avatars/1.png");
|
|
159
|
+
await disk.assertMissing("avatars/2.png");
|
|
160
|
+
await disk.assertContents("notes/todo.md", "buy milk");
|
|
161
|
+
await disk.assertCount(1, "avatars/"); // files under a prefix
|
|
162
|
+
|
|
163
|
+
restoreDisk(); // no name → restore every faked disk
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Failed assertions throw with the path and what was actually there.
|
|
167
|
+
|
|
168
|
+
## Multiple disks
|
|
169
|
+
|
|
170
|
+
Register disks by name and pick one with `storage(name)`:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
setDisk(localDisk("./storage"), "local");
|
|
174
|
+
setDisk(r2Disk(env.BUCKET), "r2");
|
|
175
|
+
|
|
176
|
+
await storage("local").put("cache/x", data);
|
|
177
|
+
await storage("r2").put("public/logo.svg", svg);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Writing a disk
|
|
181
|
+
|
|
182
|
+
A disk is the `Disk` interface. Six methods are required — `put` / `get` /
|
|
183
|
+
`exists` / `delete` / `list` / `url` — and the rest are **optional capabilities**:
|
|
184
|
+
implement `metadata`, `copy`, `move`, `signedUrl`, or `signedUploadUrl` when your
|
|
185
|
+
backend can do better than the generic fallback, and `Storage` will use them.
|
|
186
|
+
|
|
187
|
+
### Local filesystem (Node)
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
import { mkdir, readFile, writeFile, rm, readdir, stat } from "node:fs/promises";
|
|
191
|
+
import { dirname, join } from "node:path";
|
|
192
|
+
import type { Disk } from "@shaferllc/keel/core";
|
|
193
|
+
|
|
194
|
+
const localDisk = (root: string, baseUrl = "/storage"): Disk => ({
|
|
195
|
+
async put(path, bytes) {
|
|
196
|
+
const full = join(root, path);
|
|
197
|
+
await mkdir(dirname(full), { recursive: true });
|
|
198
|
+
await writeFile(full, bytes);
|
|
199
|
+
},
|
|
200
|
+
async get(path) {
|
|
201
|
+
try {
|
|
202
|
+
return new Uint8Array(await readFile(join(root, path)));
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
async exists(path) {
|
|
208
|
+
return (await this.get(path)) !== null;
|
|
209
|
+
},
|
|
210
|
+
async delete(path) {
|
|
211
|
+
await rm(join(root, path), { force: true });
|
|
212
|
+
},
|
|
213
|
+
async list(prefix = "") {
|
|
214
|
+
const all = await readdir(root, { recursive: true });
|
|
215
|
+
return all.filter((p) => p.startsWith(prefix));
|
|
216
|
+
},
|
|
217
|
+
async metadata(path) {
|
|
218
|
+
const s = await stat(join(root, path)).catch(() => null);
|
|
219
|
+
return s ? { size: s.size, lastModified: s.mtime } : null;
|
|
220
|
+
},
|
|
221
|
+
url: (path) => `${baseUrl}/${path}`,
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
This disk has no backend presigning, so `signedUrl()` falls back to an app-signed
|
|
226
|
+
URL — pair it with `serveStorage({ signed: true })`.
|
|
227
|
+
|
|
228
|
+
### Cloudflare R2 (edge, via the binding)
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
import type { Disk } from "@shaferllc/keel/core";
|
|
232
|
+
|
|
233
|
+
const r2Disk = (bucket: R2Bucket, baseUrl: string): Disk => ({
|
|
234
|
+
async put(path, bytes, options) {
|
|
235
|
+
await bucket.put(path, bytes, {
|
|
236
|
+
httpMetadata: {
|
|
237
|
+
contentType: options?.contentType,
|
|
238
|
+
cacheControl: options?.cacheControl,
|
|
239
|
+
},
|
|
240
|
+
customMetadata: options?.metadata,
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
async get(path) {
|
|
244
|
+
const obj = await bucket.get(path);
|
|
245
|
+
return obj ? new Uint8Array(await obj.arrayBuffer()) : null;
|
|
246
|
+
},
|
|
247
|
+
async exists(path) {
|
|
248
|
+
return (await bucket.head(path)) !== null;
|
|
249
|
+
},
|
|
250
|
+
async delete(path) {
|
|
251
|
+
await bucket.delete(path);
|
|
252
|
+
},
|
|
253
|
+
async list(prefix) {
|
|
254
|
+
const { objects } = await bucket.list({ prefix });
|
|
255
|
+
return objects.map((o) => o.key);
|
|
256
|
+
},
|
|
257
|
+
async metadata(path) {
|
|
258
|
+
const obj = await bucket.head(path);
|
|
259
|
+
if (!obj) return null;
|
|
260
|
+
return {
|
|
261
|
+
size: obj.size,
|
|
262
|
+
contentType: obj.httpMetadata?.contentType,
|
|
263
|
+
cacheControl: obj.httpMetadata?.cacheControl,
|
|
264
|
+
lastModified: obj.uploaded,
|
|
265
|
+
metadata: obj.customMetadata,
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
url: (path) => `${baseUrl}/${path}`,
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The R2 binding writes through the Worker, so it can't presign. For direct browser
|
|
273
|
+
uploads, use R2's S3-compatible API below.
|
|
274
|
+
|
|
275
|
+
### S3 / R2 presigned URLs
|
|
276
|
+
|
|
277
|
+
S3 and R2 speak the same SigV4-signed HTTP API, so one disk covers both, and it's
|
|
278
|
+
the disk that gives you `signedUrl` and `signedUploadUrl` for real. Sign with
|
|
279
|
+
[`aws4fetch`](https://github.com/mhart/aws4fetch) — a ~2 KB library that runs on
|
|
280
|
+
Workers because it's built on Web Crypto:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
import { AwsClient } from "aws4fetch";
|
|
284
|
+
import type { Disk } from "@shaferllc/keel/core";
|
|
285
|
+
|
|
286
|
+
const s3Disk = (options: {
|
|
287
|
+
endpoint: string; // https://<account>.r2.cloudflarestorage.com/<bucket>
|
|
288
|
+
accessKeyId: string;
|
|
289
|
+
secretAccessKey: string;
|
|
290
|
+
baseUrl: string; // your public bucket / CDN origin
|
|
291
|
+
}): Disk => {
|
|
292
|
+
const aws = new AwsClient({
|
|
293
|
+
accessKeyId: options.accessKeyId,
|
|
294
|
+
secretAccessKey: options.secretAccessKey,
|
|
295
|
+
service: "s3",
|
|
296
|
+
});
|
|
297
|
+
const object = (path: string) => `${options.endpoint}/${path}`;
|
|
298
|
+
|
|
299
|
+
/** A SigV4 URL with the credentials in the query string, valid for `expiresIn`. */
|
|
300
|
+
const presign = async (path: string, method: string, expiresIn: number, contentType?: string) => {
|
|
301
|
+
const url = new URL(object(path));
|
|
302
|
+
url.searchParams.set("X-Amz-Expires", String(expiresIn));
|
|
303
|
+
const signed = await aws.sign(new Request(url, { method, headers: contentType ? { "Content-Type": contentType } : {} }), {
|
|
304
|
+
aws: { signQuery: true },
|
|
305
|
+
});
|
|
306
|
+
return signed.url;
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
async put(path, bytes, opts) {
|
|
311
|
+
await aws.fetch(object(path), {
|
|
312
|
+
method: "PUT",
|
|
313
|
+
body: bytes,
|
|
314
|
+
headers: {
|
|
315
|
+
"Content-Type": opts?.contentType ?? "application/octet-stream",
|
|
316
|
+
...(opts?.cacheControl ? { "Cache-Control": opts.cacheControl } : {}),
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
},
|
|
320
|
+
async get(path) {
|
|
321
|
+
const res = await aws.fetch(object(path));
|
|
322
|
+
return res.ok ? new Uint8Array(await res.arrayBuffer()) : null;
|
|
323
|
+
},
|
|
324
|
+
async exists(path) {
|
|
325
|
+
return (await aws.fetch(object(path), { method: "HEAD" })).ok;
|
|
326
|
+
},
|
|
327
|
+
async delete(path) {
|
|
328
|
+
await aws.fetch(object(path), { method: "DELETE" });
|
|
329
|
+
},
|
|
330
|
+
async list(prefix = "") {
|
|
331
|
+
const url = new URL(options.endpoint);
|
|
332
|
+
url.searchParams.set("list-type", "2");
|
|
333
|
+
url.searchParams.set("prefix", prefix);
|
|
334
|
+
const xml = await (await aws.fetch(url)).text();
|
|
335
|
+
return [...xml.matchAll(/<Key>(.*?)<\/Key>/g)].map((m) => m[1]!);
|
|
336
|
+
},
|
|
337
|
+
async metadata(path) {
|
|
338
|
+
const res = await aws.fetch(object(path), { method: "HEAD" });
|
|
339
|
+
if (!res.ok) return null;
|
|
340
|
+
return {
|
|
341
|
+
size: Number(res.headers.get("Content-Length") ?? 0),
|
|
342
|
+
contentType: res.headers.get("Content-Type") ?? undefined,
|
|
343
|
+
cacheControl: res.headers.get("Cache-Control") ?? undefined,
|
|
344
|
+
lastModified: new Date(res.headers.get("Last-Modified") ?? Date.now()),
|
|
345
|
+
};
|
|
346
|
+
},
|
|
347
|
+
url: (path) => `${options.baseUrl}/${path}`,
|
|
348
|
+
|
|
349
|
+
// The capabilities that matter: the bucket signs, so the bytes skip your app.
|
|
350
|
+
signedUrl: (path, o) => presign(path, "GET", o?.expiresIn ?? 3600),
|
|
351
|
+
signedUploadUrl: (path, o) => presign(path, "PUT", o?.expiresIn ?? 3600, o?.contentType),
|
|
352
|
+
};
|
|
353
|
+
};
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
## API reference
|
|
357
|
+
|
|
358
|
+
### `storage(name?)`
|
|
359
|
+
|
|
360
|
+
`storage(name?: string): Storage`
|
|
361
|
+
|
|
362
|
+
The default disk, or a named one registered with `setDisk(disk, name)`. Throws
|
|
363
|
+
for an unknown name.
|
|
364
|
+
|
|
365
|
+
### `setDisk(disk, name?)`
|
|
366
|
+
|
|
367
|
+
`setDisk(disk: Disk, name?: string): Storage`
|
|
368
|
+
|
|
369
|
+
Registers a disk (default name `"default"`) and returns the wrapping `Storage`.
|
|
370
|
+
|
|
371
|
+
### `Storage`
|
|
372
|
+
|
|
373
|
+
Wraps a `Disk`.
|
|
374
|
+
|
|
375
|
+
| Method | Signature |
|
|
376
|
+
|--------|-----------|
|
|
377
|
+
| `put` | `(path, contents: string \| Uint8Array \| ArrayBuffer, options?: WriteOptions) => Promise<void>` |
|
|
378
|
+
| `get` | `(path) => Promise<Uint8Array \| null>` |
|
|
379
|
+
| `getText` | `(path) => Promise<string \| null>` |
|
|
380
|
+
| `exists` / `delete` | `(path) => Promise<boolean>` / `Promise<void>` |
|
|
381
|
+
| `list` | `(prefix?) => Promise<string[]>` |
|
|
382
|
+
| `metadata` | `(path) => Promise<FileMetadata \| null>` |
|
|
383
|
+
| `size` | `(path) => Promise<number \| null>` |
|
|
384
|
+
| `copy` / `move` | `(from, to) => Promise<void>` |
|
|
385
|
+
| `url` | `(path) => string` — the public URL |
|
|
386
|
+
| `signedUrl` | `(path, options?: SignedFileOptions) => Promise<string>` |
|
|
387
|
+
| `signedUploadUrl` | `(path, options?: SignedUploadOptions) => Promise<string>` |
|
|
388
|
+
| `driver` | the underlying `Disk` |
|
|
389
|
+
|
|
390
|
+
### `fakeDisk(name?)` / `restoreDisk(name?)`
|
|
391
|
+
|
|
392
|
+
`fakeDisk(name?: string): FakeStorage` swaps a disk for an in-memory
|
|
393
|
+
`FakeStorage`. `restoreDisk(name?)` puts the real one back — with no name, every
|
|
394
|
+
faked disk.
|
|
395
|
+
|
|
396
|
+
`FakeStorage` is a `Storage` plus `assertExists(path)`, `assertMissing(path)`,
|
|
397
|
+
`assertContents(path, text)`, and `assertCount(n, prefix?)`.
|
|
398
|
+
|
|
399
|
+
### `serveStorage(options?)`
|
|
400
|
+
|
|
401
|
+
`serveStorage(options?: ServeStorageOptions): MiddlewareHandler`
|
|
402
|
+
|
|
403
|
+
Serves a disk's files over HTTP. Options: `disk` (name, default `"default"`),
|
|
404
|
+
`basePath` (default `"/storage"`), `signed` (require a valid signature, 403
|
|
405
|
+
otherwise), `maxAge` (`Cache-Control` seconds).
|
|
406
|
+
|
|
407
|
+
### `signStorageUrl(url, expiresIn?)` / `verifyStorageUrl(url)`
|
|
408
|
+
|
|
409
|
+
`signStorageUrl(url: string, expiresIn?: number): Promise<string>` adds `expires`
|
|
410
|
+
and `signature` params, signed with `config('app.key')` (default one hour).
|
|
411
|
+
`verifyStorageUrl(url: string): Promise<boolean>` checks them. Signing covers the
|
|
412
|
+
path and query, not the host.
|
|
413
|
+
|
|
414
|
+
### `contentTypeFor(path)`
|
|
415
|
+
|
|
416
|
+
`contentTypeFor(path: string): string` — the MIME type for a path's extension, or
|
|
417
|
+
`application/octet-stream`.
|
|
418
|
+
|
|
419
|
+
### `MemoryDisk`
|
|
420
|
+
|
|
421
|
+
`class MemoryDisk implements Disk` — in-memory, the default and ideal for tests.
|
|
422
|
+
`new MemoryDisk(baseUrl?)` sets the `url()` prefix. Not shared across processes.
|
|
423
|
+
|
|
424
|
+
### Interfaces & types
|
|
425
|
+
|
|
426
|
+
#### `Disk`
|
|
427
|
+
|
|
428
|
+
The driver seam. Required: `put` / `get` / `exists` / `delete` / `list` / `url`.
|
|
429
|
+
Optional capabilities: `metadata` / `copy` / `move` / `signedUrl` /
|
|
430
|
+
`signedUploadUrl`.
|
|
431
|
+
|
|
432
|
+
#### `WriteOptions`
|
|
433
|
+
|
|
434
|
+
`{ contentType?, cacheControl?, visibility?, metadata? }` — passed to `put`.
|
|
435
|
+
|
|
436
|
+
#### `FileMetadata`
|
|
437
|
+
|
|
438
|
+
`{ size, contentType?, cacheControl?, visibility?, lastModified?, metadata? }`.
|
|
439
|
+
|
|
440
|
+
#### `SignedFileOptions` / `SignedUploadOptions`
|
|
441
|
+
|
|
442
|
+
`{ expiresIn? }` (seconds, default 3600), plus `contentType?` for uploads.
|
|
443
|
+
|
|
444
|
+
#### `FileVisibility`
|
|
445
|
+
|
|
446
|
+
`type FileVisibility = "public" | "private"`.
|
|
447
|
+
|
|
448
|
+
#### `Contents`
|
|
449
|
+
|
|
450
|
+
`type Contents = string | Uint8Array | ArrayBuffer` — accepted by `put`.
|