@jamx-framework/storage
v1.0.0
Published
JAMX Framework — File storage with local and S3-compatible drivers
Maintainers
Readme
@jamx-framework/storage
Descripción
Sistema de almacenamiento de archivos para JAMX Framework. Proporciona una API unificada para guardar, recuperar, eliminar y gestionar archivos con múltiples backends: almacenamiento local (filesystem) y S3-compatible (AWS S3, MinIO, Cloudflare R2, etc.). Soporta metadatos, URLs públicas, listado de archivos y operaciones de copia/movimiento.
Cómo funciona
El módulo implementa un patrón de driver:
- Storage: Clase principal que delega operaciones al driver configurado
- Drivers: Implementaciones concretas (
LocalDriver,S3Driver) que definen la persistencia - StorageFile: Objeto que representa un archivo con metadatos (key, size, contentType, etc.)
Componentes principales
Storage (src/storage.ts)
Clase principal que proporciona la API unificada:
put(key, data, options?): Sube un archivoget(key): Descarga un archivodelete(key): Elimina un archivoexists(key): Verifica existenciastat(key): Obtiene metadatoslist(prefix?): Lista archivosurl(key): Obtiene URL pública (si aplica)copy(source, dest): Copia archivomove(source, dest): Mueve archivo
Drivers
LocalDriver (src/drivers/local.ts)
Almacena archivos en el filesystem local:
- Config:
root(directorio base),baseUrl(URL pública opcional) - Los archivos se guardan en
${root}/${key} url(key)retorna${baseUrl}/${key}sibaseUrlestá configurado
S3Driver (src/drivers/s3.ts)
Almacena en S3 o servicios compatibles:
- Config:
bucket,region,accessKey,secretKey,endpoint(opcional) - Usa AWS SDK v3 (o similar)
url(key)retorna URL pública de S3 (o signed URL si privado)
Tipos
StorageDriver
interface StorageDriver {
put(key: string, data: Buffer, options?: PutOptions): Promise<void>;
get(key: string): Promise<Buffer | null>;
delete(key: string): Promise<boolean>;
exists(key: string): Promise<boolean>;
stat(key: string): Promise<StorageFile | null>;
list(prefix?: string): Promise<StorageFile[]>;
url(key: string): Promise<string>;
}StorageFile
interface StorageFile {
key: string;
size: number;
contentType: string;
lastModified: Date;
etag?: string;
}PutOptions
interface PutOptions {
contentType?: string;
metadata?: Record<string, string>;
acl?: 'private' | 'public-read' | 'public-read-write';
}StorageConfig
interface StorageConfig {
driver: 'local' | 's3';
local?: LocalDriverConfig;
s3?: S3Config;
}Uso básico
Configuración local
import { Storage, LocalDriver } from '@jamx-framework/storage';
const storage = new Storage({
driver: 'local',
local: {
root: './uploads', // directorio donde guardar archivos
baseUrl: 'http://localhost:3000/uploads', // URL pública (opcional)
},
});Configuración S3
import { Storage, S3Driver } from '@jamx-framework/storage';
const storage = new Storage({
driver: 's3',
s3: {
bucket: 'my-bucket',
region: 'us-east-1',
accessKey: process.env.AWS_ACCESS_KEY_ID!,
secretKey: process.env.AWS_SECRET_ACCESS_KEY!,
// endpoint: 'https://nyc3.digitaloceanspaces.com', // opcional para S3-compatible
},
});Subir archivos
import { Storage } from '@jamx-framework/storage';
const storage = new Storage(config);
// Subir archivo
await storage.put('avatars/user123.png', buffer, {
contentType: 'image/png',
});
// Con metadata
await storage.put('documents/report.pdf', buffer, {
contentType: 'application/pdf',
metadata: {
uploadedBy: 'user123',
description: 'Monthly report',
},
});Descargar archivos
// Descargar como Buffer
const data = await storage.get('avatars/user123.png');
if (data) {
// usar data (Buffer)
console.log('File size:', data.length);
}
// Verificar existencia
const exists = await storage.exists('avatars/user123.png');
if (exists) {
// ...
}Obtener metadatos
const file = await storage.stat('avatars/user123.png');
if (file) {
console.log('Key:', file.key);
console.log('Size:', file.size);
console.log('Content-Type:', file.contentType);
console.log('Last Modified:', file.lastModified);
console.log('ETag:', file.etag);
}Listar archivos
// Listar todos
const files = await storage.list();
for (const file of files) {
console.log(file.key, file.size);
}
// Listar con prefijo
const userFiles = await storage.list('avatars/user123/');
// Retorna archivos que empiezan con 'avatars/user123/'URLs públicas
// Local driver
const url = await storage.url('avatars/user123.png');
// → 'http://localhost:3000/uploads/avatars/user123.png'
// S3 driver (depende de configuración de bucket)
const url = await storage.url('avatars/user123.png');
// → 'https://my-bucket.s3.amazonaws.com/avatars/user123.png'Eliminar archivos
const deleted = await storage.delete('avatars/user123.png');
if (deleted) {
console.log('File deleted');
}Copiar y mover
// Copiar
await storage.copy('avatars/old.png', 'avatars/new.png');
// Mover (copy + delete)
await storage.move('avatars/temp.png', 'avatars/permanent.png');API Reference
Storage
Constructor
new Storage(config: StorageConfig)Crea una instancia de storage con el driver especificado.
put()
async put(key: string, data: Buffer, options?: PutOptions): Promise<void>Sube un archivo.
key: Ruta del archivo en el storage (ej:'users/123/avatar.png')data: Contenido del archivo como Bufferoptions.contentType: MIME type (ej:'image/png')options.metadata: Metadatos personalizados (S3 only)options.acl: Permisos de acceso (S3 only)
Ejemplo:
await storage.put('docs/report.pdf', pdfBuffer, {
contentType: 'application/pdf',
metadata: { author: 'john' },
});get()
async get(key: string): Promise<Buffer | null>Descarga un archivo. Retorna null si no existe.
Ejemplo:
const data = await storage.get('image.jpg');
if (data) {
// usar data
}delete()
async delete(key: string): Promise<boolean>Elimina un archivo. Retorna true si existía y fue eliminado, false si no existía.
exists()
async exists(key: string): Promise<boolean>Verifica si un archivo existe.
stat()
async stat(key: string): Promise<StorageFile | null>Obtiene metadatos del archivo.
StorageFile:
interface StorageFile {
key: string;
size: number; // bytes
contentType: string; // MIME type
lastModified: Date; // fecha de modificación
etag?: string; // ETag (S3)
}list()
async list(prefix?: string): Promise<StorageFile[]>Lista archivos. Si prefix se especifica, solo lista archivos que empiezan con ese prefijo.
Ejemplo:
// Todos los archivos
const all = await storage.list();
// Solo avatares
const avatars = await storage.list('avatars/');url()
async url(key: string): Promise<string>Obtiene la URL pública del archivo. Depende del driver:
- Local:
${baseUrl}/${key}(requierebaseUrlen config) - S3: URL pública de S3 (bucket debe ser público o usar signed URLs)
copy()
async copy(source: string, dest: string): Promise<void>Copia un archivo dentro del mismo storage.
move()
async move(source: string, dest: string): Promise<void>Mueve un archivo (copia + elimina origen).
LocalDriver
Configuración
interface LocalDriverConfig {
root: string; // directorio base (absoluto o relativo)
baseUrl?: string; // URL base para acceder a archivos (opcional)
}Ejemplo:
{
driver: 'local',
local: {
root: '/var/uploads',
baseUrl: 'https://cdn.example.com/uploads',
},
}Comportamiento:
- Los archivos se guardan en
${root}/${key} rootse crea automáticamente si no existeurl(key)retorna${baseUrl}/${key}sibaseUrlestá configurado- Si
baseUrlno está configurado,url()lanza error
S3Driver
Configuración
interface S3Config {
bucket: string; // nombre del bucket
region: string; // región de AWS
accessKey: string; // AWS_ACCESS_KEY_ID
secretKey: string; // AWS_SECRET_ACCESS_KEY
endpoint?: string; // endpoint personalizado (para S3-compatible)
forcePathStyle?: boolean; // usar path-style URLs (para MinIO, etc.)
}Ejemplo AWS S3:
{
driver: 's3',
s3: {
bucket: 'my-app-uploads',
region: 'us-east-1',
accessKey: process.env.AWS_ACCESS_KEY_ID!,
secretKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
}Ejemplo MinIO:
{
driver: 's3',
s3: {
bucket: 'uploads',
region: 'us-east-1',
accessKey: 'minioadmin',
secretKey: 'minioadmin',
endpoint: 'http://localhost:9000',
forcePathStyle: true,
},
}Ejemplo Cloudflare R2:
{
driver: 's3',
s3: {
bucket: 'my-bucket',
region: 'auto',
accessKey: process.env.CF_ACCESS_KEY_ID!,
secretKey: process.env.CF_SECRET_ACCESS_KEY!,
endpoint: 'https://<account-id>.r2.cloudflarestorage.com',
},
}Comportamiento:
- Usa AWS SDK S3 client
put()sube conPutObjectCommandget()descarga conGetObjectCommandurl(key)retorna URL pública si el bucket es público, o signed URL si es privado- Soporta metadata personalizada
Ejemplos completos
Upload de avatar de usuario
import { Storage } from '@jamx-framework/storage';
const storage = new Storage({
driver: 's3',
s3: {
bucket: 'my-app-avatars',
region: 'us-east-1',
accessKey: process.env.AWS_ACCESS_KEY_ID!,
secretKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
async function uploadAvatar(userId: string, file: Express.Multer.File) {
const key = `avatars/${userId}/${Date.now()}-${file.originalname}`;
await storage.put(key, file.buffer, {
contentType: file.mimetype,
metadata: {
userId,
originalName: file.originalname,
},
});
const url = await storage.url(key);
return url;
}Servir archivos estáticos
import { Storage, LocalDriver } from '@jamx-framework/storage';
import { serve } from '@jamx-framework/server';
const storage = new Storage({
driver: 'local',
local: {
root: './public/uploads',
baseUrl: '/uploads',
},
});
// En el servidor
server.use('/uploads', async (req, res) => {
const key = req.path.slice('/uploads'.length); // remover prefijo
const file = await storage.get(key);
if (!file) {
res.notFound();
return;
}
const meta = await storage.stat(key);
res.header('Content-Type', meta?.contentType ?? 'application/octet-stream');
res.send(file);
});Limpieza de archivos antiguos
import { Storage } from '@jamx-framework/storage';
const storage = new Storage(config);
async function cleanupOldFiles(days: number) {
const cutoff = Date.now() - days * 24 * 60 * 60 * 1000;
const files = await storage.list();
for (const file of files) {
if (file.lastModified.getTime() < cutoff) {
await storage.delete(file.key);
console.log(`Deleted ${file.key}`);
}
}
}
// Ejecutar diariamente
setInterval(() => cleanupOldFiles(30), 24 * 60 * 60 * 1000);Migración de local a S3
import { Storage } from '@jamx-framework/storage';
const local = new Storage({
driver: 'local',
local: { root: './old-uploads' },
});
const s3 = new Storage({
driver: 's3',
s3: { /* config */ },
});
async function migrate() {
const files = await local.list();
for (const file of files) {
const data = await local.get(file.key);
if (data) {
await s3.put(file.key, data, {
contentType: file.contentType,
});
console.log(`Migrated ${file.key}`);
}
}
}Generar signed URLs (S3 privado)
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Si necesitas signed URLs, extiende S3Driver:
class SecureS3Driver extends S3Driver {
async signedUrl(key: string, expiresIn: number = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.config.bucket,
Key: key,
});
return await getSignedUrl(this.client, command, { expiresIn });
}
}
// Uso
const storage = new Storage({ driver: 's3', s3: { /* ... */ } });
const url = await (storage.driver as SecureS3Driver).signedUrl('private/file.pdf');Consideraciones de rendimiento
LocalDriver
- I/O bloqueante: Node.js es single-threaded; muchos accesos concurrentes pueden bloquear event loop
- Escalabilidad: No escala en clúster (cada instancia tiene su propio filesystem)
- Backup: Responsabilidad del usuario (backup del directorio
root) - CDN: Para servir archivos eficientemente, usar
baseUrlapuntando a CDN
S3Driver
- Latencia: Cada operación es una llamada de red (100ms+ típicamente)
- Throughput: S3 soporta miles de requests/segundo por bucket
- Costos: Cada PUT/GET tiene costo; considerar CloudFront para caching
- Consistencia: S3 tiene read-after-write consistency para PUTs nuevos
Concurrencia
- El storage no limita concurrencia
- Para uploads masivos, considera limitar con semáforos
- Los drivers son thread-safe (no comparten estado mutable)
Caching
- El storage no incluye cache interno
- Para archivos frecuentemente accedidos, usar CDN o cache en memoria:
import { Cache } from '@jamx-framework/cache';
const cache = new Cache({ driver: 'redis' });
async function getWithCache(key: string) {
const cached = await cache.get(key);
if (cached) return Buffer.from(cached);
const data = await storage.get(key);
if (data) {
await cache.set(key, data, 3600); // cache 1 hora
}
return data;
}Testing
Tests con LocalDriver
import { Storage } from '@jamx-framework/storage';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { rmSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
describe('LocalStorage', () => {
const testDir = './test-uploads';
beforeEach(() => {
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should upload and download files', async () => {
const storage = new Storage({
driver: 'local',
local: { root: testDir, baseUrl: 'http://localhost/uploads' },
});
await storage.put('test.txt', Buffer.from('Hello World'));
const data = await storage.get('test.txt');
expect(data?.toString()).toBe('Hello World');
});
it('should list files', async () => {
const storage = new Storage({
driver: 'local',
local: { root: testDir },
});
await storage.put('a.txt', Buffer.from('A'));
await storage.put('b.txt', Buffer.from('B'));
const files = await storage.list();
expect(files).toHaveLength(2);
});
});Tests con S3Driver (mock)
import { Storage } from '@jamx-framework/storage';
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { mockClient } from 'aws-sdk-client-mock';
const s3Mock = mockClient(S3Client);
describe('S3Storage', () => {
beforeEach(() => {
s3Mock.reset();
});
it('should upload file', async () => {
s3Mock.on(PutObjectCommand).resolves({});
const storage = new Storage({
driver: 's3',
s3: {
bucket: 'test-bucket',
region: 'us-east-1',
accessKey: 'test',
secretKey: 'test',
},
});
await storage.put('key.txt', Buffer.from('content'));
expect(s3Mock.calls()).toHaveLength(1);
});
});Seguridad
Validación de keys
- Los drivers deben sanitizar
keypara evitar path traversal - LocalDriver previene
../en paths - S3Driver usa el key directamente (S3 ya previene problemas)
Permisos
- LocalDriver: Permisos del filesystem del OS
- S3Driver: Permisos de IAM (accessKey/secretKey)
- Configurar
aclapropiadamente (privatepor defecto)
URLs firmadas (S3)
Para buckets privados, generar signed URLs:
// No implementado por defecto, pero se puede extender
const url = await storage.driver.url(key); // puede fallar si bucket es privadoEncriptación
- El storage no encripta datos en tránsito (usar HTTPS para S3)
- Para encriptación en reposo, usar S3 SSE o encriptar antes de
put()
Limitaciones
LocalDriver
- No soporta directorios virtuales (todos los keys son "archivos")
- No hay límite de tamaño de archivo (depende del filesystem)
- No soporta metadatos extensivos (solo contentType en
PutOptions)
S3Driver
- Depende de AWS SDK (pesado, ~1MB)
- No soporta todos los features de S3 (multipart upload, versioning, etc.)
url()puede no funcionar para buckets privados
General
- No soporta streaming (todo es Buffer en memoria)
- No hay operaciones de bulk (delete masivo, copy masivo)
- No hay eventos (watcher de cambios)
- No soporta ranges (partial get)
Buenas prácticas
1. Usar keys estructuradas
// ✅ Bien: estructura jerárquica
const key = `users/${userId}/avatars/${Date.now()}.png`;
// ❌ No: keys planos
const key = 'avatar123.png';2. Validar contenido
import { validate } from '@jamx-framework/validator';
const schema = z.object({
contentType: z.string().regex(/^image\//),
size: z.number().max(5 * 1024 * 1024), // max 5MB
});
async function safeUpload(key: string, data: Buffer) {
// Validar antes de subir
const fileInfo = {
contentType: detectMimeType(data),
size: data.length,
};
const result = schema.parse(fileInfo);
await storage.put(key, data, { contentType: result.contentType });
}3. Manejar errores
try {
await storage.put(key, data);
} catch (err) {
if (err.code === 'ENOENT') {
// directorio no existe (LocalDriver)
await fs.mkdirSync(path.dirname(localPath), { recursive: true });
await storage.put(key, data);
} else if (err.code === 'NoSuchBucket') {
// bucket no existe (S3Driver)
await createBucket();
await storage.put(key, data);
} else {
throw err;
}
}4. Limpiar archivos huérfanos
// Después de eliminar un usuario, eliminar sus archivos
async function deleteUserFiles(userId: string) {
const prefix = `users/${userId}/`;
const files = await storage.list(prefix);
for (const file of files) {
await storage.delete(file.key);
}
}5. Usar TTL para archivos temporales
import { Scheduler } from '@jamx-framework/scheduler';
// Limpiar archivos temporales cada 24h
scheduler.add({
name: 'cleanup-temp',
cron: '0 3 * * *', // 3 AM diario
handler: async () => {
const files = await storage.list('temp/');
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const file of files) {
if (file.lastModified.getTime() < cutoff) {
await storage.delete(file.key);
}
}
},
});Integración con otros paquetes
Con @jamx-framework/server
import { JamxServer, staticFiles } from '@jamx-framework/server';
import { Storage } from '@jamx-framework/storage';
const storage = new Storage(config);
server.use('/uploads', async (req, res) => {
const key = req.path.slice('/uploads'.length);
const file = await storage.get(key);
if (!file) {
res.notFound();
return;
}
const meta = await storage.stat(key);
res.header('Content-Type', meta?.contentType ?? 'application/octet-stream');
res.send(file);
});Con @jamx-framework/auth
import { authMiddleware } from '@jamx-framework/auth';
import { Storage } from '@jamx-framework/storage';
const storage = new Storage(config);
server.use(authMiddleware());
server.use('/profile/avatar', async (req, res) => {
const user = req.locals.user;
const key = `avatars/${user.id}.png`;
const file = await storage.get(key);
if (!file) {
res.notFound();
return;
}
res.header('Content-Type', 'image/png');
res.send(file);
});Con @jamx-framework/validator
import { validate } from '@jamx-framework/validator';
import { Storage } from '@jamx-framework/storage';
const uploadSchema = z.object({
key: z.string().regex(/^[a-zA-Z0-9_\-/\.]+$/),
contentType: z.string(),
size: z.number().max(10 * 1024 * 1024), // 10MB max
});
async function upload(req: JamxRequest, res: JamxResponse) {
const result = validate(req.body, uploadSchema);
if (!result.valid) {
res.json({ errors: result.errors }, 400);
return;
}
const { key, contentType, size } = result.value;
const buffer = req.body as Buffer; // asumiendo bodyParser con raw
if (buffer.length !== size) {
res.json({ error: 'Size mismatch' }, 400);
return;
}
await storage.put(key, buffer, { contentType });
res.json({ url: await storage.url(key) });
}Preguntas frecuentes
¿Cómo manejar uploads grandes?
Para archivos > 10MB, usar streaming o multipart upload (S3):
// S3 multipart upload (no implementado por defecto)
// Considerar usar AWS SDK directamente para casos complejos¿Cómo servir archivos privados?
Para S3 privado, generar signed URLs:
// Extender S3Driver
async signedUrl(key: string, expiresIn: number = 3600): Promise<string> {
const command = new GetObjectCommand({
Bucket: this.config.bucket,
Key: key,
});
return await getSignedUrl(this.client, command, { expiresIn });
}¿Cómo cambiar de driver?
La API es la misma para todos los drivers:
// Cambiar de local a S3
const storage = new Storage({
driver: 's3',
s3: { /* config */ },
});
// El código de la aplicación no cambia
await storage.put('file.txt', buffer);¿Qué pasa si el directorio local no existe?
LocalDriver crea el directorio automáticamente en el primer put().
¿Cómo manejar conflictos de nombres?
El storage no previene overwrites. Si subes un archivo con la misma key, se sobrescribe.
// Evitar overwrite
if (await storage.exists(key)) {
throw new Error('File already exists');
}
await storage.put(key, data);¿Cómo obtener tamaño total de storage?
async function getTotalSize(): Promise<number> {
let total = 0;
const files = await storage.list();
for (const file of files) {
total += file.size;
}
return total;
}Referencia rápida
Crear storage
const storage = new Storage({
driver: 'local', // o 's3'
local: { root: './uploads' },
// o
s3: { bucket: '...', region: '...', accessKey: '...', secretKey: '...' },
});Operaciones CRUD
await storage.put(key, buffer, options);
const data = await storage.get(key);
await storage.delete(key);
const exists = await storage.exists(key);
const file = await storage.stat(key);Listar
const files = await storage.list(prefix);Utilidades
const url = await storage.url(key);
await storage.copy(source, dest);
await storage.move(source, dest);Archivos importantes
src/storage.ts- Clase Storage principalsrc/drivers/local.ts- Driver de filesystem localsrc/drivers/s3.ts- Driver de S3src/drivers/types.ts- Tipos compartidostests/unit/storage.test.ts- Tests de Storagetests/unit/drivers/local.test.ts- Tests de LocalDriver
Dependencias
@types/node- Tipos de Node.jsvitest- Testingrimraf- Limpieza- (S3Driver)
@aws-sdk/client-s3- AWS SDK (no listada en package.json, debe instalarse)
Scripts del paquete
pnpm build- Compila TypeScriptpnpm dev- Watch modepnpm test- Tests unitariospnpm test:watch- Tests en watchpnpm type-check- Verificar tipospnpm clean- Limpiar build
Notas sobre S3Driver
El S3Driver requiere @aws-sdk/client-s3 como dependencia adicional. Asegúrate de instalarla:
pnpm add -w @aws-sdk/client-s3O si el paquete no la incluye, agregarla a devDependencies del paquete.
