Como criar uma CDN de imagens usando AWS S3 e CloudFront
Coloque imagens pré-geradas em um bucket S3 privado e deixe que uma distribuição pública do CloudFront as sirva por HTTPS. A receita em Node.js abaixo cria essa configuração, publica um PNG sob um hash de conteúdo, confere os bytes completos baixados e oferece um comando de remoção separado.
Por que usar uma CDN para imagens
O CloudFront pode armazenar suas imagens em cache nos pontos de presença dele. O S3 guarda os originais; um controle de acesso à origem (OAC) assina as requisições do CloudFront ao S3. A política do bucket permite leituras exclusivamente desta distribuição, enquanto o S3 Block Public Access continua ativado. A AWS documenta essa combinação para uma origem S3 comum. As URLs do CloudFront resultantes são públicas. O OAC protege a origem e não autentica os visualizadores.
Essa configuração entrega os bytes que você envia. Ela não redimensiona imagens, não converte formatos nem interpreta parâmetros de consulta de transformação. Prepare cada variante da imagem antes de publicá-la.
Configurar um projeto Node independente
Use um shell POSIX com Node.js 26.8.1 ou uma versão mais recente dentro do Node.js 26, além de um Corepack instalado separadamente. Para uma nova instalação, escolha o patch de segurança atual na lista de versões do Node.js. O manifesto do pacote fixa o Yarn 4.12.0 e os clientes do AWS SDK v3 3.1073.0 para este exemplo. Essas versões do SDK são fixadas para fins de reprodutibilidade, e isso não é uma afirmação de que sejam os patches mais recentes.
Você também precisa de uma conta AWS autorizada a criar, inspecionar, atualizar e excluir um bucket
S3, os objetos e a política desse bucket, uma distribuição do CloudFront e um OAC. A verificação
também exige acesso de leitura à política de cache gerenciada e às configurações de acesso público,
propriedade e versionamento do S3. O script usa a partição padrão da AWS e fixa a região do S3 em
us-east-1. Ele exige AWS_ACCOUNT_ID, AWS_ACCESS_KEY_ID
e AWS_SECRET_ACCESS_KEY no ambiente; inclua AWS_SESSION_TOKEN para credenciais
temporárias. Forneça esses valores pelas ferramentas de credenciais que você já usa. O script não
carrega perfis nem recorre a outra identidade.
Comece em um diretório temporário fora de qualquer projeto JavaScript existente. A configuração
verifica os diretórios ancestrais antes de criar image-cdn, para que um
workspace pai ou uma configuração do Yarn não altere silenciosamente a instalação. Ela recusa um
destino já existente e mantém inalterado o diretório atual do seu 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
)
Se a instalação falhar depois que os arquivos do projeto tiverem sido criados, mantenha esse diretório e o manifesto dele. Tente a instalação novamente a partir do mesmo diretório temporário:
(cd image-cdn && corepack yarn install)
Salve um PNG pré-gerado válido como image-cdn/image.png. Este exemplo aceita um arquivo
não vazio de até 5 MiB e verifica a assinatura PNG dele; o exemplo não valida os pixels
decodificados. Os caminhos de entrada devem conter apenas caracteres ASCII imprimíveis e começar com
./ ou /. Use uma imagem que você pretende
tornar pública. Execute um comando por vez e mantenha cdn-state.json até que a
remoção seja concluída.
Salvar o programa de implantação e verificação
Salve este arquivo completo como image-cdn/cdn.ts. Ele usa o endpoint regional padrão
do S3, um OAC que sempre assina as requisições e a
política CachingOptimizedForUncompressedObjects gerenciada pela AWS.
Essa política tem TTL mínimo de 1 segundo, padrão de 1 dia e máximo de 365 dias. Cada imagem enviada
define Cache-Control: public, max-age=31536000, immutable.
Cada requisição do SDK tem uma única tentativa e um sinal de interrupção de 6 segundos. Cada espera pela prontidão do CloudFront tem a própria janela de 20 minutos, incluindo uma janela separada para desativar a distribuição durante a remoção. Cada fetch anônimo tem um prazo de 30 segundos que abrange também o corpo da resposta. Por isso, uma transferência válida mais lenta pode fazer a verificação falhar. Uma janela de implantação expirada significa que o resultado está incompleto; não significa que a AWS cancelou a implantação.
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
})
Teste da sua CDN
Execute o seguinte a partir do diretório temporário, com as variáveis de ambiente necessárias já definidas:
(cd image-cdn && node cdn.ts deploy ./image.png)
O comando cria um bucket, um OAC e uma distribuição com nomes baseados em UUID, anexa uma política de
bucket restrita à distribuição e envia a imagem. Para automação, deploy
também aceita --id seguido de um UUID v4 novo em letras minúsculas. Ele se
recusa a substituir um cdn-state.json existente.
Em caso de sucesso, o programa imprime um registro JSON com url,
key, bytes, sha256,
cacheControl e directOriginStatus. Abra exatamente essa
url em um navegador para exibir a imagem. Antes de imprimir o registro, o
programa lê o objeto armazenado com credenciais, compara a resposta anônima completa do CloudFront
com sua entrada local, verifica os metadados de cache e a configuração e confirma que uma requisição
anônima para o mesmo objeto S3 conhecido é negada. Um HTTP 200 sozinho não basta para passar na
verificação.
Para repetir a verificação sem enviar a imagem novamente:
(cd image-cdn && node cdn.ts verify ./image.png)
Otimização da entrega de imagens
A chave do objeto contém o digest SHA-256 da entrada. Bytes diferentes geram uma chave diferente, e
IfNoneMatch: '*' impede a substituição de um objeto existente. Para publicar um PNG
alterado, salve-o como image-cdn/image-v2.png e execute:
(cd image-cdn && node cdn.ts publish ./image-v2.png)
Use a nova URL impressa na sua aplicação. Publicar bytes idênticos novamente resulta em uma recusa de
sobrescrita; use verify quando quiser apenas verificar de novo uma imagem
existente. Não sobrescreva um nome já em cache nem espere que uma query string alterada crie uma nova
versão da imagem.
O CloudFront combina os cabeçalhos de cache da origem com os limites de TTL da política de cache. Um
max-age de um ano permite esse tempo de vida em cache; ele não garante que um
ponto de presença mantenha o objeto por um ano. A AWS descreve essa interação no
guia de expiração de cache dela.
O exemplo verifica a configuração e os cabeçalhos de resposta sem exigir um cabeçalho de cache hit
nem alegar uma melhoria de latência medida.
Solução de problemas comuns
Se deploy ou publish encerrar com falha, mantenha
cdn-state.json. Uma mutação da AWS que falha informa a fase em que ocorreu. Respostas
definitivas de erro do cliente deixam os recursos anteriores já confirmados disponíveis para
destroy. Uma resposta perdida deixa uma operação
pending, porque o resultado remoto dela é incerto; o programa se recusa a
fazer qualquer outro trabalho. Inspecione exatamente o recurso nomeado e resolva a operação incerta
antes de tentar a recuperação. Não exclua o arquivo de estado para executar a implantação de novo às
cegas.
Em uma falha comum de verificação, confira novamente a entrada e execute
verify outra vez. O comando consegue distinguir bytes errados do CloudFront,
metadados de upload alterados, uma política de cache inesperada e exposição anônima do S3. Um timeout
ou uma falha de acesso continua sendo uma verificação reprovada; nenhum dos dois comprova a negação
pela origem privada. Este exemplo não instala regras de CORS nem oferece suporte a JavaScript no
navegador que faça fetch a partir de outra origem.
Remover os recursos
Mantenha o mesmo ambiente AWS e execute:
(cd image-cdn && node cdn.ts destroy)
O CloudFront exige que uma distribuição seja desativada antes da exclusão. O programa preserva a
configuração dela e usa as ETags retornadas nas atualizações e na exclusão, seguindo a
API DeleteDistribution.
Ele espera a implantação da configuração desativada, exclui a distribuição e o OAC, remove apenas as
chaves de imagem registradas, verifica se o bucket está vazio e sem versionamento e então o exclui.
Ele confirma a ausência dos recursos antes de imprimir removed: true.
Uma remoção que falha encerra com status de erro e mantém o estado para inspeção. Objetos inesperados, mudanças de versionamento e mutações incertas exigem investigação, e não uma exclusão mais ampla. Interromper o Node.js não remove os recursos da AWS. Mantenha o projeto e providencie a limpeza se a propagação exceder a janela de espera; o uso dos recursos continua até que você os remova.
