@ayepi/files 0.1.0 → 0.2.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/README.md +1 -1
- package/ayepi-files.md +410 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
|
|
|
50
50
|
|
|
51
51
|
- [`ayepi-files.md`](./ayepi-files.md)
|
|
52
52
|
|
|
53
|
-
They
|
|
53
|
+
They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/files).
|
|
54
54
|
|
|
55
55
|
## License
|
|
56
56
|
|
package/ayepi-files.md
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
ayepi-files.md — reference for `@ayepi/files`, written for coding agents.
|
|
3
|
+
|
|
4
|
+
Copy this file into any project that depends on `@ayepi/files` (e.g. into your repo's
|
|
5
|
+
`docs/` or `.claude/` directory) and reference it from your agents and slash commands.
|
|
6
|
+
It documents the public API, the patterns the package expects, and how it works under the
|
|
7
|
+
hood, with copy-pasteable examples. Keep it in sync with the installed package version.
|
|
8
|
+
-->
|
|
9
|
+
|
|
10
|
+
# `@ayepi/files`
|
|
11
|
+
|
|
12
|
+
A generic, **S3-like**, key-based file store for [`@ayepi/core`](./ayepi-core.md): stream
|
|
13
|
+
bytes in under a key, stream them back out, list by prefix, and hand out **presigned**
|
|
14
|
+
upload/download URLs that expire. The `FileStore` interface is tiny and storage-agnostic —
|
|
15
|
+
the bundled filesystem store (`@ayepi/files/fs`) is the default, and `@ayepi/aws`'s
|
|
16
|
+
`s3Files` (see [ayepi-aws.md](./ayepi-aws.md)) implements the **same** interface, so code
|
|
17
|
+
written against `FileStore` is portable across backends. Everything is **stream-first**:
|
|
18
|
+
`put` takes a `ReadableStream` (or any `FileBody`), `get` returns a `FileObject` you read as
|
|
19
|
+
a stream. Reach for it whenever you need uploads/downloads/blob storage behind one swappable
|
|
20
|
+
contract. `@ayepi/core` is a **peer dependency**:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm add @ayepi/files @ayepi/core
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The package exposes exactly three import specifiers (per `package.json#exports`):
|
|
27
|
+
|
|
28
|
+
- **`@ayepi/files`** (the `.` entry) — dependency-light: the `FileStore` / `Presigner`
|
|
29
|
+
interfaces, the supporting types, and the `toStream` / `collect` / `transfer` stream
|
|
30
|
+
helpers. No Node imports; safe to reference from shared code.
|
|
31
|
+
- **`@ayepi/files/fs`** — `fsFiles`, the Node filesystem-backed `FileStore`.
|
|
32
|
+
- **`@ayepi/files/server`** — `mountFiles` / `createFilesHandler`, which add presigned
|
|
33
|
+
GET/PUT endpoints in front of a store that can't self-serve (the filesystem one). Node-only.
|
|
34
|
+
|
|
35
|
+
## Public API
|
|
36
|
+
|
|
37
|
+
### Types — the storage contract (`@ayepi/files`)
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
/** Metadata about a stored object (no body) — the S3 `HeadObject` shape. */
|
|
41
|
+
interface FileInfo {
|
|
42
|
+
readonly key: string; // the object's key
|
|
43
|
+
readonly size: number; // size in bytes
|
|
44
|
+
readonly contentType?: string; // MIME type, if stored
|
|
45
|
+
readonly etag?: string; // opaque content tag, when the backend supplies one
|
|
46
|
+
readonly modifiedAt: number; // last-modified (ms epoch)
|
|
47
|
+
readonly metadata?: Readonly<Record<string, string>>; // arbitrary user metadata
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Anything you can hand to `put` as the body — a stream is preferred. */
|
|
51
|
+
type FileBody = ReadableStream<Uint8Array> | Uint8Array | Blob | string;
|
|
52
|
+
|
|
53
|
+
/** A stored object's metadata plus lazy accessors for its bytes. */
|
|
54
|
+
interface FileObject {
|
|
55
|
+
readonly info: FileInfo;
|
|
56
|
+
stream(): ReadableStream<Uint8Array>; // the body as a byte stream (read it once)
|
|
57
|
+
bytes(): Promise<Uint8Array>; // read the whole body into memory
|
|
58
|
+
text(): Promise<string>; // read the whole body as a UTF-8 string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A page of `list` results. */
|
|
62
|
+
interface ListResult {
|
|
63
|
+
readonly files: readonly FileInfo[]; // the objects in this page (metadata only), key-sorted
|
|
64
|
+
readonly cursor?: string; // pass to a follow-up `list({ cursor })`; absent when complete
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`PutOptions` (`{ contentType?, metadata? }`) and `ListOptions` (`{ limit?, cursor? }`) carry
|
|
69
|
+
the optional `put` / `list` arguments.
|
|
70
|
+
|
|
71
|
+
### `FileStore`
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
interface FileStore {
|
|
75
|
+
put(key: string, body: FileBody, opts?: PutOptions): Promise<FileInfo>;
|
|
76
|
+
get(key: string): Promise<FileObject | undefined>;
|
|
77
|
+
head(key: string): Promise<FileInfo | undefined>;
|
|
78
|
+
delete(key: string): Promise<boolean>;
|
|
79
|
+
list(prefix: string, opts?: ListOptions): Promise<ListResult>;
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The whole storage surface — S3's core operations, nothing more.
|
|
84
|
+
|
|
85
|
+
- `put(key, body, opts?)` — store `body` under `key`, **overwriting** any existing object;
|
|
86
|
+
resolves the resulting `FileInfo`. `body` is any `FileBody` (stream preferred).
|
|
87
|
+
- `get(key)` — fetch an object (metadata + lazy body) or `undefined` if the key is absent.
|
|
88
|
+
- `head(key)` — fetch just the `FileInfo` (no body) or `undefined` if absent.
|
|
89
|
+
- `delete(key)` — delete an object; resolves `true` if it existed, `false` if it didn't.
|
|
90
|
+
- `list(prefix, opts?)` — list objects whose key **starts with** `prefix`, key-sorted and
|
|
91
|
+
paginated. Pass `opts.limit` to cap the page and feed the returned `cursor` back in via
|
|
92
|
+
`opts.cursor` to continue.
|
|
93
|
+
|
|
94
|
+
### `Presigner`
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
interface Presigner {
|
|
98
|
+
presignDownload(key: string, opts?: { expiresIn?: number }): Promise<string>;
|
|
99
|
+
presignUpload(key: string, opts?: { expiresIn?: number; contentType?: string }): Promise<string>;
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
A separate capability from `FileStore` because **not every store can self-serve**. `s3Files`
|
|
104
|
+
(`@ayepi/aws`) implements `Presigner` **natively** (AWS signs the URLs — no server needed);
|
|
105
|
+
the filesystem store gets a `Presigner` from `@ayepi/files/server` (`mountFiles` /
|
|
106
|
+
`createFilesHandler`), which serve the signed GET/PUT routes it returns.
|
|
107
|
+
|
|
108
|
+
- `presignDownload(key, opts?)` — a time-limited URL to GET `key`. `expiresIn` is in seconds.
|
|
109
|
+
- `presignUpload(key, opts?)` — a time-limited URL to PUT `key`. `expiresIn` in seconds;
|
|
110
|
+
`contentType` pins the `Content-Type` the upload records.
|
|
111
|
+
|
|
112
|
+
### Stream helpers (`@ayepi/files`)
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
function toStream(body: FileBody): ReadableStream<Uint8Array>;
|
|
116
|
+
function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array>;
|
|
117
|
+
function transfer(src: FileStore, srcKey: string, dst: FileStore, dstKey: string, opts?: PutOptions): Promise<FileInfo>;
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- `toStream(body)` — normalize any `FileBody` into a byte stream. An existing
|
|
121
|
+
`ReadableStream` is returned **by identity**; a `Blob` becomes its `.stream()`; a `string`
|
|
122
|
+
is UTF-8 encoded; a `Uint8Array` is wrapped in a single-chunk stream.
|
|
123
|
+
- `collect(stream)` — read a byte stream fully into one `Uint8Array` (concatenating chunks).
|
|
124
|
+
- `transfer(src, srcKey, dst, dstKey, opts?)` — stream an object from one store to another
|
|
125
|
+
(`src[srcKey] → dst[dstKey]`) **without buffering it all in memory**. Carries the source's
|
|
126
|
+
`contentType`/`metadata` unless `opts` overrides them. **Throws** if `srcKey` is missing.
|
|
127
|
+
|
|
128
|
+
### `fsFiles(opts)` — the filesystem store (`@ayepi/files/fs`)
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
function fsFiles(opts: FsFilesOptions): FileStore;
|
|
132
|
+
|
|
133
|
+
interface FsFilesOptions {
|
|
134
|
+
readonly dir: string; // root directory (created on demand)
|
|
135
|
+
readonly onError?: (err: unknown, op: string, key: string) => void; // observe an I/O error (also re-thrown)
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A Node filesystem-backed `FileStore` rooted at `opts.dir`:
|
|
140
|
+
|
|
141
|
+
- **Objects are files.** The key is a `/`-separated relative path under `dir`; nested keys
|
|
142
|
+
create subdirectories on demand (`mkdir -p` semantics on `put`).
|
|
143
|
+
- **Metadata sidecars.** `contentType`/`metadata` are stored in a small `<file>.ayepi-meta`
|
|
144
|
+
JSON sidecar next to the object — written only when there's metadata to store, and excluded
|
|
145
|
+
from listings. A missing or garbled sidecar is tolerated (yields no extra metadata). Keys
|
|
146
|
+
ending in `.ayepi-meta` are reserved.
|
|
147
|
+
- **Atomic writes.** `put` streams the body to a temp file (`<file>.<random>.tmp`) and
|
|
148
|
+
`rename`s it into place, so a reader never sees a half-written object. `etag` is derived
|
|
149
|
+
from size + mtime (`"<size>-<mtime>"`).
|
|
150
|
+
- **Streaming reads.** `get(...).stream()` is `createReadStream` off disk; `bytes()`/`text()`
|
|
151
|
+
collect that stream.
|
|
152
|
+
- **Prefix listing with cursor pagination.** `list` walks the tree, filters by `prefix`,
|
|
153
|
+
sorts keys, and returns up to `limit` (default **1000**); the `cursor` is the last key of
|
|
154
|
+
the page, and a follow-up `list(prefix, { cursor })` resumes after it. A non-existent root
|
|
155
|
+
lists nothing (empty page) rather than throwing.
|
|
156
|
+
- **Key sanitization.** Keys are rejected (`Error: invalid key`) if empty or if any
|
|
157
|
+
`/`-segment is empty, `.`, or `..` — no traversal, no `a//b`.
|
|
158
|
+
- **`onError` observe-then-rethrow.** On a **real** I/O failure (not a benign "not found"),
|
|
159
|
+
`onError(err, op, key)` is called and the error is then **re-thrown** to the caller — it
|
|
160
|
+
observes, it doesn't swallow. `op` is one of `'put'|'get'|'head'|'delete'|'list'`. A
|
|
161
|
+
missing key surfaces as `undefined`/`false` (not an error), so it does **not** fire
|
|
162
|
+
`onError`. A throwing `onError` is itself ignored so it can't mask the original I/O error.
|
|
163
|
+
|
|
164
|
+
### `@ayepi/files/server` — presigned serving for a `FileStore`
|
|
165
|
+
|
|
166
|
+
A store like `fsFiles` can't hand out working URLs on its own — it has no HTTP surface. This
|
|
167
|
+
module signs short-lived, HMAC-stamped tokens and serves the matching GET/PUT two ways. The
|
|
168
|
+
token is `base64url(payload).hmac-sha256` carrying the key, op (`get`/`put`), expiry, and
|
|
169
|
+
(for uploads) the pinned content-type — so the URL is **opaque and tamper-evident**, and the
|
|
170
|
+
key never appears in the clear. Verification uses `timingSafeEqual` and rejects expired,
|
|
171
|
+
tampered, wrong-op, or unparseable tokens.
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
interface FilesServerOptions {
|
|
175
|
+
readonly secret: string; // HMAC secret used to sign/verify tokens (server-side only)
|
|
176
|
+
readonly basePath?: string; // URL path the routes live at (default '/_files')
|
|
177
|
+
readonly expiresIn?: number; // default presigned-URL lifetime in seconds (default 900 = 15 min)
|
|
178
|
+
readonly now?: () => number; // clock injection (default Date.now) — for tests
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
#### `mountFiles(app, store, opts)`
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
function mountFiles(
|
|
186
|
+
app: Server<AnySpec>, store: FileStore, opts: FilesServerOptions,
|
|
187
|
+
): { handle: MountHandle; presign: Presigner };
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Hot-mounts two routes onto a **running** ayepi `Server` (via the same `Server.install` the
|
|
191
|
+
plugin host uses — one call, no edits to your spec) and returns a `Presigner` whose URLs
|
|
192
|
+
point at them:
|
|
193
|
+
|
|
194
|
+
- `ayepiFilesDownload` — `GET ${basePath}?t=…`, a raw `streamOut`. Verifies the token
|
|
195
|
+
(`o === 'get'`), `store.get`s the object (404 if missing), then uses the handler's
|
|
196
|
+
`download(filename, contentType)` to set the object's content-type and `length(size)` to
|
|
197
|
+
set `Content-Length` — which is what makes HTTP **Range / `206`** (resumable downloads)
|
|
198
|
+
work.
|
|
199
|
+
- `ayepiFilesUpload` — `PUT ${basePath}?t=…`, a `streamIn`. Verifies the token
|
|
200
|
+
(`o === 'put'`), streams the request body straight into `store.put` (recording the pinned
|
|
201
|
+
`contentType`), and responds `{ key, size }`.
|
|
202
|
+
|
|
203
|
+
Tear the routes down later with `app.uninstall(handle)`.
|
|
204
|
+
|
|
205
|
+
#### `createFilesHandler(store, opts)`
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
function createFilesHandler(
|
|
209
|
+
store: FileStore, opts: FilesServerOptions,
|
|
210
|
+
): { fetch: (req: Request) => Promise<Response | undefined>; presign: Presigner };
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The same verify-and-stream logic as a plain **`fetch(req)`** — **no spec, no `Server.install`,
|
|
214
|
+
works on any runtime**. It returns `undefined` for requests whose pathname isn't `basePath`,
|
|
215
|
+
so you compose it around your server's `fetch` and fall through on a path miss. GET streams
|
|
216
|
+
the object back with `content-type` + `content-length` headers (403 on a bad/wrong-op token,
|
|
217
|
+
404 if the object is missing); PUT streams the body into `store.put` and returns
|
|
218
|
+
`{ key, size }` JSON (an absent request body is treated as empty → `size: 0`); any other
|
|
219
|
+
method is `405`.
|
|
220
|
+
|
|
221
|
+
> **Why presign needs server endpoints for the filesystem store:** a `Presigner` URL only
|
|
222
|
+
> works if something serves it. S3 *is* the server, so `s3Files` signs URLs AWS already
|
|
223
|
+
> honors and needs **no** mount. The filesystem store has no HTTP surface of its own — so
|
|
224
|
+
> `mountFiles` / `createFilesHandler` supply the signed GET/PUT endpoints (and the `Presigner`
|
|
225
|
+
> that points at them). Use `@ayepi/aws`'s `s3Files` and you skip this module entirely.
|
|
226
|
+
|
|
227
|
+
## Examples
|
|
228
|
+
|
|
229
|
+
### Basic `fsFiles` CRUD
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { fsFiles } from '@ayepi/files/fs';
|
|
233
|
+
|
|
234
|
+
const files = fsFiles({ dir: './uploads' });
|
|
235
|
+
|
|
236
|
+
// put accepts a string / Uint8Array / Blob / ReadableStream
|
|
237
|
+
const info = await files.put('docs/a.txt', 'hello', { contentType: 'text/plain', metadata: { owner: 'ada' } });
|
|
238
|
+
info.size; // 5
|
|
239
|
+
info.contentType; // 'text/plain'
|
|
240
|
+
|
|
241
|
+
const obj = await files.get('docs/a.txt');
|
|
242
|
+
await obj?.text(); // 'hello'
|
|
243
|
+
(await files.head('docs/a.txt'))?.size; // 5 (metadata only, no body)
|
|
244
|
+
|
|
245
|
+
await files.delete('docs/a.txt'); // → true; → false if it didn't exist
|
|
246
|
+
await files.get('missing'); // → undefined
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Streaming a large file in and out (no buffering)
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import { createReadStream, createWriteStream } from 'node:fs';
|
|
253
|
+
import { Readable, Writable } from 'node:stream';
|
|
254
|
+
|
|
255
|
+
// IN: hand `put` a web ReadableStream — it streams to a temp file, then atomically renames.
|
|
256
|
+
await files.put('big.bin', Readable.toWeb(createReadStream('./big.bin')) as ReadableStream<Uint8Array>, {
|
|
257
|
+
contentType: 'application/octet-stream',
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// OUT: pipe the object stream straight to a sink — the whole body never sits in memory.
|
|
261
|
+
const out = await files.get('big.bin');
|
|
262
|
+
await out!.stream().pipeTo(Writable.toWeb(createWriteStream('./copy.bin')));
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Transferring between two stores
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
import { transfer } from '@ayepi/files';
|
|
269
|
+
|
|
270
|
+
const a = fsFiles({ dir: './a' });
|
|
271
|
+
const b = fsFiles({ dir: './b' });
|
|
272
|
+
await a.put('src.txt', 'payload', { contentType: 'text/plain' });
|
|
273
|
+
|
|
274
|
+
// streams src → dst, carrying the source's content-type/metadata
|
|
275
|
+
const info = await transfer(a, 'src.txt', b, 'dst.txt');
|
|
276
|
+
info.key; // 'dst.txt'
|
|
277
|
+
info.contentType; // 'text/plain'
|
|
278
|
+
|
|
279
|
+
// opts override what the source carried
|
|
280
|
+
await transfer(a, 'src.txt', b, 'dst2.txt', { contentType: 'application/json', metadata: { x: '1' } });
|
|
281
|
+
|
|
282
|
+
await transfer(a, 'missing', b, 'x'); // throws: source key "missing" not found
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
The same call works across backends — e.g. `transfer(fsStore, key, s3Store, key)` to migrate
|
|
286
|
+
a local file into S3.
|
|
287
|
+
|
|
288
|
+
### Listing by prefix with cursor pagination
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
const all = await files.list('a/'); // every key under 'a/', sorted
|
|
292
|
+
all.files.map((f) => f.key); // ['a/1', 'a/2', 'a/3']
|
|
293
|
+
|
|
294
|
+
const page1 = await files.list('a/', { limit: 2 });
|
|
295
|
+
page1.files.map((f) => f.key); // ['a/1', 'a/2']
|
|
296
|
+
page1.cursor; // 'a/2'
|
|
297
|
+
const page2 = await files.list('a/', { limit: 2, cursor: page1.cursor });
|
|
298
|
+
page2.files.map((f) => f.key); // ['a/3']
|
|
299
|
+
page2.cursor; // undefined → done
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
### `mountFiles`: a presigned PUT → GET round-trip through a real server
|
|
303
|
+
|
|
304
|
+
```ts
|
|
305
|
+
import { server, spec } from '@ayepi/core';
|
|
306
|
+
import { fsFiles } from '@ayepi/files/fs';
|
|
307
|
+
import { mountFiles } from '@ayepi/files/server';
|
|
308
|
+
|
|
309
|
+
const app = server(spec({ endpoints: {} }), []);
|
|
310
|
+
const store = fsFiles({ dir: './uploads' });
|
|
311
|
+
const { presign, handle } = mountFiles(app, store, { secret: process.env.FILES_SECRET! });
|
|
312
|
+
handle.eps.length; // 2 — the GET + PUT routes were installed
|
|
313
|
+
|
|
314
|
+
// upload via a presigned PUT URL
|
|
315
|
+
const putUrl = await presign.presignUpload('a/b.txt', { contentType: 'text/plain' });
|
|
316
|
+
const put = await app.fetch(new Request('http://host' + putUrl, { method: 'PUT', body: 'hello world' }));
|
|
317
|
+
await put.json(); // { key: 'a/b.txt', size: 11 }
|
|
318
|
+
|
|
319
|
+
// download via a presigned GET URL — Content-Length is set, so Range/206 works
|
|
320
|
+
const getUrl = await presign.presignDownload('a/b.txt', { expiresIn: 60 });
|
|
321
|
+
const ranged = await app.fetch(new Request('http://host' + getUrl, { headers: { range: 'bytes=0-4' } }));
|
|
322
|
+
ranged.status; // 206
|
|
323
|
+
ranged.headers.get('content-range'); // 'bytes 0-4/11'
|
|
324
|
+
await ranged.text(); // 'hello'
|
|
325
|
+
|
|
326
|
+
// later: app.uninstall(handle) tears the two routes back down
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
A bad / expired / wrong-op / tampered token is rejected with **403**; a valid token for a
|
|
330
|
+
missing object is **404**.
|
|
331
|
+
|
|
332
|
+
### `createFilesHandler`: composed around `app.fetch`
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { createFilesHandler } from '@ayepi/files/server';
|
|
336
|
+
|
|
337
|
+
const { fetch: filesFetch, presign } = createFilesHandler(store, { secret, basePath: '/files' });
|
|
338
|
+
|
|
339
|
+
// try the files handler first; it returns undefined for non-`/files` paths, so fall through.
|
|
340
|
+
const handler = async (req: Request): Promise<Response> =>
|
|
341
|
+
(await filesFetch(req)) ?? app.fetch(req);
|
|
342
|
+
|
|
343
|
+
const url = await presign.presignUpload('h.txt');
|
|
344
|
+
await handler(new Request('http://host' + url, { method: 'PUT', body: 'hi' })); // → { key:'h.txt', size:2 }
|
|
345
|
+
await filesFetch(new Request('http://host/somewhere-else')); // → undefined (not ours)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## How it works under the hood
|
|
349
|
+
|
|
350
|
+
- **One interface, swappable backends.** `FileStore` is the only contract. `fsFiles` and
|
|
351
|
+
`s3Files` implement it identically; `transfer` and any of your own code take `FileStore`
|
|
352
|
+
and don't care which is behind it. `Presigner` is deliberately a *separate* interface
|
|
353
|
+
because presigning is a backend capability, not a universal one.
|
|
354
|
+
- **Stream-first body handling.** `toStream` normalizes every `FileBody` to a byte stream up
|
|
355
|
+
front (passing an existing `ReadableStream` through by identity), so `put` always pipes a
|
|
356
|
+
stream and never materializes large bodies. `fsFiles.put` pipes that stream into a temp
|
|
357
|
+
file via `node:stream/promises` `pipeline`, then `rename`s — the publish is atomic and the
|
|
358
|
+
reader-visible object is always complete.
|
|
359
|
+
- **Sidecar metadata.** Rather than a database, `fsFiles` keeps `contentType`/`metadata` in a
|
|
360
|
+
per-object `.ayepi-meta` JSON file, written only when there's something to store and hidden
|
|
361
|
+
from `list` (`isSidecar` filter). `head`/`get` read it best-effort: a missing or corrupt
|
|
362
|
+
sidecar simply means "no extra metadata."
|
|
363
|
+
- **Listing.** `list` does a recursive `readdir` walk, maps disk paths back to `/`-keys,
|
|
364
|
+
filters by `prefix` and (if paging) `key > cursor`, sorts, and slices to `limit`. The
|
|
365
|
+
cursor is just the last key emitted — stable because the listing is always sorted. A
|
|
366
|
+
missing subtree (`ENOENT`) yields an empty result instead of an error.
|
|
367
|
+
- **Presigned tokens.** `signToken` builds `base64url(json).base64url(hmacSha256(json))`;
|
|
368
|
+
`verifyToken` recomputes the HMAC, compares with `timingSafeEqual` (length-checked first),
|
|
369
|
+
parses the payload, and rejects anything expired (`e <= now`) or unparseable. The handlers
|
|
370
|
+
additionally check the op matches the route (`get` for download, `put` for upload).
|
|
371
|
+
- **Two serving styles, one presigner.** Both `mountFiles` and `createFilesHandler` mint URLs
|
|
372
|
+
with the same `makePresigner` (`${basePath}?t=…`). `mountFiles` serves them through a real
|
|
373
|
+
ayepi `Server` (so the download benefits from the framework's `streamOut` + `length()`
|
|
374
|
+
Range handling); `createFilesHandler` serves them as a bare `Request → Response` for
|
|
375
|
+
runtimes where you'd rather not mount.
|
|
376
|
+
|
|
377
|
+
## Gotchas / constraints
|
|
378
|
+
|
|
379
|
+
- **`fsFiles` is local — no durability or retries.** It's a single-machine filesystem store:
|
|
380
|
+
no replication, no cross-host sharing, no retry/backoff. It's ideal for dev, tests, and
|
|
381
|
+
single-node deployments; for production blob storage use `@ayepi/aws`'s `s3Files` behind
|
|
382
|
+
the same `FileStore` interface.
|
|
383
|
+
- **Presign tokens are signed, not encrypted.** The HMAC makes the token tamper-evident and
|
|
384
|
+
the key isn't human-obvious (it's base64url'd), but the payload is **not** secret — anyone
|
|
385
|
+
with the URL can decode the key and read/write within the token's lifetime. Treat presigned
|
|
386
|
+
URLs as bearer credentials: keep `expiresIn` short and the `secret` server-side only.
|
|
387
|
+
- **`secret` must match across signing and serving.** The same `secret` (and `basePath`) must
|
|
388
|
+
be configured wherever URLs are signed and wherever they're served, or every token verifies
|
|
389
|
+
as a 403. Rotating the secret invalidates all outstanding URLs.
|
|
390
|
+
- **Keys are sanitized — no traversal.** `fsFiles` rejects empty keys and any `.`/`..`/empty
|
|
391
|
+
segment (and `a//b`), so a key can never escape `dir`. Keys ending in `.ayepi-meta` collide
|
|
392
|
+
with sidecars and are reserved. Plan key schemes accordingly.
|
|
393
|
+
- **`get`/`head`/`delete` don't fire `onError` on a miss.** A non-existent key is normal
|
|
394
|
+
control flow (`undefined` / `false`), not an I/O error. `onError` fires only for real
|
|
395
|
+
failures (e.g. a permission or disk error), and the error is **re-thrown** afterward — it's
|
|
396
|
+
for observability, not for swallowing.
|
|
397
|
+
- **`mountFiles` mutates a running server.** It installs two endpoints at `basePath` via the
|
|
398
|
+
internal `Server.install`; make sure `basePath` doesn't collide with your own routes, and
|
|
399
|
+
remember to `app.uninstall(handle)` if you tear the store down.
|
|
400
|
+
- **A stream body can be read once.** `FileObject.stream()` is a live read — consume it once.
|
|
401
|
+
Call `get` again (or use `bytes()`/`text()`) if you need the body twice.
|
|
402
|
+
|
|
403
|
+
## See also
|
|
404
|
+
|
|
405
|
+
- [ayepi-aws.md](./ayepi-aws.md) — `s3Files`, the S3-backed `FileStore` that **also**
|
|
406
|
+
implements `Presigner` natively (no `@ayepi/files/server` needed), plus the SQS queue.
|
|
407
|
+
- [ayepi-work.md](./ayepi-work.md) — the work/queue engine, for processing uploaded files
|
|
408
|
+
out of band (e.g. transcode on `put`).
|
|
409
|
+
- [ayepi-core.md](./ayepi-core.md) — `spec` / `endpoint` / `implement` / `server`, the
|
|
410
|
+
`streamIn`/`streamOut` endpoint shapes and `Server.install` that `mountFiles` builds on.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ayepi/files",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Generic S3-like key-based file storage for @ayepi/core — stream-first put/get, prefix listing, presigned upload/download URLs, with a filesystem default and stream/transfer helpers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"type": "module",
|
|
19
19
|
"sideEffects": false,
|
|
20
20
|
"files": [
|
|
21
|
-
"dist"
|
|
21
|
+
"dist",
|
|
22
|
+
"ayepi-*.md"
|
|
22
23
|
],
|
|
23
24
|
"exports": {
|
|
24
25
|
".": {
|
|
@@ -57,7 +58,7 @@
|
|
|
57
58
|
"node": ">=18"
|
|
58
59
|
},
|
|
59
60
|
"peerDependencies": {
|
|
60
|
-
"@ayepi/core": "^0.
|
|
61
|
+
"@ayepi/core": "^0.2.0"
|
|
61
62
|
},
|
|
62
63
|
"devDependencies": {
|
|
63
64
|
"@vitest/coverage-v8": "^2.1.8",
|
|
@@ -65,7 +66,7 @@
|
|
|
65
66
|
"tsdown": "^0.12.0",
|
|
66
67
|
"vitest": "^2.1.8",
|
|
67
68
|
"zod": "^4.4.3",
|
|
68
|
-
"@ayepi/core": "0.
|
|
69
|
+
"@ayepi/core": "0.2.0"
|
|
69
70
|
},
|
|
70
71
|
"keywords": [
|
|
71
72
|
"ayepi",
|