@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 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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file.js","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"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"}
@@ -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
@@ -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"}
@@ -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
@@ -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"}
@@ -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
+ }