@shaferllc/keel 0.66.0 → 0.74.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 +169 -0
- package/README.md +44 -6
- package/bin/keel-mcp.mjs +9 -0
- package/dist/api/api.config.stub +9 -0
- package/dist/api/config.d.ts +13 -0
- package/dist/api/config.js +14 -0
- package/dist/api/index.d.ts +16 -0
- package/dist/api/index.js +13 -0
- package/dist/api/provider.d.ts +10 -0
- package/dist/api/provider.js +17 -0
- package/dist/api/query.d.ts +35 -0
- package/dist/api/query.js +42 -0
- package/dist/api/resource.d.ts +91 -0
- package/dist/api/resource.js +188 -0
- package/dist/core/application.d.ts +5 -5
- package/dist/core/application.js +8 -2
- package/dist/core/cache.d.ts +81 -5
- package/dist/core/cache.js +188 -23
- package/dist/core/cli/stubs.d.ts +26 -0
- package/dist/core/cli/stubs.js +225 -0
- package/dist/core/console-prompt.d.ts +79 -0
- package/dist/core/console-prompt.js +239 -0
- package/dist/core/console-ui.d.ts +96 -0
- package/dist/core/console-ui.js +187 -0
- package/dist/core/console.d.ts +188 -0
- package/dist/core/console.js +395 -0
- package/dist/core/database.d.ts +70 -1
- package/dist/core/database.js +174 -15
- package/dist/core/env.d.ts +96 -0
- package/dist/core/env.js +140 -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/http/kernel.d.ts +2 -0
- package/dist/core/http/kernel.js +48 -0
- package/dist/core/http/router.d.ts +5 -5
- package/dist/core/http/router.js +5 -5
- package/dist/core/i18n.d.ts +162 -0
- package/dist/core/i18n.js +472 -0
- package/dist/core/index.d.ts +39 -13
- package/dist/core/index.js +19 -6
- package/dist/core/instrumentation.d.ts +113 -0
- package/dist/core/instrumentation.js +52 -0
- package/dist/core/lock.d.ts +139 -0
- package/dist/core/lock.js +215 -0
- package/dist/core/logger.d.ts +89 -4
- package/dist/core/logger.js +167 -22
- package/dist/core/mail.d.ts +128 -7
- package/dist/core/mail.js +264 -16
- package/dist/core/notification.js +10 -1
- package/dist/core/package.d.ts +120 -0
- package/dist/core/package.js +169 -0
- package/dist/core/pages.d.ts +108 -0
- package/dist/core/pages.js +199 -0
- package/dist/core/queue.d.ts +134 -9
- package/dist/core/queue.js +325 -14
- package/dist/core/repl.d.ts +33 -0
- package/dist/core/repl.js +88 -0
- package/dist/core/scheduler.js +6 -0
- package/dist/core/social.d.ts +4 -4
- package/dist/core/social.js +4 -4
- package/dist/core/storage.d.ts +159 -6
- package/dist/core/storage.js +299 -7
- package/dist/core/telemetry.d.ts +208 -0
- package/dist/core/telemetry.js +383 -0
- package/dist/core/template.d.ts +2 -3
- package/dist/core/template.js +2 -3
- package/dist/core/testing.d.ts +170 -1
- package/dist/core/testing.js +504 -2
- package/dist/db/d1.js +13 -0
- package/dist/db/pg.d.ts +13 -0
- package/dist/db/pg.js +46 -4
- package/dist/mcp/server.d.ts +19 -0
- package/dist/mcp/server.js +355 -0
- package/dist/openapi/config.d.ts +28 -0
- package/dist/openapi/config.js +25 -0
- package/dist/openapi/doc.d.ts +40 -0
- package/dist/openapi/doc.js +20 -0
- package/dist/openapi/export.d.ts +8 -0
- package/dist/openapi/export.js +19 -0
- package/dist/openapi/gate.d.ts +15 -0
- package/dist/openapi/gate.js +27 -0
- package/dist/openapi/index.d.ts +19 -0
- package/dist/openapi/index.js +15 -0
- package/dist/openapi/openapi.config.stub +29 -0
- package/dist/openapi/provider.d.ts +18 -0
- package/dist/openapi/provider.js +35 -0
- package/dist/openapi/routes.d.ts +9 -0
- package/dist/openapi/routes.js +23 -0
- package/dist/openapi/spec.d.ts +23 -0
- package/dist/openapi/spec.js +132 -0
- package/dist/openapi/ui.d.ts +8 -0
- package/dist/openapi/ui.js +31 -0
- package/dist/openapi/zod.d.ts +12 -0
- package/dist/openapi/zod.js +46 -0
- package/dist/watch/config.d.ts +33 -0
- package/dist/watch/config.js +38 -0
- package/dist/watch/entry.d.ts +53 -0
- package/dist/watch/entry.js +105 -0
- package/dist/watch/gate.d.ts +20 -0
- package/dist/watch/gate.js +32 -0
- package/dist/watch/index.d.ts +21 -0
- package/dist/watch/index.js +17 -0
- package/dist/watch/migration.d.ts +7 -0
- package/dist/watch/migration.js +20 -0
- package/dist/watch/provider.d.ts +22 -0
- package/dist/watch/provider.js +58 -0
- package/dist/watch/prune.d.ts +11 -0
- package/dist/watch/prune.js +20 -0
- package/dist/watch/recorder.d.ts +24 -0
- package/dist/watch/recorder.js +39 -0
- package/dist/watch/routes.d.ts +13 -0
- package/dist/watch/routes.js +55 -0
- package/dist/watch/store.d.ts +54 -0
- package/dist/watch/store.js +158 -0
- package/dist/watch/ui/dist/watch.css +1 -0
- package/dist/watch/ui/dist/watch.js +555 -0
- package/dist/watch/ui-shell.d.ts +12 -0
- package/dist/watch/ui-shell.js +24 -0
- package/dist/watch/watch.config.stub +47 -0
- package/dist/watch/watchers.d.ts +12 -0
- package/dist/watch/watchers.js +156 -0
- package/docs/ai-manifest.json +3206 -0
- package/docs/ai.md +128 -0
- package/docs/api-resources.md +118 -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 +385 -0
- package/docs/console.md +536 -0
- package/docs/container.md +467 -0
- package/docs/controllers.md +265 -0
- package/docs/cors.md +51 -0
- package/docs/database.md +631 -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 +121 -0
- package/docs/examples/console.ts +134 -0
- package/docs/examples/container.ts +134 -0
- package/docs/examples/controllers.ts +86 -0
- package/docs/examples/database.ts +208 -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/i18n.ts +117 -0
- package/docs/examples/inertia.ts +81 -0
- package/docs/examples/locks.ts +120 -0
- package/docs/examples/logger.ts +166 -0
- package/docs/examples/mail.ts +263 -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/pages.ts +82 -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/telemetry.ts +127 -0
- package/docs/examples/templates.ts +58 -0
- package/docs/examples/testing.ts +215 -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/i18n.md +302 -0
- package/docs/inertia.md +241 -0
- package/docs/locks.md +323 -0
- package/docs/logger.md +436 -0
- package/docs/mail.md +751 -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/openapi.md +111 -0
- package/docs/packages.md +118 -0
- package/docs/pages.md +217 -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 +452 -0
- package/docs/telemetry.md +263 -0
- package/docs/templates.md +314 -0
- package/docs/testing.md +376 -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/docs/watch.md +118 -0
- package/llms-full.txt +19816 -0
- package/llms.txt +127 -0
- package/package.json +42 -7
package/dist/core/storage.d.ts
CHANGED
|
@@ -9,37 +9,119 @@
|
|
|
9
9
|
* const bytes = await storage().get("avatars/1.png");
|
|
10
10
|
* const url = storage().url("avatars/1.png");
|
|
11
11
|
*
|
|
12
|
+
* Private files get a temporary URL instead of a public one:
|
|
13
|
+
*
|
|
14
|
+
* await storage().signedUrl("invoices/42.pdf", { expiresIn: 300 });
|
|
15
|
+
*
|
|
16
|
+
* and browsers can upload straight to the bucket — the bytes never transit the
|
|
17
|
+
* app — with a signed upload URL:
|
|
18
|
+
*
|
|
19
|
+
* await storage().signedUploadUrl("uploads/raw.mov", { contentType: "video/quicktime" });
|
|
20
|
+
*
|
|
12
21
|
* Register several disks by name and select one with `storage("s3")`.
|
|
13
22
|
*/
|
|
14
|
-
|
|
23
|
+
import type { MiddlewareHandler } from "hono";
|
|
24
|
+
/** Whether an object is world-readable or needs a signed URL. */
|
|
25
|
+
export type FileVisibility = "public" | "private";
|
|
26
|
+
/** Per-write options. Disks map these onto their backend's own metadata. */
|
|
27
|
+
export interface WriteOptions {
|
|
28
|
+
/**
|
|
29
|
+
* The object's MIME type. Inferred from the path's extension when omitted, so
|
|
30
|
+
* a `.png` is stored as `image/png` rather than `application/octet-stream`.
|
|
31
|
+
*/
|
|
32
|
+
contentType?: string;
|
|
33
|
+
/** A `Cache-Control` value to store alongside the object. */
|
|
34
|
+
cacheControl?: string;
|
|
35
|
+
/** `"public"` or `"private"`. Disks that can't express this ignore it. */
|
|
36
|
+
visibility?: FileVisibility;
|
|
37
|
+
/** Arbitrary user metadata stored with the object. */
|
|
38
|
+
metadata?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
/** What a disk knows about a stored object. */
|
|
41
|
+
export interface FileMetadata {
|
|
42
|
+
size: number;
|
|
43
|
+
contentType?: string;
|
|
44
|
+
cacheControl?: string;
|
|
45
|
+
visibility?: FileVisibility;
|
|
46
|
+
lastModified?: Date;
|
|
47
|
+
metadata?: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
export interface SignedFileOptions {
|
|
50
|
+
/** Seconds the URL stays valid. Default: 3600 (one hour). */
|
|
51
|
+
expiresIn?: number;
|
|
52
|
+
}
|
|
53
|
+
export interface SignedUploadOptions extends SignedFileOptions {
|
|
54
|
+
/** The `Content-Type` the uploader must send. */
|
|
55
|
+
contentType?: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The bridge to a storage backend — implement it once per backend.
|
|
59
|
+
*
|
|
60
|
+
* The first six methods are required. The rest are optional capabilities: a disk
|
|
61
|
+
* that can do better than the generic implementation (native `HEAD`, a
|
|
62
|
+
* server-side copy, backend presigning) implements them, and `Storage` uses them
|
|
63
|
+
* when present.
|
|
64
|
+
*/
|
|
15
65
|
export interface Disk {
|
|
16
|
-
put(path: string, bytes: Uint8Array): Promise<void>;
|
|
66
|
+
put(path: string, bytes: Uint8Array, options?: WriteOptions): Promise<void>;
|
|
17
67
|
get(path: string): Promise<Uint8Array | null>;
|
|
18
68
|
exists(path: string): Promise<boolean>;
|
|
19
69
|
delete(path: string): Promise<void>;
|
|
20
70
|
/** Paths currently stored, optionally filtered to those under `prefix`. */
|
|
21
71
|
list(prefix?: string): Promise<string[]>;
|
|
22
|
-
/** A URL for the stored object
|
|
72
|
+
/** A public URL for the stored object. */
|
|
23
73
|
url(path: string): string;
|
|
74
|
+
/** Size, content type, and friends. `Storage.metadata()` falls back to a read. */
|
|
75
|
+
metadata?(path: string): Promise<FileMetadata | null>;
|
|
76
|
+
/** A server-side copy. `Storage.copy()` falls back to read-then-write. */
|
|
77
|
+
copy?(from: string, to: string): Promise<void>;
|
|
78
|
+
/** A server-side move. `Storage.move()` falls back to copy-then-delete. */
|
|
79
|
+
move?(from: string, to: string): Promise<void>;
|
|
80
|
+
/**
|
|
81
|
+
* A backend-presigned read URL (S3/R2 SigV4, GCS, …). When a disk doesn't
|
|
82
|
+
* implement this, `Storage.signedUrl()` signs the disk's own `url()` with
|
|
83
|
+
* `config('app.key')` — serve it with `serveStorage({ signed: true })`.
|
|
84
|
+
*/
|
|
85
|
+
signedUrl?(path: string, options?: SignedFileOptions): Promise<string>;
|
|
86
|
+
/**
|
|
87
|
+
* A backend-presigned upload URL, so a browser can `PUT` straight to the
|
|
88
|
+
* bucket. There is no generic fallback — only the backend can accept the write.
|
|
89
|
+
*/
|
|
90
|
+
signedUploadUrl?(path: string, options?: SignedUploadOptions): Promise<string>;
|
|
24
91
|
}
|
|
25
92
|
export type Contents = string | Uint8Array | ArrayBuffer;
|
|
93
|
+
/** The MIME type for a path's extension, or `application/octet-stream`. */
|
|
94
|
+
export declare function contentTypeFor(path: string): string;
|
|
95
|
+
/**
|
|
96
|
+
* Sign a URL with `config('app.key')`, adding `expires` and `signature` query
|
|
97
|
+
* params. Verify it with `verifyStorageUrl()`.
|
|
98
|
+
*/
|
|
99
|
+
export declare function signStorageUrl(url: string, expiresIn?: number): Promise<string>;
|
|
100
|
+
/** Whether a URL carries a valid, unexpired signature from `signStorageUrl()`. */
|
|
101
|
+
export declare function verifyStorageUrl(url: string): Promise<boolean>;
|
|
26
102
|
/** An in-memory `Disk` — the default; ideal for tests. Not shared across processes. */
|
|
27
103
|
export declare class MemoryDisk implements Disk {
|
|
28
104
|
private baseUrl;
|
|
29
105
|
private files;
|
|
30
106
|
constructor(baseUrl?: string);
|
|
31
|
-
put(path: string, bytes: Uint8Array): Promise<void>;
|
|
107
|
+
put(path: string, bytes: Uint8Array, options?: WriteOptions): Promise<void>;
|
|
32
108
|
get(path: string): Promise<Uint8Array | null>;
|
|
33
109
|
exists(path: string): Promise<boolean>;
|
|
34
110
|
delete(path: string): Promise<void>;
|
|
35
111
|
list(prefix?: string): Promise<string[]>;
|
|
36
112
|
url(path: string): string;
|
|
113
|
+
metadata(path: string): Promise<FileMetadata | null>;
|
|
114
|
+
copy(from: string, to: string): Promise<void>;
|
|
115
|
+
move(from: string, to: string): Promise<void>;
|
|
37
116
|
}
|
|
38
117
|
export declare class Storage {
|
|
39
118
|
private disk;
|
|
40
119
|
constructor(disk: Disk);
|
|
41
|
-
/**
|
|
42
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Write a file (string, bytes, or ArrayBuffer — strings are UTF-8 encoded).
|
|
122
|
+
* The content type is inferred from the extension unless you pass one.
|
|
123
|
+
*/
|
|
124
|
+
put(path: string, contents: Contents, options?: WriteOptions): Promise<void>;
|
|
43
125
|
/** Read a file's raw bytes, or null if it doesn't exist. */
|
|
44
126
|
get(path: string): Promise<Uint8Array | null>;
|
|
45
127
|
/** Read a file as UTF-8 text, or null if it doesn't exist. */
|
|
@@ -47,11 +129,82 @@ export declare class Storage {
|
|
|
47
129
|
exists(path: string): Promise<boolean>;
|
|
48
130
|
delete(path: string): Promise<void>;
|
|
49
131
|
list(prefix?: string): Promise<string[]>;
|
|
132
|
+
/** Size, content type, and friends — or null if the file doesn't exist. */
|
|
133
|
+
metadata(path: string): Promise<FileMetadata | null>;
|
|
134
|
+
/** A file's size in bytes, or null if it doesn't exist. */
|
|
135
|
+
size(path: string): Promise<number | null>;
|
|
136
|
+
/** Copy a file. Server-side when the disk supports it, else read-then-write. */
|
|
137
|
+
copy(from: string, to: string): Promise<void>;
|
|
138
|
+
/** Move a file. Server-side when the disk supports it, else copy-then-delete. */
|
|
139
|
+
move(from: string, to: string): Promise<void>;
|
|
140
|
+
/** A public URL for the object. Use `signedUrl()` for private ones. */
|
|
50
141
|
url(path: string): string;
|
|
142
|
+
/**
|
|
143
|
+
* A temporary URL for a private object. Uses the backend's own presigning when
|
|
144
|
+
* the disk implements `signedUrl` (S3, R2, GCS); otherwise signs the disk's
|
|
145
|
+
* `url()` with `config('app.key')` — serve those with `serveStorage({ signed: true })`.
|
|
146
|
+
*/
|
|
147
|
+
signedUrl(path: string, options?: SignedFileOptions): Promise<string>;
|
|
148
|
+
/**
|
|
149
|
+
* A URL the browser can `PUT` the file to directly, so the bytes never transit
|
|
150
|
+
* your app — the point of this on the edge, where proxying a large upload
|
|
151
|
+
* through a Worker is exactly what you don't want.
|
|
152
|
+
*
|
|
153
|
+
* Only the backend can accept such a write, so this requires a disk that
|
|
154
|
+
* implements `signedUploadUrl` (see the S3/R2 recipe in the storage guide).
|
|
155
|
+
*/
|
|
156
|
+
signedUploadUrl(path: string, options?: SignedUploadOptions): Promise<string>;
|
|
51
157
|
/** The underlying driver, for backend-specific operations. */
|
|
52
158
|
get driver(): Disk;
|
|
53
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* A `Storage` backed by a `MemoryDisk`, with assertions — what `fakeDisk()`
|
|
162
|
+
* installs so tests never touch a real bucket.
|
|
163
|
+
*/
|
|
164
|
+
export declare class FakeStorage extends Storage {
|
|
165
|
+
constructor();
|
|
166
|
+
assertExists(path: string): Promise<void>;
|
|
167
|
+
assertMissing(path: string): Promise<void>;
|
|
168
|
+
/** Assert a file's contents, as UTF-8 text. */
|
|
169
|
+
assertContents(path: string, expected: string): Promise<void>;
|
|
170
|
+
/** Assert how many files are stored, optionally under a prefix. */
|
|
171
|
+
assertCount(expected: number, prefix?: string): Promise<void>;
|
|
172
|
+
}
|
|
54
173
|
/** Register a disk, optionally under a name (default: `"default"`). */
|
|
55
174
|
export declare function setDisk(disk: Disk, name?: string): Storage;
|
|
56
175
|
/** The default disk, or a named one registered with `setDisk(disk, name)`. */
|
|
57
176
|
export declare function storage(name?: string): Storage;
|
|
177
|
+
/**
|
|
178
|
+
* Swap a disk for an in-memory `FakeStorage` so tests never touch a real bucket,
|
|
179
|
+
* and assert against what was written. Undo with `restoreDisk()`.
|
|
180
|
+
*
|
|
181
|
+
* const disk = fakeDisk();
|
|
182
|
+
* await request.post("/avatars", form);
|
|
183
|
+
* await disk.assertExists("avatars/1.png");
|
|
184
|
+
*/
|
|
185
|
+
export declare function fakeDisk(name?: string): FakeStorage;
|
|
186
|
+
/** Restore the real disk after `fakeDisk()`. With no name, restores them all. */
|
|
187
|
+
export declare function restoreDisk(name?: string): void;
|
|
188
|
+
export interface ServeStorageOptions {
|
|
189
|
+
/** Which registered disk to serve from. Default: `"default"`. */
|
|
190
|
+
disk?: string;
|
|
191
|
+
/** URL prefix the files live under. Default: `"/storage"`. */
|
|
192
|
+
basePath?: string;
|
|
193
|
+
/**
|
|
194
|
+
* Require a valid signature from `signedUrl()` — the private-file mode.
|
|
195
|
+
* Unsigned or expired requests get a 403. Default: false.
|
|
196
|
+
*/
|
|
197
|
+
signed?: boolean;
|
|
198
|
+
/** `Cache-Control` max-age in seconds. Omit for no header. */
|
|
199
|
+
maxAge?: number;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Serve files from a disk over HTTP — what makes the fallback `signedUrl()` real
|
|
203
|
+
* for disks without backend presigning (the memory disk, a local-filesystem
|
|
204
|
+
* disk). Requests that don't match `basePath`, or that name a file the disk
|
|
205
|
+
* doesn't have, fall through to your routes.
|
|
206
|
+
*
|
|
207
|
+
* this.use(serveStorage()); // public files
|
|
208
|
+
* this.use(serveStorage({ basePath: "/private", signed: true }));
|
|
209
|
+
*/
|
|
210
|
+
export declare function serveStorage(options?: ServeStorageOptions): MiddlewareHandler;
|
package/dist/core/storage.js
CHANGED
|
@@ -9,8 +9,19 @@
|
|
|
9
9
|
* const bytes = await storage().get("avatars/1.png");
|
|
10
10
|
* const url = storage().url("avatars/1.png");
|
|
11
11
|
*
|
|
12
|
+
* Private files get a temporary URL instead of a public one:
|
|
13
|
+
*
|
|
14
|
+
* await storage().signedUrl("invoices/42.pdf", { expiresIn: 300 });
|
|
15
|
+
*
|
|
16
|
+
* and browsers can upload straight to the bucket — the bytes never transit the
|
|
17
|
+
* app — with a signed upload URL:
|
|
18
|
+
*
|
|
19
|
+
* await storage().signedUploadUrl("uploads/raw.mov", { contentType: "video/quicktime" });
|
|
20
|
+
*
|
|
12
21
|
* Register several disks by name and select one with `storage("s3")`.
|
|
13
22
|
*/
|
|
23
|
+
import { getMimeType } from "hono/utils/mime";
|
|
24
|
+
import { config } from "./helpers.js";
|
|
14
25
|
function toBytes(contents) {
|
|
15
26
|
if (typeof contents === "string")
|
|
16
27
|
return new TextEncoder().encode(contents);
|
|
@@ -18,7 +29,71 @@ function toBytes(contents) {
|
|
|
18
29
|
return contents;
|
|
19
30
|
return new Uint8Array(contents);
|
|
20
31
|
}
|
|
21
|
-
|
|
32
|
+
/** The MIME type for a path's extension, or `application/octet-stream`. */
|
|
33
|
+
export function contentTypeFor(path) {
|
|
34
|
+
return getMimeType(path) ?? "application/octet-stream";
|
|
35
|
+
}
|
|
36
|
+
/* -------------------------------- signing --------------------------------- */
|
|
37
|
+
/** HMAC-SHA256 hex — Web Crypto, so it works on Node and the edge. */
|
|
38
|
+
async function hmac(key, data) {
|
|
39
|
+
const enc = new TextEncoder();
|
|
40
|
+
const cryptoKey = await crypto.subtle.importKey("raw", enc.encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
41
|
+
const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(data));
|
|
42
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
43
|
+
}
|
|
44
|
+
function appKey() {
|
|
45
|
+
const key = config("app.key", "");
|
|
46
|
+
if (!key)
|
|
47
|
+
throw new Error("Signed storage URLs require config('app.key'). Set APP_KEY.");
|
|
48
|
+
return key;
|
|
49
|
+
}
|
|
50
|
+
function timingSafeEqual(a, b) {
|
|
51
|
+
if (a.length !== b.length)
|
|
52
|
+
return false;
|
|
53
|
+
let diff = 0;
|
|
54
|
+
for (let i = 0; i < a.length; i++)
|
|
55
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
56
|
+
return diff === 0;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The string we actually sign: path + query, never the host. A disk's `url()`
|
|
60
|
+
* may be absolute (a CDN) or relative (`/storage/x`), while the request that
|
|
61
|
+
* comes back in is always absolute — signing only the path makes both sides
|
|
62
|
+
* agree. It also means the same signature is valid across your own hostnames,
|
|
63
|
+
* which is what you want behind a CDN.
|
|
64
|
+
*/
|
|
65
|
+
function canonical(url, params) {
|
|
66
|
+
const { pathname } = new URL(url, "http://keel.local");
|
|
67
|
+
const query = params.toString();
|
|
68
|
+
return query ? `${pathname}?${query}` : pathname;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Sign a URL with `config('app.key')`, adding `expires` and `signature` query
|
|
72
|
+
* params. Verify it with `verifyStorageUrl()`.
|
|
73
|
+
*/
|
|
74
|
+
export async function signStorageUrl(url, expiresIn = 3600) {
|
|
75
|
+
const mark = url.indexOf("?");
|
|
76
|
+
const base = mark === -1 ? url : url.slice(0, mark);
|
|
77
|
+
// Keep any query the disk's own url() already carries — a CDN token, say.
|
|
78
|
+
const params = new URLSearchParams(mark === -1 ? "" : url.slice(mark + 1));
|
|
79
|
+
params.set("expires", String(Math.floor(Date.now() / 1000) + expiresIn));
|
|
80
|
+
const signature = await hmac(appKey(), canonical(base, params));
|
|
81
|
+
params.set("signature", signature);
|
|
82
|
+
return `${base}?${params}`;
|
|
83
|
+
}
|
|
84
|
+
/** Whether a URL carries a valid, unexpired signature from `signStorageUrl()`. */
|
|
85
|
+
export async function verifyStorageUrl(url) {
|
|
86
|
+
const parsed = new URL(url, "http://keel.local");
|
|
87
|
+
const signature = parsed.searchParams.get("signature");
|
|
88
|
+
if (!signature)
|
|
89
|
+
return false;
|
|
90
|
+
parsed.searchParams.delete("signature");
|
|
91
|
+
const expires = Number(parsed.searchParams.get("expires"));
|
|
92
|
+
if (!expires || expires < Math.floor(Date.now() / 1000))
|
|
93
|
+
return false;
|
|
94
|
+
const expected = await hmac(appKey(), canonical(parsed.pathname, parsed.searchParams));
|
|
95
|
+
return timingSafeEqual(signature, expected);
|
|
96
|
+
}
|
|
22
97
|
/** An in-memory `Disk` — the default; ideal for tests. Not shared across processes. */
|
|
23
98
|
export class MemoryDisk {
|
|
24
99
|
baseUrl;
|
|
@@ -26,11 +101,21 @@ export class MemoryDisk {
|
|
|
26
101
|
constructor(baseUrl = "/storage") {
|
|
27
102
|
this.baseUrl = baseUrl;
|
|
28
103
|
}
|
|
29
|
-
async put(path, bytes) {
|
|
30
|
-
this.files.set(path,
|
|
104
|
+
async put(path, bytes, options = {}) {
|
|
105
|
+
this.files.set(path, {
|
|
106
|
+
bytes,
|
|
107
|
+
meta: {
|
|
108
|
+
size: bytes.byteLength,
|
|
109
|
+
contentType: options.contentType ?? contentTypeFor(path),
|
|
110
|
+
cacheControl: options.cacheControl,
|
|
111
|
+
visibility: options.visibility ?? "public",
|
|
112
|
+
lastModified: new Date(),
|
|
113
|
+
metadata: options.metadata,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
31
116
|
}
|
|
32
117
|
async get(path) {
|
|
33
|
-
return this.files.get(path) ?? null;
|
|
118
|
+
return this.files.get(path)?.bytes ?? null;
|
|
34
119
|
}
|
|
35
120
|
async exists(path) {
|
|
36
121
|
return this.files.has(path);
|
|
@@ -44,6 +129,18 @@ export class MemoryDisk {
|
|
|
44
129
|
url(path) {
|
|
45
130
|
return `${this.baseUrl}/${path}`;
|
|
46
131
|
}
|
|
132
|
+
async metadata(path) {
|
|
133
|
+
return this.files.get(path)?.meta ?? null;
|
|
134
|
+
}
|
|
135
|
+
async copy(from, to) {
|
|
136
|
+
const entry = this.files.get(from);
|
|
137
|
+
if (entry)
|
|
138
|
+
this.files.set(to, { bytes: entry.bytes, meta: { ...entry.meta } });
|
|
139
|
+
}
|
|
140
|
+
async move(from, to) {
|
|
141
|
+
await this.copy(from, to);
|
|
142
|
+
this.files.delete(from);
|
|
143
|
+
}
|
|
47
144
|
}
|
|
48
145
|
/* -------------------------------- storage --------------------------------- */
|
|
49
146
|
export class Storage {
|
|
@@ -51,9 +148,15 @@ export class Storage {
|
|
|
51
148
|
constructor(disk) {
|
|
52
149
|
this.disk = disk;
|
|
53
150
|
}
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
151
|
+
/**
|
|
152
|
+
* Write a file (string, bytes, or ArrayBuffer — strings are UTF-8 encoded).
|
|
153
|
+
* The content type is inferred from the extension unless you pass one.
|
|
154
|
+
*/
|
|
155
|
+
put(path, contents, options = {}) {
|
|
156
|
+
return this.disk.put(path, toBytes(contents), {
|
|
157
|
+
...options,
|
|
158
|
+
contentType: options.contentType ?? contentTypeFor(path),
|
|
159
|
+
});
|
|
57
160
|
}
|
|
58
161
|
/** Read a file's raw bytes, or null if it doesn't exist. */
|
|
59
162
|
get(path) {
|
|
@@ -73,16 +176,118 @@ export class Storage {
|
|
|
73
176
|
list(prefix) {
|
|
74
177
|
return this.disk.list(prefix);
|
|
75
178
|
}
|
|
179
|
+
/** Size, content type, and friends — or null if the file doesn't exist. */
|
|
180
|
+
async metadata(path) {
|
|
181
|
+
if (this.disk.metadata)
|
|
182
|
+
return this.disk.metadata(path);
|
|
183
|
+
const bytes = await this.disk.get(path);
|
|
184
|
+
if (bytes == null)
|
|
185
|
+
return null;
|
|
186
|
+
return { size: bytes.byteLength, contentType: contentTypeFor(path) };
|
|
187
|
+
}
|
|
188
|
+
/** A file's size in bytes, or null if it doesn't exist. */
|
|
189
|
+
async size(path) {
|
|
190
|
+
return (await this.metadata(path))?.size ?? null;
|
|
191
|
+
}
|
|
192
|
+
/** Copy a file. Server-side when the disk supports it, else read-then-write. */
|
|
193
|
+
async copy(from, to) {
|
|
194
|
+
if (this.disk.copy)
|
|
195
|
+
return this.disk.copy(from, to);
|
|
196
|
+
const bytes = await this.disk.get(from);
|
|
197
|
+
if (bytes == null)
|
|
198
|
+
throw new Error(`Cannot copy "${from}": no such file.`);
|
|
199
|
+
const meta = await this.metadata(from);
|
|
200
|
+
await this.disk.put(to, bytes, {
|
|
201
|
+
contentType: meta?.contentType ?? contentTypeFor(to),
|
|
202
|
+
cacheControl: meta?.cacheControl,
|
|
203
|
+
visibility: meta?.visibility,
|
|
204
|
+
metadata: meta?.metadata,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** Move a file. Server-side when the disk supports it, else copy-then-delete. */
|
|
208
|
+
async move(from, to) {
|
|
209
|
+
if (this.disk.move)
|
|
210
|
+
return this.disk.move(from, to);
|
|
211
|
+
await this.copy(from, to);
|
|
212
|
+
await this.disk.delete(from);
|
|
213
|
+
}
|
|
214
|
+
/** A public URL for the object. Use `signedUrl()` for private ones. */
|
|
76
215
|
url(path) {
|
|
77
216
|
return this.disk.url(path);
|
|
78
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* A temporary URL for a private object. Uses the backend's own presigning when
|
|
220
|
+
* the disk implements `signedUrl` (S3, R2, GCS); otherwise signs the disk's
|
|
221
|
+
* `url()` with `config('app.key')` — serve those with `serveStorage({ signed: true })`.
|
|
222
|
+
*/
|
|
223
|
+
async signedUrl(path, options = {}) {
|
|
224
|
+
if (this.disk.signedUrl)
|
|
225
|
+
return this.disk.signedUrl(path, options);
|
|
226
|
+
return signStorageUrl(this.disk.url(path), options.expiresIn ?? 3600);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* A URL the browser can `PUT` the file to directly, so the bytes never transit
|
|
230
|
+
* your app — the point of this on the edge, where proxying a large upload
|
|
231
|
+
* through a Worker is exactly what you don't want.
|
|
232
|
+
*
|
|
233
|
+
* Only the backend can accept such a write, so this requires a disk that
|
|
234
|
+
* implements `signedUploadUrl` (see the S3/R2 recipe in the storage guide).
|
|
235
|
+
*/
|
|
236
|
+
async signedUploadUrl(path, options = {}) {
|
|
237
|
+
if (!this.disk.signedUploadUrl) {
|
|
238
|
+
throw new Error("This disk does not support signed upload URLs. Implement `signedUploadUrl` on it " +
|
|
239
|
+
"(the storage guide has an S3/R2 recipe) — only the storage backend can accept a direct upload.");
|
|
240
|
+
}
|
|
241
|
+
return this.disk.signedUploadUrl(path, {
|
|
242
|
+
...options,
|
|
243
|
+
contentType: options.contentType ?? contentTypeFor(path),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
79
246
|
/** The underlying driver, for backend-specific operations. */
|
|
80
247
|
get driver() {
|
|
81
248
|
return this.disk;
|
|
82
249
|
}
|
|
83
250
|
}
|
|
251
|
+
/* --------------------------------- faking --------------------------------- */
|
|
252
|
+
/**
|
|
253
|
+
* A `Storage` backed by a `MemoryDisk`, with assertions — what `fakeDisk()`
|
|
254
|
+
* installs so tests never touch a real bucket.
|
|
255
|
+
*/
|
|
256
|
+
export class FakeStorage extends Storage {
|
|
257
|
+
constructor() {
|
|
258
|
+
super(new MemoryDisk());
|
|
259
|
+
}
|
|
260
|
+
async assertExists(path) {
|
|
261
|
+
if (!(await this.exists(path))) {
|
|
262
|
+
throw new Error(`Expected "${path}" to exist on the fake disk, but it does not.`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async assertMissing(path) {
|
|
266
|
+
if (await this.exists(path)) {
|
|
267
|
+
throw new Error(`Expected "${path}" to be missing from the fake disk, but it exists.`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Assert a file's contents, as UTF-8 text. */
|
|
271
|
+
async assertContents(path, expected) {
|
|
272
|
+
await this.assertExists(path);
|
|
273
|
+
const actual = await this.getText(path);
|
|
274
|
+
if (actual !== expected) {
|
|
275
|
+
throw new Error(`Expected "${path}" to contain ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}.`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/** Assert how many files are stored, optionally under a prefix. */
|
|
279
|
+
async assertCount(expected, prefix) {
|
|
280
|
+
const actual = (await this.list(prefix)).length;
|
|
281
|
+
if (actual !== expected) {
|
|
282
|
+
const where = prefix ? ` under "${prefix}"` : "";
|
|
283
|
+
throw new Error(`Expected ${expected} file(s)${where} on the fake disk, found ${actual}.`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
84
287
|
/* -------------------------------- global ---------------------------------- */
|
|
85
288
|
const disks = new Map([["default", new Storage(new MemoryDisk())]]);
|
|
289
|
+
/** Disks displaced by `fakeDisk()`, so `restoreDisk()` can put them back. */
|
|
290
|
+
const realDisks = new Map();
|
|
86
291
|
/** Register a disk, optionally under a name (default: `"default"`). */
|
|
87
292
|
export function setDisk(disk, name = "default") {
|
|
88
293
|
const store = new Storage(disk);
|
|
@@ -96,3 +301,90 @@ export function storage(name = "default") {
|
|
|
96
301
|
throw new Error(`No storage disk named "${name}". Register it with setDisk().`);
|
|
97
302
|
return store;
|
|
98
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Swap a disk for an in-memory `FakeStorage` so tests never touch a real bucket,
|
|
306
|
+
* and assert against what was written. Undo with `restoreDisk()`.
|
|
307
|
+
*
|
|
308
|
+
* const disk = fakeDisk();
|
|
309
|
+
* await request.post("/avatars", form);
|
|
310
|
+
* await disk.assertExists("avatars/1.png");
|
|
311
|
+
*/
|
|
312
|
+
export function fakeDisk(name = "default") {
|
|
313
|
+
const existing = disks.get(name);
|
|
314
|
+
// Only remember the *real* disk — faking twice must not stash a fake.
|
|
315
|
+
if (existing && !realDisks.has(name))
|
|
316
|
+
realDisks.set(name, existing);
|
|
317
|
+
const fake = new FakeStorage();
|
|
318
|
+
disks.set(name, fake);
|
|
319
|
+
return fake;
|
|
320
|
+
}
|
|
321
|
+
/** Restore the real disk after `fakeDisk()`. With no name, restores them all. */
|
|
322
|
+
export function restoreDisk(name) {
|
|
323
|
+
const names = name ? [name] : [...realDisks.keys()];
|
|
324
|
+
for (const key of names) {
|
|
325
|
+
const real = realDisks.get(key);
|
|
326
|
+
if (real)
|
|
327
|
+
disks.set(key, real);
|
|
328
|
+
realDisks.delete(key);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Serve files from a disk over HTTP — what makes the fallback `signedUrl()` real
|
|
333
|
+
* for disks without backend presigning (the memory disk, a local-filesystem
|
|
334
|
+
* disk). Requests that don't match `basePath`, or that name a file the disk
|
|
335
|
+
* doesn't have, fall through to your routes.
|
|
336
|
+
*
|
|
337
|
+
* this.use(serveStorage()); // public files
|
|
338
|
+
* this.use(serveStorage({ basePath: "/private", signed: true }));
|
|
339
|
+
*/
|
|
340
|
+
export function serveStorage(options = {}) {
|
|
341
|
+
const basePath = (options.basePath ?? "/storage").replace(/\/+$/, "");
|
|
342
|
+
return async (c, next) => {
|
|
343
|
+
if (c.req.method !== "GET" && c.req.method !== "HEAD")
|
|
344
|
+
return next();
|
|
345
|
+
const url = new URL(c.req.url);
|
|
346
|
+
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`))
|
|
347
|
+
return next();
|
|
348
|
+
const path = decodeURIComponent(url.pathname.slice(basePath.length + 1));
|
|
349
|
+
if (!path || path.includes(".."))
|
|
350
|
+
return next(); // path traversal guard
|
|
351
|
+
const store = storage(options.disk);
|
|
352
|
+
if (options.signed) {
|
|
353
|
+
// `signedUrl()` signs the path the *disk* reports. If the disk hands out
|
|
354
|
+
// `/storage/…` while this middleware is mounted at `/private`, no signature
|
|
355
|
+
// can ever match and every request would 403 — a misconfiguration that looks
|
|
356
|
+
// exactly like an expired link. Say so instead of failing quietly.
|
|
357
|
+
const diskPath = new URL(store.url(path), "http://keel.local").pathname;
|
|
358
|
+
if (diskPath !== url.pathname) {
|
|
359
|
+
throw new Error(`serveStorage: the disk serves "${path}" at "${diskPath}", but this middleware is ` +
|
|
360
|
+
`mounted at "${url.pathname}". signedUrl() signs the disk's own URL, so no signature ` +
|
|
361
|
+
`can match here. Give the disk the matching base URL (e.g. new MemoryDisk("${basePath}")) ` +
|
|
362
|
+
`or set basePath to the disk's prefix.`);
|
|
363
|
+
}
|
|
364
|
+
if (!(await verifyStorageUrl(c.req.url)))
|
|
365
|
+
return c.text("Forbidden", 403);
|
|
366
|
+
}
|
|
367
|
+
const bytes = await store.get(path);
|
|
368
|
+
if (bytes == null)
|
|
369
|
+
return next();
|
|
370
|
+
const meta = await store.metadata(path);
|
|
371
|
+
const etag = `W/"${meta?.size ?? bytes.byteLength}-${meta?.lastModified?.getTime() ?? 0}"`;
|
|
372
|
+
c.header("Content-Type", meta?.contentType ?? contentTypeFor(path));
|
|
373
|
+
c.header("ETag", etag);
|
|
374
|
+
if (meta?.lastModified)
|
|
375
|
+
c.header("Last-Modified", meta.lastModified.toUTCString());
|
|
376
|
+
const cacheControl = meta?.cacheControl ??
|
|
377
|
+
(options.maxAge != null
|
|
378
|
+
? `${options.signed ? "private" : "public"}, max-age=${options.maxAge}`
|
|
379
|
+
: undefined);
|
|
380
|
+
if (cacheControl)
|
|
381
|
+
c.header("Cache-Control", cacheControl);
|
|
382
|
+
if (c.req.header("If-None-Match") === etag)
|
|
383
|
+
return c.body(null, 304);
|
|
384
|
+
if (c.req.method === "HEAD")
|
|
385
|
+
return c.body(null, 200);
|
|
386
|
+
// Copy out of the view's backing buffer — `bytes.buffer` alone may be larger.
|
|
387
|
+
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
388
|
+
return c.body(body, 200);
|
|
389
|
+
};
|
|
390
|
+
}
|