Créer un serveur d’origine d’images local avec Sharp et Redis
Servez un JPEG ou un WebP redimensionné depuis la même URL d’image sans mélanger leurs octets en cache. Ce guide crée un serveur d’origine d’images local, génère un exemple transparent et vérifie les réponses à froid et à chaud. Le serveur d’origine est un composant d’un CDN d’images personnalisé ; la diffusion mondiale en périphérie nécessite un CDN distinct.
Prérequis
Utilisez une version maintenue de Node.js 24 LTS, au minimum 24.15.0, Corepack avec Yarn 4, Docker et cURL.
L’exemple utilise Express 5.2.1, Sharp 0.35.4, le client Redis 6.2.1 et Redis 8.10.2. La
prise en charge native de TypeScript par Node
exécute directement les fichiers .mts, sans compilateur ni tsx.
Ne publiez que des sources JPEG ou PNG examinées, immuables et à une seule image. Ce serveur d’origine n’a aucune route de téléversement ni d’authentification : chaque image répertoriée et chaque dérivé sont publics. Les limites d’octets et de pixels ci-dessous réduisent le travail accepté ; elles n’isolent pas le décodeur natif de Sharp dans un bac à sable.
Configurer le projet
Créez un nouveau répertoire image-origin vide et ouvrez-y un terminal.
Enregistrez ce fichier 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" }
}
Enregistrez aussi .yarnrc.yml. Il sélectionne une installation locale de
node_modules et empêche l’exécutable Yarn d’un projet englobant de prendre la main :
nodeLinker: node-modules
enableGlobalCache: false
ignorePath: true
Créez le fichier de verrouillage qui délimite le projet avant l’installation. Exécutez toutes les commandes ci-dessous depuis ce répertoire :
touch yarn.lock && corepack yarn install
Conservez le fichier yarn.lock généré ; les installations ultérieures peuvent
utiliser corepack yarn install --immutable.
Enregistrez create-fixture.mts pour générer un exemple de 640 × 400 : une moitié gauche
rouge opaque et une moitié droite transparente. Il refuse de remplacer un fichier source existant.
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' })
Générez-le, puis démarrez un cache Redis jetable lié à l’interface de bouclage. Si le port 6379 est
occupé, choisissez un autre port hôte et fournissez son URL via REDIS_URL
au démarrage du serveur d’origine.
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
Le cache est jetable : cette configuration n’écrit aucun instantané Redis ni fichier en ajout seul.
Les originaux restent dans images/. La
politique d’éviction de Redis supprime des entrées du cache au besoin ;
une entrée absente peut être régénérée.
Créer un optimiseur d’images minimal
Enregistrez optimizer.mts. Le code appelant lui fournit un nom de fichier issu de la
table de publication, plutôt qu’une URL ou un chemin choisi par l’utilisateur. Ce module d’optimisation
accepte lui-même au maximum 8 MiB d’octets du fichier source et 12 millions de pixels en entrée. Le répertoire doit rester sous
le contrôle de la personne ou du processus qui publie les images pendant l’exécution des requêtes.
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()
}
L’image s’inscrit dans le carré demandé, en conservant ses proportions sans agrandissement. Le JPEG compose les zones transparentes sur un fond blanc ; le WebP préserve la transparence. La politique de sortie par défaut de Sharp convertit l’image en sRGB et supprime les métadonnées sources. La rotation applique l’orientation EXIF avant la suppression de ces métadonnées. Un décodage réussi ne constitue pas un contrôle d’intégrité de l’original : examinez les pixels sources avant publication.
Servir et mettre en cache les variantes négociées
Enregistrez server.mts. Omettez f pour négocier un
format à l’aide de Accept, ou demandez explicitement
f=jpeg ou f=webp.
Le paramètre de largeur w accepte 320, 640 ou 1 280 ; la valeur par défaut
est 640. Les autres paramètres et les paramètres en double sont rejetés.
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
})
Démarrez le serveur d’origine dans ce terminal :
corepack yarn start
Attendez l’affichage de Image origin: http://127.0.0.1:3000.
Un port occupé ou une instance Redis indisponible fait échouer le démarrage. Choisissez un autre
port pour le serveur d’origine avec PORT=3002 corepack yarn start et adaptez les URL ci-dessous.
Gardez Redis privé et de confiance : les octets mis en cache ne sont pas revalidés en tant qu’images.
Vérifier les variantes à froid et à chaud
Dans un second terminal ouvert dans le répertoire du projet, demandez la même URL quatre fois. Ces commandes écrasent les quatre fichiers de sortie nommés ; utilisez un nouveau répertoire si vous souhaitez conserver les résultats précédents.
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'
Les deux fichiers d’en-têtes devraient contenir Vary: Accept,
Cache-Control: public, max-age=3600 et le Content-Type correspondant.
Vary indique à un cache HTTP que la
réponse dépend aussi de Accept ; il ne rend pas la clé Redis sensible aux
variantes. La clé doit aussi inclure le format déterminé.
Enregistrez check-results.mts pour décoder les fichiers, vérifier leurs formats et
dimensions réels, et comparer les octets obtenus à froid et à chaud :
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
Ouvrez aussi les deux fichiers obtenus à froid dans un navigateur : la moitié rouge devrait être à gauche, avec une moitié droite transparente pour le WebP et blanche pour le JPEG. L’égalité des octets obtenus à froid et à chaud ne suffit pas à prouver qu’une réponse provient du cache. Pendant que le serveur d’origine fonctionne, inspectez les deux clés Redis :
docker exec image-origin-redis redis-cli --scan --pattern '[[]"image-v1"*'
Elles devraient être ["image-v1","photo-v1.png","320","webp"] et
["image-v1","photo-v1.png","320","jpeg"]. Chaque valeur stockée est l’image correspondante en Base64,
avec une expiration au bout d’une heure. Une nouvelle largeur crée une autre variante ; demander
w=1280 produit toujours une image de 640 × 400, car l’optimiseur n’agrandit
pas l’exemple.
Publier une source modifiée
Ajoutez un fichier photo-v2.png examiné et une nouvelle entrée
['photo-v2.png', 'photo-v2.png'] dans la table de publication.
Utilisez /images/photo-v2.png?w=320 dans la page qui l’exploite. Gardez l’ancien fichier et
l’ancienne route immuables : écraser photo-v1.png ou faire pointer son URL existante
vers une autre cible laisse les réponses déjà mises en cache par les navigateurs valides jusqu’à
leur expiration. Le nom du fichier source dans la clé Redis sépare les versions au niveau du
serveur d’origine.
Incrémentez le préfixe image-v1 de la politique d’encodage lorsque vous modifiez
les paramètres de sortie. Cela remplace les clés Redis, mais les caches HTTP existants nécessitent
toujours de nouvelles URL ou une invalidation délibérée. Un stockage objet peut fournir, par
l’intermédiaire de la personne ou du processus qui publie les images, des fichiers approuvés ;
la récupération de fichiers à partir d’URL distantes arbitraires sort du cadre de ce guide.
Arrêter et rétablir le serveur d’origine local
Appuyez sur Ctrl+C dans le terminal du serveur d’origine pour arrêter d’accepter des connexions et laisser les requêtes actives se terminer. Après 15 secondes, le processus force la fermeture des connexions et se termine en échec ; il ne s’agit pas d’une annulation immédiate des transformations. Arrêtez ensuite le cache :
docker stop image-origin-redis
Si Redis disparaît ou si une commande reste bloquée pendant deux secondes, le serveur d’origine
renvoie une réponse 503 non mise en cache plutôt que de continuer sans cache. L’expiration du délai
ferme la connexion Redis partagée et rejette ses commandes en attente. La reconnexion automatique
et la file d’attente hors ligne sont désactivées ; redémarrez ce serveur d’origine après avoir
redémarré Redis. Réexécutez uniquement la commande docker run de la configuration,
puis corepack yarn start ; ne réexécutez pas le générateur de sources. La limite de deux
requêtes inclut l’accès au cache, et chaque requête occupe une place jusqu’à ce que son gestionnaire
se termine ; une requête simultanée supplémentaire reçoit donc une réponse 503. Il s’agit d’une
limite d’admission par processus, pas d’une garantie sur l’utilisation totale de mémoire ou de CPU.
Relier un serveur d’origine à la diffusion
Redis réutilise une réponse transformée sur ce serveur d’origine. Il ne place pas de copies près
des lecteurs, n’assure pas la terminaison des connexions TLS publiques et ne permet pas de conclure
à un gain de performances. Un CDN placé devant un serveur d’origine qui transforme les images doit
inclure la largeur et le format explicite dans ses clés de cache, ou respecter correctement
Vary: Accept pour les réponses négociées.
Excluez les erreurs de la mise en cache en périphérie. Plusieurs processus d’origine multiplient
aussi la limite d’admission.
Notre guide S3 et CloudFront (English) présente une configuration de diffusion distincte adossée au stockage ; ce n’est pas une procédure de déploiement de ce serveur Express. Pour un traitement et une diffusion gérés, consultez le traitement d’images et le Smart CDN de Transloadit.
