Cómo crear una CDN de imágenes con AWS S3 y CloudFront
Coloca imágenes preparadas previamente en un bucket privado de S3 y permite que una distribución pública de CloudFront las entregue por HTTPS. El ejemplo de Node.js que aparece a continuación crea esa configuración, publica un PNG con un hash de contenido como clave, comprueba todos los bytes descargados y proporciona un comando independiente para eliminar los recursos.
Por qué usar una CDN para imágenes
CloudFront puede almacenar tus imágenes en caché en sus ubicaciones de borde. S3 almacena los originales; un control de acceso al origen (OAC) firma las solicitudes de CloudFront a S3. La política del bucket permite lecturas únicamente desde esta distribución, mientras S3 Block Public Access permanece habilitado. AWS documenta esta combinación para un origen S3 estándar. Las URL de CloudFront resultantes son públicas. El OAC protege el origen y no autentica a quienes acceden al contenido.
Esta configuración entrega los bytes que subes. No redimensiona imágenes, convierte formatos ni interpreta parámetros de consulta de transformación. Prepara cada variante de imagen antes de publicarla.
Configura un proyecto independiente de Node
Usa un shell POSIX con Node.js 26.8.1 o una versión posterior de la rama Node.js 26, y Corepack instalado por separado. Para una instalación nueva, elige el parche de seguridad actual de la lista de versiones de Node.js. El manifiesto del paquete fija Yarn 4.12.0 y los clientes del SDK de AWS v3 en la versión 3.1073.0 para este ejemplo. Esas versiones del SDK se fijan para reproducir el ejemplo; no se afirma que sean los parches más recientes.
También necesitas una cuenta de AWS con autorización para crear, inspeccionar, actualizar y eliminar
un bucket de S3, sus objetos y su política, una distribución de CloudFront y un OAC. La verificación
también requiere acceso de lectura a la política de caché administrada y a la configuración de
acceso público, propiedad y control de versiones de S3. El script usa la partición estándar de AWS y
fija la región de S3 en us-east-1. Requiere
AWS_ACCOUNT_ID, AWS_ACCESS_KEY_ID y
AWS_SECRET_ACCESS_KEY en su entorno; incluye AWS_SESSION_TOKEN si usas
credenciales temporales. Proporciónalas mediante tus herramientas habituales de gestión de
credenciales. No carga perfiles ni recurre a otra identidad.
Empieza en un directorio de trabajo temporal fuera de cualquier proyecto JavaScript existente. La
configuración comprueba los directorios antecesores antes de crear image-cdn,
para que un espacio de trabajo superior o una configuración de Yarn no puedan cambiar la instalación
silenciosamente. Rechaza un destino existente y mantiene sin cambios el directorio de tu 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 la instalación falla después de crear los archivos del proyecto, conserva ese directorio y su manifiesto. Vuelve a intentar la instalación desde el mismo directorio de trabajo temporal:
(cd image-cdn && corepack yarn install)
Guarda un PNG válido preparado previamente como image-cdn/image.png. Este ejemplo acepta
un archivo no vacío de hasta 5 MiB y comprueba su firma PNG; no valida sus píxeles decodificados. Las
rutas de entrada deben contener únicamente caracteres ASCII imprimibles y comenzar con
./ o /. Usa una imagen que quieras hacer
pública. Ejecuta un comando a la vez y conserva cdn-state.json hasta que termine la
eliminación de los recursos.
Guarda el programa de despliegue y verificación
Guarda este archivo completo como image-cdn/cdn.ts. Usa el endpoint regional estándar
de S3, un OAC que firma siempre y la
política CachingOptimizedForUncompressedObjects administrada por AWS.
Esa política tiene un TTL mínimo de 1 segundo, uno predeterminado de 1 día y uno máximo de 365 días.
Cada imagen subida establece Cache-Control: public, max-age=31536000, immutable.
Cada solicitud del SDK tiene un solo intento y una señal de cancelación de 6 segundos. Cada espera hasta que CloudFront esté listo tiene su propio plazo de 20 minutos, incluido un plazo independiente para deshabilitar la distribución durante la eliminación de los recursos. Cada descarga anónima tiene un límite de 30 segundos que abarca la lectura completa del cuerpo de la respuesta. Por eso, una transferencia válida más lenta puede fallar en la verificación. Si vence el plazo de despliegue, el resultado está incompleto; eso no significa que AWS haya cancelado el despliegue.
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
})
Prueba tu CDN
Ejecuta lo siguiente desde el directorio de trabajo temporal, con las variables de entorno requeridas ya establecidas:
(cd image-cdn && node cdn.ts deploy ./image.png)
El comando crea un bucket, un OAC y una distribución con nombres basados en un UUID, adjunta una
política de bucket cuyo alcance se limita a esa distribución y sube la imagen. Para la
automatización, deploy también acepta --id seguido
de un UUID v4 nuevo en minúsculas. Rechaza reemplazar un cdn-state.json existente.
Si se completa correctamente, imprime un registro JSON que contiene url,
key, bytes, sha256,
cacheControl y directOriginStatus. Abre exactamente ese valor de
url en un navegador para ver la imagen. Antes de imprimirlo, el programa
lee el objeto almacenado usando credenciales, compara la respuesta anónima completa de CloudFront
con tu archivo de entrada local, comprueba los metadatos y la configuración de caché, y confirma que
se deniega una solicitud anónima al mismo objeto conocido de S3. Una respuesta HTTP 200 por sí sola
no basta para superar la comprobación.
Para repetir la verificación sin volver a subir la imagen:
(cd image-cdn && node cdn.ts verify ./image.png)
Optimización de la entrega de imágenes
La clave del objeto contiene el resumen SHA-256 de la entrada. Los bytes diferentes reciben una
clave diferente, y IfNoneMatch: '*' impide reemplazar un objeto existente. Para
publicar un PNG modificado, guárdalo como image-cdn/image-v2.png y ejecuta:
(cd image-cdn && node cdn.ts publish ./image-v2.png)
Usa la nueva URL impresa en tu aplicación. Si vuelves a publicar bytes idénticos, se rechaza la
sobrescritura; usa verify cuando solo quieras volver a comprobar una imagen
existente. No sobrescribas un nombre almacenado en caché ni esperes que un cambio en la cadena de
consulta cree una nueva versión de la imagen.
CloudFront combina los encabezados de caché del origen con los límites de TTL de la política de
caché. Un max-age de un año permite ese tiempo de almacenamiento en caché;
no garantiza que una ubicación de borde conserve el objeto durante un año. AWS describe esta
interacción en su guía de caducidad de caché.
El ejemplo verifica la configuración y los encabezados de respuesta sin exigir un encabezado que
indique un acierto de caché ni afirmar una mejora medida de la latencia.
Solución de problemas comunes
Si deploy o publish termina con un error, conserva
cdn-state.json. Una operación de modificación de AWS fallida indica su fase. Las
respuestas definitivas de error del cliente dejan disponibles los recursos anteriores confirmados
para destroy. Si se pierde una respuesta, queda una operación
pending porque su resultado remoto es incierto; el programa rechaza cualquier
operación posterior. Inspecciona exactamente el recurso cuyo nombre se indica y resuelve la
operación incierta antes de intentar la recuperación. No elimines el archivo de estado ni vuelvas a
ejecutar el despliegue a ciegas.
Ante un fallo de verificación habitual, vuelve a comprobar la entrada y ejecuta
verify otra vez. Puede distinguir entre bytes incorrectos de CloudFront,
metadatos de subida modificados, una política de caché inesperada y la exposición de S3 al acceso
anónimo. Un tiempo de espera agotado o un fallo de acceso sigue siendo una comprobación fallida;
ninguno demuestra que se haya denegado el acceso al origen privado. Este ejemplo no instala reglas
CORS ni permite realizar solicitudes desde JavaScript en un navegador con un origen diferente.
Elimina los recursos
Conserva el mismo entorno de AWS y ejecuta:
(cd image-cdn && node cdn.ts destroy)
CloudFront exige deshabilitar una distribución antes de eliminarla. El programa conserva su
configuración y usa los ETags devueltos para las actualizaciones y la eliminación, siguiendo la
API DeleteDistribution.
Espera a que se despliegue la configuración deshabilitada, elimina la distribución y el OAC, elimina
únicamente las claves de imágenes registradas, comprueba que el bucket esté vacío y sin control de
versiones, y lo elimina. Comprueba la ausencia de los recursos antes de imprimir
removed: true.
Si la eliminación de los recursos falla, el programa termina con un error y conserva el estado para su inspección. Los objetos inesperados, los cambios en el control de versiones y las operaciones de modificación inciertas requieren investigación en lugar de una eliminación más amplia. Detener Node.js no elimina los recursos de AWS. Conserva el proyecto y organiza la limpieza si la propagación supera el plazo de espera; el uso de recursos continúa hasta que los elimines.
