Workshop Bun 1.4 Image: ทำ Thumbnail WebP โดยไม่เพิ่ม Native Dependency
งานอัปโหลดรูปมักเริ่มจาก “แค่ย่อ 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 ที่พิสูจน์พฤติกรรมจริง ไม่ใช่แค่ตัวอย่างที่ดูน่าเชื่อครับ