@arraypress/storage 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Sherlock / ArrayPress Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # @arraypress/storage
2
+
3
+ Storage interface, types, and helper utilities for portable file storage across R2, S3, and local filesystem.
4
+
5
+ Works in any JavaScript runtime (Cloudflare Workers, Node.js, Deno, Bun, browsers).
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @arraypress/storage
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ This package defines the `Storage` interface that all adapter packages implement. You typically don't use this package directly -- instead, install one of the adapters:
16
+
17
+ | Adapter | Backend |
18
+ |---------|---------|
19
+ | `@arraypress/storage-r2` | Cloudflare R2 native bindings |
20
+ | `@arraypress/storage-s3` | AWS S3, R2 via S3 API, MinIO |
21
+ | `@arraypress/storage-local` | Local filesystem (Node.js) |
22
+
23
+ ### Storage Interface
24
+
25
+ Every adapter returns an object implementing this interface:
26
+
27
+ ```ts
28
+ interface Storage {
29
+ upload(options: UploadOptions): Promise<UploadResult>;
30
+ download(key: string): Promise<DownloadResult | null>;
31
+ delete(key: string): Promise<void>;
32
+ exists(key: string): Promise<boolean>;
33
+ list(options?: ListOptions): Promise<ListResult>;
34
+ getSignedDownloadUrl(options: SignedUrlOptions): Promise<SignedUrl>;
35
+ getSignedUploadUrl(options: SignedUploadUrlOptions): Promise<SignedUrl>;
36
+ getPublicUrl(key: string): string;
37
+ createMultipartUpload(key: string, options?: MultipartUploadOptions): Promise<MultipartUpload>;
38
+ resumeMultipartUpload(key: string, uploadId: string): MultipartUpload;
39
+ }
40
+ ```
41
+
42
+ ### Helpers
43
+
44
+ The package also exports helper utilities for common storage operations:
45
+
46
+ ```js
47
+ import { contentHash, contentAddressedKey, safeDisposition, StorageError } from '@arraypress/storage';
48
+
49
+ // SHA-256 content hashing for deduplication
50
+ const hash = await contentHash(fileBuffer);
51
+ // => 'a1b2c3d4e5f6...'
52
+
53
+ // Generate content-addressed storage keys
54
+ const key = contentAddressedKey(hash, 'photo.jpg', 'media/');
55
+ // => 'media/a1b2c3d4e5f6.jpg'
56
+
57
+ // Determine safe Content-Disposition for downloads
58
+ safeDisposition('image/jpeg'); // => 'inline'
59
+ safeDisposition('image/svg+xml'); // => 'attachment' (XSS risk)
60
+ safeDisposition('application/pdf'); // => 'inline'
61
+ safeDisposition('text/html'); // => 'attachment'
62
+
63
+ // Typed storage errors
64
+ throw new StorageError('File not found', 'NOT_FOUND');
65
+ ```
66
+
67
+ ### Content-Addressed Uploads
68
+
69
+ Combine `contentHash` and `contentAddressedKey` to skip duplicate uploads:
70
+
71
+ ```js
72
+ import { contentHash, contentAddressedKey } from '@arraypress/storage';
73
+
74
+ async function uploadDeduped(storage, file, originalName) {
75
+ const hash = await contentHash(file);
76
+ const key = contentAddressedKey(hash, originalName, 'uploads/');
77
+
78
+ // Skip upload if identical file already exists
79
+ if (await storage.exists(key)) {
80
+ return { key, skipped: true };
81
+ }
82
+
83
+ const result = await storage.upload({
84
+ key,
85
+ body: file,
86
+ contentType: 'application/octet-stream',
87
+ });
88
+
89
+ return { key: result.key, skipped: false };
90
+ }
91
+ ```
92
+
93
+ ## API Reference
94
+
95
+ ### Types
96
+
97
+ - `Storage` -- Main storage interface implemented by all adapters
98
+ - `UploadOptions` -- Options for `upload()`: `key`, `body`, `contentType`, `metadata?`
99
+ - `UploadResult` -- Result from `upload()`: `key`, `size`, `etag?`
100
+ - `DownloadResult` -- Result from `download()`: `body`, `contentType`, `size`, `etag?`
101
+ - `ListOptions` -- Options for `list()`: `prefix?`, `limit?`, `cursor?`
102
+ - `ListResult` -- Result from `list()`: `objects`, `truncated`, `cursor?`
103
+ - `ListObject` -- Individual object in list: `key`, `size`, `lastModified?`, `etag?`
104
+ - `SignedUrlOptions` -- Options for signed URLs: `key`, `expiresIn?`
105
+ - `SignedUploadUrlOptions` -- Extends `SignedUrlOptions` with `contentType`
106
+ - `SignedUrl` -- Signed URL result: `url`, `expiresAt`
107
+ - `MultipartUploadOptions` -- Options for multipart: `contentType?`, `metadata?`
108
+ - `MultipartUpload` -- Multipart upload handle: `uploadId`, `key`, `uploadPart()`, `complete()`, `abort()`
109
+ - `MultipartPart` -- Completed part: `partNumber`, `etag`
110
+ - `StorageErrorCode` -- `'NOT_FOUND' | 'NOT_SUPPORTED' | 'PERMISSION_DENIED' | 'ALREADY_EXISTS' | 'UNKNOWN'`
111
+
112
+ ### Classes
113
+
114
+ - `StorageError` -- Error class with a `code` property (`StorageErrorCode`)
115
+
116
+ ### Functions
117
+
118
+ - `contentHash(body)` -- Compute SHA-256 hash of a body. Returns lowercase hex string. Uses Web Crypto API.
119
+ - `contentAddressedKey(hash, originalName, prefix?)` -- Generate a storage key from a hash and original filename, preserving the file extension.
120
+ - `safeDisposition(mimeType)` -- Returns `'inline'` for safe types (images, audio, video, PDF) or `'attachment'` for everything else (SVG, HTML, etc.).
121
+
122
+ ## License
123
+
124
+ MIT
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@arraypress/storage",
3
+ "version": "1.0.0",
4
+ "description": "Storage interface + types + helpers for portable file storage across R2, S3, and local filesystem",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "types": "src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "import": "./src/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "keywords": [
18
+ "storage",
19
+ "r2",
20
+ "s3",
21
+ "file-storage",
22
+ "cloudflare",
23
+ "interface"
24
+ ],
25
+ "author": "ArrayPress",
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/arraypress/storage"
30
+ }
31
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,192 @@
1
+ // === Upload ===
2
+
3
+ export interface UploadOptions {
4
+ /** Storage key (path) for the object. */
5
+ key: string;
6
+ /** File body — accepts any standard web body type. */
7
+ body: ReadableStream | ArrayBuffer | Uint8Array | Blob;
8
+ /** MIME content type. */
9
+ contentType: string;
10
+ /** Optional key-value metadata stored alongside the object. */
11
+ metadata?: Record<string, string>;
12
+ }
13
+
14
+ export interface UploadResult {
15
+ /** The key the object was stored under. */
16
+ key: string;
17
+ /** Object size in bytes. */
18
+ size: number;
19
+ /** ETag if provided by the backend. */
20
+ etag?: string;
21
+ }
22
+
23
+ // === Download ===
24
+
25
+ export interface DownloadResult {
26
+ /** Object body as a readable stream. */
27
+ body: ReadableStream;
28
+ /** MIME content type. */
29
+ contentType: string;
30
+ /** Object size in bytes. */
31
+ size: number;
32
+ /** ETag if provided by the backend. */
33
+ etag?: string;
34
+ }
35
+
36
+ // === List ===
37
+
38
+ export interface ListOptions {
39
+ /** Filter objects by key prefix. */
40
+ prefix?: string;
41
+ /** Maximum number of objects to return. */
42
+ limit?: number;
43
+ /** Pagination cursor from a previous list call. */
44
+ cursor?: string;
45
+ }
46
+
47
+ export interface ListResult {
48
+ /** Matching objects. */
49
+ objects: ListObject[];
50
+ /** Whether there are more results beyond this page. */
51
+ truncated: boolean;
52
+ /** Cursor for the next page, if truncated. */
53
+ cursor?: string;
54
+ }
55
+
56
+ export interface ListObject {
57
+ /** Object key (path). */
58
+ key: string;
59
+ /** Object size in bytes. */
60
+ size: number;
61
+ /** Last modified date, if available. */
62
+ lastModified?: Date;
63
+ /** ETag if available. */
64
+ etag?: string;
65
+ }
66
+
67
+ // === Signed URLs ===
68
+
69
+ export interface SignedUrlOptions {
70
+ /** Object key. */
71
+ key: string;
72
+ /** Expiration time in seconds. Default: 3600. */
73
+ expiresIn?: number;
74
+ }
75
+
76
+ export interface SignedUploadUrlOptions extends SignedUrlOptions {
77
+ /** Content type for the upload. */
78
+ contentType: string;
79
+ }
80
+
81
+ export interface SignedUrl {
82
+ /** The signed URL. */
83
+ url: string;
84
+ /** When the URL expires. */
85
+ expiresAt: Date;
86
+ }
87
+
88
+ // === Multipart Upload ===
89
+
90
+ export interface MultipartUploadOptions {
91
+ /** MIME content type. */
92
+ contentType?: string;
93
+ /** Optional metadata. */
94
+ metadata?: Record<string, string>;
95
+ }
96
+
97
+ export interface MultipartUpload {
98
+ /** Backend-assigned upload ID. */
99
+ uploadId: string;
100
+ /** Object key. */
101
+ key: string;
102
+ /** Upload a single part. Part numbers start at 1. */
103
+ uploadPart(partNumber: number, body: ArrayBuffer | Uint8Array): Promise<MultipartPart>;
104
+ /** Finalise the upload with all parts. */
105
+ complete(parts: MultipartPart[]): Promise<void>;
106
+ /** Cancel the upload and discard uploaded parts. */
107
+ abort(): Promise<void>;
108
+ }
109
+
110
+ export interface MultipartPart {
111
+ /** Part number (1-based). */
112
+ partNumber: number;
113
+ /** ETag returned by the backend for this part. */
114
+ etag: string;
115
+ }
116
+
117
+ // === Storage Interface ===
118
+
119
+ export interface Storage {
120
+ /** Upload an object. */
121
+ upload(options: UploadOptions): Promise<UploadResult>;
122
+
123
+ /** Download an object. Returns null if not found. */
124
+ download(key: string): Promise<DownloadResult | null>;
125
+
126
+ /** Delete an object. Does not throw if the key doesn't exist. */
127
+ delete(key: string): Promise<void>;
128
+
129
+ /** Check whether an object exists. */
130
+ exists(key: string): Promise<boolean>;
131
+
132
+ /** List objects, optionally filtered by prefix. */
133
+ list(options?: ListOptions): Promise<ListResult>;
134
+
135
+ /**
136
+ * Get a signed URL for downloading an object.
137
+ * Throws StorageError with code NOT_SUPPORTED if the backend doesn't support signed URLs.
138
+ */
139
+ getSignedDownloadUrl(options: SignedUrlOptions): Promise<SignedUrl>;
140
+
141
+ /**
142
+ * Get a signed URL for uploading an object directly.
143
+ * Throws StorageError with code NOT_SUPPORTED if the backend doesn't support signed URLs.
144
+ */
145
+ getSignedUploadUrl(options: SignedUploadUrlOptions): Promise<SignedUrl>;
146
+
147
+ /** Get the public URL for an object. Requires the bucket to be publicly accessible. */
148
+ getPublicUrl(key: string): string;
149
+
150
+ /**
151
+ * Start a multipart upload for large files.
152
+ * Throws StorageError with code NOT_SUPPORTED if the backend doesn't support multipart uploads.
153
+ */
154
+ createMultipartUpload(key: string, options?: MultipartUploadOptions): Promise<MultipartUpload>;
155
+
156
+ /**
157
+ * Resume an in-progress multipart upload.
158
+ * Throws StorageError with code NOT_SUPPORTED if the backend doesn't support multipart uploads.
159
+ */
160
+ resumeMultipartUpload(key: string, uploadId: string): MultipartUpload;
161
+ }
162
+
163
+ // === Error ===
164
+
165
+ export type StorageErrorCode =
166
+ | 'NOT_FOUND'
167
+ | 'NOT_SUPPORTED'
168
+ | 'PERMISSION_DENIED'
169
+ | 'ALREADY_EXISTS'
170
+ | 'UNKNOWN';
171
+
172
+ export declare class StorageError extends Error {
173
+ code: StorageErrorCode;
174
+ constructor(message: string, code: StorageErrorCode);
175
+ }
176
+
177
+ // === Helpers ===
178
+
179
+ /** Compute SHA-256 hash of a body. Returns lowercase hex string. Uses Web Crypto API. */
180
+ export function contentHash(body: ArrayBuffer | Uint8Array | ReadableStream | Blob): Promise<string>;
181
+
182
+ /**
183
+ * Generate a content-addressed storage key from a hash and original filename.
184
+ * Example: contentAddressedKey('abc123', 'photo.jpg', 'media/') → 'media/abc123.jpg'
185
+ */
186
+ export function contentAddressedKey(hash: string, originalName: string, prefix?: string): string;
187
+
188
+ /**
189
+ * Determine the safe Content-Disposition for a given MIME type.
190
+ * Safe types (images, audio, video, PDF) → 'inline'; everything else → 'attachment'.
191
+ */
192
+ export function safeDisposition(mimeType: string): 'inline' | 'attachment';
package/src/index.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @arraypress/storage
3
+ *
4
+ * Core storage interface, types, and helper utilities.
5
+ * Zero dependencies — works in any JavaScript runtime.
6
+ *
7
+ * @module @arraypress/storage
8
+ */
9
+
10
+ // === Error ===
11
+
12
+ export class StorageError extends Error {
13
+ /**
14
+ * @param {string} message
15
+ * @param {import('./index.d.ts').StorageErrorCode} code
16
+ */
17
+ constructor(message, code = 'UNKNOWN') {
18
+ super(message);
19
+ this.name = 'StorageError';
20
+ this.code = code;
21
+ }
22
+ }
23
+
24
+ // === Helpers ===
25
+
26
+ /**
27
+ * Compute SHA-256 hash of a body. Returns lowercase hex string.
28
+ *
29
+ * @param {ArrayBuffer | Uint8Array | ReadableStream | Blob} body
30
+ * @returns {Promise<string>}
31
+ */
32
+ export async function contentHash(body) {
33
+ let buffer;
34
+ if (body instanceof ArrayBuffer) {
35
+ buffer = body;
36
+ } else if (body instanceof Uint8Array) {
37
+ buffer = body.buffer;
38
+ } else {
39
+ // ReadableStream or Blob — convert via Response
40
+ buffer = await new Response(body).arrayBuffer();
41
+ }
42
+ const hash = await crypto.subtle.digest('SHA-256', buffer);
43
+ return Array.from(new Uint8Array(hash))
44
+ .map((b) => b.toString(16).padStart(2, '0'))
45
+ .join('');
46
+ }
47
+
48
+ /**
49
+ * Generate a content-addressed storage key from a hash and original filename.
50
+ *
51
+ * @param {string} hash - The content hash (e.g. SHA-256 hex).
52
+ * @param {string} originalName - Original filename to extract extension from.
53
+ * @param {string} [prefix=''] - Optional key prefix (e.g. 'media/').
54
+ * @returns {string}
55
+ *
56
+ * @example
57
+ * contentAddressedKey('abc123', 'photo.jpg', 'media/')
58
+ * // → 'media/abc123.jpg'
59
+ */
60
+ export function contentAddressedKey(hash, originalName, prefix = '') {
61
+ const dotIndex = originalName.lastIndexOf('.');
62
+ const ext = dotIndex > 0 ? originalName.slice(dotIndex) : '';
63
+ return `${prefix}${hash}${ext}`;
64
+ }
65
+
66
+ /** MIME types that are safe to serve inline (no XSS risk). */
67
+ const INLINE_TYPES = new Set([
68
+ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/bmp', 'image/tiff',
69
+ 'audio/mpeg', 'audio/ogg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/flac',
70
+ 'video/mp4', 'video/webm', 'video/ogg',
71
+ 'application/pdf',
72
+ ]);
73
+
74
+ /**
75
+ * Determine the safe Content-Disposition for a given MIME type.
76
+ * Safe types (images, audio, video, PDF) → 'inline'; everything else → 'attachment'.
77
+ *
78
+ * SVG, HTML, and other scriptable types are forced to 'attachment' to prevent XSS.
79
+ *
80
+ * @param {string} mimeType
81
+ * @returns {'inline' | 'attachment'}
82
+ */
83
+ export function safeDisposition(mimeType) {
84
+ return INLINE_TYPES.has(mimeType) ? 'inline' : 'attachment';
85
+ }