Crea un origen local de imágenes con Sharp y Redis
Sirve un JPEG o WebP redimensionado desde la misma URL de imagen sin mezclar sus bytes en caché. Esta guía crea un origen local de imágenes, genera una muestra transparente y verifica las respuestas con la caché fría y caliente. El origen es un componente de una CDN de imágenes personalizada; la entrega global desde el borde de la red requiere una CDN independiente.
Requisitos previos
Usa una versión de Node.js 24 LTS con mantenimiento vigente, como mínimo la 24.15.0, Corepack con
Yarn 4, Docker y cURL. El ejemplo usa Express 5.2.1, Sharp 0.35.4, el cliente de Redis 6.2.1 y
Redis 8.10.2. El soporte nativo para TypeScript de Node
permite ejecutar los archivos .mts directamente, sin un compilador ni tsx.
Publica solo imágenes de origen JPEG o PNG revisadas, inmutables y de un solo fotograma. Este origen no tiene rutas de subida ni de autenticación: cada imagen incluida y sus derivados son públicos. Los límites de bytes y píxeles que se indican a continuación reducen la carga de trabajo aceptada; no aíslan el decodificador nativo de Sharp en un entorno seguro.
Configura el proyecto
Crea un directorio image-origin nuevo y vacío y abre una terminal en él.
Guarda este archivo package.json:
{
"name": "image-origin",
"private": true,
"type": "module",
"packageManager": "yarn@4.12.0",
"scripts": { "fixture": "node create-fixture.mts", "start": "node server.mts" },
"dependencies": { "express": "5.2.1", "redis": "6.2.1", "sharp": "0.35.4" },
"devDependencies": { "@types/express": "5.0.6" }
}
Guarda también .yarnrc.yml. Selecciona una instalación local de node_modules
y evita que se use en su lugar el ejecutable de Yarn de un proyecto que contenga este directorio:
nodeLinker: node-modules
enableGlobalCache: false
ignorePath: true
Crea el archivo de bloqueo que delimita el proyecto antes de instalar las dependencias. Ejecuta todos los comandos siguientes desde este directorio:
touch yarn.lock && corepack yarn install
Conserva el archivo yarn.lock generado; las instalaciones posteriores pueden usar corepack yarn install --immutable.
Guarda create-fixture.mts para generar una muestra de 640 × 400: una mitad izquierda roja
opaca y una mitad derecha transparente. El script se niega a reemplazar un archivo de origen existente.
import { mkdir, writeFile } from 'node:fs/promises'
import sharp from 'sharp'
const width = 640
const height = 400
const pixels = Buffer.alloc(width * height * 4)
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width / 2; x += 1) {
const offset = (y * width + x) * 4
pixels[offset] = 255
pixels[offset + 3] = 255
}
}
const png = await sharp(pixels, { raw: { width, height, channels: 4 } }).png().toBuffer()
await mkdir('images', { recursive: true })
await writeFile('images/photo-v1.png', png, { flag: 'wx' })
Genera la muestra y luego inicia una caché de Redis desechable vinculada a la interfaz de loopback.
Si el puerto 6379 está ocupado, elige otro puerto del host y proporciona su URL mediante
REDIS_URL al iniciar el origen.
corepack yarn fixture &&
docker run --detach --rm --name image-origin-redis \
-p 127.0.0.1:6379:6379 redis:8.10.2 \
redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru --save '' --appendonly no
La caché es desechable: esta configuración no escribe instantáneas de Redis ni archivos de solo
anexado. Los originales permanecen en images/.
La política de expulsión de Redis elimina entradas de la caché
cuando es necesario; si una entrada no está en la caché, se puede regenerar.
Crea un optimizador de imágenes mínimo
Guarda optimizer.mts. Quien lo llama proporciona un nombre de archivo del mapa de
publicación, en lugar de una URL o una ruta seleccionada por el usuario. Acepta como máximo 8 MiB
de bytes del archivo de origen y 12 millones de píxeles de entrada. El directorio debe permanecer
bajo el control de quien publica mientras se procesan las solicitudes.
import { open, realpath } from 'node:fs/promises'
import { resolve, sep } from 'node:path'
import sharp from 'sharp'
export type Format = 'jpeg' | 'webp'
const MAX_BYTES = 8 * 1024 * 1024
const MAX_PIXELS = 12_000_000
sharp.concurrency(1)
sharp.cache(false)
export async function optimize(filename: string, size: number, format: Format): Promise<Buffer> {
const root = await realpath(resolve('images'))
const path = await realpath(resolve(root, filename))
if (!path.startsWith(root + sep)) throw new Error('Source is outside the publishing directory.')
const handle = await open(path, 'r')
let data: Buffer
try {
const stat = await handle.stat()
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_BYTES) {
throw new Error('Source size is unsupported.')
}
data = Buffer.alloc(MAX_BYTES + 1)
let length = 0
while (length < data.length) {
const { bytesRead } = await handle.read(data, length, data.length - length, null)
if (bytesRead === 0) break
length += bytesRead
}
if (length === 0 || length > MAX_BYTES) throw new Error('Source size is unsupported.')
data = data.subarray(0, length)
} finally {
await handle.close()
}
const pipeline = sharp(data, { limitInputPixels: MAX_PIXELS, failOn: 'warning' })
const metadata = await pipeline.metadata()
if (!metadata.format || !['jpeg', 'png'].includes(metadata.format) ||
(metadata.pages ?? 1) !== 1) {
throw new Error('Only single-frame JPEG and PNG sources are supported.')
}
pipeline.rotate().resize({ width: size, height: size, fit: 'inside', withoutEnlargement: true })
return format === 'jpeg'
? pipeline.flatten({ background: 'white' }).jpeg({ quality: 80 }).toBuffer()
: pipeline.webp({ quality: 80 }).toBuffer()
}
La imagen se ajusta al cuadrado solicitado y conserva su relación de aspecto sin ampliarse. JPEG compone las áreas transparentes sobre blanco; WebP conserva la transparencia. La política de salida predeterminada de Sharp convierte a sRGB y elimina los metadatos de origen. La rotación aplica la orientación EXIF antes de eliminar esos metadatos. Una decodificación exitosa no constituye una comprobación de integridad del original: revisa los píxeles de origen antes de publicar.
Sirve y almacena en caché las variantes negociadas
Guarda server.mts. Omite f para negociar un formato mediante Accept,
o solicita f=jpeg o f=webp explícitamente.
El ancho w acepta 320, 640 o 1.280; el valor predeterminado es 640.
Se rechazan los parámetros distintos de los admitidos o duplicados.
import type { Response } from 'express'
import express from 'express'
import { createClient } from 'redis'
import { type Format, optimize } from './optimizer.mts'
const published = new Map([['photo-v1.png', 'photo-v1.png']])
const sizes = new Set(['320', '640', '1280'])
const types = { webp: 'image/webp', jpeg: 'image/jpeg' }
const CACHE_SECONDS = 3600
const app = express()
app.disable('x-powered-by')
const redis = createClient({
url: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
disableOfflineQueue: true,
socket: { connectTimeout: 2000, reconnectStrategy: false },
})
redis.on('error', () => console.error('Image cache connection failed.'))
let active = 0
async function cacheCommand<T>(command: () => Promise<T>): Promise<T> {
// Closing the connection also rejects commands already sent to a stalled Redis server.
const timer = setTimeout(() => {
if (redis.isOpen) redis.destroy()
}, 2000)
try {
return await command()
} finally {
clearTimeout(timer)
}
}
function fail(res: Response, status: number, message: string): Response {
return res.status(status).set('Cache-Control', 'no-store').type('text').send(message)
}
function sendImage(res: Response, format: Format, bytes: Buffer): Response {
return res.type(types[format]).set('Cache-Control', `public, max-age=${CACHE_SECONDS}`)
.set('X-Content-Type-Options', 'nosniff').send(bytes)
}
app.get('/images/:name', async (req, res) => {
const filename = published.get(req.params.name)
if (!filename) return fail(res, 404, 'Image not found.')
const params = new URL(req.originalUrl, 'http://localhost').searchParams
if ([...params.keys()].some((key) => key !== 'w' && key !== 'f') ||
params.getAll('w').length > 1 || params.getAll('f').length > 1) {
return fail(res, 400, 'Unsupported image parameters.')
}
const size = params.get('w') ?? '640'
const requested = params.get('f')
const accepted = requested === null ? req.accepts(['image/webp', 'image/jpeg']) : null
const format = requested ?? (accepted === 'image/webp' ? 'webp' : 'jpeg')
if (requested === null && !accepted) return fail(res, 406, 'No supported image format.')
if (!sizes.has(size) || (format !== 'jpeg' && format !== 'webp')) {
return fail(res, 400, 'Unsupported image variant.')
}
if (requested === null) res.vary('Accept')
const key = JSON.stringify(['image-v1', filename, size, format])
if (active >= 2) return fail(res, 503, 'Image processor is busy.')
active += 1
try {
const cached = await cacheCommand(() => redis.get(key))
if (cached !== null) return sendImage(res, format, Buffer.from(cached, 'base64'))
const bytes = await optimize(filename, Number(size), format)
await cacheCommand(() => redis.set(key, bytes.toString('base64'), { EX: CACHE_SECONDS }))
return sendImage(res, format, bytes)
} catch {
console.error('Image request failed.')
return fail(res, 503, 'Image is temporarily unavailable.')
} finally {
active -= 1
}
})
app.use((_req, res) => fail(res, 404, 'Route not found.'))
app.use((_error: unknown, _req: express.Request, res: Response, _next: express.NextFunction) =>
fail(res, 400, 'Request could not be processed.'))
async function main(): Promise<void> {
const port = Number(process.env.PORT ?? '3000')
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Invalid PORT.')
await redis.connect()
const server = app.listen(port, '127.0.0.1')
server.requestTimeout = 10_000
server.headersTimeout = 10_000
server.on('listening', () => console.log(`Image origin: http://127.0.0.1:${port}`))
server.on('error', () => {
console.error('Image server could not start.')
if (redis.isOpen) redis.destroy()
process.exitCode = 1
})
let stopping = false
const stop = () => {
if (stopping) return
stopping = true
const deadline = setTimeout(() => {
server.closeAllConnections()
if (redis.isOpen) redis.destroy()
process.exit(1)
}, 15_000)
deadline.unref()
server.close(() => {
if (redis.isOpen) redis.destroy()
clearTimeout(deadline)
})
}
process.once('SIGTERM', stop)
process.once('SIGINT', stop)
}
main().catch(() => {
console.error('Image origin startup failed.')
if (redis.isOpen) redis.destroy()
process.exitCode = 1
})
Inicia el origen en esta terminal:
corepack yarn start
Espera a que aparezca Image origin: http://127.0.0.1:3000.
Si el puerto está ocupado o Redis no está disponible, el inicio falla. Elige otro puerto para
el origen con PORT=3002 corepack yarn start y ajusta las URL que aparecen a continuación.
Mantén Redis privado y confiable: los bytes en caché no se vuelven a validar como imágenes.
Verifica las variantes con la caché fría y caliente
En una segunda terminal, dentro del directorio del proyecto, solicita la misma URL cuatro veces. Estos comandos sobrescriben los cuatro archivos de salida indicados; usa un directorio nuevo si quieres conservar los resultados anteriores.
curl -fsSLo photo.webp -D webp.headers -H 'Accept: image/webp' 'http://127.0.0.1:3000/images/photo-v1.png?w=320' &&
curl -fsSLo photo.jpg -D jpeg.headers -H 'Accept: image/jpeg' 'http://127.0.0.1:3000/images/photo-v1.png?w=320' &&
curl -fsSLo cached.webp -H 'Accept: image/webp' 'http://127.0.0.1:3000/images/photo-v1.png?w=320' &&
curl -fsSLo cached.jpg -H 'Accept: image/jpeg' 'http://127.0.0.1:3000/images/photo-v1.png?w=320'
Ambos archivos de encabezados deberían contener Vary: Accept, Cache-Control: public, max-age=3600
y el Content-Type correspondiente.
Vary indica a una caché HTTP que la
respuesta también depende de Accept; no hace que la clave de Redis distinga
entre variantes. La clave también debe incluir el formato resuelto.
Guarda check-results.mts para decodificar los archivos, verificar sus formatos y
dimensiones reales y comparar los bytes obtenidos con la caché fría y caliente:
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import sharp from 'sharp'
for (const [cold, warm, format] of [
['photo.webp', 'cached.webp', 'webp'],
['photo.jpg', 'cached.jpg', 'jpeg'],
]) {
const bytes = await readFile(cold)
assert.deepEqual(await readFile(warm), bytes)
const decoder = sharp(bytes)
const metadata = await decoder.metadata()
assert.equal(metadata.format, format)
assert.equal(metadata.width, 320)
assert.equal(metadata.height, 200)
const { data, info } = await decoder.ensureAlpha().raw().toBuffer({ resolveWithObject: true })
const left = (100 * info.width + 80) * info.channels
assert.ok(data[left] > 245 && data[left + 1] < 15 && data[left + 2] < 15)
assert.equal(data[left + 3], 255)
const right = (100 * info.width + 240) * info.channels
assert.equal(data[right + 3], format === 'webp' ? 0 : 255)
if (format === 'jpeg') assert.ok(data[right] > 245 && data[right + 1] > 245 && data[right + 2] > 245)
console.log(`${cold}: ${format}, 320 × 200, warm bytes match`)
}
node check-results.mts
Abre también en un navegador ambos archivos obtenidos con la caché fría: la mitad roja debería estar a la izquierda, con la mitad derecha transparente en WebP y blanca en JPEG. Que los bytes obtenidos con la caché fría y caliente sean iguales no basta para demostrar un acierto de caché. Mientras el origen esté en ejecución, inspecciona las dos claves de Redis:
docker exec image-origin-redis redis-cli --scan --pattern '[[]"image-v1"*'
Deberían ser ["image-v1","photo-v1.png","320","webp"] y
["image-v1","photo-v1.png","320","jpeg"]. Cada valor almacenado es la imagen correspondiente en Base64,
con una caducidad de una hora. Un ancho nuevo crea otra variante; solicitar w=1280
sigue produciendo una imagen de 640 × 400 porque el optimizador no amplía la muestra.
Publica una imagen de origen modificada
Añade un archivo photo-v2.png revisado y una nueva entrada ['photo-v2.png', 'photo-v2.png']
en el mapa de publicación. Usa /images/photo-v2.png?w=320 en la página que lo consume.
Mantén inmutables el archivo y la ruta anteriores: si sobrescribes photo-v1.png
o cambias el destino de su URL existente, las respuestas que el navegador ya tiene en caché
siguen siendo válidas hasta que caduquen. El nombre del archivo de origen en la clave de Redis
separa las versiones en el origen.
Incrementa el prefijo de política del codificador image-v1 al cambiar los
ajustes de salida. Esto reemplaza las claves de Redis, pero las cachés HTTP existentes siguen
requiriendo URL nuevas o una invalidación deliberada. El almacenamiento de objetos puede
proporcionar archivos aprobados mediante un publicador; la obtención de archivos desde URL
remotas arbitrarias queda fuera del alcance de esta guía.
Detén y recupera el origen local
Presiona Ctrl+C en la terminal del origen para dejar de aceptar conexiones y permitir que terminen las solicitudes activas. Después de 15 segundos, el proceso fuerza el cierre de las conexiones y termina con un estado de error; esto no cancela las transformaciones de inmediato. Luego detén la caché:
docker stop image-origin-redis
Si Redis deja de estar disponible o un comando se bloquea durante dos segundos, el origen devuelve
un 503 que no se almacena en caché, en lugar de continuar sin la caché. Al vencer el plazo, se cierra
la conexión compartida de Redis y se rechazan sus comandos pendientes. La reconexión automática
y la cola sin conexión están deshabilitadas; reinicia este origen después de reiniciar Redis.
Vuelve a ejecutar solo el comando docker run de la configuración y luego corepack yarn start;
no vuelvas a ejecutar el generador de imágenes de origen. El límite de dos solicitudes incluye
el acceso a la caché y cada solicitud ocupa su lugar hasta que termina su manejador, por lo que
una solicitud concurrente adicional recibe un 503. Es un límite de admisión por proceso,
no una garantía sobre el uso total de memoria o CPU.
Conecta un origen a la entrega
Redis reutiliza una respuesta transformada en este origen. No coloca copias cerca de los lectores,
no termina las conexiones TLS públicas ni demuestra una mejora del rendimiento. Una CDN delante
de un origen que transforma imágenes debe incluir el ancho y el formato explícito en su clave,
o respetar correctamente Vary: Accept en las respuestas negociadas.
Excluye los errores de la caché del borde de la red. Tener varios procesos de origen también
multiplica el límite de admisión.
Nuestra guía de S3 y CloudFront explica una configuración de entrega independiente respaldada por almacenamiento; no es una guía de despliegue para este servidor Express. Para obtener procesamiento y entrega gestionados, consulta los servicios de procesamiento de imágenes y Smart CDN de Transloadit.
