LumenJS provides a unified file storage abstraction with adapters for local disk (development) and S3-compatible APIs: AWS S3, Cloudflare R2, and MinIO. Configure once, use everywhere via req.storage in API routes and the file:request-upload socket event in chat.
./uploads, served at /uploads/*. Zero config. Automatic in dev.The storage module ships with LumenJS. S3-compatible adapters require two optional peer dependencies:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
vite-plugin-storage plugin initialises a LocalStorageAdapter automatically and makes it available as req.storage in every API route.
Call setStorage() once in a _middleware.ts (or any startup file) to configure the global adapter:
// pages/_middleware.ts import { setStorage, createStorage } from '@nuraly/lumenjs/dist/storage/index.js'; setStorage(createStorage({ provider: 'local', // dev default - automatic, no extra setup }));
For production, switch to an S3-compatible provider:
// AWS S3 setStorage(createStorage({ provider: 's3', bucket: 'my-bucket', region: 'us-east-1', accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, publicBaseUrl: 'https://cdn.example.com', // optional CDN }));
R2 is S3-compatible with zero egress fees. Use a custom endpoint pointing to your account's R2 API:
setStorage(createStorage({ provider: 's3', endpoint: 'https://<ACCOUNT_ID>.r2.cloudflarestorage.com', bucket: 'my-bucket', region: 'auto', accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, publicBaseUrl: 'https://pub-<hash>.r2.dev', // R2 public bucket URL }));
region value must be 'auto' for R2.
setStorage(createStorage({ provider: 's3', endpoint: 'https://minio.example.com', bucket: 'my-bucket', region: 'us-east-1', // any value works for MinIO accessKeyId: process.env.MINIO_KEY!, secretAccessKey: process.env.MINIO_SECRET!, }));
When endpoint is set, the adapter automatically enables forcePathStyle: true (required by MinIO) and constructs public URLs as {endpoint}/{bucket}/{key}.
You can omit accessKeyId/secretAccessKey from config and set them via environment variables instead:
| Env var | Maps to |
|---|---|
LUMENJS_S3_ACCESS_KEY | accessKeyId |
LUMENJS_S3_SECRET_KEY | secretAccessKey |
Access the configured adapter via req.storage in any API route handler:
// api/upload.ts export async function POST(req) { if (!req.storage) throw { status: 503, message: 'Storage not configured' }; const file = req.files[0]; // from multipart/form-data const stored = await req.storage.put(file.data, { mimeType: file.contentType, fileName: file.fileName, acl: 'public-read', }); // stored → { key, url, size, mimeType, fileName } return { url: stored.url }; }
For large files or E2E-encrypted chat attachments, generate a short-lived presigned PUT URL and let the client upload directly to storage. The server never touches the file bytes:
// api/upload-url.ts export async function POST(req) { if (!req.storage) throw { status: 503, message: 'Storage not configured' }; const { key, mimeType } = req.body; return req.storage.presignPut(key, { mimeType, expiresIn: 300, // 5 minutes maxSize: 25_000_000, // 25 MB }); // returns { uploadUrl, key, expiresAt } }
On the client, PUT the file directly to the signed URL. No server proxy needed:
const { uploadUrl } = await fetch('/api/upload-url', { method: 'POST', body: JSON.stringify({ key: 'photos/cat.jpg', mimeType: 'image/jpeg' }), }).then(r => r.json()); await fetch(uploadUrl, { method: 'PUT', body: fileBlob });
Generate a temporary read URL for a private file:
// api/files/[key].ts export async function GET(req) { if (!req.storage) throw { status: 503, message: 'Storage not configured' }; const url = await req.storage.presignGet(req.params.key, { expiresIn: 3600 }); return { url }; }
await req.storage.delete('photos/old-avatar.jpg');
When the Communication module has a storage adapter configured, clients can request presigned upload URLs via file:request-upload. The flow keeps plaintext file content off the server entirely:
file:request-upload with file metadatastorage.presignPut()file:upload-ready: a presigned PUT URL// Server - pages/chat.ts import { createCommunicationHandler } from '@nuraly/lumenjs/dist/communication/server.js'; import { useStorage } from '@nuraly/lumenjs/dist/storage/index.js'; export const socket = createCommunicationHandler({ storage: useStorage() ?? undefined, config: { fileUpload: { maxFileSize: 25_000_000, maxAttachmentsPerMessage: 10, allowedMimeTypes: [ 'image/jpeg', 'image/png', 'image/webp', 'application/pdf', ], }, }, });
// Client socket.emit('file:request-upload', { conversationId: 'conv-id', mimeType: 'image/jpeg', size: 500_000, fileName: 'photo.jpg', }); socket.on('file:upload-ready', async ({ uploadUrl, key, fileId }) => { // 1. Encrypt file const { encryptedBlob, fileKey, fileIv } = await encryptFile(file); // 2. Upload encrypted blob directly to storage await fetch(uploadUrl, { method: 'PUT', body: encryptedBlob }); // 3. Send message - file key travels inside the Signal envelope socket.emit('message:send', { conversationId: 'conv-id', content: '', type: 'image', encrypted: true, envelope: signalEncrypt({ fileKey, fileIv, storageKey: key, fileId }), attachment: { url: key, mimeType: 'image/jpeg', fileSize: 500_000, fileName: 'photo.jpg' }, }); }); socket.on('file:upload-error', ({ code, message }) => { // FILE_TOO_LARGE | MIME_TYPE_NOT_ALLOWED | STORAGE_NOT_CONFIGURED console.error(code, message); });
| Method | Description |
|---|---|
put(data, opts?) | Upload a Buffer server-side. Returns { key, url, size, mimeType, fileName }. |
delete(key) | Remove a file by its storage key. |
presignPut(key, opts?) | Generate a short-lived PUT URL for direct client upload. Returns { uploadUrl, key, expiresAt }. |
presignGet(key, opts?) | Generate a short-lived GET URL for a private file. |
publicUrl(key) | Return the permanent public URL (public-read files only). |
| Field | Type | Notes |
|---|---|---|
provider | 'local' | 's3' | Required. |
bucket | string | S3 only. Required. |
region | string | S3 only. Use 'auto' for R2. |
accessKeyId | string | S3. Falls back to LUMENJS_S3_ACCESS_KEY env var. |
secretAccessKey | string | S3. Falls back to LUMENJS_S3_SECRET_KEY env var. |
endpoint | string | S3. Custom API URL for R2 / MinIO. Enables forcePathStyle automatically. |
publicBaseUrl | string | S3. CDN or public bucket base URL for publicUrl(). |
uploadDir | string | Local. Directory for uploaded files. Default: './uploads'. |
publicPath | string | Local. URL prefix for serving files. Default: '/uploads'. |