@ayepi/files 0.1.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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/fs.cjs +168 -0
- package/dist/fs.d.cts +23 -0
- package/dist/fs.d.ts +23 -0
- package/dist/fs.js +167 -0
- package/dist/index.cjs +48 -0
- package/dist/index.d.cts +126 -0
- package/dist/index.d.ts +126 -0
- package/dist/index.js +45 -0
- package/dist/server.cjs +4125 -0
- package/dist/server.d.cts +51 -0
- package/dist/server.d.ts +51 -0
- package/dist/server.js +4123 -0
- package/package.json +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Diffenderfer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @ayepi/files
|
|
2
|
+
|
|
3
|
+
A generic, **S3-like**, key-based file store for ayepi: stream bytes in under a key,
|
|
4
|
+
stream them back out, list by prefix, and hand out **presigned** upload/download URLs that
|
|
5
|
+
expire. The interface is tiny and storage-agnostic; the bundled filesystem store
|
|
6
|
+
(`@ayepi/files/fs`) is the default, and `@ayepi/aws`'s `s3Files` implements the same
|
|
7
|
+
`FileStore`. Everything is **stream-first** — `put` takes a `ReadableStream`, `get` returns
|
|
8
|
+
an object you read as a stream.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
pnpm add @ayepi/files @ayepi/core
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The `.` entry is dependency-light (just types + stream helpers); `./fs` and `./server` are
|
|
15
|
+
Node-only.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { fsFiles } from '@ayepi/files/fs'
|
|
19
|
+
import { mountFiles } from '@ayepi/files/server'
|
|
20
|
+
|
|
21
|
+
const files = fsFiles({ dir: './uploads' })
|
|
22
|
+
await files.put('reports/2026.csv', someStream, { contentType: 'text/csv' })
|
|
23
|
+
const obj = await files.get('reports/2026.csv')
|
|
24
|
+
for (const f of (await files.list('reports/')).files) console.log(f.key, f.size)
|
|
25
|
+
|
|
26
|
+
// presigned URLs for a store that can't self-serve (the filesystem one):
|
|
27
|
+
const { presign } = mountFiles(app, files, { secret: process.env.FILES_SECRET! })
|
|
28
|
+
const url = await presign.presignDownload('reports/2026.csv', { expiresIn: 60 })
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
- **One small interface.** `FileStore` is `put` / `get` / `head` / `delete` / `list` —
|
|
34
|
+
S3's core shape, nothing more. `fsFiles` and `@ayepi/aws`'s `s3Files` both implement it,
|
|
35
|
+
so code written against `FileStore` is portable across backends.
|
|
36
|
+
- **Stream-first.** `put` accepts any `FileBody` (a `ReadableStream`, `Uint8Array`, `Blob`,
|
|
37
|
+
or `string`); `get` returns a `FileObject` you read as a stream (or fully via
|
|
38
|
+
`bytes()`/`text()`). The `transfer` helper pipes one store into another without buffering.
|
|
39
|
+
- **Filesystem default.** `fsFiles({ dir })` stores each object as a file (the key is its
|
|
40
|
+
relative `/`-path) with `contentType`/`metadata` in a `.ayepi-meta` sidecar. Writes go to
|
|
41
|
+
a temp file and are atomically `rename`d into place; reads stream straight off disk.
|
|
42
|
+
- **Presigned URLs for stores that can't self-serve.** S3 signs its own URLs. The
|
|
43
|
+
filesystem store has no HTTP surface, so `@ayepi/files/server` (`mountFiles` /
|
|
44
|
+
`createFilesHandler`) signs short-lived HMAC tokens and serves the matching GET/PUT.
|
|
45
|
+
|
|
46
|
+
## For AI coding agents
|
|
47
|
+
|
|
48
|
+
This package ships dense, machine-oriented reference docs written for **AI coding agents**
|
|
49
|
+
(Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
|
|
50
|
+
|
|
51
|
+
- [`ayepi-files.md`](./ayepi-files.md)
|
|
52
|
+
|
|
53
|
+
They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/files) and are **not** shipped in the npm tarball.
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT © Philip Diffenderfer
|
package/dist/fs.cjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_index = require("./index.cjs");
|
|
3
|
+
let node_fs_promises = require("node:fs/promises");
|
|
4
|
+
let node_fs = require("node:fs");
|
|
5
|
+
let node_stream = require("node:stream");
|
|
6
|
+
let node_stream_promises = require("node:stream/promises");
|
|
7
|
+
let node_path = require("node:path");
|
|
8
|
+
let node_crypto = require("node:crypto");
|
|
9
|
+
//#region src/fs.ts
|
|
10
|
+
/**
|
|
11
|
+
* # @ayepi/files/fs
|
|
12
|
+
*
|
|
13
|
+
* The default filesystem-backed {@link FileStore}: objects live as files under a root
|
|
14
|
+
* directory (the key is the relative path, `/`-separated), with `contentType`/`metadata`
|
|
15
|
+
* kept in a small sidecar next to each object. Writes are **streamed** to a temp file and
|
|
16
|
+
* atomically `rename`d into place; reads stream straight off disk. For presigned URLs (the
|
|
17
|
+
* filesystem can't self-serve), wire it with `@ayepi/files/server`.
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
/** Sidecar suffix holding an object's `contentType`/`metadata`. Keys ending in it are reserved. */
|
|
22
|
+
const META_SUFFIX = ".ayepi-meta";
|
|
23
|
+
/** Default page size for {@link FileStore.list}. */
|
|
24
|
+
const DEFAULT_LIST_LIMIT = 1e3;
|
|
25
|
+
/** Map a `/`-separated key to an absolute path, rejecting traversal / empty segments. */
|
|
26
|
+
function keyToPath(dir, key) {
|
|
27
|
+
const parts = key.split("/");
|
|
28
|
+
if (key === "" || parts.some((p) => p === "" || p === "." || p === "..")) throw new Error(`@ayepi/files: invalid key "${key}"`);
|
|
29
|
+
return (0, node_path.join)(dir, ...parts);
|
|
30
|
+
}
|
|
31
|
+
/** Whether a file path is a metadata sidecar (excluded from listings). */
|
|
32
|
+
const isSidecar = (path) => path.endsWith(META_SUFFIX);
|
|
33
|
+
/**
|
|
34
|
+
* Create a filesystem-backed {@link FileStore} rooted at `opts.dir`.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```ts
|
|
38
|
+
* const files = fsFiles({ dir: './uploads' });
|
|
39
|
+
* await files.put('a/b.txt', 'hello', { contentType: 'text/plain' });
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
function fsFiles(opts) {
|
|
43
|
+
const root = opts.dir;
|
|
44
|
+
const report = (err, op, key) => {
|
|
45
|
+
try {
|
|
46
|
+
opts.onError?.(err, op, key);
|
|
47
|
+
} catch {}
|
|
48
|
+
};
|
|
49
|
+
/** Run an op so a real I/O failure is reported (then re-thrown); ENOENT is left to the caller. */
|
|
50
|
+
const guard = async (op, key, fn) => {
|
|
51
|
+
try {
|
|
52
|
+
return await fn();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
report(err, op, key);
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const isNotFound = (err) => err?.code === "ENOENT";
|
|
59
|
+
const readMeta = async (path) => {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(await (0, node_fs_promises.readFile)(path + META_SUFFIX, "utf8"));
|
|
62
|
+
} catch {
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const infoFor = async (key, path) => {
|
|
67
|
+
const s = await (0, node_fs_promises.stat)(path);
|
|
68
|
+
const meta = await readMeta(path);
|
|
69
|
+
return {
|
|
70
|
+
key,
|
|
71
|
+
size: s.size,
|
|
72
|
+
modifiedAt: Math.round(s.mtimeMs),
|
|
73
|
+
etag: `"${s.size}-${Math.round(s.mtimeMs)}"`,
|
|
74
|
+
contentType: meta.contentType,
|
|
75
|
+
metadata: meta.metadata
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
async put(key, body, options) {
|
|
80
|
+
const path = keyToPath(root, key);
|
|
81
|
+
return guard("put", key, async () => {
|
|
82
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
|
|
83
|
+
const tmp = `${path}.${(0, node_crypto.randomBytes)(8).toString("hex")}.tmp`;
|
|
84
|
+
await (0, node_stream_promises.pipeline)(node_stream.Readable.fromWeb(normalize(body)), (0, node_fs.createWriteStream)(tmp));
|
|
85
|
+
await (0, node_fs_promises.rename)(tmp, path);
|
|
86
|
+
const meta = {
|
|
87
|
+
contentType: options?.contentType,
|
|
88
|
+
metadata: options?.metadata ? { ...options.metadata } : void 0
|
|
89
|
+
};
|
|
90
|
+
if (meta.contentType !== void 0 || meta.metadata !== void 0) await (0, node_fs_promises.writeFile)(path + META_SUFFIX, JSON.stringify(meta));
|
|
91
|
+
return infoFor(key, path);
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
async head(key) {
|
|
95
|
+
const path = keyToPath(root, key);
|
|
96
|
+
try {
|
|
97
|
+
return await infoFor(key, path);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (isNotFound(err)) return;
|
|
100
|
+
report(err, "head", key);
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
async get(key) {
|
|
105
|
+
const path = keyToPath(root, key);
|
|
106
|
+
let info;
|
|
107
|
+
try {
|
|
108
|
+
info = await infoFor(key, path);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (isNotFound(err)) return;
|
|
111
|
+
report(err, "get", key);
|
|
112
|
+
throw err;
|
|
113
|
+
}
|
|
114
|
+
const open = () => node_stream.Readable.toWeb((0, node_fs.createReadStream)(path));
|
|
115
|
+
return {
|
|
116
|
+
info,
|
|
117
|
+
stream: open,
|
|
118
|
+
bytes: () => require_index.collect(open()),
|
|
119
|
+
text: async () => new TextDecoder().decode(await require_index.collect(open()))
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
async delete(key) {
|
|
123
|
+
const path = keyToPath(root, key);
|
|
124
|
+
return guard("delete", key, async () => {
|
|
125
|
+
try {
|
|
126
|
+
await (0, node_fs_promises.unlink)(path);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (isNotFound(err)) return false;
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
await (0, node_fs_promises.unlink)(path + META_SUFFIX).catch(() => {});
|
|
132
|
+
return true;
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
async list(prefix, options) {
|
|
136
|
+
const limit = options?.limit ?? DEFAULT_LIST_LIMIT;
|
|
137
|
+
return guard("list", prefix, async () => {
|
|
138
|
+
const keys = [];
|
|
139
|
+
const safeReaddir = async (abs) => {
|
|
140
|
+
try {
|
|
141
|
+
return await (0, node_fs_promises.readdir)(abs, { withFileTypes: true });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (isNotFound(err)) return [];
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
const walk = async (abs) => {
|
|
148
|
+
for (const e of await safeReaddir(abs)) {
|
|
149
|
+
const child = (0, node_path.join)(abs, e.name);
|
|
150
|
+
if (e.isDirectory()) await walk(child);
|
|
151
|
+
else if (!isSidecar(child)) keys.push((0, node_path.relative)(root, child).split(node_path.sep).join("/"));
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
await walk(root);
|
|
155
|
+
const matched = keys.filter((k) => k.startsWith(prefix) && (options?.cursor === void 0 || k > options.cursor)).sort();
|
|
156
|
+
const page = matched.slice(0, limit);
|
|
157
|
+
return {
|
|
158
|
+
files: await Promise.all(page.map((k) => infoFor(k, keyToPath(root, k)))),
|
|
159
|
+
cursor: matched.length > limit ? page[page.length - 1] : void 0
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Local alias so `put` can normalize its body without re-importing under a shadowed name. */
|
|
166
|
+
const normalize = (body) => require_index.toStream(body);
|
|
167
|
+
//#endregion
|
|
168
|
+
exports.fsFiles = fsFiles;
|
package/dist/fs.d.cts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { FileStore } from "./index.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/fs.d.ts
|
|
4
|
+
|
|
5
|
+
/** Options for {@link fsFiles}. */
|
|
6
|
+
interface FsFilesOptions {
|
|
7
|
+
/** Root directory objects are stored under (created on demand). */
|
|
8
|
+
readonly dir: string;
|
|
9
|
+
/** Observe an I/O error (it's also re-thrown to the caller). Off by default; must not throw. */
|
|
10
|
+
readonly onError?: (err: unknown, op: string, key: string) => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Create a filesystem-backed {@link FileStore} rooted at `opts.dir`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const files = fsFiles({ dir: './uploads' });
|
|
18
|
+
* await files.put('a/b.txt', 'hello', { contentType: 'text/plain' });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
declare function fsFiles(opts: FsFilesOptions): FileStore;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { FsFilesOptions, fsFiles };
|
package/dist/fs.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { FileStore } from "./index.js";
|
|
2
|
+
|
|
3
|
+
//#region src/fs.d.ts
|
|
4
|
+
|
|
5
|
+
/** Options for {@link fsFiles}. */
|
|
6
|
+
interface FsFilesOptions {
|
|
7
|
+
/** Root directory objects are stored under (created on demand). */
|
|
8
|
+
readonly dir: string;
|
|
9
|
+
/** Observe an I/O error (it's also re-thrown to the caller). Off by default; must not throw. */
|
|
10
|
+
readonly onError?: (err: unknown, op: string, key: string) => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Create a filesystem-backed {@link FileStore} rooted at `opts.dir`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* const files = fsFiles({ dir: './uploads' });
|
|
18
|
+
* await files.put('a/b.txt', 'hello', { contentType: 'text/plain' });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
declare function fsFiles(opts: FsFilesOptions): FileStore;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { FsFilesOptions, fsFiles };
|
package/dist/fs.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { collect, toStream } from "./index.js";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
//#region src/fs.ts
|
|
9
|
+
/**
|
|
10
|
+
* # @ayepi/files/fs
|
|
11
|
+
*
|
|
12
|
+
* The default filesystem-backed {@link FileStore}: objects live as files under a root
|
|
13
|
+
* directory (the key is the relative path, `/`-separated), with `contentType`/`metadata`
|
|
14
|
+
* kept in a small sidecar next to each object. Writes are **streamed** to a temp file and
|
|
15
|
+
* atomically `rename`d into place; reads stream straight off disk. For presigned URLs (the
|
|
16
|
+
* filesystem can't self-serve), wire it with `@ayepi/files/server`.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
/** Sidecar suffix holding an object's `contentType`/`metadata`. Keys ending in it are reserved. */
|
|
21
|
+
const META_SUFFIX = ".ayepi-meta";
|
|
22
|
+
/** Default page size for {@link FileStore.list}. */
|
|
23
|
+
const DEFAULT_LIST_LIMIT = 1e3;
|
|
24
|
+
/** Map a `/`-separated key to an absolute path, rejecting traversal / empty segments. */
|
|
25
|
+
function keyToPath(dir, key) {
|
|
26
|
+
const parts = key.split("/");
|
|
27
|
+
if (key === "" || parts.some((p) => p === "" || p === "." || p === "..")) throw new Error(`@ayepi/files: invalid key "${key}"`);
|
|
28
|
+
return join(dir, ...parts);
|
|
29
|
+
}
|
|
30
|
+
/** Whether a file path is a metadata sidecar (excluded from listings). */
|
|
31
|
+
const isSidecar = (path) => path.endsWith(META_SUFFIX);
|
|
32
|
+
/**
|
|
33
|
+
* Create a filesystem-backed {@link FileStore} rooted at `opts.dir`.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const files = fsFiles({ dir: './uploads' });
|
|
38
|
+
* await files.put('a/b.txt', 'hello', { contentType: 'text/plain' });
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
function fsFiles(opts) {
|
|
42
|
+
const root = opts.dir;
|
|
43
|
+
const report = (err, op, key) => {
|
|
44
|
+
try {
|
|
45
|
+
opts.onError?.(err, op, key);
|
|
46
|
+
} catch {}
|
|
47
|
+
};
|
|
48
|
+
/** Run an op so a real I/O failure is reported (then re-thrown); ENOENT is left to the caller. */
|
|
49
|
+
const guard = async (op, key, fn) => {
|
|
50
|
+
try {
|
|
51
|
+
return await fn();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
report(err, op, key);
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const isNotFound = (err) => err?.code === "ENOENT";
|
|
58
|
+
const readMeta = async (path) => {
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(await readFile(path + META_SUFFIX, "utf8"));
|
|
61
|
+
} catch {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const infoFor = async (key, path) => {
|
|
66
|
+
const s = await stat(path);
|
|
67
|
+
const meta = await readMeta(path);
|
|
68
|
+
return {
|
|
69
|
+
key,
|
|
70
|
+
size: s.size,
|
|
71
|
+
modifiedAt: Math.round(s.mtimeMs),
|
|
72
|
+
etag: `"${s.size}-${Math.round(s.mtimeMs)}"`,
|
|
73
|
+
contentType: meta.contentType,
|
|
74
|
+
metadata: meta.metadata
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
async put(key, body, options) {
|
|
79
|
+
const path = keyToPath(root, key);
|
|
80
|
+
return guard("put", key, async () => {
|
|
81
|
+
await mkdir(dirname(path), { recursive: true });
|
|
82
|
+
const tmp = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
83
|
+
await pipeline(Readable.fromWeb(normalize(body)), createWriteStream(tmp));
|
|
84
|
+
await rename(tmp, path);
|
|
85
|
+
const meta = {
|
|
86
|
+
contentType: options?.contentType,
|
|
87
|
+
metadata: options?.metadata ? { ...options.metadata } : void 0
|
|
88
|
+
};
|
|
89
|
+
if (meta.contentType !== void 0 || meta.metadata !== void 0) await writeFile(path + META_SUFFIX, JSON.stringify(meta));
|
|
90
|
+
return infoFor(key, path);
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
async head(key) {
|
|
94
|
+
const path = keyToPath(root, key);
|
|
95
|
+
try {
|
|
96
|
+
return await infoFor(key, path);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (isNotFound(err)) return;
|
|
99
|
+
report(err, "head", key);
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
async get(key) {
|
|
104
|
+
const path = keyToPath(root, key);
|
|
105
|
+
let info;
|
|
106
|
+
try {
|
|
107
|
+
info = await infoFor(key, path);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (isNotFound(err)) return;
|
|
110
|
+
report(err, "get", key);
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
const open = () => Readable.toWeb(createReadStream(path));
|
|
114
|
+
return {
|
|
115
|
+
info,
|
|
116
|
+
stream: open,
|
|
117
|
+
bytes: () => collect(open()),
|
|
118
|
+
text: async () => new TextDecoder().decode(await collect(open()))
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
async delete(key) {
|
|
122
|
+
const path = keyToPath(root, key);
|
|
123
|
+
return guard("delete", key, async () => {
|
|
124
|
+
try {
|
|
125
|
+
await unlink(path);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (isNotFound(err)) return false;
|
|
128
|
+
throw err;
|
|
129
|
+
}
|
|
130
|
+
await unlink(path + META_SUFFIX).catch(() => {});
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
},
|
|
134
|
+
async list(prefix, options) {
|
|
135
|
+
const limit = options?.limit ?? DEFAULT_LIST_LIMIT;
|
|
136
|
+
return guard("list", prefix, async () => {
|
|
137
|
+
const keys = [];
|
|
138
|
+
const safeReaddir = async (abs) => {
|
|
139
|
+
try {
|
|
140
|
+
return await readdir(abs, { withFileTypes: true });
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (isNotFound(err)) return [];
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const walk = async (abs) => {
|
|
147
|
+
for (const e of await safeReaddir(abs)) {
|
|
148
|
+
const child = join(abs, e.name);
|
|
149
|
+
if (e.isDirectory()) await walk(child);
|
|
150
|
+
else if (!isSidecar(child)) keys.push(relative(root, child).split(sep).join("/"));
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
await walk(root);
|
|
154
|
+
const matched = keys.filter((k) => k.startsWith(prefix) && (options?.cursor === void 0 || k > options.cursor)).sort();
|
|
155
|
+
const page = matched.slice(0, limit);
|
|
156
|
+
return {
|
|
157
|
+
files: await Promise.all(page.map((k) => infoFor(k, keyToPath(root, k)))),
|
|
158
|
+
cursor: matched.length > limit ? page[page.length - 1] : void 0
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Local alias so `put` can normalize its body without re-importing under a shadowed name. */
|
|
165
|
+
const normalize = (body) => toStream(body);
|
|
166
|
+
//#endregion
|
|
167
|
+
export { fsFiles };
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/index.ts
|
|
3
|
+
/** Normalize any {@link FileBody} into a byte stream. */
|
|
4
|
+
function toStream(body) {
|
|
5
|
+
if (body instanceof ReadableStream) return body;
|
|
6
|
+
if (body instanceof Blob) return body.stream();
|
|
7
|
+
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
8
|
+
return new ReadableStream({ start(controller) {
|
|
9
|
+
controller.enqueue(bytes);
|
|
10
|
+
controller.close();
|
|
11
|
+
} });
|
|
12
|
+
}
|
|
13
|
+
/** Read a byte stream fully into a single `Uint8Array`. */
|
|
14
|
+
async function collect(stream) {
|
|
15
|
+
const reader = stream.getReader();
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (;;) {
|
|
19
|
+
const { done, value } = await reader.read();
|
|
20
|
+
if (done) break;
|
|
21
|
+
chunks.push(value);
|
|
22
|
+
total += value.length;
|
|
23
|
+
}
|
|
24
|
+
const out = new Uint8Array(total);
|
|
25
|
+
let offset = 0;
|
|
26
|
+
for (const c of chunks) {
|
|
27
|
+
out.set(c, offset);
|
|
28
|
+
offset += c.length;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Stream an object from one store to another (`src[srcKey] → dst[dstKey]`) without buffering
|
|
34
|
+
* it all in memory. Carries the source's `contentType`/`metadata` unless overridden. Throws
|
|
35
|
+
* if the source key is missing.
|
|
36
|
+
*/
|
|
37
|
+
async function transfer(src, srcKey, dst, dstKey, opts) {
|
|
38
|
+
const obj = await src.get(srcKey);
|
|
39
|
+
if (!obj) throw new Error(`transfer: source key "${srcKey}" not found`);
|
|
40
|
+
return dst.put(dstKey, obj.stream(), {
|
|
41
|
+
contentType: opts?.contentType ?? obj.info.contentType,
|
|
42
|
+
metadata: opts?.metadata ?? obj.info.metadata
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
exports.collect = collect;
|
|
47
|
+
exports.toStream = toStream;
|
|
48
|
+
exports.transfer = transfer;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* # @ayepi/files
|
|
4
|
+
*
|
|
5
|
+
* A generic, **S3-like** key-based file store: stream bytes in under a key, stream them
|
|
6
|
+
* back out, list by prefix, and hand out **presigned** upload/download URLs that expire.
|
|
7
|
+
* The interface is tiny and storage-agnostic; the bundled {@link fsFiles | filesystem
|
|
8
|
+
* store} (`@ayepi/files/fs`) is the default, and `@ayepi/aws`'s `s3Files` implements the
|
|
9
|
+
* same {@link FileStore}. Presigned URLs for a store that can't self-serve (the filesystem
|
|
10
|
+
* one) are wired with `@ayepi/files/server` ({@link mountFiles} / `createFilesHandler`).
|
|
11
|
+
*
|
|
12
|
+
* Everything is **stream-first** — `put` takes a `ReadableStream` (or any {@link FileBody}),
|
|
13
|
+
* `get` returns a {@link FileObject} you read as a stream — with helpers
|
|
14
|
+
* ({@link toStream}, {@link collect}, {@link transfer}) for the common piping/transfer needs.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { fsFiles } from '@ayepi/files/fs';
|
|
18
|
+
* const files = fsFiles({ dir: './uploads' });
|
|
19
|
+
* await files.put('reports/2026.csv', someReadableStream, { contentType: 'text/csv' });
|
|
20
|
+
* const obj = await files.get('reports/2026.csv');
|
|
21
|
+
* await obj?.stream().pipeTo(destination);
|
|
22
|
+
* for (const f of (await files.list('reports/')).files) console.log(f.key, f.size);
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @module
|
|
26
|
+
*/
|
|
27
|
+
/** Metadata about a stored object (no body) — the S3 `HeadObject` shape. */
|
|
28
|
+
interface FileInfo {
|
|
29
|
+
/** The object's key. */
|
|
30
|
+
readonly key: string;
|
|
31
|
+
/** Size in bytes. */
|
|
32
|
+
readonly size: number;
|
|
33
|
+
/** MIME type, if known/stored. */
|
|
34
|
+
readonly contentType?: string;
|
|
35
|
+
/** An opaque content tag (e.g. a hash) when the backend supplies one. */
|
|
36
|
+
readonly etag?: string;
|
|
37
|
+
/** Last-modified time (ms epoch). */
|
|
38
|
+
readonly modifiedAt: number;
|
|
39
|
+
/** Arbitrary user metadata stored alongside the object. */
|
|
40
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
41
|
+
}
|
|
42
|
+
/** Anything you can hand to {@link FileStore.put} as the body — a stream is preferred. */
|
|
43
|
+
type FileBody = ReadableStream<Uint8Array> | Uint8Array | Blob | string;
|
|
44
|
+
/** A stored object's metadata plus lazy accessors for its bytes. */
|
|
45
|
+
interface FileObject {
|
|
46
|
+
/** The object's metadata. */
|
|
47
|
+
readonly info: FileInfo;
|
|
48
|
+
/** The body as a byte stream (read it once). */
|
|
49
|
+
stream(): ReadableStream<Uint8Array>;
|
|
50
|
+
/** Read the whole body into memory. */
|
|
51
|
+
bytes(): Promise<Uint8Array>;
|
|
52
|
+
/** Read the whole body as a UTF-8 string. */
|
|
53
|
+
text(): Promise<string>;
|
|
54
|
+
}
|
|
55
|
+
/** Options for {@link FileStore.put}. */
|
|
56
|
+
interface PutOptions {
|
|
57
|
+
/** MIME type to record (and serve). */
|
|
58
|
+
readonly contentType?: string;
|
|
59
|
+
/** Arbitrary user metadata to store. */
|
|
60
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
61
|
+
}
|
|
62
|
+
/** Options for {@link FileStore.list}. */
|
|
63
|
+
interface ListOptions {
|
|
64
|
+
/** Max keys to return in this page (the backend may return fewer). */
|
|
65
|
+
readonly limit?: number;
|
|
66
|
+
/** Continuation cursor from a previous page's {@link ListResult.cursor}. */
|
|
67
|
+
readonly cursor?: string;
|
|
68
|
+
}
|
|
69
|
+
/** A page of {@link FileStore.list} results. */
|
|
70
|
+
interface ListResult {
|
|
71
|
+
/** The objects in this page (metadata only), key-sorted. */
|
|
72
|
+
readonly files: readonly FileInfo[];
|
|
73
|
+
/** Pass to a follow-up `list({ cursor })` to continue; absent when the listing is complete. */
|
|
74
|
+
readonly cursor?: string;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The storage contract — a small, S3-like, key-based interface. Implementations:
|
|
78
|
+
* {@link fsFiles} (`@ayepi/files/fs`) and `s3Files` (`@ayepi/aws`).
|
|
79
|
+
*/
|
|
80
|
+
interface FileStore {
|
|
81
|
+
/** Store `body` under `key` (overwriting any existing object); returns the resulting metadata. */
|
|
82
|
+
put(key: string, body: FileBody, opts?: PutOptions): Promise<FileInfo>;
|
|
83
|
+
/** Fetch an object (metadata + lazy body), or `undefined` if the key doesn't exist. */
|
|
84
|
+
get(key: string): Promise<FileObject | undefined>;
|
|
85
|
+
/** Fetch just the metadata, or `undefined` if the key doesn't exist. */
|
|
86
|
+
head(key: string): Promise<FileInfo | undefined>;
|
|
87
|
+
/** Delete an object; resolves `true` if it existed. */
|
|
88
|
+
delete(key: string): Promise<boolean>;
|
|
89
|
+
/** List objects whose key starts with `prefix`, paginated. */
|
|
90
|
+
list(prefix: string, opts?: ListOptions): Promise<ListResult>;
|
|
91
|
+
}
|
|
92
|
+
/** Options for {@link Presigner.presignDownload}. */
|
|
93
|
+
interface PresignDownloadOptions {
|
|
94
|
+
/** Seconds until the URL expires (default chosen by the presigner). */
|
|
95
|
+
readonly expiresIn?: number;
|
|
96
|
+
}
|
|
97
|
+
/** Options for {@link Presigner.presignUpload}. */
|
|
98
|
+
interface PresignUploadOptions {
|
|
99
|
+
/** Seconds until the URL expires (default chosen by the presigner). */
|
|
100
|
+
readonly expiresIn?: number;
|
|
101
|
+
/** Pin the `Content-Type` the upload must use. */
|
|
102
|
+
readonly contentType?: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The presign capability — kept separate from {@link FileStore} because not every store can
|
|
106
|
+
* self-serve. `s3Files` implements it natively; the filesystem store gets it from
|
|
107
|
+
* `@ayepi/files/server` ({@link mountFiles}).
|
|
108
|
+
*/
|
|
109
|
+
interface Presigner {
|
|
110
|
+
/** A time-limited URL to GET `key`. */
|
|
111
|
+
presignDownload(key: string, opts?: PresignDownloadOptions): Promise<string>;
|
|
112
|
+
/** A time-limited URL to PUT `key`. */
|
|
113
|
+
presignUpload(key: string, opts?: PresignUploadOptions): Promise<string>;
|
|
114
|
+
}
|
|
115
|
+
/** Normalize any {@link FileBody} into a byte stream. */
|
|
116
|
+
declare function toStream(body: FileBody): ReadableStream<Uint8Array>;
|
|
117
|
+
/** Read a byte stream fully into a single `Uint8Array`. */
|
|
118
|
+
declare function collect(stream: ReadableStream<Uint8Array>): Promise<Uint8Array>;
|
|
119
|
+
/**
|
|
120
|
+
* Stream an object from one store to another (`src[srcKey] → dst[dstKey]`) without buffering
|
|
121
|
+
* it all in memory. Carries the source's `contentType`/`metadata` unless overridden. Throws
|
|
122
|
+
* if the source key is missing.
|
|
123
|
+
*/
|
|
124
|
+
declare function transfer(src: FileStore, srcKey: string, dst: FileStore, dstKey: string, opts?: PutOptions): Promise<FileInfo>;
|
|
125
|
+
//#endregion
|
|
126
|
+
export { FileBody, FileInfo, FileObject, FileStore, ListOptions, ListResult, PresignDownloadOptions, PresignUploadOptions, Presigner, PutOptions, collect, toStream, transfer };
|