ภาพวาดมาสคอตขนมปังกลมต้นฉบับกำลังหมุนมือจับให้กรอบภาพใหญ่ผ่านเครื่องแล้วออกมาเป็นกรอบเล็กบนโต๊ะสว่าง

Workshop Bun 1.4 Image: ทำ Thumbnail WebP โดยไม่เพิ่ม Native Dependency

น้องกายEngineering

งานอัปโหลดรูปมักเริ่มจาก “แค่ย่อ thumbnail” แล้วจบด้วย native addon, postinstall script และปัญหา binary ไม่ตรงเครื่อง Bun 1.4 เพิ่ม Bun.Image เป็น pipeline ใน runtime จึงอ่าน metadata, resize และ encode JPEG, PNG, WebP, HEIC หรือ AVIF ได้โดยไม่เพิ่ม npm dependency

Workshop นี้ทำของชิ้นเล็กแต่ครบวงจร: สร้าง fixture PNG, ปฏิเสธภาพที่ประกาศขนาดใหญ่เกิน, ย่อเป็น WebP และอ่านผลกลับมาตรวจว่า pipeline ทำงานจริงครับ

WHY: รูปหนึ่งไฟล์มีความเสี่ยงมากกว่าขนาดบนดิสก์

ไฟล์บีบอัดไม่กี่กิโลไบต์อาจประกาศ canvas ใหญ่มหาศาล เมื่อ decoder คลี่ pixel ออกมา RAM อาจพุ่งเหมือนกล่องเล็กที่กางแล้วเต็มห้อง เอกสาร Bun.Image จึงมี maxPixels เพื่อเช็ก width × height หลังอ่าน header แต่ก่อนจอง pixel buffer

อีกจุดคือ input ที่เป็น path ถ้ารับจากผู้ใช้ตรง ๆ จะกลายเป็นช่องอ่านไฟล์ตาม path ที่ผู้ใช้กำหนด สำหรับ upload ให้รับ bytes จาก request หลังตรวจขนาดและชนิด แล้วส่ง Buffer หรือ Blob เข้า pipeline แทน

HOW: Pipeline ที่รันและตรวจผลในตัว

ตัวอย่างนี้ฝัง PNG ขนาดเล็กไว้เป็น fixture เพื่อให้คัดลอกไปรันได้โดยไม่ต้องดาวน์โหลดอะไร จากนั้นใช้ metadata() ตรวจ input, resize() แบบรักษาสัดส่วน และ webp() เลือก output

const fixture = Buffer.from(
  "iVBORw0KGgoAAAANSUhEUgAAAAQAAAACCAYAAAB/qH1jAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQImWMI2t7xHxkzoAsAAFVRFIFPDR14AAAAAElFTkSuQmCC",
  "base64",
);

await Bun.write("input.png", fixture);

const source = Bun.file("input.png").image({
  autoOrient: true,
  maxPixels: 1_000_000,
});
const before = await source.metadata();

await Bun.file("input.png")
  .image({ maxPixels: 1_000_000 })
  .resize(2, 2, { fit: "inside", withoutEnlargement: true })
  .webp({ quality: 80 })
  .write("thumb.webp");

const after = await Bun.file("thumb.webp").image().metadata();

if (before.width !== 4 || before.height !== 2) {
  throw new Error(`unexpected input: ${before.width}x${before.height}`);
}
if (after.width !== 2 || after.height !== 1 || after.format !== "webp") {
  throw new Error(`unexpected output: ${JSON.stringify(after)}`);
}

console.log({ before, after });

สังเกตว่า pipeline เป็นแบบ lazy: การต่อ resize().webp() ยังไม่ทำงานจริงจนเรียก terminal method เช่น write(), blob() หรือ metadata() งาน decode/encode ทำ off-thread จึงไม่บล็อก JavaScript thread แบบตรง ๆ แต่เรายังต้องจำกัด concurrency เพื่อไม่ให้ request จำนวนมากแย่ง CPU และ RAM กัน

ขยายเป็น Upload Endpoint อย่างปลอดภัย

ก่อนนำเข้า server ให้เพิ่มด่านต่อไปนี้:

  • จำกัด bytes ของ request ก่อนอ่านทั้งหมดเข้า memory
  • ตรวจ magic bytes/metadata แทนเชื่อ extension หรือ Content-Type อย่างเดียว
  • ตั้ง maxPixels ตาม product จริง ไม่ใช้ตัวเลขใหญ่เผื่อไว้โดยไม่มีเหตุผล
  • สร้างชื่อปลายทางเอง อย่านำ path จากผู้ใช้ไปต่อกับ filesystem
  • จับ error.code เช่น ERR_IMAGE_TOO_MANY_PIXELS, ERR_IMAGE_DECODE_FAILED และ ERR_IMAGE_FORMAT_UNSUPPORTED

ถ้าต้องรองรับ HEIC/AVIF ให้ทดสอบบนระบบปฏิบัติการที่ deploy เพราะ codec support ต่างกัน ส่วน JPEG, PNG และ WebP เป็นตัวเลือกที่พกพาง่ายกว่า

ตรวจ Workshop ให้ครบกว่าคำว่า “รันผ่าน”

Workshop นี้ผ่านเมื่อสคริปต์เรียก terminal method จริงและตรวจ metadata ของ output ไม่ใช่เพียง type-check ควรเพิ่ม test ของทีมอีกอย่างน้อยสามกรณี: input เสีย, pixel เกินเพดาน และไฟล์เล็กกว่าขนาดเป้าหมายเมื่อใช้ withoutEnlargement

หากระบบเดิมใช้ Sharp อย่ารีบเปลี่ยนเพียงเพื่อลด dependency ให้ทำ golden images แล้วเปรียบเทียบ dimensions, orientation, สี, alpha และขนาดไฟล์ เพราะค่า default ของ resampling และ encoder อาจไม่เหมือนกัน

Recap

Bun.Image ทำให้ thumbnail pipeline อยู่ใน runtime เดียวกับแอป แต่ความสะดวกไม่ได้แทน input validation ใช้ bytes ที่ตรวจแล้ว, ตั้ง maxPixels, เขียนชื่อปลายทางเอง และอ่าน metadata ของผลกลับมาตรวจ

พรุ่งนี้ลองนำสคริปต์นี้ไปเปลี่ยน fixture เป็นรูปของทีม แล้วเพิ่มกรณีเสียหนึ่งไฟล์ หากทั้ง happy path และ failure path ผ่าน เราจะได้ workshop ที่พิสูจน์พฤติกรรมจริง ไม่ใช่แค่ตัวอย่างที่ดูน่าเชื่อครับ