Criar um servidor de origem de imagens local com Sharp e Redis
Sirva um JPEG ou WebP redimensionado pela mesma URL de imagem sem misturar seus bytes em cache. Este guia cria um servidor de origem de imagens local, gera uma amostra transparente e verifica respostas com cache frio e quente. O servidor de origem é um componente de uma CDN de imagens personalizada; a distribuição global por nós de borda exige uma CDN separada.
Pré-requisitos
Use uma versão mantida do Node.js 24 LTS, no mínimo a 24.15.0, Corepack com Yarn 4, Docker e cURL.
O exemplo usa Express 5.2.1, Sharp 0.35.4, o cliente Redis 6.2.1 e Redis 8.10.2. O
suporte nativo a TypeScript do Node
executa os arquivos .mts diretamente, sem compilador nem tsx.
Publique apenas imagens de origem JPEG ou PNG revisadas, imutáveis e com um único quadro. Este servidor de origem não tem rota de upload nem de autenticação: todas as imagens listadas e suas derivadas são públicas. Os limites de bytes e pixels abaixo reduzem o trabalho aceito; eles não isolam o decodificador nativo do Sharp em um sandbox.
Configurar o projeto
Crie um diretório image-origin novo e vazio e abra um terminal nele. Salve este 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" }
}
Salve também .yarnrc.yml. Ele seleciona uma instalação local de node_modules
e impede que o executável do Yarn de um projeto que englobe esse diretório assuma o controle:
nodeLinker: node-modules
enableGlobalCache: false
ignorePath: true
Crie o arquivo de lock que delimita o projeto antes de instalar. Execute todos os comandos abaixo nesse diretório:
touch yarn.lock && corepack yarn install
Mantenha o yarn.lock gerado; instalações posteriores podem usar corepack yarn install --immutable.
Salve create-fixture.mts para gerar uma amostra de 640 × 400: a metade esquerda vermelha
opaca e a metade direita transparente. Ele se recusa a substituir um arquivo de origem 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' })
Gere a amostra e inicie um cache Redis descartável vinculado à interface de loopback. Se a porta
6379 estiver ocupada, escolha outra porta no host e forneça sua URL por meio de
REDIS_URL ao iniciar o servidor de origem.
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
O cache é descartável: esta configuração não grava snapshots nem arquivos append-only do Redis.
Os originais permanecem em images/. A
política de remoção do Redis remove entradas em cache quando
necessário; uma ausência no cache pode levar à regeneração dessas entradas.
Criar um otimizador de imagens mínimo
Salve optimizer.mts. Quem o chama fornece um nome de arquivo do mapa de publicação,
em vez de uma URL ou um caminho selecionado pelo usuário. Ele aceita no máximo 8 MiB de bytes do
arquivo de origem e 12 milhões de pixels de entrada. O diretório deve permanecer sob o controle de
quem publica enquanto as requisições são processadas.
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()
}
A imagem se ajusta ao quadrado solicitado, preservando a proporção sem ampliação. O JPEG compõe a transparência sobre um fundo branco; o WebP a preserva. A política de saída padrão do Sharp converte para sRGB e descarta os metadados de origem. A rotação aplica a orientação EXIF antes da remoção desses metadados. A decodificação bem-sucedida não é uma verificação de integridade do original: revise os pixels de origem antes de publicar.
Servir e armazenar variantes negociadas em cache
Salve server.mts. Omita f para negociar um formato usando
Accept, ou solicite f=jpeg ou f=webp explicitamente.
A largura w aceita 320, 640 ou 1.280; o padrão é 640. Outros parâmetros
ou parâmetros duplicados são rejeitados.
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
})
Inicie o servidor de origem neste terminal:
corepack yarn start
Aguarde Image origin: http://127.0.0.1:3000. Uma porta ocupada ou um Redis
indisponível faz a inicialização falhar. Escolha outra porta para o servidor de origem com
PORT=3002 corepack yarn start e ajuste as URLs abaixo.
Mantenha o Redis privado e confiável: os bytes em cache não são revalidados como imagens.
Verificar variantes com cache frio e quente
Em um segundo terminal no diretório do projeto, solicite a mesma URL quatro vezes. Estes comandos sobrescrevem os quatro arquivos de saída nomeados; use um diretório novo se quiser manter os 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 os arquivos de cabeçalhos devem conter Vary: Accept, Cache-Control: public, max-age=3600 e o
Content-Type correspondente.
Vary informa a um cache HTTP que a
resposta também depende de Accept; isso não faz a chave do Redis distinguir
variantes. A chave também deve incluir o formato resolvido.
Salve check-results.mts para decodificar os arquivos, verificar seus formatos e dimensões
reais e comparar os bytes obtidos com cache frio e quente:
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
Abra também os dois arquivos obtidos com cache frio em um navegador: a metade vermelha deve estar à esquerda, com a metade direita transparente no WebP e branca no JPEG. A igualdade dos bytes obtidos com cache frio e quente, por si só, não comprova um acerto no cache. Enquanto o servidor de origem estiver em execução, inspecione as duas chaves do Redis:
docker exec image-origin-redis redis-cli --scan --pattern '[[]"image-v1"*'
Elas devem ser ["image-v1","photo-v1.png","320","webp"] e
["image-v1","photo-v1.png","320","jpeg"]. Cada valor armazenado é a imagem correspondente em Base64,
com expiração em uma hora. Uma nova largura cria outra variante; solicitar
w=1280 ainda produz 640 × 400 porque o otimizador não amplia a amostra.
Publicar uma imagem de origem alterada
Adicione um arquivo photo-v2.png revisado e uma nova entrada
['photo-v2.png', 'photo-v2.png'] no mapa de publicação.
Use /images/photo-v2.png?w=320 na página que consome a imagem. Mantenha o arquivo e a rota
antigos imutáveis: sobrescrever photo-v1.png ou alterar o destino da URL existente
mantém válidas as respostas já armazenadas no cache do navegador até a expiração. O nome do arquivo
de origem na chave do Redis separa as versões no servidor de origem.
Incremente o prefixo de política do codificador image-v1 ao alterar as
configurações de saída. Isso substitui as chaves do Redis, mas os caches HTTP existentes ainda
exigem novas URLs ou invalidação deliberada. O armazenamento de objetos pode fornecer arquivos
aprovados por meio de um publicador; buscar URLs remotas arbitrárias está fora do escopo deste guia.
Parar e recuperar o servidor de origem local
Pressione Ctrl+C no terminal do servidor de origem para parar de aceitar conexões e permitir que as requisições ativas terminem. Após 15 segundos, o processo força o fechamento das conexões e termina com status de falha; isso não cancela as transformações imediatamente. Depois, pare o cache:
docker stop image-origin-redis
Se o Redis ficar indisponível ou um comando ficar sem responder por dois segundos, o servidor de
origem retorna um 503 não armazenado em cache, em vez de continuar sem o cache. Ao atingir o prazo
limite, a conexão compartilhada com o Redis é fechada e seus comandos pendentes são rejeitados.
A reconexão automática e a fila offline estão desativadas; reinicie este servidor de origem após
reiniciar o Redis. Execute novamente apenas o comando docker run da configuração
e, depois, corepack yarn start; não execute novamente o gerador da imagem de origem.
O limite de duas requisições inclui o acesso ao cache, e cada requisição ocupa sua vaga até que seu
handler termine; por isso, uma requisição simultânea adicional recebe 503. Esse é um limite de
admissão por processo, não uma garantia de uso total de memória ou CPU.
Conectar um servidor de origem à distribuição
O Redis reutiliza uma resposta transformada neste servidor de origem. Ele não coloca cópias perto
dos leitores, não termina conexões TLS públicas nem comprova uma melhoria de desempenho. Uma CDN
à frente de um servidor de origem que transforma imagens deve incluir a largura e o formato
explícito na chave de cache ou respeitar corretamente Vary: Accept nas respostas
negociadas. Exclua os erros do cache de borda. Múltiplos processos do servidor de origem também
multiplicam o limite de admissão.
Nosso guia de S3 e CloudFront (English) aborda uma configuração separada de distribuição baseada em armazenamento; ele não é um guia de implantação para este servidor Express. Para processamento e distribuição gerenciados, confira o processamento de imagens e o Smart CDN da Transloadit.
