Comment créer un CDN d’images avec AWS S3 et CloudFront
Placez des images préconstruites dans un bucket S3 privé et laissez une distribution CloudFront publique les servir en HTTPS. La recette Node.js ci-dessous crée cette configuration, publie un PNG sous un hachage de son contenu, vérifie l’intégralité des octets téléchargés et fournit une commande de suppression distincte.
Pourquoi utiliser un CDN pour les images
CloudFront peut mettre vos images en cache dans ses emplacements périphériques. S3 stocke les originaux ; un contrôle d’accès à l’origine (OAC, origin access control) signe les requêtes de CloudFront vers S3. La politique du bucket autorise les lectures provenant exactement de cette distribution, tandis que S3 Block Public Access reste activé. AWS documente cette combinaison pour une origine S3 standard. Les URL CloudFront obtenues sont publiques. L’OAC protège l’origine et n’authentifie pas les visiteurs.
Cette configuration diffuse les octets que vous téléversez. Elle ne redimensionne pas les images, ne convertit pas les formats et n’interprète pas les paramètres de requête de transformation. Préparez chaque variante d’image avant de la publier.
Configurer un projet Node autonome
Utilisez un shell POSIX avec Node.js 26.8.1 ou une version plus récente de Node.js 26, ainsi que Corepack installé séparément. Pour une nouvelle installation, choisissez le correctif de sécurité actuel dans la liste des versions de Node.js. Le manifeste du paquet fige Yarn 4.12.0 et les clients AWS SDK v3 3.1073.0 pour cet exemple. Ces versions du SDK sont figées pour la reproductibilité ; elles ne prétendent pas être les derniers correctifs.
Vous avez également besoin d’un compte AWS autorisé à créer, inspecter, mettre à jour et supprimer un
bucket S3, ses objets et sa politique de bucket, une distribution CloudFront et un OAC. La vérification
nécessite aussi un accès en lecture à la politique de cache gérée et aux paramètres S3 d’accès public,
de propriété et de gestion des versions. Le script utilise la partition AWS standard et fixe la Région
S3 à us-east-1. Il exige AWS_ACCOUNT_ID, AWS_ACCESS_KEY_ID
et AWS_SECRET_ACCESS_KEY dans son environnement ; ajoutez AWS_SESSION_TOKEN pour des identifiants
temporaires. Fournissez-les via vos outils d’identifiants existants. Le script ne charge pas de profils
et ne se rabat pas sur une autre identité.
Commencez dans un répertoire temporaire, en dehors de tout projet JavaScript existant. La procédure
d’installation vérifie les répertoires parents avant de créer image-cdn, afin qu’un espace de
travail parent ou une configuration Yarn ne puisse pas modifier silencieusement son installation. Elle
refuse une destination existante et ne change pas le répertoire courant de votre shell.
(
command -v node >/dev/null || { printf 'Install Node.js first.\n' >&2; exit 1; }
command -v corepack >/dev/null || { printf 'Install Corepack first.\n' >&2; exit 1; }
node --input-type=module <<'NODE'
import { existsSync } from 'node:fs'
import { mkdir, realpath, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
const [major, minor, patch] = process.versions.node.split('.').map(Number)
if (major !== 26 || minor < 8 || (minor === 8 && patch < 1)) {
throw new Error('Use Node.js 26.8.1 or a newer patch/minor in Node.js 26.')
}
for (let directory = await realpath('.'); ; directory = dirname(directory)) {
const projectFiles = ['package.json', '.yarnrc.yml', '.yarnrc', '.pnp.cjs', '.pnp.loader.mjs',
'yarn.lock', 'package-lock.json', 'pnpm-lock.yaml', '.npmrc']
if (projectFiles.some((name) => existsSync(join(directory, name)))) {
throw new Error('Start outside an existing JavaScript project or package-manager configuration.')
}
if (dirname(directory) === directory) break
}
await mkdir('image-cdn')
await writeFile('image-cdn/package.json', `${JSON.stringify({
name: 'image-cdn-example', private: true, type: 'module', packageManager: 'yarn@4.12.0',
dependencies: { '@aws-sdk/client-cloudfront': '3.1073.0', '@aws-sdk/client-s3': '3.1073.0' },
}, null, 2)}\n`, { flag: 'wx' })
await writeFile('image-cdn/.yarnrc.yml', 'nodeLinker: node-modules\n', { flag: 'wx' })
NODE
if [ "$?" -ne 0 ]; then exit 1; fi
cd image-cdn || exit 1
corepack yarn install
)
Si l’installation échoue après la création des fichiers du projet, conservez ce répertoire et son manifeste. Relancez l’installation depuis le même répertoire temporaire :
(cd image-cdn && corepack yarn install)
Enregistrez un PNG préconstruit valide sous image-cdn/image.png. Cet exemple accepte un fichier non vide
jusqu’à 5 MiB et vérifie sa signature PNG ; il ne valide pas ses pixels décodés. Les chemins d’entrée
doivent contenir uniquement des caractères ASCII imprimables et commencer par ./ ou
/. Utilisez une image que vous comptez rendre publique. Exécutez une commande à la fois
et conservez cdn-state.json jusqu’à la fin de la suppression.
Enregistrer le programme de déploiement et de vérification
Enregistrez ce fichier complet sous image-cdn/cdn.ts. Il utilise le point de terminaison S3 régional
standard, un OAC qui signe toujours les requêtes et la
politique CachingOptimizedForUncompressedObjects gérée par AWS.
Cette politique a un TTL minimal de 1 seconde, un TTL par défaut de 1 jour et un TTL maximal de
365 jours. Chaque image téléversée définit Cache-Control: public, max-age=31536000, immutable.
Chaque requête SDK effectue une seule tentative et dispose d’un signal d’abandon de 6 secondes. Chaque attente de disponibilité CloudFront dispose de sa propre fenêtre de 20 minutes, y compris une fenêtre distincte pour la désactivation de la distribution lors de la suppression. Chaque récupération anonyme a un délai de 30 secondes qui s’étend jusqu’à la fin du corps de la réponse. Un transfert valide plus lent peut donc faire échouer la vérification. L’expiration d’une fenêtre de déploiement signifie que le résultat est incomplet ; elle ne signifie pas qu’AWS a annulé le déploiement.
import assert from 'node:assert/strict'
import { createHash, randomUUID } from 'node:crypto'
import { readFile, rename, stat, writeFile } from 'node:fs/promises'
import { addAbortSignal, Readable } from 'node:stream'
import { setTimeout as delay } from 'node:timers/promises'
import { parseArgs } from 'node:util'
import {
CloudFrontClient, CreateDistributionCommand, CreateOriginAccessControlCommand,
DeleteDistributionCommand, DeleteOriginAccessControlCommand, GetCachePolicyCommand,
GetDistributionCommand, GetOriginAccessControlCommand, UpdateDistributionCommand,
} from '@aws-sdk/client-cloudfront'
import {
CreateBucketCommand, DeleteBucketCommand, DeleteObjectCommand, GetBucketOwnershipControlsCommand,
GetBucketPolicyCommand, GetBucketVersioningCommand, GetObjectCommand, GetPublicAccessBlockCommand,
HeadBucketCommand, ListObjectsV2Command, PutBucketPolicyCommand, PutObjectCommand, S3Client,
} from '@aws-sdk/client-s3'
type State = {
owner: string
bucket: string
bucketCreated: boolean
keys: string[]
attemptedKey?: string
pending?: string
oacId?: string
distributionId?: string
distributionArn?: string
domain?: string
removed?: boolean
}
const region = 'us-east-1'
const cachePolicyId = 'b2884449-e4de-46a7-ac36-70bc7f1ddd6d'
const cacheControl = 'public, max-age=31536000, immutable'
const maxBytes = 5 * 1024 * 1024
const statePath = 'cdn-state.json'
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
class RecipeError extends Error {}
function check(condition: unknown, message: string): asserts condition {
if (!condition) throw new RecipeError(message)
}
function required(name: string): string {
const value = process.env[name]
check(value, `Set ${name} before running this command.`)
return value
}
function statusOf(error: unknown): number | undefined {
if (typeof error !== 'object' || error === null || !('$metadata' in error)) return
const metadata = error.$metadata
if (typeof metadata !== 'object' || metadata === null || !('httpStatusCode' in metadata)) return
return typeof metadata.httpStatusCode === 'number' ? metadata.httpStatusCode : undefined
}
function validState(value: unknown): value is State {
if (typeof value !== 'object' || value === null) return false
return 'owner' in value && typeof value.owner === 'string' && uuidPattern.test(value.owner)
&& 'bucket' in value && value.bucket === `image-cdn-${value.owner}`
&& 'bucketCreated' in value && typeof value.bucketCreated === 'boolean'
&& 'keys' in value && Array.isArray(value.keys)
&& value.keys.every((key: unknown) => typeof key === 'string' && /^images\/[a-f0-9]{64}\.png$/.test(key))
&& ['attemptedKey', 'pending', 'oacId', 'distributionId', 'distributionArn', 'domain'].every((key) =>
!(key in value) || typeof Reflect.get(value, key) === 'string')
&& (!('removed' in value) || typeof value.removed === 'boolean')
}
async function main(): Promise<void> {
const { positionals, values } = parseArgs({ allowPositionals: true, options: { id: { type: 'string' } } })
const [command, input, ...extra] = positionals
check(extra.length === 0 && ['deploy', 'publish', 'verify', 'destroy'].includes(command ?? ''),
'Usage: node cdn.ts deploy|publish|verify image.png [--id UUID], or node cdn.ts destroy')
check(command === 'deploy' || values.id === undefined, '--id is only accepted for deploy.')
check(command === 'destroy' ? input === undefined : input !== undefined, 'Supply exactly one PNG input, except for destroy.')
let bytes = Buffer.alloc(0)
if (input !== undefined) {
check(/^[\x20-\x7e]+$/.test(input) && (input.startsWith('./') || input.startsWith('/')),
'Use an ASCII input path beginning with ./ or /.')
const info = await stat(input).catch(() => { throw new RecipeError('Cannot read the PNG input.') })
check(info.isFile() && info.size > 0 && info.size <= maxBytes, 'Input must be a PNG file of 1 byte to 5 MiB.')
bytes = await readFile(input)
check(bytes.length <= maxBytes && bytes.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex')),
'Input must have a PNG signature and be at most 5 MiB. Prepare a valid PNG before upload.')
}
const digest = createHash('sha256').update(bytes).digest('hex')
const key = `images/${digest}.png`
const account = required('AWS_ACCOUNT_ID')
check(/^\d{12}$/.test(account), 'AWS_ACCOUNT_ID must be your 12-digit account ID.')
const credentials = {
accessKeyId: required('AWS_ACCESS_KEY_ID'), secretAccessKey: required('AWS_SECRET_ACCESS_KEY'),
sessionToken: process.env.AWS_SESSION_TOKEN,
}
const s3 = new S3Client({ region, endpoint: `https://s3.${region}.amazonaws.com`, credentials, maxAttempts: 1, followRegionRedirects: false })
const cf = new CloudFrontClient({ region: 'us-east-1', endpoint: 'https://cloudfront.amazonaws.com', credentials, maxAttempts: 1 })
const options = () => ({ abortSignal: AbortSignal.timeout(6_000) })
let state: State
async function save(): Promise<void> {
await writeFile(`${statePath}.tmp`, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 })
await rename(`${statePath}.tmp`, statePath)
}
async function mutate<T>(phase: string, action: () => Promise<T>, remember: (result: T) => void): Promise<void> {
state.pending = phase
await save()
try {
const result = await action()
remember(result)
delete state.pending
await save()
} catch (error) {
const status = statusOf(error)
// A definitive client-error response refused this operation. A lost response stays uncertain.
if (status !== undefined && status >= 400 && status < 500) {
delete state.pending
await save()
}
throw new RecipeError(`${phase} failed. Keep ${statePath}; inspect it before recovery.`, { cause: error })
}
}
function bucketInput() {
return { Bucket: state.bucket, ExpectedBucketOwner: account }
}
function policy() {
return {
Version: '2012-10-17', Statement: [{ Effect: 'Allow', Principal: { Service: 'cloudfront.amazonaws.com' },
Action: 's3:GetObject', Resource: `arn:aws:s3:::${state.bucket}/images/*`,
Condition: { StringEquals: { 'AWS:SourceArn': state.distributionArn } } }],
}
}
async function unversioned(): Promise<void> {
const result = await s3.send(new GetBucketVersioningCommand(bucketInput()), options())
check(result.Status === undefined, 'Versioning changed. Stop and inventory versions before deleting anything.')
}
async function deployed(enabled: boolean) {
check(state.distributionId, 'No confirmed distribution ID in the state file.')
const deadline = Date.now() + 1_200_000
for (let observation = 0; observation < 21 && Date.now() < deadline; observation += 1) {
const result = await cf.send(new GetDistributionCommand({ Id: state.distributionId }), {
abortSignal: AbortSignal.timeout(Math.min(6_000, Math.max(1, deadline - Date.now()))),
})
const config = result.Distribution?.DistributionConfig
check(config?.CallerReference === state.owner, 'Distribution ownership mismatch.')
check(config.Enabled === enabled, 'Distribution enabled state differs from the requested state.')
if (result.Distribution?.Status === 'Deployed') return result
check(result.Distribution?.Status === 'InProgress', 'Unexpected distribution deployment status.')
await delay(Math.min(60_000, Math.max(1, deadline - Date.now())))
}
throw new RecipeError('Deployment wait expired. Outcome is incomplete; retain the state and run destroy.')
}
async function verify(): Promise<void> {
check(state.domain && state.distributionArn && state.oacId, 'Deployment is incomplete.')
const distribution = await deployed(true)
const config = distribution.Distribution?.DistributionConfig
check(config, 'Distribution configuration is missing.')
const origins = config.Origins
check(origins, 'Distribution origins are missing.')
const origin = origins.Items?.[0]
check(origins.Quantity === 1 && origin?.DomainName === `${state.bucket}.s3.${region}.amazonaws.com`
&& origin.OriginAccessControlId === state.oacId && origin.S3OriginConfig?.OriginAccessIdentity === '',
'Expected a regular S3 origin using this OAC.')
const behavior = config.DefaultCacheBehavior
check(behavior, 'Default cache behavior is missing.')
check(behavior.CachePolicyId === cachePolicyId && behavior.ViewerProtocolPolicy === 'https-only'
&& behavior.TrustedKeyGroups?.Enabled !== true && behavior.TrustedSigners?.Enabled !== true,
'Expected public HTTPS viewers and the selected cache policy.')
const access = await s3.send(new GetPublicAccessBlockCommand(bucketInput()), options())
const block = access.PublicAccessBlockConfiguration
check(block?.BlockPublicAcls && block.IgnorePublicAcls && block.BlockPublicPolicy && block.RestrictPublicBuckets,
'All four S3 Block Public Access settings must stay enabled.')
const ownership = await s3.send(new GetBucketOwnershipControlsCommand(bucketInput()), options())
check(ownership.OwnershipControls?.Rules?.[0]?.ObjectOwnership === 'BucketOwnerEnforced', 'S3 ACLs must stay disabled.')
const storedPolicy = await s3.send(new GetBucketPolicyCommand(bucketInput()), options())
assert.deepStrictEqual(JSON.parse(storedPolicy.Policy ?? 'null'), policy())
const oac = await cf.send(new GetOriginAccessControlCommand({ Id: state.oacId }), options())
const signing = oac.OriginAccessControl?.OriginAccessControlConfig
check(signing?.Name === state.bucket && signing.SigningBehavior === 'always'
&& signing.SigningProtocol === 'sigv4' && signing.OriginAccessControlOriginType === 's3', 'OAC signing mismatch.')
const cache = await cf.send(new GetCachePolicyCommand({ Id: behavior.CachePolicyId }), options())
const cacheConfig = cache.CachePolicy?.CachePolicyConfig
check(cacheConfig?.MinTTL === 1 && cacheConfig.DefaultTTL === 86400 && cacheConfig.MaxTTL === 31536000,
'CloudFront cache TTLs differ from the declared policy.')
const readSignal = AbortSignal.timeout(6_000)
const stored = await s3.send(new GetObjectCommand({ ...bucketInput(), Key: key }), { abortSignal: readSignal })
check(stored.CacheControl === cacheControl && stored.ContentType === 'image/png' && stored.ContentLength === bytes.length,
'Uploaded image metadata differs from the declared cache policy or input.')
check(stored.Body instanceof Readable, 'Expected the Node.js S3 response stream.')
addAbortSignal(readSignal, stored.Body)
const storedBytes = await stored.Body.transformToByteArray()
check(storedBytes && Buffer.from(storedBytes).equals(bytes), 'Stored S3 bytes differ from the input.')
const url = `https://${state.domain}/${key}`
const response = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(30_000) })
check(response.status === 200 && response.headers.get('cache-control') === cacheControl
&& response.headers.get('content-type') === 'image/png', 'CloudFront response status or metadata differs.')
const delivered = Buffer.from(await response.arrayBuffer())
check(delivered.length === bytes.length && delivered.equals(bytes)
&& createHash('sha256').update(delivered).digest('hex') === digest, 'CloudFront image bytes differ from the input.')
const originResponse = await fetch(`https://${state.bucket}.s3.${region}.amazonaws.com/${key}`, {
redirect: 'error', signal: AbortSignal.timeout(30_000),
})
await originResponse.body?.cancel()
check([401, 403, 404].includes(originResponse.status), 'Anonymous S3 origin access was not denied.')
console.log(JSON.stringify({ url, key, bytes: delivered.length, sha256: digest,
cacheControl, directOriginStatus: originResponse.status }))
}
async function publish(): Promise<void> {
await unversioned()
state.attemptedKey = key
await mutate('PutObject', () => s3.send(new PutObjectCommand({ ...bucketInput(), Key: key,
Body: bytes, ContentType: 'image/png', CacheControl: cacheControl, IfNoneMatch: '*',
ServerSideEncryption: 'AES256' }), options()), () => {
if (!state.keys.includes(key)) state.keys.push(key)
delete state.attemptedKey
})
await verify()
}
async function absent(action: () => Promise<unknown>): Promise<void> {
try {
await action()
} catch (error) {
if (statusOf(error) === 404) return
throw error
}
throw new RecipeError('A resource still exists after deletion. Retain the state file.')
}
async function destroy(): Promise<void> {
if (state.bucketCreated) {
await unversioned()
const initial = await s3.send(new ListObjectsV2Command(bucketInput()), options())
check(!initial.IsTruncated && (initial.Contents ?? []).every((object) => object.Key && state.keys.includes(object.Key)),
'Unexpected objects or pagination. Stop; do not delete unrecorded keys.')
}
if (state.distributionId) {
const current = await cf.send(new GetDistributionCommand({ Id: state.distributionId }), options())
const config = current.Distribution?.DistributionConfig
check(config?.CallerReference === state.owner && current.ETag, 'Distribution ownership or ETag mismatch.')
if (config.Enabled) {
await mutate('DisableDistribution', () => cf.send(new UpdateDistributionCommand({ Id: state.distributionId,
IfMatch: current.ETag, DistributionConfig: { ...config, Enabled: false } }), options()), () => {})
}
const disabled = await deployed(false)
check(disabled.ETag, 'Missing disabled distribution ETag.')
await mutate('DeleteDistribution', () => cf.send(new DeleteDistributionCommand({ Id: state.distributionId,
IfMatch: disabled.ETag }), options()), () => {})
await absent(() => cf.send(new GetDistributionCommand({ Id: state.distributionId }), options()))
delete state.distributionId
await save()
}
if (state.oacId) {
const current = await cf.send(new GetOriginAccessControlCommand({ Id: state.oacId }), options())
check(current.OriginAccessControl?.OriginAccessControlConfig?.Name === state.bucket && current.ETag,
'OAC ownership or ETag mismatch.')
await mutate('DeleteOriginAccessControl', () => cf.send(new DeleteOriginAccessControlCommand({ Id: state.oacId,
IfMatch: current.ETag }), options()), () => {})
await absent(() => cf.send(new GetOriginAccessControlCommand({ Id: state.oacId }), options()))
delete state.oacId
await save()
}
if (state.bucketCreated) {
await unversioned()
const listing = await s3.send(new ListObjectsV2Command(bucketInput()), options())
check(!listing.IsTruncated && (listing.Contents ?? []).every((object) => object.Key && state.keys.includes(object.Key)),
'Unexpected objects or pagination. Stop; do not delete unrecorded keys.')
for (const ownedKey of state.keys) {
await mutate('DeleteObject', () => s3.send(new DeleteObjectCommand({ ...bucketInput(), Key: ownedKey }), options()), () => {})
}
const empty = await s3.send(new ListObjectsV2Command(bucketInput()), options())
check(!empty.IsTruncated && (empty.Contents ?? []).length === 0, 'Bucket is not confirmed empty.')
await mutate('DeleteBucket', () => s3.send(new DeleteBucketCommand(bucketInput()), options()), () => {})
await absent(() => s3.send(new HeadBucketCommand(bucketInput()), options()))
state.bucketCreated = false
await save()
}
state.removed = true
await save()
console.log(JSON.stringify({ removed: true, bucket: state.bucket }))
}
try {
if (command === 'deploy') {
const owner = values.id ?? randomUUID()
check(uuidPattern.test(owner), '--id must be a lowercase UUID v4.')
state = { owner, bucket: `image-cdn-${owner}`, bucketCreated: false, keys: [] }
await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, { flag: 'wx', mode: 0o600 })
await mutate('CreateBucket', () => s3.send(new CreateBucketCommand({ Bucket: state.bucket }),
options()), () => { state.bucketCreated = true })
await unversioned()
await mutate('CreateOriginAccessControl', () => cf.send(new CreateOriginAccessControlCommand({
OriginAccessControlConfig: { Name: state.bucket, OriginAccessControlOriginType: 's3', SigningBehavior: 'always', SigningProtocol: 'sigv4' },
}), options()), (result) => {
check(result.OriginAccessControl?.Id, 'Missing OAC ID.')
state.oacId = result.OriginAccessControl.Id
})
await mutate('CreateDistribution', () => cf.send(new CreateDistributionCommand({ DistributionConfig: {
CallerReference: state.owner, Comment: state.bucket, Enabled: true,
Origins: { Quantity: 1, Items: [{ Id: 'images', DomainName: `${state.bucket}.s3.${region}.amazonaws.com`,
OriginAccessControlId: state.oacId, S3OriginConfig: { OriginAccessIdentity: '' } }] },
DefaultCacheBehavior: { TargetOriginId: 'images', ViewerProtocolPolicy: 'https-only', CachePolicyId: cachePolicyId,
Compress: false, AllowedMethods: { Quantity: 2, Items: ['GET', 'HEAD'], CachedMethods: { Quantity: 2, Items: ['GET', 'HEAD'] } },
TrustedSigners: { Enabled: false, Quantity: 0 }, TrustedKeyGroups: { Enabled: false, Quantity: 0 } },
ViewerCertificate: { CloudFrontDefaultCertificate: true },
} }), options()), (result) => {
const distribution = result.Distribution
check(distribution?.Id && distribution.ARN && distribution.DomainName, 'Missing distribution identifiers.')
state.distributionId = distribution.Id
state.distributionArn = distribution.ARN
state.domain = distribution.DomainName
})
check(state.distributionArn === `arn:aws:cloudfront::${account}:distribution/${state.distributionId}`, 'AWS account mismatch.')
await mutate('PutBucketPolicy', () => s3.send(new PutBucketPolicyCommand({ ...bucketInput(), Policy: JSON.stringify(policy()) }), options()), () => {})
await publish()
} else {
const saved: unknown = JSON.parse(await readFile(statePath, 'utf8'))
check(validState(saved), 'Invalid state file. Do not edit ownership records.')
state = saved
check(!state.distributionId || !state.distributionArn || state.distributionArn === `arn:aws:cloudfront::${account}:distribution/${state.distributionId}`,
'State belongs to another account, or the distribution ID was changed.')
check(!state.pending, `Uncertain operation: ${state.pending}. Inspect AWS before recovery; do not blindly retry.`)
if (command === 'destroy') await destroy()
else {
check(!state.removed, 'Resources were removed. Create a fresh project for another deployment.')
if (command === 'publish') await publish()
else await verify()
}
}
} finally {
s3.destroy()
cf.destroy()
}
}
main().catch((error: unknown) => {
console.error(error instanceof RecipeError ? error.message : 'AWS or local operation failed. Retain cdn-state.json and check access, input, and state.')
process.exitCode = 1
})
Tester votre CDN
Exécutez la commande suivante depuis le répertoire temporaire, avec les variables d’environnement requises déjà définies :
(cd image-cdn && node cdn.ts deploy ./image.png)
La commande crée un bucket, un OAC et une distribution nommés par UUID, attache une politique de bucket
limitée à la distribution et téléverse l’image. Pour l’automatisation, deploy accepte aussi
--id suivi d’un nouvel UUID v4 en minuscules. La commande refuse de remplacer un
cdn-state.json existant.
En cas de succès, la commande affiche un enregistrement JSON contenant url, key,
bytes, sha256, cacheControl et
directOriginStatus. Ouvrez exactement la valeur url dans un navigateur pour afficher l’image. Avant
d’afficher cet enregistrement, le programme lit l’objet stocké avec des identifiants, compare
l’intégralité de la réponse CloudFront anonyme à votre fichier d’entrée local, vérifie les métadonnées
de cache et la configuration, et confirme qu’une requête anonyme pour le même objet S3 connu est
refusée. Un code HTTP 200 seul ne suffit pas à réussir la vérification.
Pour relancer la vérification sans téléverser à nouveau :
(cd image-cdn && node cdn.ts verify ./image.png)
Optimiser la diffusion des images
La clé de l’objet contient l’empreinte SHA-256 de l’entrée. Des octets différents produisent une clé
différente, et IfNoneMatch: '*' empêche le remplacement d’un objet existant. Pour publier un PNG
modifié, enregistrez-le sous image-cdn/image-v2.png et exécutez :
(cd image-cdn && node cdn.ts publish ./image-v2.png)
Utilisez la nouvelle URL affichée dans votre application. Republier des octets identiques entraîne un
refus d’écrasement ; utilisez verify lorsque vous voulez seulement revérifier une image
existante. N’écrasez pas un nom mis en cache et ne comptez pas sur une chaîne de requête modifiée pour
créer une nouvelle version d’image.
CloudFront combine les en-têtes de cache de l’origine avec les bornes de TTL de la politique de cache.
Un max-age d’un an autorise cette durée de vie en cache ; il ne garantit pas qu’un emplacement
périphérique conserve l’objet pendant un an. AWS décrit cette interaction dans son
guide sur l’expiration du cache.
L’exemple vérifie la configuration et les en-têtes de réponse sans exiger d’en-tête de succès de cache
ni revendiquer une amélioration mesurée de la latence.
Résoudre les problèmes courants
Si deploy ou publish se termine en échec, conservez cdn-state.json. Une mutation AWS
ayant échoué indique sa phase. Les réponses d’erreur client définitives laissent les ressources
antérieures confirmées disponibles pour destroy. Une réponse perdue laisse une opération
pending, car son résultat distant est incertain ; le programme refuse alors toute autre action.
Inspectez exactement la ressource nommée et résolvez l’opération incertaine avant de tenter une
récupération. Ne supprimez pas le fichier d’état et ne relancez pas le déploiement à l’aveugle.
En cas d’échec ordinaire de la vérification, revérifiez l’entrée et exécutez de nouveau
verify. Cette commande peut distinguer des octets CloudFront incorrects, des métadonnées de
téléversement modifiées, une politique de cache inattendue et une exposition anonyme de S3. Un délai
dépassé ou un échec d’accès reste une vérification échouée ; aucun des deux ne prouve le refus d’accès
à l’origine privée. Cet exemple n’installe pas de règles CORS et ne prend pas en charge les
récupérations effectuées par du JavaScript de navigateur depuis une autre origine.
Supprimer les ressources
Conservez le même environnement AWS et exécutez :
(cd image-cdn && node cdn.ts destroy)
CloudFront exige qu’une distribution soit désactivée avant sa suppression. Le programme conserve la
configuration de la distribution et utilise les ETags renvoyés pour les mises à jour et la suppression,
conformément à l’API DeleteDistribution.
Il attend le déploiement de la configuration désactivée, supprime la distribution et l’OAC, ne
supprime que les clés d’image enregistrées, vérifie que le bucket est vide et sans gestion des
versions, puis le supprime. Il vérifie l’absence des ressources avant d’afficher removed: true.
En cas d’échec, la suppression se termine avec un code d’erreur et conserve l’état pour inspection. Les objets inattendus, les modifications de la gestion des versions et les mutations incertaines nécessitent une investigation plutôt qu’une suppression plus large. Arrêter Node.js ne supprime pas les ressources AWS. Conservez le projet et prévoyez le nettoyage si la propagation dépasse la fenêtre d’attente ; l’utilisation des ressources continue jusqu’à ce que vous les supprimiez.
