Substituindo o Sharp no Node.js por recursos nativos do Bun para rodar um servidor de mídia
TuBrief 편집팀
2026년 8월 22일
0
Computing/Software원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
커뮤니티의 다른 글
댓글 (0)
Log in to leave a comment
아직 작성된 글이 없습니다
원본 영상을 바탕으로 AI의 도움을 받아 작성했습니다. 원본 영상이 기준입니다.
Log in to leave a comment
아직 작성된 글이 없습니다
Às vezes, uma imagem de Docker que funcionava perfeitamente no ambiente local acaba gerando erros de build no servidor de produção. Se você investigar a causa, o culpado quase sempre é o sharp e a biblioteca libvips, que utilizam bindings nativos em C++. Somando isso às faturas de dezenas de dólares por mês em serviços como Cloudinary ou Imgix, é natural começar a questionar por que todo esse esforço é necessário apenas para adicionar uma funcionalidade básica de processamento de imagens.
Aqui está um guia prático de transição para construir um pipeline de processamento de imagens usando apenas os recursos nativos do runtime único do Bun, sem etapas externas de compilação em C++, reduzindo o tamanho dos containers e economizando custos na nuvem.
Quando você usa o sharp em um ambiente Node.js, ele se conecta dinamicamente às bibliotecas C do sistema operacional através do node-gyp e da camada N-API. Ao tentar criar uma imagem Docker baseada no leve Alpine Linux, um node-gyp rebuild forçado é executado dentro do container devido a incompatibilidades entre as bibliotecas C glibc e musl.
Durante esse processo, toolchains de compilação inteiras como GCC, Python e make entram no container. É muito comum que um código que rodava perfeitamente no ambiente local (macOS ARM64) falhe com um erro de segmentação de memória (SIGSEGV) assim que é enviado para o servidor de produção (Linux x86_64).
O Bun inclui codecs JPEG, PNG e WebP diretamente em seu binário de execução. Não há necessidade de instalar compiladores externos ou pacotes de SO separados, como o libvips.
| Item de comparação | Node.js (Sharp + libvips) | Bun Nativo (Bun.Image) |
|---|---|---|
| Dependência de binding C++ | node-gyp, N-API obrigatórios |
Nenhum (incorporado ao binário do runtime) |
| Toolchain de build | Necessário GCC, Python, make | Desnecessário |
| Tamanho do pacote de deploy | Centenas de MB incluindo toolchains | Reduzido ao nível da imagem base |
| Erros em tempo de execução | Ocorre SIGSEGV em caso de incompatibilidade glibc/musl | Evitado por link estático embutido |
O Bun.Image oferece suporte a uma interface de encadeamento (chaining), permitindo que você migre o código existente do sharp quase sem alterações. Ele manipula Uint8Array diretamente, sem cópias de memória.
`typescript
// Código existente baseado no sharp
import sharp from "sharp";
export async function processImageSharp(inputBuffer: Buffer): Promise {
const image = sharp(inputBuffer);
const metadata = await image.metadata();
if (!metadata.width || metadata.width > 2000) {
return await image
.resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
.rotate(90)
.webp({ quality: 85 })
.toBuffer();
}
return inputBuffer;
}
`
`typescript
// Código convertido para Bun.Image
export async function processImageBun(inputBytes: Uint8Array): Promise {
// Lê apenas o cabeçalho rapidamente para verificar o tamanho sem decodificar todo o bitmap
const meta = await new Bun.Image(inputBytes).metadata();
if (!meta.width || meta.width > 2000) {
return await new Bun.Image(inputBytes)
.resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
.rotate(90)
.webp({ quality: 85 })
.bytes();
}
return inputBytes;
}
`
O método metadata() analisa apenas a área do cabeçalho sem decodificar a imagem inteira. Isso reduz o desperdício de CPU ao lidar com imagens originais grandes.
A correspondência da API por tarefa é a seguinte:
new Bun.Image(bytes) ou Bun.file(path).image() em vez de sharp(buf)..webp({ quality: 85 }) exatamente igual..bytes() em vez de .toBuffer() para retornar um Uint8Array..placeholder() sem precisar de bibliotecas adicionais.A ordem de migração é simples: remova sharp e @types/sharp do package.json, altere o formato de saída das funções utilitárias para bytes() e execute a validação de recursos com bun test.
SaaS de imagens como o Cloudinary aumentam os custos drasticamente com qualquer pico de tráfego. No estágio de um serviço desenvolvido por uma única pessoa, você pode criar seu próprio servidor de cache de redimensionamento eficiente combinando apenas bun:sqlite e Bun.serve.
`typescript
import { Database } from "bun:sqlite";
const db = new Database("image_cache.sqlite");
// Aplicando o modo WAL para melhor desempenho de leitura e escrita simultâneas
db.exec("PRAGMA journal_mode = WAL;");
db.exec( CREATE TABLE IF NOT EXISTS image_cache ( key TEXT PRIMARY KEY, data BLOB NOT NULL, placeholder TEXT NOT NULL, mime_type TEXT NOT NULL, created_at INTEGER NOT NULL ));
const selectQuery = db.query("SELECT data, mime_type FROM image_cache WHERE key = ?");
const insertQuery = db.query( INSERT OR REPLACE INTO image_cache (key, data, placeholder, mime_type, created_at) VALUES (?, ?, ?, ?, ?));
export async function getOrGenerateThumbnail(
originalBytes: Uint8Array,
cacheKey: string,
width: number = 300
): Promise<{ bytes: Uint8Array; mimeType: string }> {
const cached = selectQuery.get(cacheKey) as { data: Uint8Array; mime_type: string } | null;
if (cached) {
return { bytes: cached.data, mimeType: cached.mime_type };
}
const imagePipeline = new Bun.Image(originalBytes);
const transformedBytes = await imagePipeline.resize(width).webp({ quality: 80 }).bytes();
const placeholder = await imagePipeline.placeholder();
insertQuery.run(cacheKey, transformedBytes, placeholder, "image/webp", Date.now());
return { bytes: transformedBytes, mimeType: "image/webp" };
}
`
`typescript
// Endpoint de entrega de mídia
Bun.serve({
port: 3000,
async fetch(req) {
const url = new URL(req.url);
if (url.pathname.startsWith("/images/")) {
const imageId = url.pathname.replace("/images/", "");
const width = parseInt(url.searchParams.get("w") || "300", 10);
const cacheKey = `${imageId}_w${width}`;
const originalFile = Bun.file(`./uploads/${imageId}`);
if (!(await originalFile.exists())) {
return new Response("Image Not Found", { status: 404 });
}
const originalBytes = await originalFile.bytes();
const { bytes, mimeType } = await getOrGenerateThumbnail(originalBytes, cacheKey, width);
return new Response(bytes, {
headers: {
"Content-Type": mimeType,
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}
return new Response("Not Found", { status: 404 });
},
});
`
Apenas a primeira requisição recebida passa pelo redimensionamento e é salva no SQLite em formato BLOB; as requisições seguintes são servidas diretamente do cache do banco de dados. Definir um tempo de Cache-Control longo no cabeçalho de resposta também faz com que o cache funcione nos níveis de navegador e CDN.
A codificação e decodificação de imagens são tarefas intensivas em CPU. Se você converter imagens no event loop principal quando houver picos de solicitações de upload, a resposta geral do servidor vai congelar. É por isso que a latência P99 dispara na casa das centenas de milissegundos e o servidor de API inteiro acaba travando.
É preciso utilizar a API Worker do Bun para delegar o trabalho de processamento de imagem a uma thread em segundo plano, mantendo o loop principal livre.
`typescript
// imageWorker.ts
declare var self: Worker;
interface ResizeTask {
id: string;
buffer: ArrayBuffer;
width: number;
}
self.onmessage = async (event: MessageEvent) => {
const { id, buffer, width } = event.data;
try {
const inputBytes = new Uint8Array(buffer);
const processedBytes = await new Bun.Image(inputBytes)
.resize(width)
.webp({ quality: 80 })
.bytes();
self.postMessage(
{ id, success: true, buffer: processedBytes.buffer },
[processedBytes.buffer] as any
);
} catch (error) {
self.postMessage({ id, success: false, error: (error as Error).message });
}
};
`
`typescript
// server.ts
const worker = new Worker("./imageWorker.ts");
const pendingTasks = new Map<string, (buf: ArrayBuffer) => void>();
worker.onmessage = (event) => {
const { id, success, buffer, error } = event.data;
const resolve = pendingTasks.get(id);
if (resolve && success) {
resolve(buffer);
pendingTasks.delete(id);
} else if (!success) {
console.error(Falha na tarefa (${id}):, error);
pendingTasks.delete(id);
}
};
export function dispatchImageJob(id: string, buffer: ArrayBuffer, width: number): Promise {
return new Promise((resolve) => {
pendingTasks.set(id, resolve);
// Transfere apenas a propriedade sem custo de cópia de memória usando um objeto Transferable
worker.postMessage({ id, buffer, width }, [buffer]);
});
}
`
O uso de um Transferable ArrayBuffer elimina o custo de cópia de memória mesmo ao enviar e receber buffers de vários megabytes entre as threads. Mesmo quando há um acúmulo de requisições pesadas, a thread principal responde com 202 Accepted e continua processando outras requisições de API sem atrasos.
É necessário verificar previamente algumas diferenças que podem surgir entre o ambiente de desenvolvimento local e o container de produção:
Bun.Image oferece suporte comum a JPEG, PNG, WebP, GIF e BMP em todos os ambientes Linux, macOS e Windows. Por outro lado, o suporte a HEIC ou AVIF pode variar dependendo do SO do servidor. Em ambientes de produção Linux, é mais seguro fixar o formato de saída padrão em WebP ou JPEG.python3, make, g++ e libvips-dev do Dockerfile. O build se tornará mais rápido e a imagem do container ficará muito mais leve./tmp utilizado pelo buffer interno de decodificação do Bun deve obrigatoriamente ter permissão de escrita para evitar falhas no processo.PRAGMA journal_mode = WAL; logo após a criação do arquivo SQLite, poderá encontrar erros de travamento de banco de dados (DB lock) quando houver um pico de requisições simultâneas de leitura e escrita.Eliminar dependências externas desnecessárias reduz naturalmente a probabilidade de falhas no build. Apenas aproveitar ativamente as ferramentas embutidas de um runtime único pode aliviar bastante a complexidade operacional e os custos de manutenção da infraestrutura do seu serviço.