@everystack/storage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # @everystack/storage
2
+
3
+ File upload handler with pluggable storage backends (filesystem, S3), presigned URLs, validation, and upload metadata tracking via Drizzle ORM.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @everystack/storage drizzle-orm
9
+ ```
10
+
11
+ For S3 storage:
12
+
13
+ ```bash
14
+ pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
15
+ ```
16
+
17
+ ## Entry Points
18
+
19
+ | Import | Description |
20
+ |--------|-------------|
21
+ | `@everystack/storage` | Handler, adapters, validation |
22
+ | `@everystack/storage/schema` | Drizzle `uploads` table |
23
+ | `@everystack/storage/client` | Client-side presigned URL helpers |
24
+
25
+ ## Quick Start
26
+
27
+ ```typescript
28
+ import { createStorageHandler, createObjectStorage } from '@everystack/storage';
29
+ import { db } from './db';
30
+
31
+ const storage = createObjectStorage({
32
+ type: 's3',
33
+ bucket: 'my-uploads',
34
+ region: 'us-east-1',
35
+ cdnUrl: 'https://cdn.example.com',
36
+ });
37
+
38
+ const handler = createStorageHandler({
39
+ db,
40
+ storage,
41
+ basePath: '/api/storage',
42
+ auth: {
43
+ verifyToken: async (token) => verifyJWT(token),
44
+ },
45
+ validation: {
46
+ maxSize: 10 * 1024 * 1024, // 10MB
47
+ allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
48
+ },
49
+ });
50
+ ```
51
+
52
+ ### Mounting as an Expo Router API Route
53
+
54
+ ```typescript
55
+ // app/api/storage/[...path]+api.ts
56
+ export function GET(request: Request) { return handler(request); }
57
+ export function POST(request: Request) { return handler(request); }
58
+ export function DELETE(request: Request) { return handler(request); }
59
+ ```
60
+
61
+ ## Handler Endpoints
62
+
63
+ | Method | Path | Description |
64
+ |--------|------|-------------|
65
+ | `POST` | `/presign` | Get a presigned upload URL |
66
+ | `POST` | `/upload` | Direct multipart file upload |
67
+ | `GET` | `/files/:key` | Download a file (or redirect to presigned URL) |
68
+ | `GET` | `/uploads` | List upload metadata (paginated) |
69
+ | `GET` | `/uploads/:id` | Get upload metadata |
70
+ | `DELETE` | `/uploads/:id` | Delete upload + file |
71
+
72
+ ### Presigned Upload Flow
73
+
74
+ ```typescript
75
+ // 1. Client requests presigned URL
76
+ const res = await fetch('/api/storage/presign', {
77
+ method: 'POST',
78
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ name: 'photo.jpg', type: 'image/jpeg', size: 1024000 }),
80
+ });
81
+ const { url, key } = await res.json();
82
+
83
+ // 2. Client uploads directly to S3
84
+ await fetch(url, { method: 'PUT', body: file, headers: { 'Content-Type': 'image/jpeg' } });
85
+
86
+ // 3. Use the `key` to reference the file
87
+ ```
88
+
89
+ ### Direct Upload
90
+
91
+ ```typescript
92
+ const formData = new FormData();
93
+ formData.append('file', file);
94
+
95
+ const res = await fetch('/api/storage/upload', {
96
+ method: 'POST',
97
+ headers: { Authorization: `Bearer ${token}` },
98
+ body: formData,
99
+ });
100
+ const { id, key } = await res.json();
101
+ ```
102
+
103
+ ## Handler Options
104
+
105
+ ```typescript
106
+ interface StorageHandlerOptions {
107
+ db: DrizzleDb;
108
+ storage: ObjectStorageAdapter;
109
+ basePath?: string;
110
+ bucket?: string; // Logical bucket name for metadata (default: 'default')
111
+ auth?: {
112
+ verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
113
+ };
114
+ validation?: {
115
+ maxSize?: number; // Max file size in bytes
116
+ allowedTypes?: string[]; // MIME types (e.g., ['image/jpeg', 'image/png'])
117
+ allowedExtensions?: string[]; // File extensions (e.g., ['.jpg', '.png'])
118
+ };
119
+ keyGenerator?: (file: { name: string; type: string; userId?: string }) => string;
120
+ publishJob?: (type: string, payload: unknown) => Promise<string>;
121
+ }
122
+ ```
123
+
124
+ ### Custom Key Generator
125
+
126
+ ```typescript
127
+ const handler = createStorageHandler({
128
+ db,
129
+ storage,
130
+ keyGenerator: ({ name, type, userId }) => {
131
+ const ext = name.split('.').pop();
132
+ return `users/${userId}/${crypto.randomUUID()}.${ext}`;
133
+ },
134
+ });
135
+ ```
136
+
137
+ ### Image Processing Integration
138
+
139
+ Wire storage uploads to `@everystack/jobs` for automatic image processing:
140
+
141
+ ```typescript
142
+ import { createJobClient, PostgresJobAdapter } from '@everystack/jobs';
143
+
144
+ const jobClient = createJobClient(new PostgresJobAdapter(db));
145
+
146
+ const handler = createStorageHandler({
147
+ db,
148
+ storage,
149
+ publishJob: (type, payload) => jobClient.publish(type, payload),
150
+ });
151
+ // Image uploads automatically publish a 'process-image' job
152
+ ```
153
+
154
+ ## Storage Adapters
155
+
156
+ ### Filesystem
157
+
158
+ ```typescript
159
+ import { createObjectStorage } from '@everystack/storage';
160
+
161
+ const storage = createObjectStorage({
162
+ type: 'filesystem',
163
+ directory: './uploads',
164
+ baseUrl: 'http://localhost:3000/files',
165
+ });
166
+ ```
167
+
168
+ ### S3
169
+
170
+ ```typescript
171
+ const storage = createObjectStorage({
172
+ type: 's3',
173
+ bucket: 'my-app-uploads',
174
+ region: 'us-east-1',
175
+ endpoint: 'https://s3.us-east-1.amazonaws.com', // Optional custom endpoint
176
+ cdnUrl: 'https://cdn.example.com', // Optional CDN prefix
177
+ });
178
+ ```
179
+
180
+ ### Custom Adapter
181
+
182
+ Implement the `ObjectStorageAdapter` interface:
183
+
184
+ ```typescript
185
+ interface ObjectStorageAdapter {
186
+ put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
187
+ get(key: string): Promise<{ data: Buffer; contentType: string } | null>;
188
+ exists(key: string): Promise<boolean>;
189
+ list(prefix: string): Promise<string[]>;
190
+ delete(key: string): Promise<void>;
191
+ deleteMany?(keys: string[]): Promise<void>;
192
+ getPresignedUploadUrl?(key: string, contentType: string, expiresIn?: number): Promise<string>;
193
+ getPresignedDownloadUrl?(key: string, expiresIn?: number): Promise<string>;
194
+ }
195
+ ```
196
+
197
+ ## Schema
198
+
199
+ Add the uploads table to your Drizzle migrations:
200
+
201
+ ```typescript
202
+ import { uploads } from '@everystack/storage/schema';
203
+ // Use in your Drizzle schema alongside your app tables
204
+ ```
205
+
206
+ The `uploads` table tracks: `id`, `key`, `bucket`, `contentType`, `size`, `originalName`, `metadata`, `userId`, `createdAt`.
207
+
208
+ ## Peer Dependencies
209
+
210
+ | Package | Version | Required |
211
+ |---------|---------|----------|
212
+ | `drizzle-orm` | `>=0.30.0` | Yes |
213
+ | `@aws-sdk/client-s3` | `>=3.0.0` | For S3 adapter |
214
+ | `@aws-sdk/s3-request-presigner` | `>=3.0.0` | For S3 presigned URLs |
215
+
216
+ ---
217
+
218
+ Part of [everystack](https://github.com/scalable-technology/everystack) — a self-hosted application stack for Expo apps on AWS.
219
+
220
+ ## License
221
+
222
+ MIT
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "@everystack/storage",
3
+ "version": "0.1.0",
4
+ "description": "File uploads via S3 + CloudFront CDN with presigned URLs",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "type": "module",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "default": "./src/index.ts"
18
+ },
19
+ "./schema": {
20
+ "types": "./src/schema.ts",
21
+ "default": "./src/schema.ts"
22
+ },
23
+ "./client": {
24
+ "types": "./src/client/index.ts",
25
+ "default": "./src/client/index.ts"
26
+ }
27
+ },
28
+ "peerDependencies": {
29
+ "drizzle-orm": ">=0.30.0",
30
+ "@aws-sdk/client-s3": ">=3.0.0",
31
+ "@aws-sdk/s3-request-presigner": ">=3.0.0",
32
+ "react": ">=18.0.0",
33
+ "react-native": ">=0.72.0"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "@aws-sdk/client-s3": {
37
+ "optional": true
38
+ },
39
+ "@aws-sdk/s3-request-presigner": {
40
+ "optional": true
41
+ },
42
+ "react": {
43
+ "optional": true
44
+ },
45
+ "react-native": {
46
+ "optional": true
47
+ }
48
+ },
49
+ "devDependencies": {
50
+ "@aws-sdk/client-s3": "^3.700.0",
51
+ "@aws-sdk/s3-request-presigner": "^3.700.0",
52
+ "@types/jest": "^29.5.14",
53
+ "@types/node": "^22.0.0",
54
+ "drizzle-orm": "^0.41.0",
55
+ "jest": "^29.7.0",
56
+ "ts-jest": "^29.3.0",
57
+ "typescript": "^5.7.0"
58
+ },
59
+ "scripts": {
60
+ "test": "NODE_OPTIONS='--experimental-vm-modules' jest",
61
+ "build": "tsc --build",
62
+ "lint": "tsc --noEmit"
63
+ }
64
+ }
@@ -0,0 +1,93 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import type { ObjectStorageAdapter } from '../types';
4
+
5
+ const MIME_MAP: Record<string, string> = {
6
+ '.jpg': 'image/jpeg',
7
+ '.jpeg': 'image/jpeg',
8
+ '.png': 'image/png',
9
+ '.gif': 'image/gif',
10
+ '.webp': 'image/webp',
11
+ '.avif': 'image/avif',
12
+ '.svg': 'image/svg+xml',
13
+ '.pdf': 'application/pdf',
14
+ '.json': 'application/json',
15
+ '.txt': 'text/plain',
16
+ '.html': 'text/html',
17
+ '.css': 'text/css',
18
+ '.js': 'application/javascript',
19
+ '.mp4': 'video/mp4',
20
+ '.mp3': 'audio/mpeg',
21
+ '.zip': 'application/zip',
22
+ };
23
+
24
+ function guessContentType(key: string): string {
25
+ const ext = path.extname(key).toLowerCase();
26
+ return MIME_MAP[ext] || 'application/octet-stream';
27
+ }
28
+
29
+ export class FilesystemObjectStorage implements ObjectStorageAdapter {
30
+ constructor(
31
+ private directory: string,
32
+ private baseUrl?: string
33
+ ) {}
34
+
35
+ private resolve(key: string): string {
36
+ return path.join(this.directory, key);
37
+ }
38
+
39
+ async put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void> {
40
+ const filePath = this.resolve(key);
41
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
42
+ await fs.writeFile(filePath, data);
43
+ }
44
+
45
+ async get(key: string): Promise<{ data: Buffer; contentType: string } | null> {
46
+ try {
47
+ const filePath = this.resolve(key);
48
+ const data = await fs.readFile(filePath);
49
+ const contentType = guessContentType(key);
50
+ return { data, contentType };
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ async exists(key: string): Promise<boolean> {
57
+ try {
58
+ await fs.access(this.resolve(key));
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ async list(prefix: string): Promise<string[]> {
66
+ const dir = this.resolve(prefix);
67
+ try {
68
+ const entries = await fs.readdir(dir, { recursive: true });
69
+ return entries.map((e) => path.join(prefix, String(e)));
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
75
+ async delete(key: string): Promise<void> {
76
+ try {
77
+ await fs.unlink(this.resolve(key));
78
+ } catch {
79
+ // Ignore if file doesn't exist
80
+ }
81
+ }
82
+
83
+ async getPresignedUploadUrl(key: string, contentType: string, expiresIn?: number): Promise<string> {
84
+ // Filesystem doesn't support real presigned URLs — return handler upload path
85
+ const base = this.baseUrl || '';
86
+ return `${base}/upload?key=${encodeURIComponent(key)}&contentType=${encodeURIComponent(contentType)}`;
87
+ }
88
+
89
+ async getPresignedDownloadUrl(key: string, expiresIn?: number): Promise<string> {
90
+ const base = this.baseUrl || '';
91
+ return `${base}/files/${encodeURIComponent(key)}`;
92
+ }
93
+ }
@@ -0,0 +1,10 @@
1
+ import type { ObjectStorageAdapter, ObjectStorageOptions } from '../types';
2
+
3
+ export function createObjectStorage(options: ObjectStorageOptions): ObjectStorageAdapter {
4
+ if (options.type === 'filesystem') {
5
+ const { FilesystemObjectStorage } = require('./filesystem') as typeof import('./filesystem');
6
+ return new FilesystemObjectStorage(options.directory, options.baseUrl);
7
+ }
8
+ const { S3ObjectStorage } = require('./s3') as typeof import('./s3');
9
+ return new S3ObjectStorage(options.bucket, options.region, options.endpoint, options.cdnUrl);
10
+ }
@@ -0,0 +1,190 @@
1
+ import type { ObjectStorageAdapter } from '../types';
2
+
3
+ let S3ClientClass: any;
4
+ let PutObjectCommand: any;
5
+ let GetObjectCommand: any;
6
+ let DeleteObjectCommand: any;
7
+ let DeleteObjectsCommand: any;
8
+ let ListObjectsV2Command: any;
9
+ let HeadObjectCommand: any;
10
+ let getSignedUrl: any;
11
+
12
+ async function loadS3(): Promise<void> {
13
+ if (S3ClientClass) return;
14
+ try {
15
+ const s3Mod = await import('@aws-sdk/client-s3');
16
+ S3ClientClass = s3Mod.S3Client;
17
+ PutObjectCommand = s3Mod.PutObjectCommand;
18
+ GetObjectCommand = s3Mod.GetObjectCommand;
19
+ DeleteObjectCommand = s3Mod.DeleteObjectCommand;
20
+ DeleteObjectsCommand = s3Mod.DeleteObjectsCommand;
21
+ ListObjectsV2Command = s3Mod.ListObjectsV2Command;
22
+ HeadObjectCommand = s3Mod.HeadObjectCommand;
23
+ } catch {
24
+ throw new Error(
25
+ '@aws-sdk/client-s3 is required for S3 storage. Install it: pnpm add @aws-sdk/client-s3'
26
+ );
27
+ }
28
+ }
29
+
30
+ async function loadPresigner(): Promise<void> {
31
+ if (getSignedUrl) return;
32
+ try {
33
+ const mod = await import('@aws-sdk/s3-request-presigner');
34
+ getSignedUrl = mod.getSignedUrl;
35
+ } catch {
36
+ throw new Error(
37
+ '@aws-sdk/s3-request-presigner is required for presigned URLs. Install it: pnpm add @aws-sdk/s3-request-presigner'
38
+ );
39
+ }
40
+ }
41
+
42
+ export class S3ObjectStorage implements ObjectStorageAdapter {
43
+ private client: any = null;
44
+ private bucket: string;
45
+ private region?: string;
46
+ private endpoint?: string;
47
+ private cdnUrl?: string;
48
+
49
+ constructor(bucket: string, region?: string, endpoint?: string, cdnUrl?: string) {
50
+ this.bucket = bucket;
51
+ this.region = region;
52
+ this.endpoint = endpoint;
53
+ this.cdnUrl = cdnUrl;
54
+ }
55
+
56
+ private async getClient(): Promise<any> {
57
+ if (!this.client) {
58
+ await loadS3();
59
+ this.client = new S3ClientClass({
60
+ region: this.region,
61
+ ...(this.endpoint && {
62
+ endpoint: this.endpoint,
63
+ forcePathStyle: true,
64
+ }),
65
+ });
66
+ }
67
+ return this.client;
68
+ }
69
+
70
+ async put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void> {
71
+ const client = await this.getClient();
72
+ await client.send(
73
+ new PutObjectCommand({
74
+ Bucket: this.bucket,
75
+ Key: key,
76
+ Body: data,
77
+ ContentType: contentType || 'application/octet-stream',
78
+ })
79
+ );
80
+ }
81
+
82
+ async get(key: string): Promise<{ data: Buffer; contentType: string } | null> {
83
+ const client = await this.getClient();
84
+ try {
85
+ const response = await client.send(
86
+ new GetObjectCommand({ Bucket: this.bucket, Key: key })
87
+ );
88
+ const chunks: Uint8Array[] = [];
89
+ for await (const chunk of response.Body) {
90
+ chunks.push(chunk);
91
+ }
92
+ return {
93
+ data: Buffer.concat(chunks),
94
+ contentType: response.ContentType || 'application/octet-stream',
95
+ };
96
+ } catch (err: any) {
97
+ if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) {
98
+ return null;
99
+ }
100
+ throw err;
101
+ }
102
+ }
103
+
104
+ async exists(key: string): Promise<boolean> {
105
+ const client = await this.getClient();
106
+ try {
107
+ await client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ async list(prefix: string): Promise<string[]> {
115
+ const client = await this.getClient();
116
+ const keys: string[] = [];
117
+ let continuationToken: string | undefined;
118
+
119
+ do {
120
+ const response = await client.send(
121
+ new ListObjectsV2Command({
122
+ Bucket: this.bucket,
123
+ Prefix: prefix,
124
+ ContinuationToken: continuationToken,
125
+ })
126
+ );
127
+
128
+ if (response.Contents) {
129
+ for (const obj of response.Contents) {
130
+ if (obj.Key) keys.push(obj.Key);
131
+ }
132
+ }
133
+
134
+ continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined;
135
+ } while (continuationToken);
136
+
137
+ return keys;
138
+ }
139
+
140
+ async delete(key: string): Promise<void> {
141
+ const client = await this.getClient();
142
+ await client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
143
+ }
144
+
145
+ async deleteMany(keys: string[]): Promise<void> {
146
+ if (keys.length === 0) return;
147
+ const client = await this.getClient();
148
+
149
+ // S3 DeleteObjects supports up to 1000 keys per request
150
+ for (let i = 0; i < keys.length; i += 1000) {
151
+ const batch = keys.slice(i, i + 1000);
152
+ await client.send(
153
+ new DeleteObjectsCommand({
154
+ Bucket: this.bucket,
155
+ Delete: {
156
+ Objects: batch.map((Key: string) => ({ Key })),
157
+ Quiet: true,
158
+ },
159
+ })
160
+ );
161
+ }
162
+ }
163
+
164
+ async getPresignedUploadUrl(key: string, contentType: string, expiresIn: number = 3600): Promise<string> {
165
+ const client = await this.getClient();
166
+ await loadPresigner();
167
+ return getSignedUrl(
168
+ client,
169
+ new PutObjectCommand({
170
+ Bucket: this.bucket,
171
+ Key: key,
172
+ ContentType: contentType,
173
+ }),
174
+ { expiresIn }
175
+ );
176
+ }
177
+
178
+ async getPresignedDownloadUrl(key: string, expiresIn: number = 3600): Promise<string> {
179
+ if (this.cdnUrl) {
180
+ return `${this.cdnUrl}/${key}`;
181
+ }
182
+ const client = await this.getClient();
183
+ await loadPresigner();
184
+ return getSignedUrl(
185
+ client,
186
+ new GetObjectCommand({ Bucket: this.bucket, Key: key }),
187
+ { expiresIn }
188
+ );
189
+ }
190
+ }
@@ -0,0 +1,258 @@
1
+ import type { UploadMetadata } from '../types';
2
+
3
+ export interface StorageClientOptions {
4
+ baseUrl: string;
5
+ getToken?: () => string | null | Promise<string | null>;
6
+ }
7
+
8
+ export interface UploadProgress {
9
+ loaded: number;
10
+ total: number;
11
+ percentage: number;
12
+ }
13
+
14
+ export interface UploadResult {
15
+ id: string;
16
+ key: string;
17
+ }
18
+
19
+ export function useUpload(clientOptions: StorageClientOptions) {
20
+ let React: any;
21
+ try {
22
+ React = require('react');
23
+ } catch {
24
+ throw new Error('react is required for useUpload hook');
25
+ }
26
+
27
+ const { useState, useCallback, useRef } = React;
28
+ const [progress, setProgress] = useState(null as UploadProgress | null);
29
+ const [isUploading, setIsUploading] = useState(false);
30
+ const [error, setError] = useState(null as Error | null);
31
+ const [result, setResult] = useState(null as UploadResult | null);
32
+ const abortRef = useRef(null as (() => void) | null);
33
+
34
+ const cancel = useCallback(() => {
35
+ abortRef.current?.();
36
+ abortRef.current = null;
37
+ }, []);
38
+
39
+ const upload = useCallback(async (file: File) => {
40
+ setIsUploading(true);
41
+ setError(null);
42
+ setProgress(null);
43
+ abortRef.current = null;
44
+
45
+ try {
46
+ const headers: Record<string, string> = {};
47
+ if (clientOptions.getToken) {
48
+ const token = await clientOptions.getToken();
49
+ if (token) headers.Authorization = `Bearer ${token}`;
50
+ }
51
+
52
+ // Try presigned URL first
53
+ const presignRes = await fetch(`${clientOptions.baseUrl}/presign`, {
54
+ method: 'POST',
55
+ headers: { ...headers, 'content-type': 'application/json' },
56
+ body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
57
+ });
58
+
59
+ if (presignRes.ok) {
60
+ const { url, key } = await presignRes.json() as { url: string; key: string };
61
+
62
+ // Upload via XHR for real progress tracking
63
+ await new Promise<void>((resolve, reject) => {
64
+ const xhr = new XMLHttpRequest();
65
+ xhr.upload.onprogress = (e: ProgressEvent) => {
66
+ if (e.lengthComputable) {
67
+ setProgress({
68
+ loaded: e.loaded,
69
+ total: e.total,
70
+ percentage: Math.round((e.loaded / e.total) * 100),
71
+ });
72
+ }
73
+ };
74
+ xhr.onload = () => {
75
+ if (xhr.status >= 200 && xhr.status < 300) {
76
+ resolve();
77
+ } else {
78
+ reject(new Error(`Upload failed: ${xhr.status}`));
79
+ }
80
+ };
81
+ xhr.onerror = () => reject(new Error('Upload failed'));
82
+ xhr.onabort = () => reject(new Error('Upload cancelled'));
83
+ xhr.open('PUT', url);
84
+ xhr.setRequestHeader('content-type', file.type);
85
+ abortRef.current = () => xhr.abort();
86
+ xhr.send(file);
87
+ });
88
+
89
+ setProgress({ loaded: file.size, total: file.size, percentage: 100 });
90
+ const uploadResult = { id: key, key };
91
+ setResult(uploadResult);
92
+ return uploadResult;
93
+ }
94
+
95
+ // Fallback to multipart upload
96
+ const formData = new FormData();
97
+ formData.append('file', file);
98
+
99
+ const uploadRes = await fetch(`${clientOptions.baseUrl}/upload`, {
100
+ method: 'POST',
101
+ headers,
102
+ body: formData,
103
+ });
104
+
105
+ if (!uploadRes.ok) {
106
+ const err = await uploadRes.json().catch(() => ({ error: 'Upload failed' }));
107
+ throw new Error((err as any).error || 'Upload failed');
108
+ }
109
+
110
+ const uploadResult = await uploadRes.json() as UploadResult;
111
+ setProgress({ loaded: file.size, total: file.size, percentage: 100 });
112
+ setResult(uploadResult);
113
+ return uploadResult;
114
+ } catch (err) {
115
+ const error = err instanceof Error ? err : new Error(String(err));
116
+ setError(error);
117
+ throw error;
118
+ } finally {
119
+ setIsUploading(false);
120
+ abortRef.current = null;
121
+ }
122
+ }, [clientOptions.baseUrl, clientOptions.getToken]);
123
+
124
+ return { upload, cancel, progress, isUploading, error, result };
125
+ }
126
+
127
+ export function usePresignedUrl(clientOptions: StorageClientOptions, key: string | null) {
128
+ let React: any;
129
+ try {
130
+ React = require('react');
131
+ } catch {
132
+ throw new Error('react is required for usePresignedUrl hook');
133
+ }
134
+
135
+ const { useState, useEffect } = React;
136
+ const [url, setUrl] = useState(null as string | null);
137
+ const [loading, setLoading] = useState(false);
138
+
139
+ useEffect(() => {
140
+ if (!key) return;
141
+ let cancelled = false;
142
+
143
+ async function load() {
144
+ setLoading(true);
145
+ try {
146
+ const fileUrl = `${clientOptions.baseUrl}/files/${encodeURIComponent(key!)}`;
147
+ if (!cancelled) {
148
+ setUrl(fileUrl);
149
+ setLoading(false);
150
+ }
151
+ } catch {
152
+ if (!cancelled) setLoading(false);
153
+ }
154
+ }
155
+
156
+ load();
157
+ return () => { cancelled = true; };
158
+ }, [clientOptions.baseUrl, key]);
159
+
160
+ return { url, loading };
161
+ }
162
+
163
+ export function useUploads(clientOptions: StorageClientOptions, options?: { limit?: number; offset?: number }) {
164
+ let React: any;
165
+ try {
166
+ React = require('react');
167
+ } catch {
168
+ throw new Error('react is required for useUploads hook');
169
+ }
170
+
171
+ const { useState, useEffect } = React;
172
+ const [data, setData] = useState([] as UploadMetadata[]);
173
+ const [loading, setLoading] = useState(true);
174
+ const [error, setError] = useState(null as Error | null);
175
+
176
+ useEffect(() => {
177
+ let cancelled = false;
178
+
179
+ async function load() {
180
+ try {
181
+ const params = new URLSearchParams();
182
+ if (options?.limit) params.set('limit', String(options.limit));
183
+ if (options?.offset) params.set('offset', String(options.offset));
184
+
185
+ const query = params.toString();
186
+ const url = `${clientOptions.baseUrl}/uploads${query ? `?${query}` : ''}`;
187
+
188
+ const headers: Record<string, string> = {};
189
+ if (clientOptions.getToken) {
190
+ const token = await clientOptions.getToken();
191
+ if (token) headers.Authorization = `Bearer ${token}`;
192
+ }
193
+
194
+ const res = await fetch(url, { headers });
195
+ if (!res.ok) throw new Error('Failed to fetch uploads');
196
+
197
+ const result = await res.json();
198
+ if (!cancelled) {
199
+ setData(result as UploadMetadata[]);
200
+ setLoading(false);
201
+ }
202
+ } catch (err) {
203
+ if (!cancelled) {
204
+ setError(err instanceof Error ? err : new Error(String(err)));
205
+ setLoading(false);
206
+ }
207
+ }
208
+ }
209
+
210
+ load();
211
+ return () => { cancelled = true; };
212
+ }, [clientOptions.baseUrl, options?.limit, options?.offset]);
213
+
214
+ return { data, loading, error };
215
+ }
216
+
217
+ // Image URL builder — constructs CloudFront image Lambda URLs with transform params.
218
+ // Mirrors the contract from the Image Lambda's parseParams function.
219
+ export interface ImageUrlOptions {
220
+ width?: number;
221
+ height?: number;
222
+ fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
223
+ format?: 'webp' | 'avif' | 'jpeg' | 'png';
224
+ quality?: number;
225
+ dpr?: number;
226
+ }
227
+
228
+ export function useImageUrl(baseUrl: string, key: string | null, options?: ImageUrlOptions): string | null {
229
+ let React: any;
230
+ try {
231
+ React = require('react');
232
+ } catch {
233
+ throw new Error('react is required for useImageUrl hook');
234
+ }
235
+
236
+ const { useMemo } = React;
237
+
238
+ return useMemo(() => {
239
+ if (!key) return null;
240
+
241
+ const base = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
242
+ const encodedKey = key.split('/').map(encodeURIComponent).join('/');
243
+ const url = `${base}/media/${encodedKey}`;
244
+
245
+ if (!options) return url;
246
+
247
+ const params = new URLSearchParams();
248
+ if (options.width != null) params.set('w', String(Math.min(Math.max(1, Math.round(options.width)), 4096)));
249
+ if (options.height != null) params.set('h', String(Math.min(Math.max(1, Math.round(options.height)), 4096)));
250
+ if (options.fit) params.set('fit', options.fit);
251
+ if (options.format) params.set('fm', options.format);
252
+ if (options.quality != null) params.set('q', String(Math.min(Math.max(1, Math.round(options.quality)), 100)));
253
+ if (options.dpr != null) params.set('dpr', String(Math.min(Math.max(1, options.dpr), 3)));
254
+
255
+ const qs = params.toString();
256
+ return qs ? `${url}?${qs}` : url;
257
+ }, [baseUrl, key, options?.width, options?.height, options?.fit, options?.format, options?.quality, options?.dpr]);
258
+ }
@@ -0,0 +1,213 @@
1
+ import { sql } from 'drizzle-orm';
2
+ import { blobs } from '../schema';
3
+ import { validateUpload } from '../validation';
4
+ import { defaultKeyGenerator } from '../key-generator';
5
+ import type { StorageHandlerOptions } from '../types';
6
+
7
+ /**
8
+ * Compute SHA-256 hash of a buffer. Returns hex string.
9
+ */
10
+ async function computeSha256(data: ArrayBuffer | Buffer | Uint8Array): Promise<string> {
11
+ const hashBuffer = await crypto.subtle.digest('SHA-256', new Uint8Array(data as ArrayBuffer));
12
+ return Array.from(new Uint8Array(hashBuffer))
13
+ .map((b) => b.toString(16).padStart(2, '0'))
14
+ .join('');
15
+ }
16
+
17
+ export function createStorageHandler(options: StorageHandlerOptions): (request: Request) => Promise<Response> {
18
+ const basePath = options.basePath || '';
19
+ const { db, storage, auth, validation, publishJob } = options;
20
+ const keyGenerator = options.keyGenerator || defaultKeyGenerator;
21
+ const bucket = options.bucket || 'default';
22
+
23
+ function json(data: unknown, status: number = 200): Response {
24
+ return new Response(JSON.stringify(data), {
25
+ status,
26
+ headers: { 'content-type': 'application/json' },
27
+ });
28
+ }
29
+
30
+ async function verifyAuth(request: Request): Promise<Record<string, unknown> | null> {
31
+ if (!auth) return {};
32
+ const header = request.headers.get('authorization');
33
+ if (!header?.startsWith('Bearer ')) return null;
34
+ return auth.verifyToken(header.slice(7));
35
+ }
36
+
37
+ return async (request: Request): Promise<Response> => {
38
+ const url = new URL(request.url);
39
+ let pathname = url.pathname;
40
+
41
+ if (basePath && pathname.startsWith(basePath)) {
42
+ pathname = pathname.slice(basePath.length) || '/';
43
+ }
44
+
45
+ // POST /presign — generate presigned upload URL
46
+ if (request.method === 'POST' && pathname === '/presign') {
47
+ const user = await verifyAuth(request);
48
+ if (!user) return json({ error: 'Unauthorized' }, 401);
49
+
50
+ const body = (await request.json()) as { name: string; type: string; size: number };
51
+ if (!body.name || !body.type) {
52
+ return json({ error: 'Missing name or type' }, 400);
53
+ }
54
+
55
+ if (validation) {
56
+ const result = validateUpload(body, validation);
57
+ if (!result.valid) return json({ error: result.error }, 400);
58
+ }
59
+
60
+ const key = keyGenerator({
61
+ name: body.name,
62
+ type: body.type,
63
+ userId: user.sub as string | undefined,
64
+ });
65
+
66
+ if (storage.getPresignedUploadUrl) {
67
+ const uploadUrl = await storage.getPresignedUploadUrl(key, body.type);
68
+ return json({ url: uploadUrl, key });
69
+ }
70
+
71
+ // Fallback: return direct upload URL
72
+ const uploadUrl = `${basePath}/upload?key=${encodeURIComponent(key)}`;
73
+ return json({ url: uploadUrl, key });
74
+ }
75
+
76
+ // POST /upload — direct multipart upload
77
+ if (request.method === 'POST' && pathname === '/upload') {
78
+ const user = await verifyAuth(request);
79
+ if (!user) return json({ error: 'Unauthorized' }, 401);
80
+
81
+ let formData: FormData;
82
+ try {
83
+ formData = await request.formData();
84
+ } catch {
85
+ return json({ error: 'Invalid multipart form data' }, 400);
86
+ }
87
+
88
+ const file = formData.get('file') as File | null;
89
+ if (!file) return json({ error: 'Missing file field' }, 400);
90
+
91
+ if (validation) {
92
+ const result = validateUpload(
93
+ { name: file.name, type: file.type, size: file.size },
94
+ validation
95
+ );
96
+ if (!result.valid) return json({ error: result.error }, 400);
97
+ }
98
+
99
+ const key = keyGenerator({
100
+ name: file.name,
101
+ type: file.type,
102
+ userId: user.sub as string | undefined,
103
+ });
104
+
105
+ const buffer = Buffer.from(await file.arrayBuffer());
106
+ await storage.put(key, buffer, file.type);
107
+
108
+ // Compute content hash for dedup
109
+ const sha256 = await computeSha256(buffer);
110
+
111
+ // Write metadata to DB
112
+ const result = await db
113
+ .insert(blobs)
114
+ .values({
115
+ type: 'original',
116
+ sha256,
117
+ s3Key: key,
118
+ bucket,
119
+ sizeBytes: file.size,
120
+ contentType: file.type,
121
+ metadata: {
122
+ originalName: file.name,
123
+ userId: (user.sub as string) || null,
124
+ },
125
+ })
126
+ .returning({ id: blobs.id });
127
+
128
+ const uploadId = result[0].id;
129
+
130
+ // Publish image processing job if it's an image
131
+ if (publishJob && file.type.startsWith('image/')) {
132
+ await publishJob('process-image', { key, uploadId });
133
+ }
134
+
135
+ return json({ id: uploadId, key }, 201);
136
+ }
137
+
138
+ // GET /files/:key — serve file
139
+ const filesMatch = pathname.match(/^\/files\/(.+)$/);
140
+ if (request.method === 'GET' && filesMatch) {
141
+ const key = decodeURIComponent(filesMatch[1]);
142
+
143
+ // If adapter supports presigned download URLs, redirect
144
+ if (storage.getPresignedDownloadUrl) {
145
+ const downloadUrl = await storage.getPresignedDownloadUrl(key);
146
+ return Response.redirect(downloadUrl, 302);
147
+ }
148
+
149
+ const file = await storage.get(key);
150
+ if (!file) return json({ error: 'File not found' }, 404);
151
+
152
+ return new Response(new Uint8Array(file.data), {
153
+ headers: {
154
+ 'content-type': file.contentType,
155
+ 'cache-control': 'public, max-age=86400',
156
+ },
157
+ });
158
+ }
159
+
160
+ // GET /uploads — list upload metadata
161
+ if (request.method === 'GET' && pathname === '/uploads') {
162
+ const user = await verifyAuth(request);
163
+ if (!user) return json({ error: 'Unauthorized' }, 401);
164
+
165
+ const limit = url.searchParams.has('limit') ? parseInt(url.searchParams.get('limit')!, 10) : 50;
166
+ const offset = url.searchParams.has('offset') ? parseInt(url.searchParams.get('offset')!, 10) : 0;
167
+
168
+ let query = db.select().from(blobs);
169
+
170
+ if (user.sub) {
171
+ query = query.where(sql`${blobs.metadata}->>'userId' = ${user.sub}`);
172
+ }
173
+
174
+ query = query.orderBy(sql`${blobs.createdAt} DESC`).limit(limit).offset(offset);
175
+
176
+ const result = await query;
177
+ return json(result);
178
+ }
179
+
180
+ // GET /uploads/:id — get upload metadata
181
+ const uploadMatch = pathname.match(/^\/uploads\/([^/]+)$/);
182
+ if (request.method === 'GET' && uploadMatch) {
183
+ const result = await db
184
+ .select()
185
+ .from(blobs)
186
+ .where(sql`${blobs.id} = ${uploadMatch[1]}`);
187
+
188
+ if (!result[0]) return json({ error: 'Upload not found' }, 404);
189
+ return json(result[0]);
190
+ }
191
+
192
+ // DELETE /uploads/:id — delete upload + file
193
+ const deleteMatch = pathname.match(/^\/uploads\/([^/]+)$/);
194
+ if (request.method === 'DELETE' && deleteMatch) {
195
+ const user = await verifyAuth(request);
196
+ if (!user) return json({ error: 'Unauthorized' }, 401);
197
+
198
+ const result = await db
199
+ .select()
200
+ .from(blobs)
201
+ .where(sql`${blobs.id} = ${deleteMatch[1]}`);
202
+
203
+ if (!result[0]) return json({ error: 'Upload not found' }, 404);
204
+
205
+ await storage.delete(result[0].s3Key!);
206
+ await db.delete(blobs).where(sql`${blobs.id} = ${deleteMatch[1]}`);
207
+
208
+ return json({ ok: true });
209
+ }
210
+
211
+ return json({ error: 'Not found' }, 404);
212
+ };
213
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { createStorageHandler } from './handler/index';
2
+ export { createObjectStorage } from './adapters/index';
3
+ export { FilesystemObjectStorage } from './adapters/filesystem';
4
+ export { S3ObjectStorage } from './adapters/s3';
5
+ export { validateUpload } from './validation';
6
+ export { defaultKeyGenerator } from './key-generator';
7
+ export type {
8
+ ObjectStorageAdapter,
9
+ ObjectStorageOptions,
10
+ StorageHandlerOptions,
11
+ UploadMetadata,
12
+ UploadValidation,
13
+ StorageType,
14
+ } from './types';
15
+ export type { ValidationResult } from './validation';
@@ -0,0 +1,10 @@
1
+ export function defaultKeyGenerator(file: { name: string; type: string; userId?: string }): string {
2
+ const now = new Date();
3
+ const year = now.getFullYear();
4
+ const month = String(now.getMonth() + 1).padStart(2, '0');
5
+ const uuid = crypto.randomUUID();
6
+ const parts = file.name.split('.');
7
+ const ext = parts.length > 1 ? parts.pop()!.toLowerCase() : 'bin';
8
+
9
+ return `uploads/${year}/${month}/${uuid}.${ext}`;
10
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Storage schema — everyschema dist.blobs
3
+ *
4
+ * Content-addressed storage — in-DB (bytea) or S3-backed.
5
+ * Replaces the old `uploads` table.
6
+ *
7
+ * Type values for everystack:
8
+ * 'original' — user-uploaded content (S3-backed)
9
+ * 'thumbnail' — derived image variant (parentId → original)
10
+ * 'preview' — derived preview image
11
+ * 'webp' — derived WebP variant
12
+ *
13
+ * @see everyschema — canonical schema definitions
14
+ */
15
+
16
+ import {
17
+ pgSchema,
18
+ text,
19
+ timestamp,
20
+ uuid,
21
+ bigint,
22
+ index,
23
+ uniqueIndex,
24
+ jsonb,
25
+ customType,
26
+ } from 'drizzle-orm/pg-core';
27
+
28
+ const distSchema = pgSchema('dist');
29
+
30
+ const bytea = customType<{ data: Buffer; driverValue: Buffer }>({
31
+ dataType() {
32
+ return 'bytea';
33
+ },
34
+ });
35
+
36
+ // =============================================================================
37
+ // BLOBS - Raw binary storage, deduped by sha256
38
+ // =============================================================================
39
+
40
+ /**
41
+ * Content-addressed storage — in-DB or S3-backed.
42
+ *
43
+ * Storage modes:
44
+ * In-DB: data (bytea) populated, s3Key/bucket NULL
45
+ * S3: s3Key + bucket populated, data NULL
46
+ *
47
+ * Derived content uses parentId:
48
+ * original → thumbnail, preview, webp, etc.
49
+ *
50
+ * metadata stores intrinsic content properties:
51
+ * Images: { width, height, format, originalName, userId }
52
+ * Video/audio: { codec, duration, bitrate }
53
+ */
54
+ export const blobs = distSchema.table(
55
+ 'blobs',
56
+ {
57
+ id: uuid('id').primaryKey().defaultRandom(),
58
+ repositoryId: uuid('repository_id'),
59
+ type: text('type').notNull(), // 'original' | 'thumbnail' | 'preview' | 'webp' | etc.
60
+ sha256: text('sha256').notNull().unique(),
61
+
62
+ // Storage: in-DB (bytea) or S3-backed (s3Key + bucket)
63
+ data: bytea('data'),
64
+ s3Key: text('s3_key'),
65
+ bucket: text('bucket'),
66
+
67
+ sizeBytes: bigint('size_bytes', { mode: 'number' }).notNull(),
68
+ contentType: text('content_type'),
69
+
70
+ // Derived content (thumbnail → original, webp → original, etc.)
71
+ parentId: uuid('parent_id'),
72
+
73
+ // Intrinsic content metadata
74
+ metadata: jsonb('metadata'),
75
+
76
+ createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
77
+ },
78
+ (table) => ({
79
+ typeIdx: index('blobs_type_idx').on(table.type),
80
+ sha256Idx: uniqueIndex('blobs_sha256_idx').on(table.sha256),
81
+ contentTypeIdx: index('blobs_content_type_idx').on(table.contentType),
82
+ createdAtIdx: index('blobs_created_at_idx').on(table.createdAt),
83
+ repoIdx: index('blobs_repo_idx').on(table.repositoryId),
84
+ parentIdx: index('blobs_parent_idx').on(table.parentId),
85
+ s3KeyIdx: index('blobs_s3_key_idx').on(table.s3Key),
86
+ })
87
+ );
package/src/types.ts ADDED
@@ -0,0 +1,48 @@
1
+ export interface ObjectStorageAdapter {
2
+ put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
3
+ get(key: string): Promise<{ data: Buffer; contentType: string } | null>;
4
+ exists(key: string): Promise<boolean>;
5
+ list(prefix: string): Promise<string[]>;
6
+ delete(key: string): Promise<void>;
7
+ /** Batch delete up to 1000 keys per call. Falls back to sequential delete if not implemented. */
8
+ deleteMany?(keys: string[]): Promise<void>;
9
+ getPresignedUploadUrl?(key: string, contentType: string, expiresIn?: number): Promise<string>;
10
+ getPresignedDownloadUrl?(key: string, expiresIn?: number): Promise<string>;
11
+ }
12
+
13
+ export interface UploadMetadata {
14
+ id: string;
15
+ key: string;
16
+ bucket: string;
17
+ contentType: string;
18
+ size: number;
19
+ originalName: string | null;
20
+ metadata: Record<string, unknown> | null;
21
+ userId: string | null;
22
+ createdAt: Date;
23
+ }
24
+
25
+ export interface UploadValidation {
26
+ maxSize?: number;
27
+ allowedTypes?: string[];
28
+ allowedExtensions?: string[];
29
+ }
30
+
31
+ export type StorageType = 'filesystem' | 's3';
32
+
33
+ export type ObjectStorageOptions =
34
+ | { type: 'filesystem'; directory: string; baseUrl?: string }
35
+ | { type: 's3'; bucket: string; region?: string; endpoint?: string; cdnUrl?: string };
36
+
37
+ export interface StorageHandlerOptions {
38
+ db: any;
39
+ storage: ObjectStorageAdapter;
40
+ basePath?: string;
41
+ bucket?: string;
42
+ auth?: {
43
+ verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
44
+ };
45
+ validation?: UploadValidation;
46
+ keyGenerator?: (file: { name: string; type: string; userId?: string }) => string;
47
+ publishJob?: (type: string, payload: unknown) => Promise<string>;
48
+ }
@@ -0,0 +1,39 @@
1
+ import type { UploadValidation } from './types';
2
+
3
+ export interface ValidationResult {
4
+ valid: boolean;
5
+ error?: string;
6
+ }
7
+
8
+ export function validateUpload(
9
+ file: { name: string; type: string; size: number },
10
+ rules: UploadValidation
11
+ ): ValidationResult {
12
+ if (rules.maxSize && file.size > rules.maxSize) {
13
+ return {
14
+ valid: false,
15
+ error: `File size ${file.size} exceeds maximum ${rules.maxSize} bytes`,
16
+ };
17
+ }
18
+
19
+ if (rules.allowedTypes && rules.allowedTypes.length > 0) {
20
+ if (!rules.allowedTypes.includes(file.type)) {
21
+ return {
22
+ valid: false,
23
+ error: `File type ${file.type} is not allowed. Allowed: ${rules.allowedTypes.join(', ')}`,
24
+ };
25
+ }
26
+ }
27
+
28
+ if (rules.allowedExtensions && rules.allowedExtensions.length > 0) {
29
+ const ext = file.name.split('.').pop()?.toLowerCase();
30
+ if (!ext || !rules.allowedExtensions.includes(ext)) {
31
+ return {
32
+ valid: false,
33
+ error: `File extension .${ext || ''} is not allowed. Allowed: ${rules.allowedExtensions.map((e) => `.${e}`).join(', ')}`,
34
+ };
35
+ }
36
+ }
37
+
38
+ return { valid: true };
39
+ }