@nifrajs/storage 1.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/README.md +44 -0
- package/dist/file.d.ts +14 -0
- package/dist/file.d.ts.map +1 -0
- package/dist/file.js +110 -0
- package/dist/file.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/key.d.ts +12 -0
- package/dist/key.d.ts.map +1 -0
- package/dist/key.js +24 -0
- package/dist/key.js.map +1 -0
- package/dist/memory.d.ts +10 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +47 -0
- package/dist/memory.js.map +1 -0
- package/dist/r2.d.ts +41 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +57 -0
- package/dist/r2.js.map +1 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +15 -0
- package/dist/types.js.map +1 -0
- package/package.json +46 -0
- package/src/file.ts +121 -0
- package/src/index.ts +24 -0
- package/src/key.ts +27 -0
- package/src/memory.ts +63 -0
- package/src/r2.ts +87 -0
- package/src/types.ts +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# @nifrajs/storage
|
|
2
|
+
|
|
3
|
+
Blob storage for nifra — one `StorageAdapter` interface, several adapters. The **persistence half of
|
|
4
|
+
[`@nifrajs/uploads`](../uploads)**: `uploads` decides what's allowed (MIME, size, signed URLs, EXIF
|
|
5
|
+
stripping); `storage` puts the bytes somewhere. **Dependency-free.**
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { FileStorage } from "@nifrajs/storage"
|
|
9
|
+
|
|
10
|
+
const storage = new FileStorage("./uploads")
|
|
11
|
+
await storage.put("avatars/u1.png", bytes, { contentType: "image/png" })
|
|
12
|
+
|
|
13
|
+
const object = await storage.get("avatars/u1.png") // { body, size, contentType?, metadata? } | null
|
|
14
|
+
await storage.delete("avatars/u1.png")
|
|
15
|
+
const keys = await storage.list({ prefix: "avatars/" })
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Adapters
|
|
19
|
+
|
|
20
|
+
| Adapter | Use | Notes |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `MemoryStorage` | dev / tests | not durable, not shared across instances |
|
|
23
|
+
| `FileStorage(root)` | a long-running server (Bun/Node/Deno) | bytes-only: `contentType` inferred from the extension, custom metadata not persisted |
|
|
24
|
+
| `R2Storage(env.BUCKET)` | the edge (Cloudflare R2) | round-trips `contentType` + metadata; binding typed structurally (no `@cloudflare/workers-types`) |
|
|
25
|
+
|
|
26
|
+
Implement `StorageAdapter` (`put` / `get` / `delete` / `exists` / `list`) for **S3 / GCS / anything else** —
|
|
27
|
+
the same five methods, and your routes don't change.
|
|
28
|
+
|
|
29
|
+
## Key safety
|
|
30
|
+
|
|
31
|
+
Keys are POSIX-ish relative paths (`avatars/u1.png`). **Every** adapter rejects unsafe keys — absolute
|
|
32
|
+
paths, `..` traversal, NUL bytes, backslashes — via `assertSafeKey`, so a `FileStorage` key can never
|
|
33
|
+
escape its root and a key valid in one adapter is valid in all.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { assertSafeKey, StorageKeyError } from "@nifrajs/storage"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## API
|
|
40
|
+
|
|
41
|
+
- `new MemoryStorage()` · `new FileStorage(root)` · `new R2Storage(bucket)`.
|
|
42
|
+
- `put(key, data, { contentType?, metadata? })` — `data` is `Uint8Array | ArrayBuffer | string`.
|
|
43
|
+
- `get(key)` → `{ body, size, contentType?, metadata? } | null` · `delete(key)` · `exists(key)` · `list({ prefix?, limit? })`.
|
|
44
|
+
- `assertSafeKey(key)` / `StorageKeyError` · `toBytes(data)`.
|
package/dist/file.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ListOptions, type PutOptions, type StorageAdapter, type StorageData, type StorageObject } from "./types.js";
|
|
2
|
+
export declare class FileStorage implements StorageAdapter {
|
|
3
|
+
private readonly root;
|
|
4
|
+
constructor(root: string);
|
|
5
|
+
/** Resolve `key` to an absolute path, asserting it stays inside `root` (defense-in-depth on top of `assertSafeKey`). */
|
|
6
|
+
private pathFor;
|
|
7
|
+
put(key: string, data: StorageData, _options?: PutOptions): Promise<void>;
|
|
8
|
+
get(key: string): Promise<StorageObject | null>;
|
|
9
|
+
delete(key: string): Promise<void>;
|
|
10
|
+
exists(key: string): Promise<boolean>;
|
|
11
|
+
list(options?: ListOptions): Promise<string[]>;
|
|
12
|
+
private walk;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=file.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"AASA,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,aAAa,EAEnB,MAAM,YAAY,CAAA;AA4BnB,qBAAa,WAAY,YAAW,cAAc;IAChD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAQ;gBAEjB,IAAI,EAAE,MAAM;IAIxB,wHAAwH;IACxH,OAAO,CAAC,OAAO;IAST,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,QAAQ,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAM7E,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAc/C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IASrC,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;YAU1C,IAAI;CAgBnB"}
|
package/dist/file.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-filesystem {@link StorageAdapter} for long-running servers (Bun/Node/Deno). Keys map to paths
|
|
3
|
+
* under `root`; `assertSafeKey` plus a resolved-path containment check keep writes inside `root` (no
|
|
4
|
+
* traversal). Bytes-only: `contentType` is inferred from the key's extension on read, and custom metadata
|
|
5
|
+
* is NOT persisted — use `MemoryStorage`/`R2Storage` (or your own adapter) if you need metadata round-tripped.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
8
|
+
import { dirname, join, posix, resolve, sep } from "node:path";
|
|
9
|
+
import { assertSafeKey, StorageKeyError } from "./key.js";
|
|
10
|
+
import { toBytes, } from "./types.js";
|
|
11
|
+
const CONTENT_TYPES = {
|
|
12
|
+
png: "image/png",
|
|
13
|
+
jpg: "image/jpeg",
|
|
14
|
+
jpeg: "image/jpeg",
|
|
15
|
+
gif: "image/gif",
|
|
16
|
+
webp: "image/webp",
|
|
17
|
+
avif: "image/avif",
|
|
18
|
+
svg: "image/svg+xml",
|
|
19
|
+
pdf: "application/pdf",
|
|
20
|
+
json: "application/json",
|
|
21
|
+
txt: "text/plain",
|
|
22
|
+
csv: "text/csv",
|
|
23
|
+
html: "text/html",
|
|
24
|
+
css: "text/css",
|
|
25
|
+
js: "text/javascript",
|
|
26
|
+
mp4: "video/mp4",
|
|
27
|
+
webm: "video/webm",
|
|
28
|
+
mp3: "audio/mpeg",
|
|
29
|
+
zip: "application/zip",
|
|
30
|
+
};
|
|
31
|
+
function inferContentType(key) {
|
|
32
|
+
const ext = key.slice(key.lastIndexOf(".") + 1).toLowerCase();
|
|
33
|
+
return CONTENT_TYPES[ext];
|
|
34
|
+
}
|
|
35
|
+
export class FileStorage {
|
|
36
|
+
root;
|
|
37
|
+
constructor(root) {
|
|
38
|
+
this.root = resolve(root);
|
|
39
|
+
}
|
|
40
|
+
/** Resolve `key` to an absolute path, asserting it stays inside `root` (defense-in-depth on top of `assertSafeKey`). */
|
|
41
|
+
pathFor(key) {
|
|
42
|
+
assertSafeKey(key);
|
|
43
|
+
const full = resolve(this.root, key);
|
|
44
|
+
if (full !== this.root && !full.startsWith(this.root + sep)) {
|
|
45
|
+
throw new StorageKeyError(`storage key ${JSON.stringify(key)} escapes the storage root`);
|
|
46
|
+
}
|
|
47
|
+
return full;
|
|
48
|
+
}
|
|
49
|
+
async put(key, data, _options = {}) {
|
|
50
|
+
const path = this.pathFor(key);
|
|
51
|
+
await mkdir(dirname(path), { recursive: true });
|
|
52
|
+
await writeFile(path, toBytes(data));
|
|
53
|
+
}
|
|
54
|
+
async get(key) {
|
|
55
|
+
const path = this.pathFor(key);
|
|
56
|
+
let body;
|
|
57
|
+
try {
|
|
58
|
+
body = new Uint8Array(await readFile(path));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null; // ENOENT (or unreadable) → treated as missing
|
|
62
|
+
}
|
|
63
|
+
const contentType = inferContentType(key);
|
|
64
|
+
return contentType === undefined
|
|
65
|
+
? { body, size: body.byteLength }
|
|
66
|
+
: { body, size: body.byteLength, contentType };
|
|
67
|
+
}
|
|
68
|
+
async delete(key) {
|
|
69
|
+
await rm(this.pathFor(key), { force: true });
|
|
70
|
+
}
|
|
71
|
+
async exists(key) {
|
|
72
|
+
try {
|
|
73
|
+
await stat(this.pathFor(key));
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async list(options = {}) {
|
|
81
|
+
const keys = [];
|
|
82
|
+
await this.walk(this.root, "", keys);
|
|
83
|
+
let out = keys.sort();
|
|
84
|
+
if (options.prefix !== undefined)
|
|
85
|
+
out = out.filter((k) => k.startsWith(options.prefix));
|
|
86
|
+
if (options.limit !== undefined)
|
|
87
|
+
out = out.slice(0, options.limit);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
async walk(dir, prefix, out) {
|
|
91
|
+
let names;
|
|
92
|
+
try {
|
|
93
|
+
names = await readdir(dir);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return; // missing root → empty listing
|
|
97
|
+
}
|
|
98
|
+
for (const name of names) {
|
|
99
|
+
const full = join(dir, name);
|
|
100
|
+
// POSIX-join the key segments so listed keys are portable regardless of the host separator.
|
|
101
|
+
const key = prefix === "" ? name : posix.join(prefix, name);
|
|
102
|
+
const info = await stat(full);
|
|
103
|
+
if (info.isDirectory())
|
|
104
|
+
await this.walk(full, key, out);
|
|
105
|
+
else if (info.isFile())
|
|
106
|
+
out.push(key);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=file.js.map
|
package/dist/file.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file.js","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
|
|
3
|
+
* `@nifrajs/uploads`: `uploads` validates + signs, `storage` puts the bytes somewhere. Adapters cover
|
|
4
|
+
* dev (memory), a long-running server (local disk), and the edge (Cloudflare R2); implement
|
|
5
|
+
* {@link StorageAdapter} for S3/GCS/etc. Dependency-free.
|
|
6
|
+
*
|
|
7
|
+
* import { FileStorage } from "@nifrajs/storage"
|
|
8
|
+
* const storage = new FileStorage("./uploads")
|
|
9
|
+
* await storage.put("avatars/u1.png", bytes, { contentType: "image/png" })
|
|
10
|
+
* const object = await storage.get("avatars/u1.png")
|
|
11
|
+
*/
|
|
12
|
+
export { FileStorage } from "./file.js";
|
|
13
|
+
export { assertSafeKey, StorageKeyError } from "./key.js";
|
|
14
|
+
export { MemoryStorage } from "./memory.js";
|
|
15
|
+
export { type R2BucketLike, type R2ObjectLike, R2Storage } from "./r2.js";
|
|
16
|
+
export { type ListOptions, type PutOptions, type StorageAdapter, type StorageData, type StorageObject, toBytes, } from "./types.js";
|
|
17
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAE,KAAK,YAAY,EAAE,KAAK,YAAY,EAAE,SAAS,EAAE,MAAM,SAAS,CAAA;AACzE,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,aAAa,EAClB,OAAO,GACR,MAAM,YAAY,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
|
|
3
|
+
* `@nifrajs/uploads`: `uploads` validates + signs, `storage` puts the bytes somewhere. Adapters cover
|
|
4
|
+
* dev (memory), a long-running server (local disk), and the edge (Cloudflare R2); implement
|
|
5
|
+
* {@link StorageAdapter} for S3/GCS/etc. Dependency-free.
|
|
6
|
+
*
|
|
7
|
+
* import { FileStorage } from "@nifrajs/storage"
|
|
8
|
+
* const storage = new FileStorage("./uploads")
|
|
9
|
+
* await storage.put("avatars/u1.png", bytes, { contentType: "image/png" })
|
|
10
|
+
* const object = await storage.get("avatars/u1.png")
|
|
11
|
+
*/
|
|
12
|
+
export { FileStorage } from "./file.js";
|
|
13
|
+
export { assertSafeKey, StorageKeyError } from "./key.js";
|
|
14
|
+
export { MemoryStorage } from "./memory.js";
|
|
15
|
+
export { R2Storage } from "./r2.js";
|
|
16
|
+
export { toBytes, } from "./types.js";
|
|
17
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AACzD,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAA;AAC3C,OAAO,EAAwC,SAAS,EAAE,MAAM,SAAS,CAAA;AACzE,OAAO,EAML,OAAO,GACR,MAAM,YAAY,CAAA"}
|
package/dist/key.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage-key safety. A key is a POSIX-ish relative path (`avatars/u1.png`); we reject anything that
|
|
3
|
+
* could escape a `FileStorage` root or otherwise misbehave — absolute paths, `..` traversal, NUL bytes,
|
|
4
|
+
* and backslashes (Windows traversal). Enforced by EVERY adapter (not just `FileStorage`) so a key is
|
|
5
|
+
* portable across them, and so the check can't be forgotten on the one adapter where it's a vulnerability.
|
|
6
|
+
*/
|
|
7
|
+
export declare class StorageKeyError extends Error {
|
|
8
|
+
readonly name = "StorageKeyError";
|
|
9
|
+
}
|
|
10
|
+
/** Throw {@link StorageKeyError} unless `key` is a safe relative storage key. */
|
|
11
|
+
export declare function assertSafeKey(key: string): void;
|
|
12
|
+
//# sourceMappingURL=key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"key.d.ts","sourceRoot":"","sources":["../src/key.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,qBAAa,eAAgB,SAAQ,KAAK;IACxC,SAAkB,IAAI,qBAAoB;CAC3C;AAED,iFAAiF;AACjF,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAc/C"}
|
package/dist/key.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage-key safety. A key is a POSIX-ish relative path (`avatars/u1.png`); we reject anything that
|
|
3
|
+
* could escape a `FileStorage` root or otherwise misbehave — absolute paths, `..` traversal, NUL bytes,
|
|
4
|
+
* and backslashes (Windows traversal). Enforced by EVERY adapter (not just `FileStorage`) so a key is
|
|
5
|
+
* portable across them, and so the check can't be forgotten on the one adapter where it's a vulnerability.
|
|
6
|
+
*/
|
|
7
|
+
export class StorageKeyError extends Error {
|
|
8
|
+
name = "StorageKeyError";
|
|
9
|
+
}
|
|
10
|
+
/** Throw {@link StorageKeyError} unless `key` is a safe relative storage key. */
|
|
11
|
+
export function assertSafeKey(key) {
|
|
12
|
+
if (key.length === 0 || key.length > 1024) {
|
|
13
|
+
throw new StorageKeyError(`storage key must be 1–1024 chars (got ${key.length})`);
|
|
14
|
+
}
|
|
15
|
+
if (key.startsWith("/") || key.includes("\\") || key.includes("\0")) {
|
|
16
|
+
throw new StorageKeyError(`unsafe storage key ${JSON.stringify(key)} (absolute, backslash, or NUL)`);
|
|
17
|
+
}
|
|
18
|
+
for (const segment of key.split("/")) {
|
|
19
|
+
if (segment === "..") {
|
|
20
|
+
throw new StorageKeyError(`unsafe storage key ${JSON.stringify(key)} (".." path traversal)`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=key.js.map
|
package/dist/key.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"key.js","sourceRoot":"","sources":["../src/key.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,OAAO,eAAgB,SAAQ,KAAK;IACtB,IAAI,GAAG,iBAAiB,CAAA;CAC3C;AAED,iFAAiF;AACjF,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC,IAAI,GAAG,CAAC,MAAM,GAAG,IAAI,EAAE,CAAC;QAC1C,MAAM,IAAI,eAAe,CAAC,yCAAyC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAA;IACnF,CAAC;IACD,IAAI,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACpE,MAAM,IAAI,eAAe,CACvB,sBAAsB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,gCAAgC,CAC1E,CAAA;IACH,CAAC;IACD,KAAK,MAAM,OAAO,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC;QACrC,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,MAAM,IAAI,eAAe,CAAC,sBAAsB,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAA;QAC9F,CAAC;IACH,CAAC;AACH,CAAC"}
|
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ListOptions, type PutOptions, type StorageAdapter, type StorageData, type StorageObject } from "./types.js";
|
|
2
|
+
export declare class MemoryStorage implements StorageAdapter {
|
|
3
|
+
private readonly objects;
|
|
4
|
+
put(key: string, data: StorageData, options?: PutOptions): Promise<void>;
|
|
5
|
+
get(key: string): Promise<StorageObject | null>;
|
|
6
|
+
delete(key: string): Promise<void>;
|
|
7
|
+
exists(key: string): Promise<boolean>;
|
|
8
|
+
list(options?: ListOptions): Promise<string[]>;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=memory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAMA,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,aAAa,EAEnB,MAAM,YAAY,CAAA;AAQnB,qBAAa,aAAc,YAAW,cAAc;IAClD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA2B;IAE7C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAU5E,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAY/C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKrC,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;CAMzD"}
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process {@link StorageAdapter} — a Map of key → bytes + metadata. For dev and tests; not durable
|
|
3
|
+
* (a restart empties it) and not shared across instances. Methods are `async` so an unsafe key REJECTS
|
|
4
|
+
* (consistent with the disk/R2 adapters), never throws synchronously.
|
|
5
|
+
*/
|
|
6
|
+
import { assertSafeKey } from "./key.js";
|
|
7
|
+
import { toBytes, } from "./types.js";
|
|
8
|
+
export class MemoryStorage {
|
|
9
|
+
objects = new Map();
|
|
10
|
+
async put(key, data, options = {}) {
|
|
11
|
+
assertSafeKey(key);
|
|
12
|
+
const entry = { body: toBytes(data) };
|
|
13
|
+
this.objects.set(key, {
|
|
14
|
+
...entry,
|
|
15
|
+
...(options.contentType !== undefined ? { contentType: options.contentType } : {}),
|
|
16
|
+
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async get(key) {
|
|
20
|
+
assertSafeKey(key);
|
|
21
|
+
const entry = this.objects.get(key);
|
|
22
|
+
if (entry === undefined)
|
|
23
|
+
return null;
|
|
24
|
+
return {
|
|
25
|
+
body: entry.body,
|
|
26
|
+
size: entry.body.byteLength,
|
|
27
|
+
...(entry.contentType !== undefined ? { contentType: entry.contentType } : {}),
|
|
28
|
+
...(entry.metadata !== undefined ? { metadata: entry.metadata } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async delete(key) {
|
|
32
|
+
assertSafeKey(key);
|
|
33
|
+
this.objects.delete(key);
|
|
34
|
+
}
|
|
35
|
+
async exists(key) {
|
|
36
|
+
assertSafeKey(key);
|
|
37
|
+
return this.objects.has(key);
|
|
38
|
+
}
|
|
39
|
+
async list(options = {}) {
|
|
40
|
+
const prefix = options.prefix ?? "";
|
|
41
|
+
let keys = [...this.objects.keys()].filter((k) => k.startsWith(prefix)).sort();
|
|
42
|
+
if (options.limit !== undefined)
|
|
43
|
+
keys = keys.slice(0, options.limit);
|
|
44
|
+
return keys;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=memory.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory.js","sourceRoot":"","sources":["../src/memory.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AACxC,OAAO,EAML,OAAO,GACR,MAAM,YAAY,CAAA;AAQnB,MAAM,OAAO,aAAa;IACP,OAAO,GAAG,IAAI,GAAG,EAAiB,CAAA;IAEnD,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,IAAiB,EAAE,UAAsB,EAAE;QAChE,aAAa,CAAC,GAAG,CAAC,CAAA;QAClB,MAAM,KAAK,GAAU,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,EAAE,CAAA;QAC5C,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE;YACpB,GAAG,KAAK;YACR,GAAG,CAAC,OAAO,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAClF,GAAG,CAAC,OAAO,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC1E,CAAC,CAAA;IACJ,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,aAAa,CAAC,GAAG,CAAC,CAAA;QAClB,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QACnC,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO,IAAI,CAAA;QACpC,OAAO;YACL,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,UAAU;YAC3B,GAAG,CAAC,KAAK,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,KAAK,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC9E,GAAG,CAAC,KAAK,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACtE,CAAA;IACH,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,aAAa,CAAC,GAAG,CAAC,CAAA;QAClB,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;IAC1B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,aAAa,CAAC,GAAG,CAAC,CAAA;QAClB,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC9B,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,UAAuB,EAAE;QAClC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,EAAE,CAAA;QACnC,IAAI,IAAI,GAAG,CAAC,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QAC9E,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;YAAE,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,KAAK,CAAC,CAAA;QACpE,OAAO,IAAI,CAAA;IACb,CAAC;CACF"}
|
package/dist/r2.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ListOptions, type PutOptions, type StorageAdapter, type StorageData, type StorageObject } from "./types.js";
|
|
2
|
+
/** The slice of R2's object metadata this adapter reads. */
|
|
3
|
+
export interface R2ObjectLike {
|
|
4
|
+
readonly size: number;
|
|
5
|
+
readonly httpMetadata?: {
|
|
6
|
+
readonly contentType?: string;
|
|
7
|
+
};
|
|
8
|
+
readonly customMetadata?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
/** The slice of the R2 bucket binding this adapter calls. `env.<BUCKET>` satisfies it. */
|
|
11
|
+
export interface R2BucketLike {
|
|
12
|
+
put(key: string, value: ArrayBuffer | ArrayBufferView, options?: {
|
|
13
|
+
httpMetadata?: {
|
|
14
|
+
contentType?: string;
|
|
15
|
+
};
|
|
16
|
+
customMetadata?: Record<string, string>;
|
|
17
|
+
}): Promise<unknown>;
|
|
18
|
+
get(key: string): Promise<(R2ObjectLike & {
|
|
19
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
20
|
+
}) | null>;
|
|
21
|
+
head(key: string): Promise<R2ObjectLike | null>;
|
|
22
|
+
delete(key: string): Promise<void>;
|
|
23
|
+
list(options?: {
|
|
24
|
+
prefix?: string;
|
|
25
|
+
limit?: number;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
objects: ReadonlyArray<{
|
|
28
|
+
key: string;
|
|
29
|
+
}>;
|
|
30
|
+
}>;
|
|
31
|
+
}
|
|
32
|
+
export declare class R2Storage implements StorageAdapter {
|
|
33
|
+
private readonly bucket;
|
|
34
|
+
constructor(bucket: R2BucketLike);
|
|
35
|
+
put(key: string, data: StorageData, options?: PutOptions): Promise<void>;
|
|
36
|
+
get(key: string): Promise<StorageObject | null>;
|
|
37
|
+
delete(key: string): Promise<void>;
|
|
38
|
+
exists(key: string): Promise<boolean>;
|
|
39
|
+
list(options?: ListOptions): Promise<string[]>;
|
|
40
|
+
}
|
|
41
|
+
//# sourceMappingURL=r2.d.ts.map
|
package/dist/r2.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2.d.ts","sourceRoot":"","sources":["../src/r2.ts"],"names":[],"mappings":"AAOA,OAAO,EACL,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,WAAW,EAChB,KAAK,aAAa,EAEnB,MAAM,YAAY,CAAA;AAEnB,4DAA4D;AAC5D,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,YAAY,CAAC,EAAE;QAAE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACzD,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjD;AAED,0FAA0F;AAC1F,MAAM,WAAW,YAAY;IAC3B,GAAG,CACD,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,WAAW,GAAG,eAAe,EACpC,OAAO,CAAC,EAAE;QAAE,YAAY,CAAC,EAAE;YAAE,WAAW,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,GAC7F,OAAO,CAAC,OAAO,CAAC,CAAA;IACnB,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,YAAY,GAAG;QAAE,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC,CAAA;KAAE,CAAC,GAAG,IAAI,CAAC,CAAA;IAC1F,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAA;IAC/C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,IAAI,CAAC,OAAO,CAAC,EAAE;QACb,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,KAAK,CAAC,EAAE,MAAM,CAAA;KACf,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,aAAa,CAAC;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC,CAAA;CACzD;AAED,qBAAa,SAAU,YAAW,cAAc;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,YAAY;IAE3C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,GAAE,UAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAY5E,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IAe/C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKlC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKrC,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;CAOzD"}
|
package/dist/r2.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare R2 {@link StorageAdapter} — wraps an R2 bucket binding. The binding is typed structurally
|
|
3
|
+
* (the slice we use), so this stays dependency-free: no `@cloudflare/workers-types`. Pass `env.MY_BUCKET`.
|
|
4
|
+
*
|
|
5
|
+
* const storage = new R2Storage(env.UPLOADS)
|
|
6
|
+
*/
|
|
7
|
+
import { assertSafeKey } from "./key.js";
|
|
8
|
+
import { toBytes, } from "./types.js";
|
|
9
|
+
export class R2Storage {
|
|
10
|
+
bucket;
|
|
11
|
+
constructor(bucket) {
|
|
12
|
+
this.bucket = bucket;
|
|
13
|
+
}
|
|
14
|
+
async put(key, data, options = {}) {
|
|
15
|
+
assertSafeKey(key);
|
|
16
|
+
const put = {};
|
|
17
|
+
if (options.contentType !== undefined)
|
|
18
|
+
put.httpMetadata = { contentType: options.contentType };
|
|
19
|
+
if (options.metadata !== undefined)
|
|
20
|
+
put.customMetadata = { ...options.metadata };
|
|
21
|
+
// R2 wants a copied ArrayBuffer (not a view into a larger buffer).
|
|
22
|
+
await this.bucket.put(key, toBytes(data).slice().buffer, put);
|
|
23
|
+
}
|
|
24
|
+
async get(key) {
|
|
25
|
+
assertSafeKey(key);
|
|
26
|
+
const object = await this.bucket.get(key);
|
|
27
|
+
if (object === null)
|
|
28
|
+
return null;
|
|
29
|
+
const body = new Uint8Array(await object.arrayBuffer());
|
|
30
|
+
return {
|
|
31
|
+
body,
|
|
32
|
+
size: object.size,
|
|
33
|
+
...(object.httpMetadata?.contentType !== undefined
|
|
34
|
+
? { contentType: object.httpMetadata.contentType }
|
|
35
|
+
: {}),
|
|
36
|
+
...(object.customMetadata !== undefined ? { metadata: object.customMetadata } : {}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
async delete(key) {
|
|
40
|
+
assertSafeKey(key);
|
|
41
|
+
await this.bucket.delete(key);
|
|
42
|
+
}
|
|
43
|
+
async exists(key) {
|
|
44
|
+
assertSafeKey(key);
|
|
45
|
+
return (await this.bucket.head(key)) !== null;
|
|
46
|
+
}
|
|
47
|
+
async list(options = {}) {
|
|
48
|
+
const listOpts = {};
|
|
49
|
+
if (options.prefix !== undefined)
|
|
50
|
+
listOpts.prefix = options.prefix;
|
|
51
|
+
if (options.limit !== undefined)
|
|
52
|
+
listOpts.limit = options.limit;
|
|
53
|
+
const result = await this.bucket.list(listOpts);
|
|
54
|
+
return result.objects.map((o) => o.key);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=r2.js.map
|
package/dist/r2.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2.js","sourceRoot":"","sources":["../src/r2.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAA;AACxC,OAAO,EAML,OAAO,GACR,MAAM,YAAY,CAAA;AAyBnB,MAAM,OAAO,SAAS;IACS;IAA7B,YAA6B,MAAoB;QAApB,WAAM,GAAN,MAAM,CAAc;IAAG,CAAC;IAErD,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,IAAiB,EAAE,UAAsB,EAAE;QAChE,aAAa,CAAC,GAAG,CAAC,CAAA;QAClB,MAAM,GAAG,GAGL,EAAE,CAAA;QACN,IAAI,OAAO,CAAC,WAAW,KAAK,SAAS;YAAE,GAAG,CAAC,YAAY,GAAG,EAAE,WAAW,EAAE,OAAO,CAAC,WAAW,EAAE,CAAA;QAC9F,IAAI,OAAO,CAAC,QAAQ,KAAK,SAAS;YAAE,GAAG,CAAC,cAAc,GAAG,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAA;QAChF,mEAAmE;QACnE,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IAC/D,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,aAAa,CAAC,GAAG,CAAC,CAAA;QAClB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QACzC,IAAI,MAAM,KAAK,IAAI;YAAE,OAAO,IAAI,CAAA;QAChC,MAAM,IAAI,GAAG,IAAI,UAAU,CAAC,MAAM,MAAM,CAAC,WAAW,EAAE,CAAC,CAAA;QACvD,OAAO;YACL,IAAI;YACJ,IAAI,EAAE,MAAM,CAAC,IAAI;YACjB,GAAG,CAAC,MAAM,CAAC,YAAY,EAAE,WAAW,KAAK,SAAS;gBAChD,CAAC,CAAC,EAAE,WAAW,EAAE,MAAM,CAAC,YAAY,CAAC,WAAW,EAAE;gBAClD,CAAC,CAAC,EAAE,CAAC;YACP,GAAG,CAAC,MAAM,CAAC,cAAc,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,CAAC,cAAc,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACpF,CAAA;IACH,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,aAAa,CAAC,GAAG,CAAC,CAAA;QAClB,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;IAC/B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,aAAa,CAAC,GAAG,CAAC,CAAA;QAClB,OAAO,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAA;IAC/C,CAAC;IAED,KAAK,CAAC,IAAI,CAAC,UAAuB,EAAE;QAClC,MAAM,QAAQ,GAAwC,EAAE,CAAA;QACxD,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS;YAAE,QAAQ,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAA;QAClE,IAAI,OAAO,CAAC,KAAK,KAAK,SAAS;YAAE,QAAQ,CAAC,KAAK,GAAG,OAAO,CAAC,KAAK,CAAA;QAC/D,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAA;QAC/C,OAAO,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAA;IACzC,CAAC;CACF"}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
|
|
3
|
+
* `@nifrajs/uploads` (which validates + signs but doesn't store): `uploads` decides what's allowed,
|
|
4
|
+
* `storage` puts the bytes somewhere. Adapters: {@link MemoryStorage} (dev/test), `FileStorage` (local
|
|
5
|
+
* disk), `R2Storage` (Cloudflare R2). Bring your own for S3/GCS by implementing {@link StorageAdapter}.
|
|
6
|
+
*/
|
|
7
|
+
/** Accepted `put` payloads — normalized to bytes by each adapter. */
|
|
8
|
+
export type StorageData = Uint8Array | ArrayBuffer | string;
|
|
9
|
+
export interface PutOptions {
|
|
10
|
+
/** MIME type stored alongside the object (returned by `get`). */
|
|
11
|
+
readonly contentType?: string;
|
|
12
|
+
/** Arbitrary string metadata stored with the object. */
|
|
13
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
14
|
+
}
|
|
15
|
+
/** An object read back from storage. `body` is buffered (not streamed) — fine for typical uploads. */
|
|
16
|
+
export interface StorageObject {
|
|
17
|
+
readonly body: Uint8Array;
|
|
18
|
+
readonly size: number;
|
|
19
|
+
readonly contentType?: string;
|
|
20
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
21
|
+
}
|
|
22
|
+
export interface ListOptions {
|
|
23
|
+
/** Only keys starting with this prefix. */
|
|
24
|
+
readonly prefix?: string;
|
|
25
|
+
/** Cap the number of keys returned. */
|
|
26
|
+
readonly limit?: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A blob store keyed by string. Keys are POSIX-ish paths (`avatars/u1.png`); every adapter rejects unsafe
|
|
30
|
+
* keys (absolute, `..` traversal, NUL, backslash) so a key valid in one adapter is valid in all. All
|
|
31
|
+
* methods are async.
|
|
32
|
+
*/
|
|
33
|
+
export interface StorageAdapter {
|
|
34
|
+
/** Store `data` under `key`, overwriting any existing object. */
|
|
35
|
+
put(key: string, data: StorageData, options?: PutOptions): Promise<void>;
|
|
36
|
+
/** Read the object at `key`, or `null` if it doesn't exist. */
|
|
37
|
+
get(key: string): Promise<StorageObject | null>;
|
|
38
|
+
/** Remove `key`. No-op if it doesn't exist. */
|
|
39
|
+
delete(key: string): Promise<void>;
|
|
40
|
+
/** Whether an object exists at `key`. */
|
|
41
|
+
exists(key: string): Promise<boolean>;
|
|
42
|
+
/** List stored keys (optionally filtered by prefix + capped). */
|
|
43
|
+
list(options?: ListOptions): Promise<string[]>;
|
|
44
|
+
}
|
|
45
|
+
/** Normalize any accepted payload to bytes. */
|
|
46
|
+
export declare function toBytes(data: StorageData): Uint8Array;
|
|
47
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,qEAAqE;AACrE,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,WAAW,GAAG,MAAM,CAAA;AAE3D,MAAM,WAAW,UAAU;IACzB,iEAAiE;IACjE,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,wDAAwD;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACrD;AAED,sGAAsG;AACtG,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAA;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACrD;AAED,MAAM,WAAW,WAAW;IAC1B,2CAA2C;IAC3C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,uCAAuC;IACvC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CACxB;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,iEAAiE;IACjE,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACxE,+DAA+D;IAC/D,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAAA;IAC/C,+CAA+C;IAC/C,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IAClC,yCAAyC;IACzC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IACrC,iEAAiE;IACjE,IAAI,CAAC,OAAO,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAA;CAC/C;AAED,+CAA+C;AAC/C,wBAAgB,OAAO,CAAC,IAAI,EAAE,WAAW,GAAG,UAAU,CAIrD"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
|
|
3
|
+
* `@nifrajs/uploads` (which validates + signs but doesn't store): `uploads` decides what's allowed,
|
|
4
|
+
* `storage` puts the bytes somewhere. Adapters: {@link MemoryStorage} (dev/test), `FileStorage` (local
|
|
5
|
+
* disk), `R2Storage` (Cloudflare R2). Bring your own for S3/GCS by implementing {@link StorageAdapter}.
|
|
6
|
+
*/
|
|
7
|
+
/** Normalize any accepted payload to bytes. */
|
|
8
|
+
export function toBytes(data) {
|
|
9
|
+
if (typeof data === "string")
|
|
10
|
+
return new TextEncoder().encode(data);
|
|
11
|
+
if (data instanceof Uint8Array)
|
|
12
|
+
return data;
|
|
13
|
+
return new Uint8Array(data);
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AA6CH,+CAA+C;AAC/C,MAAM,UAAU,OAAO,CAAC,IAAiB;IACvC,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;IACnE,IAAI,IAAI,YAAY,UAAU;QAAE,OAAO,IAAI,CAAA;IAC3C,OAAO,IAAI,UAAU,CAAC,IAAI,CAAC,CAAA;AAC7B,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/storage",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Blob storage for nifra — one StorageAdapter interface with memory, local-filesystem, and Cloudflare R2 adapters (key-traversal-safe). The persistence half of @nifrajs/uploads. Dependency-free; bring your own adapter for S3/GCS.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"bun",
|
|
7
|
+
"typescript",
|
|
8
|
+
"nifra",
|
|
9
|
+
"web-framework",
|
|
10
|
+
"full-stack",
|
|
11
|
+
"edge",
|
|
12
|
+
"ssr"
|
|
13
|
+
],
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"bun": "./src/index.ts",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"default": "./dist/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/nifrajs/nifra.git",
|
|
40
|
+
"directory": "packages/storage"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/nifrajs/nifra/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/nifrajs/nifra#readme"
|
|
46
|
+
}
|
package/src/file.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-filesystem {@link StorageAdapter} for long-running servers (Bun/Node/Deno). Keys map to paths
|
|
3
|
+
* under `root`; `assertSafeKey` plus a resolved-path containment check keep writes inside `root` (no
|
|
4
|
+
* traversal). Bytes-only: `contentType` is inferred from the key's extension on read, and custom metadata
|
|
5
|
+
* is NOT persisted — use `MemoryStorage`/`R2Storage` (or your own adapter) if you need metadata round-tripped.
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"
|
|
8
|
+
import { dirname, join, posix, resolve, sep } from "node:path"
|
|
9
|
+
import { assertSafeKey, StorageKeyError } from "./key.ts"
|
|
10
|
+
import {
|
|
11
|
+
type ListOptions,
|
|
12
|
+
type PutOptions,
|
|
13
|
+
type StorageAdapter,
|
|
14
|
+
type StorageData,
|
|
15
|
+
type StorageObject,
|
|
16
|
+
toBytes,
|
|
17
|
+
} from "./types.ts"
|
|
18
|
+
|
|
19
|
+
const CONTENT_TYPES: Readonly<Record<string, string>> = {
|
|
20
|
+
png: "image/png",
|
|
21
|
+
jpg: "image/jpeg",
|
|
22
|
+
jpeg: "image/jpeg",
|
|
23
|
+
gif: "image/gif",
|
|
24
|
+
webp: "image/webp",
|
|
25
|
+
avif: "image/avif",
|
|
26
|
+
svg: "image/svg+xml",
|
|
27
|
+
pdf: "application/pdf",
|
|
28
|
+
json: "application/json",
|
|
29
|
+
txt: "text/plain",
|
|
30
|
+
csv: "text/csv",
|
|
31
|
+
html: "text/html",
|
|
32
|
+
css: "text/css",
|
|
33
|
+
js: "text/javascript",
|
|
34
|
+
mp4: "video/mp4",
|
|
35
|
+
webm: "video/webm",
|
|
36
|
+
mp3: "audio/mpeg",
|
|
37
|
+
zip: "application/zip",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inferContentType(key: string): string | undefined {
|
|
41
|
+
const ext = key.slice(key.lastIndexOf(".") + 1).toLowerCase()
|
|
42
|
+
return CONTENT_TYPES[ext]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class FileStorage implements StorageAdapter {
|
|
46
|
+
private readonly root: string
|
|
47
|
+
|
|
48
|
+
constructor(root: string) {
|
|
49
|
+
this.root = resolve(root)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Resolve `key` to an absolute path, asserting it stays inside `root` (defense-in-depth on top of `assertSafeKey`). */
|
|
53
|
+
private pathFor(key: string): string {
|
|
54
|
+
assertSafeKey(key)
|
|
55
|
+
const full = resolve(this.root, key)
|
|
56
|
+
if (full !== this.root && !full.startsWith(this.root + sep)) {
|
|
57
|
+
throw new StorageKeyError(`storage key ${JSON.stringify(key)} escapes the storage root`)
|
|
58
|
+
}
|
|
59
|
+
return full
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async put(key: string, data: StorageData, _options: PutOptions = {}): Promise<void> {
|
|
63
|
+
const path = this.pathFor(key)
|
|
64
|
+
await mkdir(dirname(path), { recursive: true })
|
|
65
|
+
await writeFile(path, toBytes(data))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async get(key: string): Promise<StorageObject | null> {
|
|
69
|
+
const path = this.pathFor(key)
|
|
70
|
+
let body: Uint8Array
|
|
71
|
+
try {
|
|
72
|
+
body = new Uint8Array(await readFile(path))
|
|
73
|
+
} catch {
|
|
74
|
+
return null // ENOENT (or unreadable) → treated as missing
|
|
75
|
+
}
|
|
76
|
+
const contentType = inferContentType(key)
|
|
77
|
+
return contentType === undefined
|
|
78
|
+
? { body, size: body.byteLength }
|
|
79
|
+
: { body, size: body.byteLength, contentType }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async delete(key: string): Promise<void> {
|
|
83
|
+
await rm(this.pathFor(key), { force: true })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async exists(key: string): Promise<boolean> {
|
|
87
|
+
try {
|
|
88
|
+
await stat(this.pathFor(key))
|
|
89
|
+
return true
|
|
90
|
+
} catch {
|
|
91
|
+
return false
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async list(options: ListOptions = {}): Promise<string[]> {
|
|
96
|
+
const keys: string[] = []
|
|
97
|
+
await this.walk(this.root, "", keys)
|
|
98
|
+
let out = keys.sort()
|
|
99
|
+
if (options.prefix !== undefined)
|
|
100
|
+
out = out.filter((k) => k.startsWith(options.prefix as string))
|
|
101
|
+
if (options.limit !== undefined) out = out.slice(0, options.limit)
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async walk(dir: string, prefix: string, out: string[]): Promise<void> {
|
|
106
|
+
let names: string[]
|
|
107
|
+
try {
|
|
108
|
+
names = await readdir(dir)
|
|
109
|
+
} catch {
|
|
110
|
+
return // missing root → empty listing
|
|
111
|
+
}
|
|
112
|
+
for (const name of names) {
|
|
113
|
+
const full = join(dir, name)
|
|
114
|
+
// POSIX-join the key segments so listed keys are portable regardless of the host separator.
|
|
115
|
+
const key = prefix === "" ? name : posix.join(prefix, name)
|
|
116
|
+
const info = await stat(full)
|
|
117
|
+
if (info.isDirectory()) await this.walk(full, key, out)
|
|
118
|
+
else if (info.isFile()) out.push(key)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
|
|
3
|
+
* `@nifrajs/uploads`: `uploads` validates + signs, `storage` puts the bytes somewhere. Adapters cover
|
|
4
|
+
* dev (memory), a long-running server (local disk), and the edge (Cloudflare R2); implement
|
|
5
|
+
* {@link StorageAdapter} for S3/GCS/etc. Dependency-free.
|
|
6
|
+
*
|
|
7
|
+
* import { FileStorage } from "@nifrajs/storage"
|
|
8
|
+
* const storage = new FileStorage("./uploads")
|
|
9
|
+
* await storage.put("avatars/u1.png", bytes, { contentType: "image/png" })
|
|
10
|
+
* const object = await storage.get("avatars/u1.png")
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { FileStorage } from "./file.ts"
|
|
14
|
+
export { assertSafeKey, StorageKeyError } from "./key.ts"
|
|
15
|
+
export { MemoryStorage } from "./memory.ts"
|
|
16
|
+
export { type R2BucketLike, type R2ObjectLike, R2Storage } from "./r2.ts"
|
|
17
|
+
export {
|
|
18
|
+
type ListOptions,
|
|
19
|
+
type PutOptions,
|
|
20
|
+
type StorageAdapter,
|
|
21
|
+
type StorageData,
|
|
22
|
+
type StorageObject,
|
|
23
|
+
toBytes,
|
|
24
|
+
} from "./types.ts"
|
package/src/key.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage-key safety. A key is a POSIX-ish relative path (`avatars/u1.png`); we reject anything that
|
|
3
|
+
* could escape a `FileStorage` root or otherwise misbehave — absolute paths, `..` traversal, NUL bytes,
|
|
4
|
+
* and backslashes (Windows traversal). Enforced by EVERY adapter (not just `FileStorage`) so a key is
|
|
5
|
+
* portable across them, and so the check can't be forgotten on the one adapter where it's a vulnerability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class StorageKeyError extends Error {
|
|
9
|
+
override readonly name = "StorageKeyError"
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Throw {@link StorageKeyError} unless `key` is a safe relative storage key. */
|
|
13
|
+
export function assertSafeKey(key: string): void {
|
|
14
|
+
if (key.length === 0 || key.length > 1024) {
|
|
15
|
+
throw new StorageKeyError(`storage key must be 1–1024 chars (got ${key.length})`)
|
|
16
|
+
}
|
|
17
|
+
if (key.startsWith("/") || key.includes("\\") || key.includes("\0")) {
|
|
18
|
+
throw new StorageKeyError(
|
|
19
|
+
`unsafe storage key ${JSON.stringify(key)} (absolute, backslash, or NUL)`,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
for (const segment of key.split("/")) {
|
|
23
|
+
if (segment === "..") {
|
|
24
|
+
throw new StorageKeyError(`unsafe storage key ${JSON.stringify(key)} (".." path traversal)`)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/memory.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process {@link StorageAdapter} — a Map of key → bytes + metadata. For dev and tests; not durable
|
|
3
|
+
* (a restart empties it) and not shared across instances. Methods are `async` so an unsafe key REJECTS
|
|
4
|
+
* (consistent with the disk/R2 adapters), never throws synchronously.
|
|
5
|
+
*/
|
|
6
|
+
import { assertSafeKey } from "./key.ts"
|
|
7
|
+
import {
|
|
8
|
+
type ListOptions,
|
|
9
|
+
type PutOptions,
|
|
10
|
+
type StorageAdapter,
|
|
11
|
+
type StorageData,
|
|
12
|
+
type StorageObject,
|
|
13
|
+
toBytes,
|
|
14
|
+
} from "./types.ts"
|
|
15
|
+
|
|
16
|
+
interface Entry {
|
|
17
|
+
readonly body: Uint8Array
|
|
18
|
+
readonly contentType?: string
|
|
19
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class MemoryStorage implements StorageAdapter {
|
|
23
|
+
private readonly objects = new Map<string, Entry>()
|
|
24
|
+
|
|
25
|
+
async put(key: string, data: StorageData, options: PutOptions = {}): Promise<void> {
|
|
26
|
+
assertSafeKey(key)
|
|
27
|
+
const entry: Entry = { body: toBytes(data) }
|
|
28
|
+
this.objects.set(key, {
|
|
29
|
+
...entry,
|
|
30
|
+
...(options.contentType !== undefined ? { contentType: options.contentType } : {}),
|
|
31
|
+
...(options.metadata !== undefined ? { metadata: options.metadata } : {}),
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async get(key: string): Promise<StorageObject | null> {
|
|
36
|
+
assertSafeKey(key)
|
|
37
|
+
const entry = this.objects.get(key)
|
|
38
|
+
if (entry === undefined) return null
|
|
39
|
+
return {
|
|
40
|
+
body: entry.body,
|
|
41
|
+
size: entry.body.byteLength,
|
|
42
|
+
...(entry.contentType !== undefined ? { contentType: entry.contentType } : {}),
|
|
43
|
+
...(entry.metadata !== undefined ? { metadata: entry.metadata } : {}),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async delete(key: string): Promise<void> {
|
|
48
|
+
assertSafeKey(key)
|
|
49
|
+
this.objects.delete(key)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async exists(key: string): Promise<boolean> {
|
|
53
|
+
assertSafeKey(key)
|
|
54
|
+
return this.objects.has(key)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async list(options: ListOptions = {}): Promise<string[]> {
|
|
58
|
+
const prefix = options.prefix ?? ""
|
|
59
|
+
let keys = [...this.objects.keys()].filter((k) => k.startsWith(prefix)).sort()
|
|
60
|
+
if (options.limit !== undefined) keys = keys.slice(0, options.limit)
|
|
61
|
+
return keys
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/r2.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare R2 {@link StorageAdapter} — wraps an R2 bucket binding. The binding is typed structurally
|
|
3
|
+
* (the slice we use), so this stays dependency-free: no `@cloudflare/workers-types`. Pass `env.MY_BUCKET`.
|
|
4
|
+
*
|
|
5
|
+
* const storage = new R2Storage(env.UPLOADS)
|
|
6
|
+
*/
|
|
7
|
+
import { assertSafeKey } from "./key.ts"
|
|
8
|
+
import {
|
|
9
|
+
type ListOptions,
|
|
10
|
+
type PutOptions,
|
|
11
|
+
type StorageAdapter,
|
|
12
|
+
type StorageData,
|
|
13
|
+
type StorageObject,
|
|
14
|
+
toBytes,
|
|
15
|
+
} from "./types.ts"
|
|
16
|
+
|
|
17
|
+
/** The slice of R2's object metadata this adapter reads. */
|
|
18
|
+
export interface R2ObjectLike {
|
|
19
|
+
readonly size: number
|
|
20
|
+
readonly httpMetadata?: { readonly contentType?: string }
|
|
21
|
+
readonly customMetadata?: Record<string, string>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The slice of the R2 bucket binding this adapter calls. `env.<BUCKET>` satisfies it. */
|
|
25
|
+
export interface R2BucketLike {
|
|
26
|
+
put(
|
|
27
|
+
key: string,
|
|
28
|
+
value: ArrayBuffer | ArrayBufferView,
|
|
29
|
+
options?: { httpMetadata?: { contentType?: string }; customMetadata?: Record<string, string> },
|
|
30
|
+
): Promise<unknown>
|
|
31
|
+
get(key: string): Promise<(R2ObjectLike & { arrayBuffer(): Promise<ArrayBuffer> }) | null>
|
|
32
|
+
head(key: string): Promise<R2ObjectLike | null>
|
|
33
|
+
delete(key: string): Promise<void>
|
|
34
|
+
list(options?: {
|
|
35
|
+
prefix?: string
|
|
36
|
+
limit?: number
|
|
37
|
+
}): Promise<{ objects: ReadonlyArray<{ key: string }> }>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class R2Storage implements StorageAdapter {
|
|
41
|
+
constructor(private readonly bucket: R2BucketLike) {}
|
|
42
|
+
|
|
43
|
+
async put(key: string, data: StorageData, options: PutOptions = {}): Promise<void> {
|
|
44
|
+
assertSafeKey(key)
|
|
45
|
+
const put: {
|
|
46
|
+
httpMetadata?: { contentType?: string }
|
|
47
|
+
customMetadata?: Record<string, string>
|
|
48
|
+
} = {}
|
|
49
|
+
if (options.contentType !== undefined) put.httpMetadata = { contentType: options.contentType }
|
|
50
|
+
if (options.metadata !== undefined) put.customMetadata = { ...options.metadata }
|
|
51
|
+
// R2 wants a copied ArrayBuffer (not a view into a larger buffer).
|
|
52
|
+
await this.bucket.put(key, toBytes(data).slice().buffer, put)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async get(key: string): Promise<StorageObject | null> {
|
|
56
|
+
assertSafeKey(key)
|
|
57
|
+
const object = await this.bucket.get(key)
|
|
58
|
+
if (object === null) return null
|
|
59
|
+
const body = new Uint8Array(await object.arrayBuffer())
|
|
60
|
+
return {
|
|
61
|
+
body,
|
|
62
|
+
size: object.size,
|
|
63
|
+
...(object.httpMetadata?.contentType !== undefined
|
|
64
|
+
? { contentType: object.httpMetadata.contentType }
|
|
65
|
+
: {}),
|
|
66
|
+
...(object.customMetadata !== undefined ? { metadata: object.customMetadata } : {}),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async delete(key: string): Promise<void> {
|
|
71
|
+
assertSafeKey(key)
|
|
72
|
+
await this.bucket.delete(key)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async exists(key: string): Promise<boolean> {
|
|
76
|
+
assertSafeKey(key)
|
|
77
|
+
return (await this.bucket.head(key)) !== null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async list(options: ListOptions = {}): Promise<string[]> {
|
|
81
|
+
const listOpts: { prefix?: string; limit?: number } = {}
|
|
82
|
+
if (options.prefix !== undefined) listOpts.prefix = options.prefix
|
|
83
|
+
if (options.limit !== undefined) listOpts.limit = options.limit
|
|
84
|
+
const result = await this.bucket.list(listOpts)
|
|
85
|
+
return result.objects.map((o) => o.key)
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nifrajs/storage — one blob-storage interface, several adapters. The persistence half of
|
|
3
|
+
* `@nifrajs/uploads` (which validates + signs but doesn't store): `uploads` decides what's allowed,
|
|
4
|
+
* `storage` puts the bytes somewhere. Adapters: {@link MemoryStorage} (dev/test), `FileStorage` (local
|
|
5
|
+
* disk), `R2Storage` (Cloudflare R2). Bring your own for S3/GCS by implementing {@link StorageAdapter}.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Accepted `put` payloads — normalized to bytes by each adapter. */
|
|
9
|
+
export type StorageData = Uint8Array | ArrayBuffer | string
|
|
10
|
+
|
|
11
|
+
export interface PutOptions {
|
|
12
|
+
/** MIME type stored alongside the object (returned by `get`). */
|
|
13
|
+
readonly contentType?: string
|
|
14
|
+
/** Arbitrary string metadata stored with the object. */
|
|
15
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** An object read back from storage. `body` is buffered (not streamed) — fine for typical uploads. */
|
|
19
|
+
export interface StorageObject {
|
|
20
|
+
readonly body: Uint8Array
|
|
21
|
+
readonly size: number
|
|
22
|
+
readonly contentType?: string
|
|
23
|
+
readonly metadata?: Readonly<Record<string, string>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ListOptions {
|
|
27
|
+
/** Only keys starting with this prefix. */
|
|
28
|
+
readonly prefix?: string
|
|
29
|
+
/** Cap the number of keys returned. */
|
|
30
|
+
readonly limit?: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A blob store keyed by string. Keys are POSIX-ish paths (`avatars/u1.png`); every adapter rejects unsafe
|
|
35
|
+
* keys (absolute, `..` traversal, NUL, backslash) so a key valid in one adapter is valid in all. All
|
|
36
|
+
* methods are async.
|
|
37
|
+
*/
|
|
38
|
+
export interface StorageAdapter {
|
|
39
|
+
/** Store `data` under `key`, overwriting any existing object. */
|
|
40
|
+
put(key: string, data: StorageData, options?: PutOptions): Promise<void>
|
|
41
|
+
/** Read the object at `key`, or `null` if it doesn't exist. */
|
|
42
|
+
get(key: string): Promise<StorageObject | null>
|
|
43
|
+
/** Remove `key`. No-op if it doesn't exist. */
|
|
44
|
+
delete(key: string): Promise<void>
|
|
45
|
+
/** Whether an object exists at `key`. */
|
|
46
|
+
exists(key: string): Promise<boolean>
|
|
47
|
+
/** List stored keys (optionally filtered by prefix + capped). */
|
|
48
|
+
list(options?: ListOptions): Promise<string[]>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Normalize any accepted payload to bytes. */
|
|
52
|
+
export function toBytes(data: StorageData): Uint8Array {
|
|
53
|
+
if (typeof data === "string") return new TextEncoder().encode(data)
|
|
54
|
+
if (data instanceof Uint8Array) return data
|
|
55
|
+
return new Uint8Array(data)
|
|
56
|
+
}
|