FormData y Streaming
QuickModel ofrece soporte nativo para FormData, Blob y File, así como streaming para campos binarios grandes. Esta guía cubre las cuatro capas:
- Capa 1 —
BlobTransformeryFileTransformer - Capa 2 — Auto-detección del formato de origen
- Capa 3 — API
fromFormData()ytoFormData() - Capa 4 —
toReadableStream(),fromStream()ypipeStream()
Capa 1 — BlobTransformer y FileTransformer
Usa el constructor Blob o File directamente en @Quick para registrar el transformer:
import { QModel } from 'quickmodel';
interface IProfileDto {
name: string;
avatar: File;
thumbnail: Blob;
}
@Quick({ avatar: File, thumbnail: Blob })
class ProfileDto extends QModel<IProfileDto> {
declare name: string;
declare avatar: File;
declare thumbnail: Blob;
}También puedes usar los aliases de cadena 'blob' y 'file':
@Quick({ avatar: 'file', thumbnail: 'blob' })
class ProfileDto extends QModel<IProfileDto> { ... }Serialización y deserialización
BlobTransformer hace un round-trip de objetos Blob mediante un descriptor ligero:
// Forma serializada
{ size: 1024, type: 'image/png', _blobRef: true }
// La deserialización acepta:
// - { size, type, _blobRef } ← descriptor serializado
// - ArrayBuffer / Uint8Array ← creación programática
// - 'data:image/png;base64,...' ← data URIFileTransformer preserva los metadatos de File a través de la serialización:
// Forma serializada
{ name: 'foto.jpg', size: 204800, type: 'image/jpeg', lastModified: 1709123456 }
// La deserialización acepta:
// - { name, size, type, lastModified } ← descriptor serializado
// - instancia de File ← pass-through
// - instancia de Blob ← se convierte a File (desde streaming)
// - 'data:image/jpeg;base64,...' ← data URICapa 2 — Auto-detección
fromFormData() inspecciona cada FormDataEntryValue en tiempo de ejecución y elige la estrategia de conversión correcta sin ninguna configuración explícita:
¿Qué es el valor?
│
├── instancia de File → preservar como File (input de archivo real del navegador)
├── instancia de Blob → preservar como Blob
├── ArrayBuffer / Uint8Array → envolver automáticamente en Blob (programático)
├── string "data:..." → decodificar base64 → Blob (JS legacy / APIs)
├── string "https://..." → mantener como string URL (referencia CDN)
├── string "/storage/..." → mantener como string path (referencia servidor interno)
└── string "foto.jpg" → mantener como string (solo nombre de archivo)Esto cubre el 90% de los casos reales sin ninguna configuración.
Capa 3 — fromFormData() y toFormData()
fromFormData(fd, opts?)
Método estático. Convierte un FormData en una instancia del modelo usando coercionStrategy: 'loose' y auto-detección por defecto.
// Default: auto-detección
const dto = UploadDto.fromFormData(formData);
// Forzar todos los campos binarios como referencias de ruta (ej. proxy interno)
const dto = UploadDto.fromFormData(formData, { fileSource: 'reference' });
// Overrides por campo — estrategias mixtas en el mismo formulario
const dto = UploadDto.fromFormData(formData, {
fields: {
avatar: 'binary', // upload binario real
document: 'reference', // ruta del servidor
thumbnail: 'base64', // data URI
},
});Valores de fileSource:
| Valor | Comportamiento |
|---|---|
'auto' (default) | Inspección en runtime — árbol de decisión anterior |
'binary' | Mantener File/Blob tal cual; envolver ArrayBuffer/Uint8Array en Blob |
'reference' | Tratar el string como ruta/URL, no deserializar como binario |
'base64' | Esperar prefijo data: y decodificar a Blob; error si no está presente |
toFormData(opts?)
Método de instancia. Construye un FormData desde los campos del modelo.
// Default: preservar objetos binarios
const fd = await dto.$qToFormData();
// Proxy / logging — no enviar datos binarios por la red
const fd = await dto.$qToFormData({ fileMode: 'reference' });
// API legacy que espera base64
const fd = await dto.$qToFormData({ fileMode: 'base64' });
// Overrides por campo
const fd = await dto.$qToFormData({
fields: { avatar: 'binary', signature: 'base64' },
});Tabla de conversiones por fileMode:
| Tipo runtime del campo | 'auto' / 'binary' | 'reference' | 'base64' |
|---|---|---|---|
File | .append(k, file) | .append(k, file.name) | .append(k, dataURI) |
Blob | .append(k, blob, 'file') | .append(k, '[Blob]') | .append(k, dataURI) |
ArrayBuffer | auto-wrap en Blob + append | .append(k, '[binary]') | .append(k, dataURI) |
Uint8Array | auto-wrap en Blob + append | .append(k, '[binary]') | .append(k, dataURI) |
string / number / boolean | .append(k, String(v)) — todos los modos |
spoofMethod — tunelización de métodos HTTP
Algunos backends (Laravel, Symfony, Rails) solo aceptan multipart/form-data con POST. Para trabajar con esas APIs, puedes inyectar un campo _method como la primera entrada del FormData — el backend lo lee y enruta la petición como si fuera PUT, PATCH o DELETE.
// Opción de llamada puntual
const fd = await dto.$qToFormData({ spoofMethod: 'PUT' });
// FormData: _method=PUT, name=Alice, avatar=<File>Cascada de tres niveles (menor → mayor prioridad)
| Nivel | Dónde | Ejemplo |
|---|---|---|
| Global | QConfig.defaults | QConfig.set({ defaults: { spoofMethod: 'PUT' } }) |
| Decorador | @Quick({}, { spoofMethod }) | @Quick({}, { spoofMethod: 'PATCH' }) |
| Opción de llamada | toFormData({ spoofMethod }) | toFormData({ spoofMethod: 'DELETE' }) |
Un nivel superior siempre gana. Si spoofMethod es undefined en todos los niveles, no se añade ningún campo _method.
// 1. Global — aplica a todos los modelos a menos que se sobreescriba
QConfig.set({ defaults: { spoofMethod: 'PUT' } });
// 2. Decorador — sobreescribe el global para un modelo concreto
@Quick({}, { spoofMethod: 'PATCH' })
class UploadDto extends QModel<IUploadDto> { ... }
// 3. Opción de llamada — mayor prioridad, sobreescribe todo
const fd = await dto.$qToFormData({ spoofMethod: 'DELETE' });
// → _method=DELETE (ignora el PUT global y el PATCH del decorador)Valores con seguridad de tipos — IQSpoofMethod
La opción spoofMethod está tipada como IQSpoofMethod, que proporciona autocompletado para todos los métodos HTTP estándar (RFC 7231, WebDAV, DeltaV) más un comodín string & {} para métodos personalizados:
import type { IQSpoofMethod } from 'quickmodel';
const methods: IQSpoofMethod[] = ['PUT', 'PATCH', 'DELETE', 'PURGE', 'SEARCH'];fileMode por campo — @QType({ fileMode })
Cuando un campo siempre necesita una estrategia de serialización concreta independientemente del punto de llamada, decláralo directamente en la propiedad con @QType. Así evitas repetir el override en cada llamada a toFormData().
class UploadDto extends QModel<IUploadDto> {
declare name: string;
// Siempre serializar como base64 — consumidor de API legacy
@QType(File, { fileMode: 'base64' })
declare signature: File;
// Siempre mantener referencia de ruta — asset gestionado por CDN
@QType(File, { fileMode: 'reference' })
declare thumbnail: File;
}Orden de prioridad (menor → mayor):
| Nivel | Dónde | Se aplica a |
|---|---|---|
| Decorador | @QType({ fileMode }) | ese campo en todas las llamadas |
| Opción de llamada | toFormData({ fileMode }) | todos los campos en esta llamada |
| Opción de llamada por campo | toFormData({ fields: { k: mode } }) | ese campo en esta llamada |
Una opción de llamada por campo siempre gana. Si no se proporciona ninguna opción de llamada, se aplica el valor del decorador. Si el decorador no tiene fileMode, el campo vuelve a 'auto' por defecto.
serialize({ fileMode }) — campos binarios en JSON plano
serialize() también acepta fileMode para controlar cómo se representan los campos File/Blob en la salida de objeto plano / JSON — útil para logging, caché o transporte por una API que no usa multipart.
// Default: File → { name, size, type, lastModified }
const plain = dto.$qSerialize();
// Solo referencia — sin datos binarios en la salida JSON
const plain = dto.$qSerialize({ fileMode: 'reference' });
// { avatar: 'foto.jpg', ... }
// Base64 — incrustar el binario dentro del JSON
const plain = dto.$qSerialize({ fileMode: 'base64' });
// { avatar: 'data:image/jpeg;base64,/9j/...', ... }Default global con QConfig:
QConfig.set({ defaults: { fileMode: 'reference' } });
// cada llamada a serialize() / toFormData() usará 'reference' por defecto
// salvo que se sobreescriba a nivel de decorador o de llamadaCapa 4 — Streaming
Cargar 500 MB en un ArrayBuffer antes de enviarlo bloquea el hilo y puede causar OOM en servidores con alta concurrencia. La solución estándar de la Web Platform es ReadableStream<Uint8Array>.
toReadableStream(opts)
Emite los bytes de un campo binario como chunks Uint8Array. El archivo nunca está completamente en memoria.
const stream = dto.$qToReadableStream({
field: 'video',
chunkSize: 64 * 1024, // default: 256 KB
});
// Subir a S3 sin cargar el archivo en memoria
await s3.putObject({
Bucket: 'uploads',
Key: 'video.mp4',
Body: stream,
ContentType: dto.video.type,
});
// O devolver directamente como respuesta HTTP
return new Response(stream, {
headers: { 'Content-Type': dto.video.type },
});Con progreso:
let emitted = 0;
const stream = dto.$qToReadableStream({
field: 'video',
chunkSize: 64 * 1024,
onChunk: (chunk, total) => {
emitted += chunk.byteLength;
socket.emit('upload-progress', Math.round((emitted / total) * 100));
},
});multipart: true — streaming de formulario completo
Cuando necesitas subir un formulario completo (campos de texto + ficheros binarios) a un backend que solo acepta POST multipart/form-data, puedes streamear el mensaje entero sin materializar ningún FormData en memoria:
const stream = dto.$qToReadableStream({ multipart: true });
await fetch('/api/upload', {
method: 'POST',
body: stream,
headers: {
'Content-Type': `multipart/form-data; boundary=${stream.boundary}`,
},
});El stream expone la propiedad boundary (generada automáticamente como 32 caracteres hex aleatorios) que debes incluir en la cabecera Content-Type. Pasa un valor personalizado si el receptor requiere un token concreto:
const stream = dto.$qToReadableStream({
multipart: true,
boundary: 'mi-boundary-personalizado',
chunkSize: 64 * 1024, // tamaño de chunk para campos binarios
});Qué se emite:
| Tipo del campo | Comportamiento |
|---|---|
string / number / boolean | Emitido como parte text/plain |
File / Blob | Streamed de forma lazy como parte binaria con Content-Type |
File / Blob + @QType({ fileMode: 'reference' }) | Parte de texto con solo el nombre del fichero |
null / undefined | El campo se omite silenciosamente |
La salida cumple RFC 2046 y puede ser parseada por cualquier parser estándar multipart/form-data, incluido el Request.formData() nativo de navegadores y Bun:
// Verificación de round-trip
const req = new Request('https://api/upload', {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${stream.boundary}`,
},
body: stream,
});
const fd = await req.formData();
// fd.get('nombre') === dto.nombre ✅
// (fd.get('avatar') as File).arrayBuffer() === bytes originales ✅fromStream(stream, opts)
Reconstruye un campo binario acumulando los chunks de un ReadableStream entrante.
async function handleUpload(req: Request) {
const dto = await UploadDto.fromStream(req.body, {
field: 'video', // en qué campo del modelo acumular los chunks
maxBytes: 500 * 1024 * 1024, // límite de seguridad: 500 MB
onProgress: (received, total) => {
console.log(`${received}/${total} bytes recibidos`);
},
});
// dto.video → Blob con todos los chunks acumulados
await saveToStorage(dto.video);
}pipeStream(src, dst, opts?) — modo sin memoria
Conecta directamente el stream de entrada con un stream de escritura sin acumular nada. El servidor actúa como conductor puro de bytes.
// Pipe directo de la request al S3 — cero bytes en memoria en el servidor
await UploadDto.pipeStream(req.body, s3UploadStream, {
maxBytes: 500 * 1024 * 1024,
onProgress: (bytes) => socket.emit('progress', bytes),
});Árbol de decisión — cuándo usar qué
¿El archivo cabe cómodamente en memoria? (< ~50 MB)
│
├── SÍ → fromFormData() / toFormData()
│ ├── Quiero el binario real → fileMode/fileSource: 'auto' (default)
│ ├── Solo necesito la referencia → fileMode/fileSource: 'reference'
│ └── API legacy / JSON con base64 → fileMode/fileSource: 'base64'
│
└── NO → toReadableStream() / fromStream() / pipeStream()
├── Subir a S3/CDN → toReadableStream({ field }) → S3 body
├── Enviar formulario completo → toReadableStream({ multipart: true })
├── Recibir upload grande → fromStream(req.body, { field })
├── Pipe directo sin memoria → pipeStream(src, dst)
└── Progreso en tiempo real → callbacks onChunk / onProgressEscenarios de uso completos
Escenario 1 — Frontend envía formulario con archivo → Backend lo recibe
// FRONTEND
const fd = new FormData(formEl); // avatar: File del <input type="file">
const dto = UploadDto.fromFormData(fd);
// dto.avatar → File { name: 'foto.jpg', size: 204800, type: 'image/jpeg' }
// dto.userId → 42 (number, coerción automática desde string '42')
const { valid, rules } = dto.$qValidationReport();
if (!valid) {
showErrors(rules);
return;
}
// Archivo pequeño — envío directo
await fetch('/api/upload', {
method: 'POST',
body: await dto.$qToFormData(),
});
// Archivo grande — envío streaming con progreso
const stream = dto.$qToReadableStream({
field: 'avatar',
onChunk: (chunk, total) => updateProgressBar(chunk.byteLength, total),
});
await fetch('/api/upload', { method: 'POST', body: stream });// BACKEND — recibir, validar y procesar
async function handleUpload(req: Request) {
const fd = await req.formData();
const dto = UploadDto.fromFormData(fd);
const { valid, rules } = dto.$qValidationReport();
if (!valid) return Response.json({ errors: rules }, { status: 422 });
// Subir a S3 sin cargar en memoria — pipe directo
const uploadStream = s3.createUploadStream({
Bucket: 'uploads',
Key: dto.avatar.name,
});
await UploadDto.pipeStream(dto.avatar.stream(), uploadStream);
return Response.json({ url: cdnUrl });
}Escenario 2 — Microservicio interno: solo referencias, sin binarios
// FormData { avatar: '/storage/users/42/foto.jpg', ... }
const dto = UploadDto.fromFormData(fd, { fileSource: 'reference' });
// dto.avatar → '/storage/users/42/foto.jpg' (string, cero bytes en memoria)Escenario 3 — Round-trip con API legacy base64
// Recibir: "data:image/jpeg;base64,/9j/..."
const dto = UploadDto.fromFormData(fd, { fileSource: 'base64' });
// dto.avatar → Blob { type: 'image/jpeg' }
// Reenviar en el mismo formato
const outFd = await dto.$qToFormData({ fileMode: 'base64' });
// outFd.get('avatar') → 'data:image/jpeg;base64,/9j/...' — round-trip exactoSkill MCP
El skill quickmodel_form_data guía el flujo completo de FormData — generación de código fromFormData() / toFormData(), elección de la opción correcta de fileMode/fileSource, y añadir streaming con callbacks de progreso.
/mcp quickmodel_form_data intent=fromFormData model_fields="avatar: File, userId: number, tags: string[]"