ที่อยู่ในหน้านี้ถูกแทนด้วยที่อยู่ของ server นี้เอง https://qm.weekday100.com
Queue Manager — คู่มือการเชื่อมต่อ (Integration Guide)
ใส่ script tag บรรทัดเดียว แล้วหน้าไหนของเว็บคุณก็มีหน้ารอคิว (virtual waiting room) คุ้มกันได้ การตั้งค่าพื้นฐานไม่ต้อง build ไม่ต้องติดตั้ง npm package และไม่ต้องแก้ backend ถ้ามีหน้าที่ห้ามใครเข้าเด็ดขาดหากไม่มี pass ที่ใช้ได้ ให้ใช้วิธีตรวจฝั่ง server ซึ่งอธิบายไว้ด้านล่าง
(pass คือสิทธิ์เข้าหน้าเว็บที่ผู้เข้าชมได้รับเมื่อรอคิวเสร็จ)
เริ่มต้นอย่างรวดเร็ว (60 วินาที)
- สร้าง room (room คือคิวหนึ่งคิว พร้อมการตั้งค่าของมัน) ทำได้สองทาง: ใน admin dashboard (เปิดที่
https://qm.weekday100.com/แล้วลงชื่อเข้าด้วยADMIN_KEYของคุณ) หรือใช้ curl คำสั่งเดียว:
curl -s -X POST "https://qm.weekday100.com/api/admin/rooms" \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"id":"checkout","name":"Checkout","ratePerMinute":120,
"targetUrl":"https://shop.example.com/"}'
room id ยาว 1-64 ตัวอักษร ใช้ได้เฉพาะ a-z A-Z 0-9 _ - และมีบาง id ที่ถูกจองไว้ เพราะตั๋วของ room เก็บเป็น qm_<room> จึงต้องไม่ซ้ำกับชื่อที่ queue ใช้อยู่แล้ว: id ที่ขึ้นต้นด้วย dk_ (ตั๋วของ room dk_x จะเป็น cookie door key ของ room x คือ qm_dk_x), meta_, notify_ หรือ email_ และ id ที่ตรงกับ console, theme, lang, admin_key, autotune, autotune_cfg, wait_samples, hidden_series, selected_room, alerts, offline_attempts, origin_down_attempts พอดี การสร้าง room แบบนี้จะได้ 400 invalid_room_id ดู OPERATIONS.md หัวข้อ Two deployments: snippet and inline
ต้องใส่ targetUrl ทุกครั้ง อย่าคิดว่าเป็นของเสริม มันคือปลายทางของ room และทำให้เว็บของคุณเองอยู่ในรายชื่อเว็บที่อนุญาตทั้งสองรายการ (ดู Which origins a room allows)
ถ้าสร้าง room โดยไม่มี targetUrl คิวจะทำงานปกติและปล่อยคนออกจากคิวได้ แต่ทุกคนที่ถูกปล่อย จะติดอยู่ที่หน้ารอ เพราะ qm_return ถูกปฏิเสธ (ไม่มีเว็บไหนได้รับอนุญาตเลย) และไม่มีปลายทางสำรองให้ส่งไป นี่คือเหตุที่ GET /api/v1/verify?roomId=checkout ไม่ผ่านที่ข้อ return_policy
- ใส่โค้ดนี้ใน
<head>ของทุกหน้าที่ต้องการคุ้มกัน ให้อยู่ก่อน script อื่นทุกตัว เพื่อให้ผู้เข้าชมที่ต้องรอคิวถูกส่งไปหน้ารอก่อนที่หน้าเว็บจะทำอย่างอื่น:
<script async
src="https://qm.weekday100.com/snippet/qm.js"
data-qm-server="https://qm.weekday100.com"
data-qm-room="checkout"></script>
เปลี่ยน qm.weekday100.com เป็น host ของ Queue Manager ของคุณ และ checkout เป็น room id ของคุณ หรือจะไม่แก้เองก็ได้: กดปุ่ม Install ของ room ใน dashboard จะได้ tag เดียวกันนี้ ที่ใส่ host และ room id ของคุณไว้แล้ว พร้อมลิงก์ไปหน้ารอของ room นั้นที่ใช้งานจริง แค่นี้ก็เสร็จ ผลที่ได้:
- room อยู่ในสถานะ active และผู้เข้าชมไม่มี pass ที่ใช้ได้: ผู้เข้าชมถูกส่งไปหน้ารอ และเมื่อถึงคิวจะถูกส่งกลับมาหน้าเดิมที่เขามาโดยอัตโนมัติ
- room อยู่ในสถานะ bypass (หรือถูกลบไปแล้ว): ทุกคนผ่านหมด tag ไม่ทำอะไร
- ติดต่อ queue server ไม่ได้: ทุกคนผ่านหมด (ดู Fail-open)
script นี้เป็น async จึงไม่ทำให้หน้าเว็บแสดงผลช้าลง ขนาดที่ส่งจริงผ่านเครือข่าย วัดจาก deployment นี้:
| Encoding | จำนวน byte ที่ส่งจริง |
|---|---|
Content-Encoding: br (Chrome, Edge, Firefox, Safari 16.4+) | 5,220 |
Content-Encoding: gzip | 6,114 |
Content-Encoding: deflate | 6,102 |
ไม่บีบอัด (client ไม่ได้ส่ง Accept-Encoding) | 15,865 |
วัดเองกับ deployment ของคุณได้ ตัวเลขที่ได้คือขนาดที่ response ส่งจริง ไม่ใช่ขนาดของไฟล์ต้นฉบับ:
curl -s -o /dev/null -w '%{size_download} bytes\n' \
-H 'Accept-Encoding: gzip' https://qm.weekday100.com/snippet/qm.js
/snippet/qm.js ส่งมาพร้อม Vary: Accept-Encoding และ ETag แยกตาม encoding และตอบ If-None-Match ด้วย 304 ที่ไม่มี body ผู้เข้าชมที่กลับมาอีกจึงเสียแค่ request ถามว่าไฟล์เปลี่ยนหรือยังหนึ่งครั้ง ไม่ต้องโหลดไฟล์ซ้ำ Cache-Control เป็น no-cache (ถามใหม่ทุกครั้ง ไม่ใช้ของเก่า) เวอร์ชันใหม่ที่คุณ deploy จึงไปถึงทุกหน้าที่คุ้มกันไว้ ตั้งแต่ครั้งถัดไปที่ผู้เข้าชมเปิดหน้า
การทำงาน
สรุปเป็นคำพูด: (1) ผู้เข้าชมเปิดหน้าที่คุ้มกันไว้ (2) tag ถามคิว (/api/check) ว่าเขามี pass ที่ใช้ได้หรือไม่ ถ้ามี หน้าก็โหลดตามปกติ ถ้าไม่มี (3) เขารอที่หน้ารอ ซึ่งแสดงลำดับแบบสดและเวลารอโดยประมาณ (4) ถึงคิวเขาตามลำดับ ticket (5) เขาถูกส่งกลับไป หน้าที่อยู่เดิม พร้อม pass ที่ลงลายเซ็นแล้ว (qm_token) (6) tag เก็บ pass ไว้ และการตรวจครั้งถัดไปตอบว่า pass ขั้นตอนเดียวกันนี้ พร้อม request แต่ละตัว:
Customer site (shop.example) Queue Manager (qm.weekday100.com)
──────────────────────────── ────────────────────────────────
1. Page loads, qm.js runs
│ token? (from ?qm_token=, localStorage, or cookie)
│
├──── GET /api/check?roomId=checkout&token=... ──────────────►
│ room bypass/missing? → pass
│ token signed+passed? → pass
│ otherwise → queue
◄──── {"action":"pass"} or {"action":"queue","waitingRoomUrl":"/w/checkout"}
│
2a. "pass" → nothing happens, visitor browses normally
2b. "queue" → redirect to
https://qm.weekday100.com/w/checkout?qm_return=<current page URL>
│
│ visitor waits: live position,
│ ETA, SSE updates. Engine
│ promotes FIFO at ratePerMinute.
│
3. Turn comes up → waiting room redirects back:
https://shop.example/original-page?qm_token=<signed pass token>
│
4. qm.js stores the token (localStorage + cookie), strips qm_token
from the address bar, re-checks → "pass" → visitor is through.
The pass stays valid for passedTtlSec (default 600 s), so normal
navigation across protected pages does not re-queue.
ความยุติธรรมของคิวมาจากฝั่ง server: ใครมาก่อนได้ก่อน (FIFO) ตามหมายเลข ticket อย่างเคร่งครัด และผู้เข้าชมที่กลับมาพร้อม token ที่เก็บไว้จะได้ที่เดิมในคิว
ผู้เข้าชมจะไปที่ไหนหลังออกจาก queue
เมื่อถึงคิวผู้เข้าชม (ระบบ promote หรือปล่อยเขาออกจากคิว) หน้ารอจะเลือกปลายทางตามลำดับนี้:
qm_returnคือหน้าที่ผู้เข้าชมอยู่ตอนที่ snippet (หรือ middleware ของคุณ) ส่งเขาไปหน้ารอ ถ้ามีค่านี้ จะใช้ค่านี้เสมอ: snippet บนหน้านั้นจะรับ token ไปเก็บ แล้วลบออกจากช่อง addresstargetUrlของ room ใช้เฉพาะตอนที่ผู้เข้าชมเข้าหน้ารอ ตรง ๆ (ลิงก์ที่ bookmark ไว้ URL ที่มีคนแชร์มา ไม่มีqm_return) ให้ตั้งเป็นหน้าที่มี snippet ถ้า URL ที่มี token ไปลงหน้าที่ไม่มี snippet?qm_token=จะค้างให้เห็นในช่อง address และถูกบันทึกใน access log ของเว็บนั้น- หน้าที่ผู้เข้าชมมาจาก (referring page) เป็นทางสุดท้าย ใช้กับปุ่ม "Continue" ที่ผู้เข้าชมกดเองเท่านั้น ไม่มีการส่งต่ออัตโนมัติ
ตั้ง targetUrl ให้ room ที่ใช้ snippet คุ้มกันอยู่แล้วได้ ไม่มีปัญหา ผู้เข้าชมที่ snippet ส่งมายังกลับไปหน้าเดิมที่มา targetUrl ใช้กับคนที่เข้าหน้ารอตรง ๆ เท่านั้น
ชี้ waiting room ของแต่ละ room ไปยังหน้าที่ถูกป้องกันของ room นั้นเอง
⚠️ pass ใช้ได้กับ room ที่ออกให้เท่านั้น pass จาก roomAใช้ไม่ได้เลยกับหน้าที่ snippet ตรวจ roomBและจะไม่มีอะไรเตือนคุณว่าตั้งผิด เพราะทั้งสองฝั่งทำงานถูกตามที่ออกแบบ: หน้ารอของ roomAให้ผู้เข้าชมต่อคิว ปล่อยเขาออกจากคิว แล้วส่งไปที่targetUrlพร้อม?qm_token=<pass for A>snippet บนหน้านั้นตั้งไว้เป็นdata-qm-room="B"จึงตรวจ roomBผู้เข้าชมไม่มี pass ของBจึงถูกส่งไปต่อคิวของ room B ผลคือผู้เข้าชมต้องรอสองรอบ หรือเด้งไปมาระหว่างสอง room ส่วนคิวของ roomAดูปกติและว่าง เพราะมันปล่อยคนออกจริง ๆ ไม่มี error ไม่มีข้อความใน console ไม่มี request ที่ล้มเหลว pass นั้นใช้ได้จริง แต่ใช้ได้กับประตูที่ไม่มีใครยืนรออยู่ กับดักนี้มักเจอในสามแบบ: | สิ่งที่คุณต่อไว้ | สิ่งที่เกิดขึ้นจริง | |---|---| |targetUrlของ roomA→ หน้าที่ติด tagdata-qm-room="B"| ผู้เข้าชมของAที่ถูกปล่อยมาถึงแล้ว ถูกBส่งไปต่อคิวทันที | | หน้าหนึ่งติด tag ของAหน้าที่ลิงก์ต่อไปติด tag ของB| ผ่านAแล้วก็ยังเข้าหน้าของBไม่ได้ ต้องต่อคิวรอบสอง | | เปลี่ยน room id แต่ tag ที่ deploy ไปแล้วยังใช้ id เก่า | tag อ้างถึง room ที่ไม่มีแล้ว →{"action":"pass"}แปลว่า หน้ารอปิดอยู่ สำหรับหน้าเหล่านั้น | วิธีจับปัญหานี้: room id ในdata-qm-room(และในROOMของ middleware ของคุณ) ต้องเป็น string เดียวกัน กับ room ที่คุณส่งคนไปหน้ารอของมัน หน้าที่ต้องใช้คิวร่วมกัน ต้องใช้ room id เดียวกัน หน้าที่ต้องการอัตราปล่อยคนแยกกัน ต้องมี room แยก และ หน้ารอแยก แบบที่สามตรวจเจอได้ และระบบนับให้แล้ว:GET /api/admin/healthรายงานunknownRoomChecksและ Prometheus มีqm_unknown_room_checks_total{room="..."}ค่าที่ไม่ใช่ศูนย์แปลว่ามี snippet ที่ deploy แล้วอ้างถึง room ที่ไม่มีอยู่ และไม่ได้คุ้มกันอะไรเลย ให้ตั้ง alert ไว้ สองแบบแรกฝั่ง server ตรวจไม่เจอ เพราะทั้งสอง room มีอยู่จริง และทุก request ถูกรูปแบบ จึงต้องลองเดินผ่านทั้ง flow หนึ่งรอบใน browser จริงก่อนวันเปิดขาย: เข้าคิว รอจนถูกปล่อย แล้วดูว่าคุณไปถึงหน้าที่คุ้มกันไว้ โดยไม่ ต้องต่อคิวรอบสอง
room อนุญาต origin ใดบ้าง (tagOrigins, returnOrigins)
room หนึ่งมีรายชื่อเว็บ 2 รายการ แต่ละรายการตอบคำถามคนละข้อ:
| รายการ | ตอบคำถามว่า | ถ้าลืมใส่เว็บไหน… |
|---|---|---|
tagOrigins | เว็บไหน ใช้ queue ของ room นี้ได้ | คนที่เข้าเว็บนั้น ไม่ต้องต่อคิวเลย |
returnOrigins | ถึงคิวแล้ว ส่งคนกลับไปเว็บไหนได้ | คน ติดอยู่ ที่หน้ารอ |
เว็บใน targetUrl ของ room อยู่ในทั้งสองรายการเสมอ ใส่เฉพาะเว็บอื่นเพิ่ม
อ่านจากบนลงล่าง: หน้าที่มี tag ต้องอยู่ใน tagOrigins คำตอบของคิวจึงจะไปถึงหน้านั้นได้ ไม่อย่างนั้นผู้เข้าชมของหน้านั้นจะข้ามคิวไปโดยไม่มี error ใด ๆ เมื่อถึงคิวผู้เข้าชม หน้ารอจะส่งเขากลับไปที่ qm_return เฉพาะเมื่อเว็บนั้นอยู่ใน returnOrigins ไม่อย่างนั้นเขาจะไปที่ targetUrl ของ room และถ้าไม่มี targetUrl เขาจะติดอยู่ที่หน้ารอ เว็บของ targetUrl อยู่ในทั้งสองรายการ tagOrigins: null แปลว่าใช้รายการเดียวกับ returnOrigins และทุกเว็บที่เพิ่มเข้าไปใน returnOrigins คือเว็บที่ใครก็ส่งผู้เข้าชมของคุณ ไปได้ผ่านหน้ารอของคุณ
⚠️ เช็ค tagOrigins ทุกครั้งก่อนเปิดใช้จริง ตั้งผิดแล้วไม่มี error ที่ไหนเลย เว็บจะปล่อยทุกคนเข้าโดยไม่ต่อคิว ขณะที่ room ยังดูปกติดี
tagOrigins: ใครใช้ queue ได้
ทุกครั้งที่ผู้เข้าชมเปิดหน้าเว็บ tag บนหน้านั้นจะถาม queue server ว่า "คนนี้ต้องรอไหม" ถ้าหน้านั้นอยู่บนเว็บที่ไม่อยู่ใน tagOrigins server จะไม่ยอมให้ browser อ่านคำตอบ (กฎ CORS ของ browser) tag จึงไม่ได้คำตอบ และ tag ถูกออกแบบให้ปล่อยผู้เข้าชมผ่าน แทนที่จะทำให้เว็บพังเมื่อติดต่อ queue ไม่ได้ ผลคือ:
- ผู้เข้าชมเข้าเว็บได้ทันที โดยไม่ต่อคิว
- ไม่มีหน้า error ไม่มี redirect ไม่มีอะไรใน console ของ browser ให้สังเกต
- การ์ด room แสดงว่า room ปกติดี แค่ไม่มี traffic
นี่คือเหตุที่การลืมใส่เว็บในรายการนี้อันตราย: queue หยุดป้องกันเว็บนั้นไปเงียบ ๆ
ใส่ ทุก เว็บที่ tag ทำงานอยู่ สิ่งเหล่านี้นับเป็นคนละเว็บ: https://shop.example.com กับ https://www.shop.example.com, domain ของแบรนด์ที่สอง, host ของ staging และ port ที่ไม่ใช่ค่าปกติ
tagOrigins มีค่าเริ่มต้นเป็น null แปลว่า "ใช้รายการเดียวกับ returnOrigins" room ที่ตั้งไว้ก่อนจะมีสองรายการนี้จึงทำงานเหมือนเดิมทุกอย่าง
returnOrigins: ส่งผู้เข้าชมไปที่ไหนได้
หน้ารอจำไว้ว่าผู้เข้าชมแต่ละคนมาจากหน้าไหน (qm_return) และส่งกลับไปหน้านั้นเมื่อถึงคิว qm_return อยู่ใน URL ใครก็แก้ได้ ถ้าปลายทางไม่อยู่ใน returnOrigins ค่านั้นจะถูกทิ้ง ส่วน connector ฝั่ง server จะให้ผู้เข้าชมรออยู่ที่หน้ารอต่อ เพราะไม่มีที่ปลอดภัยให้ส่งไป
ความเสี่ยงของรายการนี้อยู่อีกด้าน: ใส่เว็บเกินเข้ามา ใครก็ใช้หน้ารอของเราส่งผู้เข้าชม ต่อไปเว็บนั้นได้ (open redirect) จึงควรใส่ให้น้อย เฉพาะเว็บที่ยินดีให้ส่งคนไป
ตั้งค่า
ใน dashboard: Edit room → "Pages allowed to run the tag" (tagOrigins) และ "Origins visitors may be returned to" (returnOrigins) หรือผ่าน API:
curl -X POST "$QM_SERVER/api/v1/admin/rooms" -H "Authorization: Bearer $ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"id":"checkout",
"tagOrigins":["https://www.shop.example.com","https://landing.shop.example.com"],
"returnOrigins":["https://www.shop.example.com","https://shop.example.com"]}'
เขียนแต่ละเว็บเป็น scheme + host (+ port ถ้าไม่ใช่ค่าปกติ) ไม่ต้องมี path: https://www.shop.example.com ไม่ใช่ https://www.shop.example.com/checkout
เช็คก่อนเปิดใช้
ใส่ --origin ทุกครั้ง เป็นเว็บที่ tag ทำงานอยู่:
npx --yes "$QM_SERVER/connectors/node/latest.tgz" verify --server "$QM_SERVER" --room checkout \
--origin https://www.shop.example.com
คำสั่งนี้รันตัวเช็คจากไฟล์ tarball ที่ queue server ของคุณให้บริการ จึงไม่ต้องติดตั้งอะไรก่อน และไม่ไปหาที่ npm registry เลย อย่าย่อเหลือ npx queue-manager (QM-489) เพราะชื่อนี้บน registry เป็น package อื่นที่ไม่เกี่ยวกับเรา
ถ้าไม่ใส่ --origin (หรือ --url ซึ่งมี origin อยู่ในตัว) การเช็คจะถามเรื่อง tagOrigins ไม่ได้เลย และจะผ่านแม้การติดตั้งนั้นใช้ได้แค่เว็บเดียว
ปุ่ม Run check ใน dashboard เอาเว็บมาจาก URL หน้าที่ใส่ และรายงานผลเดียว คือ pass, fail หรือ warn:
- Fail: tag บนเว็บนั้นอ่านคำตอบของ queue ไม่ได้ ปุ่ม Allow \<origin\> เพิ่มเว็บนั้นเข้า
tagOriginsอย่างเดียว - Warn: หลังแก้ข้อแรกแล้ว ในการเช็ครอบถัดไป เว็บนั้นใช้ queue ได้แล้ว แต่ยังส่งผู้เข้าชม กลับไปไม่ได้ ปุ่มแยกชื่อ Also return visitors to \<origin\> เพิ่มเว็บนั้นเข้า
returnOriginsที่แยกเป็นอีกขั้นโดยตั้งใจ เพราะการขยายที่ที่ส่งผู้เข้าชมไปได้เป็นการตัดสินใจด้าน security
จะเห็นทีละข้อเท่านั้น: warn จะไม่ขึ้นจนกว่าจะแก้ fail เพราะถ้าเว็บยังอ่านคำตอบของ queue ไม่ได้ การเช็คจะไปไม่ถึงคำถามเรื่องส่งกลับ
เฝ้าดูตอนใช้งานจริง
ถ้ามีหน้าที่ติด tag อยู่บนเว็บที่ room ไม่อนุญาต server จะนับไว้: GET /api/admin/health รายงาน refusedOriginChecks (room → origin → จำนวน) และ Prometheus มี qm_refused_origin_checks_total{room,origin} ค่ามากกว่าศูนย์แปลว่ามีหน้าที่ถูกเสิร์ฟ โดยไม่ต่อคิวอยู่ตอนนี้ ให้ตั้ง alert ไว้
room ที่ไม่มีทั้ง targetUrl และ returnOrigins ไม่ได้ระบุเว็บใดเลย จะเปิดให้ทุกเว็บ (*) และ /api/admin/health จะแสดงรายชื่อ room แบบนี้ไว้ จะได้ไม่เข้าใจผิดว่าเป็น room ที่ล็อกไว้แล้ว
ข้อมูลอ้างอิงการตั้งค่า
ตั้งค่า tag ได้สองช่องทาง: ใช้ attribute data- ได้เสมอ และ ถ้าโหลด qm.js เป็นไฟล์แยกผ่าน src ใส่เป็น query parameter บน src นั้นได้อีกทาง
ถ้ามีทั้งสองอย่าง attribute ชนะ query parameter จะถูกอ่านก็ต่อเมื่อไม่มี attribute หรือ attribute เป็น string ว่าง คำว่า "ชนะ" ดูแค่ว่ามี attribute อยู่หรือไม่ ไม่ได้ดูว่าค่าใช้ได้หรือเปล่า เช่น data-qm-timeout="fast" เป็นค่าที่ใช้ไม่ได้ แต่ก็ยังทำให้ ?timeout= ถูกข้าม แล้วการตั้งค่านั้นจะกลับไปใช้ ค่า default ไม่ใช่ค่าจาก parameter
| Attribute | src parameter | ต้องใส่ไหม | ค่า default | คำอธิบาย |
|---|---|---|---|---|
data-qm-server | server= | ไม่¹ | origin ของ src ของ tag เอง | origin (scheme + host + port) ของ Queue Manager ที่คุณ deploy เช่น https://qm.weekday100.com มี slash ต่อท้ายก็ได้ |
data-qm-room | room= | ต้อง | — | room id ที่ใช้ตรวจหน้านี้ |
data-qm-timeout | timeout= | ไม่ | 2000 | รอผลการตรวจที่ประตู (door check) กี่มิลลิวินาที ถ้าเกินนี้จะเลิกรอและปล่อยผู้เข้าชมผ่าน |
data-qm-spa | spa= | ไม่ | — | ใส่ auto ตรงตัว (ตัวพิมพ์เล็กหรือใหญ่ก็ได้) เพื่อให้ตรวจใหม่เองทุกครั้งที่เปลี่ยนหน้าฝั่ง client (history.pushState / replaceState / popstate) ค่าอื่นทุกค่าคือปิด ดู Single-page apps |
¹ ไม่ใส่ได้เฉพาะตอนที่ qm.js ถูกโหลด จาก Queue Manager ที่คุณ deploy เอง ซึ่งเป็นการติดตั้งแบบปกติ tag จะคุยกับ origin ที่มันถูกโหลดมา มีสองกรณีที่ไม่ใส่แล้วพัง และพังคนละแบบ:
- โหลดเป็นไฟล์แยกจาก CDN ของคุณเองหรือผ่าน reverse proxy tag จะเข้าใจว่า origin ของ CDN คือ queue server การตรวจที่ประตูทุกครั้งจะได้ 404 หรือถูก CORS ปฏิเสธ tag จึงปล่อยผ่าน (fail open) เว็บยังใช้ได้ แต่ traffic ทั้งหมดไม่ต้องต่อคิว
- ฝังโค้ดลงในหน้า (inline) หรือรวมเข้าไปใน JavaScript ของแอป element ที่รันอยู่ไม่มี
srcจึงไม่มี origin ให้ใช้แทน และไม่มี query string attributedata-qm-serverและdata-qm-roomจึงเป็นช่องทางตั้งค่าช่องทางเดียวที่มี ถ้าไม่มี server tag จะ หยุดทำงานเงียบ ๆ: ไม่ redirect ไม่มีข้อความใน console ไม่มีอะไรให้สังเกต ติดตั้งแบบ inline ต้องใส่ทั้งสอง attribute
ช่องทาง src มีไว้เพราะ template ของ tag manager ใส่ได้แค่ URL ตั้ง attribute บน element ที่มันสร้างไม่ได้ (ดู Google Tag Manager template) และยังเป็นทางออกเมื่อ CMS หรือตัวกรองของ CSP ตัด attribute data-* ทิ้ง:
<script async src="https://qm.weekday100.com/snippet/qm.js?room=checkout"></script>
อย่าใช้ server, room, timeout หรือ spa เป็น query parameter บน URL ของ snippet เพื่อเรื่องอื่น โดยเฉพาะอย่าใช้เป็นตัวบังคับโหลดไฟล์ใหม่ (cache-buster) จะพังหรือไม่ ขึ้นกับ attribute ที่อยู่คู่กัน และพังแบบเงียบทั้งสองทาง:
- ไม่มี attribute → parameter คือ การตั้งค่า ถ้าใช้
?room=1695เป็น cache-buster tag จะไปใช้ room1695ซึ่งแทบแน่นอนว่าไม่มีอยู่ การตรวจที่ประตูจึงตอบpassและหน้ารอก็ปิดไปเฉย ๆ สำหรับหน้านั้น - มี attribute และไม่ว่าง → parameter ถูกอ่านแล้วทิ้ง ใส่
?room=saleเพื่อแทนค่าdata-qm-room="checkout"ที่ deploy ไว้ จะไม่มีผลอะไร และไม่มีคำเตือน
parameter ชื่ออื่นถูกข้ามทั้งหมด ?v=3 จึงใช้เป็น cache-buster ได้อย่างปลอดภัย แต่อย่าใส่ #fragment ต่อท้าย URL ของ snippet เพราะ query string ถูกตัดที่ ? แค่จุดเดียว fragment จึงไปติดอยู่ในค่าของ parameter ตัวสุดท้าย: qm.js?room=checkout#v3 ตั้ง room เป็น checkout#v3 ซึ่งไม่ใช่ room id ที่ถูกต้อง กรณีนี้เห็นชัด (tag แสดง id ที่ผิดใน console แล้วไม่ทำอะไร) แต่ผลก็คือไม่ทำอะไรอยู่ดี
ให้ใช้ชื่อไฟล์ qm.js ตามเดิม เรื่องนี้สำคัญแค่กรณีเดียว: เมื่อใช้ document.currentScript ไม่ได้ (ใส่ผ่าน innerHTML หรือ tag manager) และ element ไม่มีทั้ง data-qm-server และ data-qm-room ครบ ทางสุดท้ายของ tag คือหาตัวเองจาก src ของ element <script> ในหน้า โดย src ต้องเป็นแบบใดแบบหนึ่ง:
- มี
/snippet/qm.jsอยู่ตรงไหนก็ได้ แบบนี้ยืดหยุ่นกว่า: ถ้าโหลดจาก path ปกติ หลังชื่อไฟล์จะมีอะไรก็ได้ รวมถึง#fragment - ลงท้ายด้วย
/qm.jsตามด้วย?หรือจบ URL ทันที ถ้าไม่ได้โหลดจาก path ปกติ ใช้ได้แค่แบบนี้
ดังนั้นไฟล์ที่เปลี่ยนชื่อ (/assets/qm.v3.js) หรือมีอย่างอื่นที่ไม่ใช่ ? ต่อท้ายชื่อ (/assets/qm.js#v3) จะหาไม่เจอ และ tag จะไม่ทำอะไร ให้ใส่เวอร์ชันใน query parameter แทน
อีกเรื่องที่ทำให้ tag หาตัวเองผิด สำหรับหน้าที่มี tag มากกว่าหนึ่งตัว: เมื่อใช้ document.currentScript ไม่ได้ หรือ element ที่รันอยู่ไม่มี data-qm-room tag จะใช้ element ตัวสุดท้าย ที่ตรงกับ script[data-qm-server][data-qm-room] attribute และ query string ใน src ของ element ตัวนั้นจะกลายเป็นการตั้งค่า ส่วน ?room= บน element ที่รันจริงจะถูกทิ้ง ใส่ tag หน้าละตัวเดียว
URL parameter ที่ระบบจองไว้ (อย่าใช้ชื่อเหล่านี้เป็น query param ของคุณเองบนหน้าที่คุ้มกัน):
| Parameter | ส่งจากไหนไปไหน | ความหมาย |
|---|---|---|
qm_return | เว็บ → หน้ารอ | URL เต็มของหน้าที่ผู้เข้าชมอยู่ หน้ารอจะส่งเขากลับไปที่นั่นเมื่อถึงคิว |
qm_token | หน้ารอ → เว็บ | pass token ที่ลงลายเซ็นแล้ว qm.js เก็บไว้ แล้วลบออกจากช่อง address ทันทีด้วย history.replaceState |
ที่เก็บ token: localStorage key qm_<roomId> และสำเนาใน cookie qm_<roomId> (SameSite=Lax, 24 ชม.) สำหรับ browser ที่ใช้ storage ไม่ได้
กุญแจ door-session (sess) — จำเป็นสำหรับการเชื่อมต่อแบบกำหนดเอง
ถ้าคุณเขียนการเชื่อมต่อเอง ต้องเก็บ sess ไว้ตอนที่มันมา เพราะมันมาครั้งเดียว
pass token ใช้ได้ครั้งเดียวที่ประตู การเรียก /api/check ครั้งแรก ที่ปล่อยผู้เข้าชมเข้าไป จะตอบพร้อม field เพิ่ม:
{"action":"pass","reason":"admitted","session":true,"ttlSec":1800,"sess":"<key>"}
มีสอง field ที่ชื่อคล้ายกัน อย่าสับสน:
| Field | คืออะไร | มาเมื่อไร |
|---|---|---|
session | boolean บอกสถานะ ("pass นี้มี door session ที่ยังเปิดอยู่") | ทุกครั้งที่ตรวจแล้วปล่อยผ่าน |
sess | ตัวกุญแจจริง | ครั้งเดียว ตอนตรวจครั้งแรกที่ใช้ pass |
(door session คือช่วงที่ผู้เข้าชมอยู่ในเว็บหลังผ่านประตูแล้ว) การตรวจครั้งหลังจะไม่ส่ง sess ซ้ำ
sess ออกให้ครั้งเดียวเท่านั้น ตอนที่ใช้ pass ให้เก็บไว้ฝั่งเว็บที่คุ้มกัน (first-party) (qm.js ใช้ localStorage key qmk_<roomId> และสำเนาใน cookie ชื่อเดียวกัน จงใจไม่ใช้ qms_ เพราะนั่นเป็น session cookie แบบ HttpOnly ของ queue server เอง) แล้วส่งกลับไปทุกครั้งที่ตรวจครั้งต่อไป เป็น &sess=<key> หรือเป็น request header X-QM-Session ก็ได้
sess คือสิ่งที่กันคนแอบใช้สิทธิ์ของคนอื่น token ถูกส่งใน ?qm_token= จึงไปอยู่ใน access log ของทุก origin และ CDN ถ้าไม่มี sess ใครที่อ่าน log เจอสักบรรทัดก็เข้า session ที่ยังเปิดอยู่ของเจ้าของได้ และทุกคนที่อยู่หลัง NAT ของบริษัทหรือ CGNAT วงเดียวกัน ในระดับเครือข่ายดูเหมือนกันหมด ไม่มี fingerprint ไหนแยกได้ กุญแจ sess ไม่เคยอยู่ใน URL บนเว็บของคุณ และไม่เคยเข้าไปใน log การมีกุญแจนี้จึงเป็นหลักฐานยืนยันตัวที่ token ให้ไม่ได้
ถ้าตรวจโดยส่ง token มาแต่ไม่มีกุญแจ จะได้คำตอบนี้:
{"action":"queue","reason":"session_elsewhere","clearToken":true,"waitingRoomUrl":"/w/checkout"}
ต้องทำตาม clearToken: ลบ token (และกุญแจ) ที่เก็บไว้ก่อน redirect ไม่อย่างนั้นผู้เข้าชมจะเด้งไปมาระหว่างเว็บคุณกับหน้ารอ ผู้เข้าชมที่ทำกุญแจหายจริง ๆ (เปลี่ยนเครื่อง ล้างข้อมูลเว็บ) จะถูกส่งกลับไปที่หน้ารอ ที่นั่น queue session cookie ของเขา ยืนยันได้ว่าเป็นใคร แล้วเขาจะได้ session คืนทันที
inline proxy ในตัวทำเรื่องนี้ให้เอง: ใน response ที่ตอบ clearToken มันจะทำให้ qm_<room> และ qm_dk_<room> หมดอายุ และออกที่ใหม่ในคิวให้ใน response เดียวกัน ผู้เข้าชมที่ไม่มี JavaScript จึงไม่เจอหน้ารอของที่ในคิวที่เขาไม่ได้ถืออยู่
การเชื่อมต่อฝั่ง server ที่รับรองผู้เข้าชมให้ (vouched) ด้วย ?ip=&agent= จะไม่ได้ sess เพราะยืนยันตัวตนแล้ว และไม่มี browser storage ให้เก็บ session ที่เปิดแบบนี้จะผูกกับ ช่องทาง (channel) แทน: การตรวจ ticket นั้นทุกครั้งหลังจากนี้ต้องมาจากการเชื่อมต่อที่ยืนยันตัวตนเดิม (Public API key เดียวกัน) หรือแนบ queue session cookie ของผู้เข้าชมมา เป็นการป้องกันแบบเดียวกัน แค่ใช้หลักฐานคนละอย่าง ถ้าไม่มีกฎนี้ ใครก็ได้ที่อยู่หลัง NAT เดียวกับ public address ที่ถูกรับรอง เอา ?qm_token= ที่หลุดออกไปมาใช้ซ้ำโดยไม่ต้องบอกว่าเป็นใคร ก็ใช้ session นั้นได้ และข้ามขีดจำกัด maxConcurrent ไปทั้งหมด
ผลที่ต้องวางแผนไว้สองข้อ:
- อย่าใช้หลายช่องทางใน room เดียว ถ้าบาง path เรียก
/api/checkฝั่ง server และบาง path โหลดsnippet/qm.jsใน browser ผู้เข้าชมที่ผ่านทางหนึ่งจะถูกปฏิเสธในอีกทาง (session_elsewhere,clearToken:true) เขากลับมาได้โดยเด้งผ่านหน้ารอหนึ่งรอบ แต่ก็ ต้อง เด้งจริง ใช้ช่องทางเดียวต่อหนึ่ง room - token หลุดไปแล้วก็ยังกู้คืนได้ queue cookie ของผู้เข้าชมเอา session แบบ vouched คืนได้ที่
/api/joinเหมือนกับ session ที่ผูกกับกุญแจทุกอย่าง
Single-page apps
ถ้าเว็บเป็น SPA ให้เปิดการตรวจทุกครั้งที่เปลี่ยนหน้า ไม่อย่างนั้นหน้าที่คุ้มกันบางหน้าจะไม่ถูกตรวจ
ปกติ snippet ตรวจครั้งเดียวตอนโหลดหน้า SPA (แอปหน้าเดียว) ที่เปลี่ยนหน้าด้วย History API ไม่โหลดหน้าใหม่เลย การเปลี่ยน route ไปหน้าที่คุ้มกันจึงไม่ถูกตรวจ แก้ได้สองวิธี:
วิธี A — อัตโนมัติ (แนะนำ) เพิ่ม attribute ตัวเดียว:
<script async
src="https://qm.weekday100.com/snippet/qm.js"
data-qm-server="https://qm.weekday100.com"
data-qm-room="checkout"
data-qm-spa="auto"></script>
snippet จะครอบ history.pushState / history.replaceState (เรียกฟังก์ชันเดิมก่อน React Router, Vue Router, Next.js, SvelteKit และตัวอื่น ๆ จึงไม่ได้รับผลกระทบ) และฟัง popstate ทุกครั้งที่เปลี่ยนหน้าฝั่ง client จะตรวจใหม่ การตรวจใหม่ใช้ทรัพยากรน้อย (GET หนึ่งครั้ง ที่ปล่อยผ่านถ้าล้มเหลว) และผู้เข้าชมที่มี pass ที่ใช้ได้จะได้ "pass" ตลอดช่วง passedTtlSec จึงไม่มีหน้าจอกะพริบ และไม่ต้องต่อคิวซ้ำ
วิธี B — เรียกเองจาก router ของคุณ เรียก window.QueueManager.check() หลังเปลี่ยนไปหน้าที่คุ้มกัน:
// React Router v6+
const location = useLocation();
useEffect(() => { window.QueueManager?.check(); }, [location.pathname]);
// Vue Router
router.afterEach(() => window.QueueManager?.check());
// Next.js (App Router)
const pathname = usePathname();
useEffect(() => { window.QueueManager?.check(); }, [pathname]);
ใช้วิธีเรียกเองเมื่อคุ้มกันแค่ บาง route: เช็ค route เองก่อนเรียก และไม่ต้องส่ง request เลยในหน้าที่ไม่ได้คุ้มกัน
window.QueueManager ยังมี token() (คืน token ที่เก็บไว้ หรือ null), roomId, server และ version
คุ้มกันการเรียก API/XHR จาก SPA snippet คุม การเปลี่ยนหน้า ไม่ได้คุมการเรียก fetch() ที่แอปของคุณทำ ถ้าคนแห่มาที่ JSON API แทนที่จะมาที่หน้าเว็บ ให้ตรวจฝั่ง server ที่ API route (ดู Server-side check) และตอบผู้เรียกที่ต้องรอคิวด้วย status ที่แอปของคุณเอาไปจัดการต่อได้:
// API route variant of the middleware: 401 + queue URL instead of a 302
if (action === 'queue') {
return res.status(401).json({ queued: true,
waitingRoomUrl: new URL(waitingRoomUrl, QM_SERVER).href });
}
fetch wrapper ของ SPA เห็น queued: true แล้วทำ location.href = waitingRoomUrl + '?qm_return=' + encodeURIComponent(location.href)
การกำหนดเป้าหมาย URL (room ป้องกันหน้าใดบ้าง)
จะให้ room คุ้มกันหน้าไหน ให้ตั้งที่ตัว room ไม่ต้องแก้ที่จุดที่คุณวางโค้ดตรวจไว้ พิมพ์ /checkout* ลงในช่อง Queued URLs ของ room แล้วกดบันทึก ทุกจุดที่เชื่อมต่อไว้ จะใช้กฎนี้ตั้งแต่การตรวจครั้งถัดไป ไม่ต้อง deploy ไม่ต้องแก้ theme ไม่ต้องออก Worker เวอร์ชันใหม่ นี่คือสิ่งที่คุณแก้ได้เองตอนตีสอง โดยไม่ต้องปลุกคนที่ ship โค้ดได้
ขอบเขตนี้ (scope) คือ การตั้งค่าของ room ที่เปลี่ยนได้ขณะระบบทำงาน ไม่ได้ฝังอยู่ในโค้ด
รูปแบบกฎ บรรทัดละหนึ่งกฎ:
| คุณเขียน | ความหมาย |
|---|---|
/checkout | path นี้ตรงตัวเท่านั้น (/checkout/ เป็น path คนละตัว) |
/checkout* | path นี้ และทุกอย่างที่อยู่ใต้หรือต่อท้ายมัน |
*/cart | path ใดก็ได้ที่ลงท้ายด้วย /cart บน host ใดก็ได้ |
/checkout?step=2 | กฎที่มี ? จะเทียบทั้ง path และ query |
https://shop.example.com/checkout* | ระบุ origin ด้วย host อื่นที่มี path เดียวกันจะไม่ตรง |
!/checkout/thank-you | ข้อยกเว้น: ไม่ให้ต่อคิวหน้านี้เด็ดขาด |
ตัวพิมพ์เล็กใหญ่ถือว่าเหมือนกัน ข้อยกเว้นชนะเสมอ ไม่ว่าจะเรียงไว้ลำดับไหน /checkout* อยู่เหนือ !/checkout/thank-you ได้ผลเหมือนสลับกัน เพราะกฎที่ผลขึ้นกับลำดับ ทำให้พลาดง่าย ไม่ใช่ข้อดี ขีดจำกัด: 25 กฎ กฎละไม่เกิน 300 ตัวอักษร และ wildcard ไม่เกิน 10 ตัวต่อกฎ
ถ้าเว้นช่องว่างไว้ room จะไม่มีขอบเขต: ทุกหน้าที่มี tag ต้องต่อคิว เหมือนก่อนมีความสามารถนี้ทุกอย่าง room ที่มีอยู่แล้วไม่ถูกเปลี่ยน
แต่ละจุดเชื่อมต่อรู้ขอบเขตได้อย่างไร
snippet/qm.js(≥ 1.2.0) ส่ง URL ปัจจุบันมาทุกครั้งที่ตรวจ (/api/v1/check?roomId=…&url=…) แล้ว server เป็นคนตัดสิน tag ไม่ได้เก็บอะไรเรื่องขอบเขตไว้เลย แก้ขอบเขตของ room ใน console แล้วมีผลทันทีโดยไม่ต้องแตะหน้าเว็บ- connector ฝั่ง server และ edge ทำแบบเดียวกันก็ได้ คือส่ง
?url=แล้วให้ server ตอบ หรือถ้าอยากข้ามการเรียกผ่านเครือข่ายสำหรับหน้าที่ชัดว่าอยู่นอกขอบเขต ก็ดึงกฎจากGET /api/v1/targeting?roomId=…แล้ว cache ไว้ตามETag - อะไรก็ตามที่ไม่ส่งทั้ง
url=และRefererจะได้คำตอบunknownและ ถูกให้ต่อคิวไปก่อน ถ้าปล่อยผ่านในกรณีนี้ การเชื่อมต่อที่เก่าแล้วจะเลิกคุ้มกันเว็บไปเงียบ ๆ การให้ต่อคิวจึงเป็นทางที่ปลอดภัย และหน้าของ room จะบอกชื่อตัวที่ส่งมาแบบนี้ ให้คุณไปแก้ได้
หน้าที่อยู่นอกขอบเขตจะได้ {"action":"pass","reason":"out_of_scope"} และไม่มีการออกที่ในคิวให้
ตรวจสอบว่ากฎทำงานตามที่คุณคิด
หน้าของ room แสดงทุกกฎ พร้อม จำนวนครั้งที่กฎนั้นตรงกับการตรวจ และครั้งล่าสุดที่ตรง และแสดงจำนวนการตรวจที่ไม่ตรงกฎไหนเลย กับการตรวจที่มาโดยไม่มี URL เลย ถ้ากฎไหนขึ้นว่า never matched ระหว่างที่เปิดขายจริง แปลว่า room ชี้ไปผิดหน้า นี่คือ กับดักข้าม room ที่เมื่อก่อนไม่มีอะไรเตือน
ลองก่อนบันทึกได้ (dry run): วาง URL ลงไป แล้วดูว่ากฎไหนจะชนะ
# what the connectors read
curl -s 'https://qm.weekday100.com/api/v1/targeting?roomId=checkout'
# → {"roomId":"checkout","rev":3,"scoped":true,"updatedAt":…,
# "patterns":[{"pattern":"/checkout*","action":"queue"},
# {"pattern":"/checkout/thank-you","action":"ignore"}]}
# dry run, from the operator side (does not touch the match counters)
curl -s -H "Authorization: Bearer $ADMIN_KEY" \
--get --data-urlencode 'url=https://shop.example.com/checkout/thank-you' \
'https://qm.weekday100.com/api/v1/admin/rooms/checkout/targeting'
# → …"test":{"scope":"out","rule":"/checkout/thank-you","action":"ignore","queued":false}
Fail-open โดยการออกแบบ
คิวที่พังต้องไม่ทำให้เว็บคุณล่ม snippet จะปล่อยผู้เข้าชมผ่าน (ไม่ทำอะไรเลย) เมื่อเกิดเรื่องใดเรื่องหนึ่งต่อไปนี้ เรียกว่า fail open:
/api/checkไม่ตอบภายในเวลาที่กำหนด (ค่า default 2 วินาที)- request ล้มเหลวที่ระดับเครือข่าย (DNS, TLS, ต่อไม่ติด, CORS)
- server ตอบด้วย status 5xx
- body ของ response ไม่ใช่ JSON ที่ถูกต้อง หรือไม่มี
actionที่รู้จัก - ตั้งค่า script tag ผิด (ไม่มี attribute server/room)
429 ไม่อยู่ในรายการนี้ การตรวจที่ถูกจำกัดอัตรา (rate limit) แปลว่า queue server ยังทำงานอยู่และกำลังลดภาระ ซึ่งมักเกิดตอนคนเยอะที่สุด ตอนที่คิวสำคัญที่สุดพอดี ถ้าปล่อยผ่านตอนได้ 429 ผู้เข้าชมทุกคนจะข้ามคิวได้ connector ทุกตัวที่ออกไปแล้ว (snippet 1.4.0, Node package 1.1.0, WordPress plugin 1.2.0, Cloudflare Worker 1.2.0) จึงส่งผู้เข้าชมไปหน้ารอของ room แทน คือ /w/<room> บน server ที่ตั้งไว้ (URL เดียวกับที่คำตอบ queue ระบุ เพราะ body {"code":"rate_limited"} ของตัวจำกัดอัตราไม่ได้ระบุ URL ไว้) พร้อมตั้ง qm_return กรณีนี้ไม่นับเป็น fail-open ถ้า qm_rate_limited_checks_total ขึ้นต่อเนื่อง ให้เพิ่ม CHECK_LIMIT_PER_MIN (QM-379)
สรุปสั้น ๆ: ได้คำตอบ pass → หน้าโหลด; ได้คำตอบ queue หรือ 429 → ส่งผู้เข้าชมไปหน้ารอ; ไม่ได้คำตอบที่ใช้ได้ภายใน 2 วินาที (หมดเวลา, error ของเครือข่ายหรือ CORS, 5xx, คำตอบที่ไม่ใช่ JSON หรือ tag ที่ตั้งค่าผิด) → หน้าโหลดโดยไม่ต้องต่อคิว
ยกเว้นผู้เข้าชมที่ผ่านประตูเข้ามาแล้ว (QM-413) คิวให้กุญแจ door-session sess ครั้งเดียว ตอนตรวจที่ใช้ pass ไป ใครที่ถือกุญแจนี้อยู่จึงเคยถูกปล่อยเข้ามาแล้ว และ 429 แปลว่าคิวยุ่งเกินกว่าจะตอบ ไม่ได้แปลว่าตัดสินว่าเขาเข้าไม่ได้
| Connector | ถ้าได้ 429 และผู้เข้าชมมีกุญแจ |
|---|---|
| snippet 1.5.0 | ให้ผู้เข้าชมที่มีกุญแจ qmk_<room> เก็บไว้ อยู่ในหน้าเดิมต่อ |
| Node package 1.2.0 | ให้ผ่านการตรวจแบบไม่ vouch ที่ส่งกุญแจมา (action: "pass", reason: "rate_limited") |
ทั้งสองกรณีไม่ใช่การปล่อยผ่านเพราะพัง (fail-open) และไม่ถูกนับเป็น fail-open
ตั้งแต่ snippet 1.5.3 และ Node package 1.3.1 (QM-527) แค่มีกุญแจยังไม่พอ กุญแจจะนับก็ต่อเมื่อ หน้าเว็บหรือ process นั้นเห็นคิวออกกุญแจนี้เองภายใน 30 นาทีที่ผ่านมา (session idle TTL ค่าปกติ) snippet เก็บเวลาที่ออกกุญแจไว้ใน qmt_<room> ส่วน Node client จำกุญแจที่ได้รับไว้ในหน่วยความจำ (ไม่เกิน 10,000 อัน ตัวที่เก่าสุดถูกทิ้งก่อน) กุญแจที่แต่งขึ้นเอง qmk_<room> เก่าจากการเข้าชมครั้งก่อน หรือกุญแจที่ app process อื่นได้รับ จะถูกส่งไปต่อคิวเมื่อได้ 429
กฎนี้ใช้ได้เฉพาะช่องทางที่คิวออกกุญแจให้ คือช่องทางตรงที่ไม่ vouch การตรวจแบบ vouched (?ip=&agent= พร้อม Public API key) ไม่ได้กุญแจ และ server ไม่อ่านกุญแจในช่องทางนี้เลย Cloudflare Worker, WordPress plugin และ Node middleware ในโหมด vouched ที่เป็นค่าปกติ จึงไม่มีกุญแจให้แสดง ถ้ามี cookie qmk_<room> มาถึงตัวเหล่านี้ ก็เป็นกุญแจเก่าหรือปลอม ตัวเหล่านี้ยังส่งทุกคนไปหน้ารอเมื่อได้ 429
กุญแจไม่หายเมื่อผู้เข้าชมผ่านอีกห้องหนึ่งมา (QM-453) snippet 1.5.1 รับ ?qm_token= เฉพาะ token ของห้องตัวเองเท่านั้น (ดูจากชื่อห้องใน token) ถ้าเป็น token ของห้องอื่น เช่น กลับมาจากห้อง B ผ่าน qm_return มาที่หน้าที่ติด tag ของห้อง A snippet จะลบ token ออกจาก แถบที่อยู่ แต่ไม่เขียนทับ qm_<room> และไม่ลบ qmk_<room> ของห้อง A ก่อน 1.5.1 ผู้เข้าชมคนนี้จะเสียกุญแจของห้อง A และถูกส่งกลับไปต่อคิวทุกครั้งที่ตรวจ จน session หมดอายุ ตั้งแต่ 1.2.1 Node middleware และ Cloudflare Worker ก็ทำแบบเดียวกัน (QM-490) คือถ้า ?qm_token= เป็นของห้องอื่น จะลบออกจากแถบที่อยู่ แล้วตรวจด้วย cookie qm_<room> แทน
ลิงก์ทำให้ผู้เข้าชมหลุดออกไม่ได้ (QM-481) ตั้งแต่ snippet 1.5.2 ?qm_token= ของห้องนี้ เป็นแค่ตัวเลือกที่ยังไม่ได้ยืนยัน snippet ลบมันออกจากแถบที่อยู่ แล้วส่งไปตรวจโดยไม่แนบกุญแจ จะเขียนทับ qm_<room> และลบ qmk_<room> ตัวเก่าก็ต่อเมื่อ server ตอบ pass เท่านั้น ถ้า server ไม่รับ pass และกุญแจเดิมยังอยู่ และ snippet จะตรวจด้วย pass เดิมแทน ก่อน 1.5.2 ลิงก์ที่มี token ปลอมของห้องนี้จะเขียนทับ pass และลบกุญแจของผู้เข้าชมทันที ก่อนที่ server จะได้ดูด้วยซ้ำ
connector ตรวจความถูกต้องของกุญแจไม่ได้ เพราะไม่มี secret มันดูแค่ว่ามีกุญแจหรือไม่ กุญแจปลอมหรือเก่าจึงพาคนถือผ่าน 429 ไปได้ สิ่งที่จำกัดความเสียหายไว้:
- ใช้ได้เฉพาะช่วงที่การตรวจถูกปฏิเสธอยู่ กุญแจยังถูกส่งขึ้นไปเป็น
X-QM-Sessionการตรวจครั้งถัดไปที่ได้คำตอบจะตัดสินกุญแจนั้น และผู้เข้าชมที่ไม่มี session ที่ยังเปิดอยู่จะถูกส่งไปต่อคิวตรงนั้น - ไม่เคยทับคำตอบที่คิวให้มาแล้ว
- บน snippet มันไม่ได้ให้อะไรที่ผู้เข้าชมเอาเองไม่ได้อยู่แล้วด้วยการบล็อก script
กรณีแย่ที่สุดคือผู้เข้าชมที่ room ไม่เคยปล่อยเข้า อยู่ในเว็บได้ตลอดช่วงที่ยังได้ 429 นี่คือเหตุที่ qm_rate_limited_checks_total ที่ขึ้นต่อเนื่องต้องแก้ ไม่ใช่ปล่อยไว้
server ก็ทำแบบเดียวกัน: การตรวจ room ที่ไม่รู้จักหรือถูกลบไปแล้วจะได้ {"action":"pass","reason":"no_room"} แต่ตอนนี้ไม่เงียบแล้ว room id ที่ไม่รู้จักแต่ละตัวจะถูกนับ แสดงใน GET /api/admin/health เป็น unknownRoomChecks และใน Prometheus เป็น qm_unknown_room_checks_total{room="..."} และบันทึก log หนึ่งครั้ง ตัวนับที่ไม่ใช่ศูนย์ แปลว่ามี snippet ที่ deploy แล้วอ้างถึง room ที่ไม่มีอยู่ และหน้ารอของมันไม่ได้ทำอะไรเลย
credential ก็เช่นกัน การตรวจแบบ vouched (?ip=&agent=) จาก connector ฝั่ง server หรือ edge ที่ใช้ PUBLIC_API_KEY ผิด จะได้ 401 และ connector ทุกตัวที่ออกไปแล้วจะปล่อยผ่าน ซึ่งถูกต้อง แต่ถ้าไม่นับไว้จะมองไม่เห็นเลย เพราะ room มีอยู่ origin ถูก และผู้เข้าชมไม่เคยไปถึงหน้ารอ ตัวนับอื่นจึงไม่ขยับ server นับกรณีนี้เป็น refusedKeyChecks ใน GET /api/admin/health มีใน Prometheus เป็น qm_refused_key_checks_total{room="..."} และบันทึก log หนึ่งครั้งต่อ room พร้อมอักษรสี่ตัวแรกของ key ที่ส่งมา ค่าที่ไม่ใช่ศูนย์แปลว่ามี deployment ที่ไหนสักแห่ง กำลังเปิดหน้าเว็บโดยไม่ต้องต่อคิวอยู่ตอนนี้ เกือบทุกครั้งเกิดจากเปลี่ยน key แล้วลืมเปลี่ยนสักที่ ให้ตั้ง alert ไว้คู่กับอีกสามตัว
เราเลือกแบบนี้โดยตั้งใจ: ช่วงที่ queue server ล่ม origin ของคุณจะรับ traffic ที่ไม่ได้กรอง ซึ่งก็คือ traffic เดียวกับที่จะได้รับถ้าไม่มีหน้ารอเลย ทางเลือกอีกแบบคือปิดเมื่อพัง (fail-closed) ซึ่งทำให้คิวสะดุดนิดเดียวก็กลายเป็นเว็บล่มทั้งเว็บ ถ้ามี URL ที่ห้ามเข้าเด็ดขาดหากไม่มี pass (เช่น การเปิดขายสินค้าจำนวนจำกัด) ให้ตรวจฝั่ง server ด้วย (หัวข้อถัดไป) แล้วเลือกเองว่าจะให้ทำอย่างไรเมื่อระบบพัง
การตรวจฝั่ง server (defense in depth)
snippet ฝั่ง client มีไว้เพื่อความสะดวก contract /api/check เดียวกันใช้จาก edge หรือ backend ของคุณได้ด้วย ผู้เข้าชมปลอม pass ไม่ได้ เพราะ token ลงลายเซ็นด้วย HMAC-SHA256 โดย queue server
(defense in depth คือการป้องกันหลายชั้น ถ้าชั้นหนึ่งหลุด ยังมีอีกชั้น)
Contract
GET {server}/api/v1/check?roomId=<id>&token=<token>
200 {"action":"pass"}
200 {"action":"queue","waitingRoomUrl":"/w/<id>"}
/api/v1/* คือ contract สาธารณะที่ตรึงเวอร์ชันไว้ ส่วน path /api/* ที่ไม่มีเวอร์ชัน เป็นชื่อเรียกอีกชื่อที่ใช้ได้ตลอดไป และตอบเหมือนกันทุกอย่าง (ดู API versioning) ตัวอย่างด้านล่างใช้แบบสั้นเพื่อให้อ่านง่าย ในโค้ดที่ deploy จริงให้ใช้ /api/v1/
ใช้ HTTP method ผิดกับ path ใน contract (เช่น DELETE /api/v1/join) จะได้ 405 {"code":"method_not_allowed"} พร้อม header Allow ไม่ใช่ 400
waitingRoomUrl อาจเป็น URL แบบย่อ (relative) ให้ต่อกับ origin ของ queue server ลองด้วย curl:
# no token → queue
curl -s "https://qm.weekday100.com/api/check?roomId=checkout"
# → {"action":"queue","waitingRoomUrl":"/w/checkout"}
# with a valid passed token → pass
curl -s "https://qm.weekday100.com/api/check?roomId=checkout&token=eyJ..."
# → {"action":"pass"}
Node / Express — ติดตั้ง package
package นี้ ยังไม่อยู่บน npm ให้ติดตั้งจากไฟล์ tarball ที่ queue server ของคุณสร้างและเปิดให้โหลด เปลี่ยน https://qm.weekday100.com เป็น origin ของ server ของคุณ และเปลี่ยน 1.3.0 เป็นเวอร์ชัน connector ปัจจุบัน:
npm install https://qm.weekday100.com/connectors/node/queue-manager-node-1.3.0.tgz
ให้ใช้ URL ที่มีเวอร์ชัน อย่าใช้ latest.tgz npm จะบันทึก sha512 ของ tarball ที่โหลดมาไว้ใน package-lock.json คู่กับ URL นั้น เนื้อไฟล์ที่ latest.tgz เปลี่ยนทุกครั้งที่ออก connector เวอร์ชันใหม่ เมื่อ server อัปเกรดแล้ว npm ci จึงล้มด้วย EINTEGRITY URL ที่มีเวอร์ชันส่งไฟล์เดิม ทุก byte เสมอ และ server ยังเปิดให้โหลดทุกเวอร์ชันที่เคยออก lockfile จึงติดตั้งได้ต่อไป เวลาอัปเกรด ให้รันคำสั่งใน upgrade ของ registry (URL เวอร์ชันใหม่) แล้ว commit ทั้ง package.json และ package-lock.json npm update ไม่ขยับ dependency ที่เป็น URL
GET /api/v1/connectors มีคำสั่งติดตั้งสำหรับ server ที่คุณกำลังเชื่อมต่ออยู่จริงเสมอ อยู่ใน install พร้อม published: false package นี้ไม่ได้อยู่บน npm registry และยังไม่มีใครจดชื่อ scope @queue-manager ไว้ที่นั่น (QM-573) ห้ามติดตั้งด้วยชื่อ package เด็ดขาด เพราะใครก็จดชื่อนั้นไปได้ ให้ติดตั้งจาก URL tarball แบบระบุเวอร์ชันของ server คุณเท่านั้น
const { middleware } = require('@queue-manager/node');
app.use('/checkout', middleware({
server: 'https://qm.weekday100.com',
roomId: 'checkout',
publicApiKey: process.env.QM_PUBLIC_API_KEY, // vouches for the visitor
cookie: { secure: true },
}));
แค่นี้คือการเชื่อมต่อทั้งหมด package นี้:
- ใช้ contract
/api/v1/checkที่ตรึงเวอร์ชันไว้ - รับรองที่อยู่จริงของผู้เข้าชม (vouch)
- ทำตาม
clearToken - ลบ
qm_tokenออกจากช่อง address - ปล่อยผ่าน (fail open) เมื่อหมดเวลา ได้ 5xx ได้ body ที่ผิดรูปแบบ หรือเจอ room ที่ไม่รู้จัก แต่ให้ต่อคิวเมื่อได้
429
ถ้าไม่มี publicApiKey มันจะไม่ยอม start แทนที่จะผูกผู้เข้าชมทุกคนเข้ากับ app server ของคุณแบบเงียบ ๆ (ดูเหตุผลในเวอร์ชันเขียนเองด้านล่าง) npx --no queue-manager verify ทำให้ room id ที่พิมพ์ผิด กลายเป็น build ที่ล้มเหลว แทนที่จะเป็น room ที่ไม่มีใครต้องต่อคิว (ต้องมี --no เพราะมันบังคับให้รันตัวโปรแกรมใน node_modules ของคุณเอง ไม่ไปหาที่ registry ซึ่งบนนั้นชื่อ queue-manager เปล่า ๆ เป็น package อื่นที่ไม่เกี่ยวกับเรา และชื่อแบบมี scope @queue-manager/node ได้ 404) อยากอัปเกรดก็รันคำสั่งติดตั้งซ้ำ ดู Installable connectors เรื่อง registry, hash สำหรับตรวจความถูกต้อง และตัวเทียบเท่าสำหรับ WordPress และ GTM
เขียน middleware เอง (แบบ Express, ครบถ้วน)
ถ้าอยากดูแลโค้ดเอง นี่คือ contract ทั้งหมด ก่อน copy ไปใช้ อ่านสองเรื่องที่พลาดง่ายนี้ก่อน:
- ต้องรับรองผู้เข้าชม (vouch) การตรวจฝั่ง server มาจากที่อยู่ของ คุณ และใช้
User-Agentของ HTTP client ของ คุณ แต่ pass ถูกออกใน browser ของผู้เข้าชม และผูกกับ client นั้น การตรวจฝั่ง server ที่ไม่บอกว่าเป็นใครจึงเหมือนคนอื่นเอา pass มาใช้ ประตูจะปฏิเสธ และปฏิเสธทุกครั้ง ผลคือวนไม่รู้จบระหว่างเว็บคุณกับหน้ารอ ไม่ใช่เด้งครั้งเดียวแล้วจบ ให้ส่งที่อยู่และ agent จริงของผู้เข้าชม และยืนยันตัวตนการเรียกด้วยPUBLIC_API_KEY(แจกจ่ายได้ มันเปิดได้แค่การตรวจที่ประตูแบบ vouched ไม่มีสิทธิ์อื่น ห้ามส่งADMIN_KEYจาก edge เด็ดขาด) package บน npm จะ throw ตอน start แทนที่จะปล่อยให้คุณ deploy แบบนี้ ที่อยู่ต้องเป็นที่อยู่สาธารณะจริงของผู้เข้าชม: การตรวจแบบ vouched ครั้งแรกของ ticket ที่เจ้าของรอคิวใน browser จะถูกปฏิเสธ หากไม่ได้มาจากที่อยู่ (IPv6: /64 เดียวกัน) ที่คิวเห็น เพื่อไม่ให้?qm_token=ที่รั่วไหลถูกใช้ผ่านเว็บของคุณได้ ที่อยู่ต่างตระกูล (เข้าคิวผ่าน IPv6 แต่เข้าเว็บคุณผ่าน IPv4) ก็ถูกปฏิเสธเช่นกัน: ผู้เข้าชมแบบ dual-stack จะถูกเด้งกลับหน้ารอหนึ่งครั้ง หน้ารอจะบันทึกที่อยู่ตระกูลนั้นไว้ และการตรวจครั้งถัดไปจะผ่าน หลัง proxy ต้องตั้งสวิตช์ของ connector (QM-541) หลัง Cloudflare, nginx หรือ load balancer การเชื่อมต่อที่แอปเห็นมาจาก proxy และโดยค่าเริ่มต้น Node middleware รับรองที่อยู่ของ socket ส่วน WordPress plugin รับรองREMOTE_ADDRซึ่งคือที่อยู่ของ proxy ผู้เข้าชมทุกคนที่รอคิวมาจึงถูกปฏิเสธด้วยbound_to_other_clientและวนไม่รู้จบ ให้ใช้: - Node:
trustProxy: N(จำนวน proxy ที่อยู่หน้าแอป อ่านที่อยู่จาก X-Forwarded-For นับจากขวา N hop ถ้ามีรายการน้อยกว่า N หรือรายการนั้นไม่ใช่ IP address จะรับรองที่อยู่ของ socket แทน ไม่ใช้รายการซ้ายสุดที่ผู้เข้าชมกำหนดเองได้) หรือvouchIp: (req) => ...เพื่อส่งที่อยู่เอง เช่นreq.headers['cf-connecting-ip']หลัง Cloudflare - WordPress:
QM_TRUSTED_PROXYในwp-config.phpดีที่สุดคือจำนวน proxydefine( 'QM_TRUSTED_PROXY', 1 );(อ่านที่อยู่จาก X-Forwarded-For นับจากขวาตามจำนวนนั้น เหมือนtrustProxy) หรือชื่อ header เดียวที่ proxy เขียนทับทุก requestdefine( 'QM_TRUSTED_PROXY', 'CF-Connecting-IP' );ส่วนdefine( 'QM_TRUSTED_PROXY', true );จะอ่าน CF-Connecting-IP, True-Client-IP, X-Real-IP แล้วจึง X-Forwarded-For หนึ่ง hop จากขวา ใช้เฉพาะเมื่อ proxy เขียนทับสาม header แรกเสมอ หรือใช้ filterqm_client_ipคืนที่อยู่เอง - Cloudflare Worker: ไม่ต้องตั้งอะไร มันรับรอง
CF-Connecting-IPอยู่แล้ว
ตั้งเฉพาะเมื่อทุก request ผ่าน proxy นั้นจริง ๆ เท่านั้น ไม่เช่นนั้น client เขียน header เหล่านี้เองได้ ทั้งสอง connector จะเตือนครั้งเดียวเมื่อเห็น X-Forwarded-For โดยที่ไม่ได้ตั้งทั้งสองอย่าง
- การเชื่อมต่อแบบ vouched ไม่ได้
sessช่องทางคือหลักฐาน: การตรวจ ticket นั้นทุกครั้งหลังจากนี้ ต้องกลับมาผ่านการเชื่อมต่อที่ยืนยันตัวตนเดิม อย่าสร้าง session cookie ขึ้นเองสำหรับเรื่องนี้ และอย่าใช้ช่องทาง vouched ฝั่ง server ปนกับ snippet ใน browser ใน room เดียว ดู หัวข้อsess
const QM_SERVER = 'https://qm.weekday100.com';
const ROOM = 'checkout';
const QM_KEY = process.env.QM_PUBLIC_API_KEY; // PUBLIC_API_KEY, not ADMIN_KEY
// Read the cookie off the raw header rather than req.cookies: this example has
// to run with nothing but Express, and req.cookies is undefined unless
// cookie-parser is mounted. Reading it here would throw before the try below,
// which turns a queue integration into a 500 on the page it is protecting —
// fail-CLOSED, the one outcome this whole document is written to avoid.
function readCookie(req, name) {
const raw = req.headers.cookie || '';
const hit = raw.split(';').map((s) => s.trim()).find((s) => s.startsWith(name + '='));
return hit ? decodeURIComponent(hit.slice(name.length + 1)) : '';
}
async function queueGate(req, res, next) {
const here = `https://${req.headers.host}${req.originalUrl}`;
try {
const token = req.query.qm_token || readCookie(req, `qm_${ROOM}`) || '';
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 2000);
const q = new URLSearchParams({
roomId: ROOM,
token,
ip: req.ip, // the VISITOR, not this server
agent: req.get('user-agent') || '',
url: here, // so URL targeting can apply
});
const r = await fetch(`${QM_SERVER}/api/v1/check?${q}`, {
signal: ctrl.signal,
headers: { Authorization: `Bearer ${QM_KEY}` },
});
clearTimeout(timer);
// 429 is the queue UP and shedding load: queue, do not fail open.
if (r.status === 429) {
return res.redirect(new URL(`/w/${ROOM}`, QM_SERVER) + `?qm_return=${encodeURIComponent(here)}`);
}
if (r.status >= 500) return next(); // fail-open: the queue is down
// 401/403 is NOT the queue being down — it is this middleware holding the
// wrong key, and falling through would serve the whole sale unprotected
// with nothing in the logs. Fail open (a broken queue must never be an
// outage) but say so loudly, every time, so a key rotation cannot switch
// protection off in silence.
if (r.status === 401 || r.status === 403) {
console.error(`[queue] ${r.status} from the queue server: QM_PUBLIC_API_KEY is wrong or revoked. `
+ `${ROOM} is being served UNPROTECTED.`);
return next();
}
const body = await r.json();
if (body.action === 'queue') {
if (body.clearToken) res.clearCookie(`qm_${ROOM}`); // token names someone
const back = encodeURIComponent(here); // else's session
return res.redirect(new URL(body.waitingRoomUrl, QM_SERVER) + `?qm_return=${back}`);
}
// action === "pass"
if (req.query.qm_token) { // persist token for later requests
res.cookie(`qm_${ROOM}`, req.query.qm_token,
{ maxAge: 86400_000, sameSite: 'lax', httpOnly: true });
}
return next();
} catch {
return next(); // timeout/network → fail-open
}
}
app.use('/checkout', queueGate);
ฝั่ง server ได้ของดีที่ snippet ใน browser ให้ไม่ได้: cookie ของ token ตั้งเป็น httpOnly ได้ และไม่มีหน้ากะพริบก่อน redirect ผู้เข้าชมที่ต้องต่อคิวจะไม่ได้รับหน้าที่คุ้มกันเลยแม้แต่นิดเดียว
รับรองผู้เข้าชมไม่ได้? ถ้าหาที่อยู่ของผู้เข้าชมไม่ได้ (serverless บางแพลตฟอร์ม) ให้ใช้ snippet ใน browser สำหรับ room นั้นแทน การตรวจฝั่ง server แบบไม่บอกว่าเป็นใคร ไม่ใช่แบบเบากว่าของวิธีนี้ มันคือ room ที่ไม่มีใครผ่านประตูได้เลย
pseudo-code สำหรับ nginx (njs / แบบ Lua)
auth_request ที่มากับ nginx อ่าน JSON body ไม่ได้ ให้ใช้ชั้น script (njs, OpenResty/Lua) ตามโครงนี้:
location /checkout {
# subrequest to the queue server with a short timeout
# GET $QM_SERVER/api/check?roomId=checkout&token=$cookie_qm_checkout
# &ip=$remote_addr&agent=$http_user_agent
# Authorization: Bearer $QM_PUBLIC_API_KEY # required: see below
# timeout 2s;
#
# if (status == 429) -> # busy, not down: queue
# return 302 $QM_SERVER + "/w/checkout?qm_return=" + escape(...);
# if (request failed OR status >= 500) -> proxy_pass upstream # fail-open
# if (status == 401 or 403) -> log LOUDLY, then proxy_pass
# (a wrong key is a config error, not a queue outage: fail open, but
# never silently — this location is unprotected until it is fixed)
# if (body.action == "pass") -> proxy_pass upstream
# if (body.action == "queue") ->
# return 302 $QM_SERVER + body.waitingRoomUrl
# + "?qm_return=" + escape($scheme://$host$request_uri);
}
ไม่ว่าใช้แพลตฟอร์มไหน ต้องทำให้ครบทุกข้อ:
- ตั้ง timeout สั้น
- ถือว่าความล้มเหลวทุกแบบคือ "pass" (
429ไม่ใช่ความล้มเหลว: ส่งไปที่/w/<room>) - ส่ง
qm_returnต่อไป เพื่อให้ผู้เข้าชมกลับมาถูกหน้า - รับรองผู้เข้าชม (vouch) การตรวจที่มาจากระบบของคุณ ไม่ใช่จาก browser ของผู้เข้าชม ต้องมี
?ip=&agent=คู่กับPUBLIC_API_KEYไม่อย่างนั้นคิวจะปฏิเสธ pass ที่มันออกให้เอง
PHP (ไม่ใช้ framework, ครบถ้วน)
วางโค้ดนี้ไว้บนสุดของหน้า .php ที่ต้องการคุ้มกัน ก่อนที่จะมี output ใด ๆ:
ต้องใส่ argument$keyโค้ดนี้รันบน server ของคุณ ไม่ได้รันใน browser ของผู้เข้าชม คิวจึงเห็นที่อยู่ของ app server ของคุณ ไม่ใช่ของผู้เข้าชม ถ้าไม่รับรองผู้เข้าชม (?ip=&agent=ที่ได้รับอนุญาตด้วยPUBLIC_API_KEY) ผู้เข้าชมทุกคนที่ถึงคิวจะได้คำตอบbound_to_other_clientและเด้งกลับไปหน้ารอไม่รู้จบ ดู Sessions
<?php
function qm_gate(string $server, string $room, string $key): void {
$token = $_GET['qm_token'] ?? $_COOKIE["qm_$room"] ?? '';
$sess = $_COOKIE["qmk_$room"] ?? '';
// Vouch for the visitor, or nobody gets through the door. Use whatever your
// stack trusts for the client address (REMOTE_ADDR behind no proxy).
$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$agent = $_SERVER['HTTP_USER_AGENT'] ?? '';
$hdr = "Authorization: Bearer $key\r\n"
. ($sess ? "X-QM-Session: $sess\r\n" : '');
$ctx = stream_context_create(['http' => [
'timeout' => 2,
'header' => $hdr,
'ignore_errors' => true,
]]);
$raw = @file_get_contents(
"$server/api/check?roomId=" . rawurlencode($room) .
"&token=" . rawurlencode($token) .
"&ip=" . rawurlencode($ip) . "&agent=" . rawurlencode($agent), false, $ctx);
if ($raw === false) return; // network/timeout -> fail-open
// 429: the queue is up and shedding load -> queue, do not fail open.
if (str_contains($http_response_header[0] ?? '', ' 429 ')) {
$back = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
. "://{$_SERVER['HTTP_HOST']}{$_SERVER['REQUEST_URI']}";
header("Location: $server/w/" . rawurlencode($room) . '?qm_return=' . rawurlencode($back), true, 302);
exit;
}
$body = json_decode($raw, true);
if (!is_array($body) || !isset($body['action'])) return; // malformed -> fail-open
if ($body['action'] === 'queue' && !empty($body['waitingRoomUrl'])) {
if (!empty($body['clearToken'])) {
setcookie("qm_$room", '', time() - 3600, '/');
setcookie("qmk_$room", '', time() - 3600, '/');
}
$back = (isset($_SERVER['HTTPS']) ? 'https' : 'http')
. "://{$_SERVER['HTTP_HOST']}{$_SERVER['REQUEST_URI']}";
$url = (str_starts_with($body['waitingRoomUrl'], 'http')
? $body['waitingRoomUrl'] : $server . $body['waitingRoomUrl'])
. '?qm_return=' . rawurlencode($back);
header("Location: $url", true, 302);
exit;
}
// action "pass" (or anything unexpected): let through
$opts = ['expires' => time() + 86400, 'path' => '/',
'httponly' => true, 'samesite' => 'Lax'];
if (!empty($_GET['qm_token'])) setcookie("qm_$room", $_GET['qm_token'], $opts);
if (!empty($body['sess'])) setcookie("qmk_$room", $body['sess'], $opts);
}
qm_gate('https://qm.weekday100.com', 'checkout', getenv('QM_PUBLIC_API_KEY'));
?>
Cloudflare Worker (edge connector)
ตัวนี้เป็นไฟล์สำเร็จรูปที่มีเวอร์ชันและ checksum ไม่ใช่โค้ดให้ copy ไปแปะ queue server ของคุณสร้างและเปิดให้โหลดเอง connector ที่คุณติดตั้งจึงตรงกับ contract ที่ server นั้นใช้เสมอ:
curl -fsSL https://qm.weekday100.com/connectors/cloudflare/latest.js -o qm-edge.js
npx wrangler deploy qm-edge.js --name qm-edge --compatibility-date 2025-01-01 \
--var QM_SERVER:https://qm.weekday100.com --var QM_ROOM:checkout \
--var QM_PUBLIC_API_KEY:<PUBLIC_API_KEY> --route 'shop.example.com/*'
หรือเอาทั้งโปรเจกต์ wrangler ไป ซึ่งมี wrangler.toml และการตรวจก่อน deploy ที่ไม่ยอมให้ room id ที่พิมพ์ผิดหลุดออกไป:
mkdir qm-edge && curl -fsSL https://qm.weekday100.com/connectors/cloudflare/latest.tgz \
| tar -xz -C qm-edge --strip-components=1
cd qm-edge
$EDITOR wrangler.toml # QM_SERVER, QM_ROOM, routes
npx wrangler secret put QM_PUBLIC_API_KEY
QM_PUBLIC_API_KEY=... npm run deploy # `verify` runs first; exit 1 stops the deploy
ไฟล์ทั้งสองมี SHA-256 ประกาศไว้ใน GET /api/v1/connectors และหน้าต่าง Install ของ room ใน console แสดงคำสั่งที่ใส่ origin และ room id ของคุณไว้แล้ว
ทำไม connector ตัวนี้สำคัญที่สุด middleware บน npm และ WordPress plugin รัน ข้างใน origin ของคุณ tag ใน browser และ template ของ GTM รันใน browser ของผู้เข้าชม ใครปิด JavaScript ก็เดินผ่านไปได้เลย ตัวนี้รัน ด้านหน้า origin ของคุณ: ผู้เข้าชมที่ต้องต่อคิวไม่เคยไปถึง server ของคุณ และการ redirect เกิดขึ้นก่อนจะมี HTML ใด ๆ และเป็นตัวเดียวที่ operator ใช้ได้โดยไม่ต้องเปลี่ยนระบบเดิมเลย origin จะเป็น .NET ร้านค้าสำเร็จรูปของผู้ให้บริการ หรือระบบที่ไม่มีใครมี source code ก็ได้
(edge คือเครือข่าย server ของผู้ให้บริการอย่าง Cloudflare ที่อยู่ระหว่างผู้เข้าชมกับ origin ของคุณ)
สิ่งที่มันทำในแต่ละ request
| ขั้นตอน | สิ่งที่ทำ |
|---|---|
| ขอบเขต (Scope) | ดึงกฎ URL ของ room จาก GET /api/v1/targeting เก็บ cache ไว้ใน isolate ตาม ETag และรีเฟรชเบื้องหลัง แก้ขอบเขตของ room ระหว่างเกิดเหตุคือกดบันทึกใน console ไม่ต้อง deploy ใหม่ cache ใช้แค่เพื่อ ข้าม การตรวจในหน้าที่กฎไม่รวมไว้ ถ้า cache ยังว่างหรือเก่า จะส่งไปตรวจกับ server ตัวจริงเสมอ จึงไม่มีทางปล่อยให้ traffic ข้ามคิว |
| ตรวจ (Check) | GET /api/v1/check พร้อม cookie qm_<room> ของผู้เข้าชม URL ของหน้า และรับรองที่อยู่จริงกับ User-Agent ของผู้เข้าชม รอไม่เกิน 2 วินาที |
| ต่อคิว (Queue) | 302 ไปหน้ารอพร้อม qm_return, Cache-Control: no-store และลบ cookie เมื่อ server ตอบ clearToken |
| ผ่าน (Pass) | ส่งต่อไป origin pass ที่มากับ ?qm_token= จะถูกเก็บเป็น cookie และลบออกจากช่อง address ด้วยการ redirect ทำที่ edge จึงใช้ได้แม้ปิด JavaScript |
| ไม่ว่าง (Busy) | การตรวจได้ 429: คิวยังทำงานและกำลังลดภาระ จึง ไม่ใช่ ความล้มเหลว → 302 ไป /w/<room> บน QM_SERVER พร้อม qm_return ไม่มี X-QM-Failover ไม่นับ และบันทึก log ครั้งเดียวต่อ isolate |
| ล้มเหลว (Failure) | หมดเวลา, 5xx, 401, body อ่านไม่ออก, โค้ดผิดพลาด: ส่งต่อไป origin คิวที่ล่มต้องไม่ทำให้เว็บที่มันคุ้มกันล่มตาม แต่ไม่เงียบ: response มี header X-QM-Failover ค่าเป็น timeout, unreachable, http-<status> หรือ invalid-response และถูกนับใน Analytics Engine เมื่อผูก QM_ANALYTICS ไว้ (query สำหรับ alert อยู่ใน OPERATIONS หัวข้อ The edge gate fails open, and says so) |
| ตรวจการตั้งค่า (Validation) | ครั้งเดียวต่อ isolate นอกเส้นทางของ request: GET /api/v1/verify แล้วพิมพ์ข้อที่ไม่ผ่านลงใน log ของ Worker (wrangler tail qm-edge) |
การตั้งค่า
| Var | ต้องใส่ไหม | ความหมาย |
|---|---|---|
QM_SERVER | ต้อง | origin ของ queue server ของคุณ |
QM_ROOM | ต้อง | room id ตรงตามที่เห็นใน console |
QM_PUBLIC_API_KEY | ต้อง | PUBLIC_API_KEY ของ server (ไม่ใช่ ADMIN_KEY) |
QM_GATE | ไม่ | navigations (default) · documents · all |
QM_ANALYTICS | ไม่ | binding ของ dataset ใน Analytics Engine ที่ใช้นับการปล่อยผ่าน (fail-open) |
QM_GATE เลือกว่าจะตรวจ request แบบไหน:
navigations: ตรวจ request GET/HEAD ที่ browser ไม่ได้บอกว่าเป็นไฟล์ประกอบหน้า (subresource) client ที่ไม่บอกอะไรเลย เช่น curl, scraper, บอทซื้อของ ถูก ตรวจ เพราะ traffic แบบนี้คือสิ่งที่ การเปิดขายต้องกั้นไว้ ทางที่ปลอดภัยจึงคือส่งเข้าคิวdocuments: ตรวจเฉพาะSec-Fetch-Dest: documentall: ตรวจทุก request ทุก method (POST ถูก redirect แล้ว body จะหาย เลือกค่านี้เมื่อตั้งใจจริงเท่านั้น)
ทำไม Public API key จึงไม่ใช่ตัวเลือก
ต้องใส่ QM_PUBLIC_API_KEY เสมอ
ที่ในคิวของผู้เข้าชมผูกกับ browser ที่ใช้ต่อคิว Worker ไม่ใช่ browser นั้น มันเรียก queue server จาก colo (ศูนย์ข้อมูล) ของ Cloudflare การตรวจจาก edge แบบไม่บอกว่าเป็นใคร กับ pass ของผู้เข้าชมที่ถึงคิวแล้ว จึงได้คำตอบ bound_to_other_client และผู้เข้าชมเด้งไปมา ระหว่างเว็บคุณกับหน้ารอไม่รู้จบ นี่คือกฎเดียวกับที่เขียนไว้ใน Sessions: การตรวจฝั่ง server แบบไม่บอกว่าเป็นใคร ไม่ใช่แบบเบากว่าของการตรวจแบบ vouched มันคือ room ที่ไม่มีใครผ่านประตูได้เลย
QM_PUBLIC_API_KEY ทำให้ Worker รับรองผู้เข้าชมได้ (?ip=&agent=) ด้วยที่อยู่ที่ Cloudflare รายงานใน CF-Connecting-IP ถ้าไม่มี key นี้ Worker จะไม่ตรวจอะไรเลย และเขียนบอกไว้ใน log เว็บที่ไม่มีการคุ้มกันก็แย่ แต่ลูกค้าทุกคนติด redirect วนไม่รู้จบแย่กว่า การตรวจก่อน deploy จะไม่ผ่านถ้าไม่มี key หรือ server ปฏิเสธ key จึงไม่มีกรณีไหนหลุดไปถึง production
Source code
อ่านก่อนรัน: เปิดอ่านได้ตรง ๆ ที่ https://qm.weekday100.com/connectors/cloudflare/latest.js เป็น JavaScript ประมาณ 300 บรรทัดที่ไม่มี dependency และ byte ชุดเดียวกันอยู่ใน tarball ของโปรเจกต์ที่ src/worker.js ค่า digest อยู่ใน registry คุณจึงตรึงไว้ใน CI ได้:
curl -s https://qm.weekday100.com/api/v1/connectors \
| grep -A2 '"id": "cloudflare"'
การ port ไปยัง edge อื่น
โครงเดียวกันนี้ใช้กับ CloudFront Functions / Lambda@Edge, Fastly Compute และ Akamai EdgeWorkers ได้: อ่าน cookie, เรียก GET /api/v1/check ด้วย timeout สั้นพร้อมรับรองที่อยู่ ของผู้เข้าชม, ตอบ 302 เมื่อได้ queue หรือ 429, ส่งต่อเมื่อได้ pass และกรณีอื่นให้ปล่อยผ่านเสมอ แพลตฟอร์มเหล่านี้ยังไม่มี connector สำเร็จรูป ดู Limitations and roadmap
Connector ที่ติดตั้งได้
connector สี่ตัว สร้างและเปิดให้โหลดโดย queue server ของคุณเอง เวอร์ชันที่ operator ติดตั้งจึงตรงกับ contract ที่ server นั้นใช้เสมอ ไม่มีทางที่ connector จะมาจากคนละ release กับคิวที่มันคุยด้วย สิ่งที่ต่างกันคือ มันรันที่ไหน ซึ่งเป็นเรื่องที่ต้องเลือกจริง ๆ:
| Connector | รันที่ไหน | ผู้เข้าชมที่ปิด JS | ต้องแก้ origin ไหม |
|---|---|---|---|
| Cloudflare Worker | ที่ edge ก่อนถึง origin ของคุณ | ถูกกั้นไว้ | ไม่ |
@queue-manager/node | ข้างใน origin ของคุณ | ถูกกั้นไว้ | ต้อง |
| WordPress plugin | ข้างใน origin ของคุณ | ถูกกั้นไว้ | ติดตั้ง plugin |
| GTM template / snippet | ใน browser ของผู้เข้าชม | เดินผ่านไปได้เลย | ไม่ |
registry เปิดให้ทุกคนดูได้:
curl -s https://qm.weekday100.com/api/v1/connectors
{
"serverVersion": "1.3.0",
"apiVersion": "v1",
"connectors": [
{
"id": "node",
"name": "@queue-manager/node",
"kind": "server-sdk",
"version": "1.3.0",
"platform": "Node.js >= 18",
"install": "npm install https://qm.weekday100.com/connectors/node/queue-manager-node-1.3.0.tgz",
"selfHostedInstall": "npm install https://qm.weekday100.com/connectors/node/queue-manager-node-1.3.0.tgz",
"published": false,
"upgrade": "npm install https://qm.weekday100.com/connectors/node/queue-manager-node-1.3.0.tgz",
"verifyCommand": "npx --yes https://qm.weekday100.com/connectors/node/latest.tgz verify --server https://qm.weekday100.com --room <roomId> --origin https://<your-site> --strict",
"download": "https://qm.weekday100.com/connectors/node/latest.tgz",
"docs": "https://qm.weekday100.com/docs/integration#installable-connectors",
"integrity": "sha256-+0O2S3DhR9eMI2e3kSDgzB9CAFfGJpyuQybypYDd/aY=",
"sha256": "fb43b64b70e1...",
"bytes": 16062,
"notes": "Express/Connect/node:http middleware, install-time verify, drift detection."
}
]
}
ไฟล์ทุกตัวสร้างจาก source ชุดเดียวกับ server และแพ็กด้วย timestamp ที่ตายตัว byte (และ integrity) จึงสร้างซ้ำได้เหมือนเดิมทุกครั้ง และตรึงไว้ใน CI ได้ งาน monitoring จะคอยเรียก endpoint นี้เพื่อแจ้งเตือนเมื่อ connector ไม่ตรงกับ server ก็ได้ หน้าต่าง Install ของ room ใน dashboard แสดงรายการเดียวกันพร้อมคำสั่งที่ copy ไปใช้ได้เลย และปุ่ม Run check ที่ตรวจการติดตั้งกับ room ที่ใช้งานจริงก่อนคุณ deploy
npm — @queue-manager/node
ดู Node / Express ด้านบน ใช้ npx --no queue-manager verify --strict ใน CI และ npx --no queue-manager update เพื่อดูว่าไม่ตรงกับ server หรือเปล่า (จะ exit 1 เมื่อเวอร์ชันที่ติดตั้งตามหลัง server) ถ้าไม่ใส่ --no npx จะไปหาที่ registry ซึ่ง queue-manager เปล่า ๆ เป็น package อื่นที่ไม่เกี่ยวกับเรา (QM-489) และ @queue-manager/node ไม่มีใครจดไว้ (QM-573) install ในเอกสาร registry ด้านบนคือคำสั่งที่ใช้ได้กับ server ที่คุณเชื่อมต่ออยู่เสมอ
WordPress plugin
ดาวน์โหลด https://qm.weekday100.com/connectors/wordpress/latest.zip แล้วอัปโหลดที่ Plugins → Add New → Upload Plugin จากนั้นไปที่ Settings → Queue Manager ใส่ URL ของ server และ room id ติ๊ก Queue visitors on this site แล้วบันทึก ทุกครั้งที่บันทึกจะเรียก /api/v1/verify กับ server ของคุณ และคิว จะไม่เปิดใช้จนกว่าจะผ่าน การตั้งค่าถูกบันทึกไว้ แต่ room id ที่พิมพ์ผิดจะทำให้ plugin ยังปิดอยู่พร้อมแสดงข้อที่ไม่ผ่านบนหน้าตั้งค่า ไม่ใช่เว็บที่ไม่มีการคุ้มกันแบบเงียบ ๆ
plugin ตรวจฝั่ง server (hook template_redirect ก่อนมี output ใด ๆ) ผู้เข้าชมที่ต้องต่อคิวจึงไม่ได้รับหน้าที่คุ้มกันเลย มันเช็คอัปเดตจาก connector registry ตามรอบอัปเดตปกติของ WordPress จึงขึ้นใน Plugins → Updates เหมือน plugin อื่น
หลัง Cloudflare, nginx หรือ load balancer ให้เพิ่ม define( 'QM_TRUSTED_PROXY', 1 ); (จำนวน proxy หน้า PHP) หรือ define( 'QM_TRUSTED_PROXY', 'CF-Connecting-IP' ); ใน wp-config.php หรือคืนที่อยู่ของผู้เข้าชมจาก filter qm_client_ip ไม่เช่นนั้น plugin จะรับรองที่อยู่ของ proxy และคิวจะส่งผู้เข้าชมทุกคนที่รอมาแล้วกลับไปหน้ารอ ดู Middleware เขียนเอง ข้อ 1
กำหนดขอบเขตด้วยกฎ path ของ plugin เอง หรือไม่กำหนดแล้วใช้ URL targeting ของ room แทน กฎจะอยู่บน queue server และแก้ได้โดยไม่ต้อง deploy
Google Tag Manager template
ดาวน์โหลด https://qm.weekday100.com/connectors/gtm/template.tpl แล้ว import ที่ Templates → New → Import (ไฟล์ /connectors/gtm/latest.zip มี template เดียวกันพร้อม metadata สำหรับส่งเข้า gallery) หรือค้น "Queue Manager" ใน Community Template Gallery เมื่อขึ้นไปอยู่ที่นั่นแล้ว จากนั้นสร้าง tag จาก template: Server URL และ Room ID เป็นช่องที่ตรวจค่าให้ ไม่ใช่ข้อความอิสระใน Custom HTML tag
- Trigger: ใช้ Initialization — All Pages ถ้า container ของคุณรองรับ ไม่อย่างนั้นใช้ Page View ยิ่งทำงานเร็ว หน้าจอยิ่งกะพริบน้อย
- รัน Preview ก่อน publish tag จะถาม server นี้ว่ามี room นี้ไหม และ room รับเว็บที่คุณกำลัง preview หรือเปล่า แล้วเขียนผลลงใน debug console ของ GTM: เป็น
room "checkout" checked outหรือบรรทัดREFUSED room "checkout"ที่บอกว่าต้องแก้อะไร container ที่ publish แล้วทำงานกับ traffic จริง ไม่มีอะไรจับการพิมพ์ผิดได้ ตอนนี้จึงเป็นจังหวะเดียวที่จับได้ การตรวจนี้รันเฉพาะใน Preview/debug เท่านั้น การเปิดหน้าของผู้เข้าชมไม่เคยเรียกมัน - ข้อจำกัดที่ต้องบอกตรง ๆ: GTM โหลดหลัง script ใน
<head>ของหน้า ช่วงที่หน้าจอกะพริบก่อน redirect จึงนานกว่าการใส่ tag ตรง ๆ ถ้าเป็นการเปิดขายที่ต้องกั้นเด็ดขาด ให้ใช้ tag ตรง ๆ หรือตรวจที่ server/edge
<details> <summary>Custom HTML tag (หากใช้ template ไม่ได้)</summary>
<script>
(function () {
var s = document.createElement('script');
s.async = true;
s.src = 'https://qm.weekday100.com/snippet/qm.js';
s.setAttribute('data-qm-server', 'https://qm.weekday100.com');
s.setAttribute('data-qm-room', 'checkout');
document.head.appendChild(s);
})();
</script>
ที่นี่ไม่มีอะไรตรวจ room id ให้ กดปุ่ม Run check ในหน้าต่าง Install ของ room หรือรัน curl "https://qm.weekday100.com/api/v1/verify?roomId=checkout" ก่อน publish container </details>
Shopify
ไม่มี connector ไปที่ Online Store → Themes → Edit code → theme.liquid แล้ววาง tag จาก Quickstart ต่อจาก <head> ทันที ส่วนหน้า checkout ผู้ใช้ Shopify Plus ใส่ได้ผ่าน checkout.liquid / Checkout Extensibility แพ็กเกจปกติคุ้มกันหน้าตะกร้าและหน้าสินค้าได้ ซึ่งเป็นที่ที่คนแห่มาถึงก่อน
WordPress แบบไม่ใช้ plugin
ถ้าไม่อยากติดตั้งอะไร script tag ก็ยังใช้ได้ ใส่ใน functions.php ของ child theme:
add_action('wp_head', function () {
echo '<script async src="https://qm.weekday100.com/snippet/qm.js"
data-qm-server="https://qm.weekday100.com"
data-qm-room="checkout"></script>';
}, 0); // priority 0: as early in <head> as wp_head allows
แบบนี้เป็นการตรวจฝั่ง client จึงมีหน้าจอกะพริบก่อน redirect ซึ่ง plugin ไม่มี และไม่มีอะไรตรวจ room id ให้คุณ
การกำหนดเวอร์ชัน API (/api/v1/*)
โค้ดที่คุณ deploy ใหม่ให้เรียก /api/v1/* เสมอ
contract สาธารณะมีให้เรียกได้สองแบบ โดยใช้ตัวจัดการ (handler) ชุดเดียวกัน:
| รูปแบบ | ใช้เมื่อ |
|---|---|
/api/v1/<route> | แบบหลัก ตรึงไว้ตามรูปแบบที่เขียนในเอกสารนี้ |
/api/<route> | ชื่อเรียกอีกชื่อที่ใช้ได้ตลอดไป เก็บไว้ให้การเชื่อมต่อที่ deploy ไปแล้ว |
/api/v1/check, /api/v1/join, /api/v1/status, /api/v1/events และ route ของ admin ใต้ /api/v1/admin/* ตอบเหมือนแบบไม่มีเวอร์ชันทุก byte: status code เดียวกัน JSON เดียวกัน ค่า code เดียวกัน header เดียวกัน ต่างกันแค่ path
ทำไมต้องมี: qm.js ของคุณอยู่บน origin ของ คุณ ไม่ได้อยู่บน queue server ทั้งสองจึง deploy ใหม่พร้อมกันในทีเดียวไม่ได้ การตรึงเวอร์ชันไว้ใน path แปลว่าถ้า contract เปลี่ยนในอนาคต จะออกเป็น /api/v2/* และการเชื่อมต่อที่คุณ deploy ไว้จะได้คำตอบแบบเดิม ตรงตามที่เขียนโค้ดไว้ ไม่ได้รับแบบใหม่โดยไม่รู้ตัว
นโยบาย:
- การเชื่อมต่อใหม่ใช้
/api/v1/*snippet ทำแบบนี้อยู่แล้ว: เรียก/api/v1/checkและจะถอยไปใช้/api/check(ครั้งเดียว ตลอดอายุของหน้านั้น) เฉพาะเมื่อ server ตอบ404ซึ่งเป็นสิ่งที่ queue server รุ่นก่อน v1 ตอบ - path ที่ไม่มีเวอร์ชันไม่ได้ถูกเลิกใช้ และจะไม่ถูกลบ มันชี้ไปที่ contract ใหม่ล่าสุดเสมอ ตอนนี้คือ v1 จึงเหมือนกันทุกอย่าง วันที่มี v2 มันจะตามไป v2 นี่แหละคือเหตุที่การเชื่อมต่อที่ตรึงเวอร์ชัน ไม่ควรใช้ path เหล่านี้
- การเปลี่ยนที่ทำให้ของเดิมพังได้ จะใช้ prefix ใหม่ ไม่เปลี่ยนรูปแบบใต้ prefix เดิมเด็ดขาด การเพิ่ม field ที่ไม่บังคับใน response ไม่ทำให้ของเดิมพัง จึงใส่ใน v1 ได้
- Health (
/healthz), หน้ารอ (/w/:id) และไฟล์ snippet ไม่อยู่ในส่วนที่มีเวอร์ชัน
# identical answers, one of them pinned
curl -s "https://qm.weekday100.com/api/v1/check?roomId=checkout"
curl -s "https://qm.weekday100.com/api/check?roomId=checkout"
Error code
ให้โค้ดของคุณแยกกรณีด้วย code เท่านั้น อย่าใช้ข้อความที่คนอ่าน
error ทุกตัวตอบรูปแบบเดียวกัน คือ {"error": "…", "code": "…"} และ code เป็นส่วนที่ไม่เปลี่ยน ตารางนี้คือ code ที่การเชื่อมต่อบนเว็บของลูกค้าได้รับจริง:
code | Status | เมื่อไร |
|---|---|---|
room_not_found | 404 | server นี้ไม่มี room นี้: พิมพ์ผิดใน data-qm-room หรือ room ถูกลบไปแล้ว |
invalid_token | 401 | ตรวจ token ไม่ผ่าน หรือไม่ได้ส่ง token มาในที่ที่ต้องมี ให้ทิ้ง token แล้วเข้าคิวใหม่ |
stale_ticket | 409 | ticket เป็นของคิวรุ่นก่อน (room ถูกรีเซ็ต) ให้ทิ้งแล้วเข้าคิวใหม่ |
rate_limited | 429 | เกินโควตาต่อที่อยู่: JOIN_LIMIT_PER_MIN สำหรับ POST /api/join, NOTIFY_LIMIT_PER_MIN สำหรับ POST /api/notify, CHECK_LIMIT_PER_MIN สำหรับ /api/check, /api/status, /api/targeting, /api/verify, /api/verify.gif และ /api/connectors ให้ทำตาม Retry-After |
invalid_room_id | 400 | ไม่มี roomId หรือไม่ใช่ string ที่ไม่ว่าง (body ของ POST /api/join, query ของ GET /api/verify) |
invalid_token_field | 400 | มี token ใน body ของ POST /api/join หรือ POST /api/notify แต่ไม่ใช่ string |
blocked_by_protection | 403 | เฉพาะ POST /api/join: ระบบกันบอทของ room อยู่ในโหมด enforce และให้คะแนน request นี้ว่าเป็นบอท จึงไม่ได้ที่ในคิว X-QM-Protection บอกคะแนน ไม่ใช่การจำกัดอัตรา ลองใหม่ก็ไม่ช่วย |
queue_identity_limit | 429 | POST /api/join: ที่อยู่นี้ถือที่ในคิวครบ queueMaxPerIp ของ room แล้ว (default 16) และไม่ได้ส่งที่ไหนมาเลย ไม่ได้อะไรกลับไปที่ใช้ได้ Retry-After: 30 |
prequeue_identity_limit | 429 | POST /api/join: ผู้ใช้รายนี้ถือที่ใน pre-queue ของการเปิดขายตามเวลาครบ preQueueMaxPerIp แล้ว Retry-After: 30 |
prequeue_full | 503 | pre-queue นั้นเต็มแล้ว ลองใหม่ |
storage_unavailable | 503 | server บันทึกการเข้าคิวลง disk ไม่ได้ จึงไม่ยอมแจก ticket ที่อาจลืมทีหลัง ลองใหม่ |
sse_capacity | 503 | /events (หรือ /api/events): server เปิด stream อยู่ครบ SSE_MAX_TOTAL แล้ว Retry-After: 5 ระหว่างนั้นให้เรียก /api/status เป็นระยะแทน |
sse_per_ip_limit | 429 | /events: ที่อยู่นี้เปิด stream อยู่ครบ SSE_PER_IP แล้ว Retry-After: 5 |
unauthorized | 401 | ต้องมี key แต่ไม่มีหรือผิด เช่น /api/check?ip=&agent= ที่ไม่มี PUBLIC_API_KEY |
credential_in_query | 401 | /api/check?ip=&agent= ที่ส่ง key มาเป็น ?key= และไม่มี header Authorization รับ key ได้เฉพาะแบบ Authorization: Bearer <key> |
edge_required | 403 | request ไม่ได้มาผ่าน Cloudflare zone ที่ server นี้ deploy อยู่ข้างหลัง (ตั้ง EDGE_SECRET ไว้ และ request ไม่มี header ที่ Cloudflare ใส่ให้) ผู้เข้าชมแก้เองไม่ได้ และลองใหม่ก็ไม่ช่วย ถ้าเป็นการเปิดหน้าใน browser จะได้หน้า 403 แบบเรียบ ๆ แทน |
server_saturated | 503 | process รับ connection เต็มเพดานแล้ว จึงตัด request ที่ไม่ใช่การโหลดหน้าทิ้ง (การโหลดหน้าจะได้หน้า offline แทน) Retry-After: 5 |
uri_too_long | 414 | URL ของ request ยาวเกิน 4096 byte |
bad_request, request_aborted | 400 | อ่าน URL ไม่ออก หรือ body ของ request ขาดกลางทาง |
not_inline_host | 404 | path /__qm/… บน host ที่ไม่ได้อยู่หน้า inline room ใดเลย |
internal | 500 | server ผิดพลาดโดยไม่คาดคิด ข้อความเป็น internal error เสมอ ลองใหม่ |
email_notify_disabled | 404 | POST /api/notify บน server ที่ operator ไม่ได้ตั้ง EMAIL_NOTIFY ความสามารถนี้ปิดไว้เป็นค่าเริ่มต้น หน้ารอซ่อนปุ่มของมันไว้ จะเจอ code นี้ก็ต่อเมื่อเรียก route นี้ตรง ๆ |
invalid_email | 400 | ที่อยู่ที่ส่งมาให้ POST /api/notify ไม่ใช่อีเมล |
ticket_ended | 409 | POST /api/notify สำหรับบัตรคิวที่สิ้นสุดแล้ว (ผ่านเข้าไปแล้ว ถูก eject ถูก purge หรือช่วงเวลาเข้าหมดไป) ไม่มีที่ในแถวให้ผูกอีเมลไว้อีก ให้เข้าคิวใหม่ก่อน การถอนอีเมล (email ว่าง) รับเสมอ |
invalid_json, invalid_body | 400 | body ของ request ผิดรูปแบบ |
payload_too_large | 413 | body ใหญ่เกินขีดจำกัด |
method_not_allowed | 405 | ใช้ HTTP method ผิดกับ path ใน contract header Allow บอก method ที่ถูก |
not_found | 404 | server นี้ไม่มี path นี้ |
/api/check เป็นข้อยกเว้นที่สำคัญ และตั้งใจให้เป็นแบบนี้: มันอยู่หน้าการโหลดทุกหน้า room ที่ไม่รู้จัก token ที่ตรวจไม่ได้ หรือติดต่อ server ไม่ได้ จึงจบที่ปล่อยผู้เข้าชม ผ่าน ไม่ใช่ error ที่คุณต้องจัดการ มันตอบ 200 {"action":"pass"} และ server นับกรณี room ที่ไม่รู้จักไว้ ให้ operator เห็นว่าตั้งค่าผิด ดู Fail-open by design
การกำหนดเวอร์ชัน, self-hosting และ SRI
/snippet/qm.js มาจาก Queue Manager ที่คุณ deploy เอง ไม่ผ่าน CDN ของบุคคลที่สาม คุณ จึงเป็นคนคุมว่ามันจะเปลี่ยนเมื่อไร snippet เขียนเวอร์ชันไว้ในคอมเมนต์บนสุด และบอกตอนรันผ่าน window.QueueManager.version ใช้ semver: รูปแบบ attribute data- และการปล่อยผ่านเมื่อพัง ไม่เปลี่ยนระหว่างเวอร์ชันย่อย (minor)
วิธีตรึงเวอร์ชัน เรียงจากเข้มที่สุด:
- copy
qm.jsไปไว้ในระบบจัดการไฟล์ของคุณเอง มันไม่มี dependency และไม่ต้อง build เสิร์ฟจากเว็บของคุณเอง (first-party) จะไม่ต้องโหลดข้าม origin เลย และใช้วิธี cache-busting ปกติของคุณจัดการการอัปเกรดได้ - Subresource integrity (SRI) คือให้ browser ตรวจว่าไฟล์ตรงกับ hash ที่ระบุไว้ server ของคุณ ประกาศ digest ของ byte ที่มันเสิร์ฟอยู่ ไว้คู่กับของ connector ตัวอื่นทั้งหมด คุณจึงไม่ต้องคำนวณเอง และไม่ต้องจำว่าต้องคำนวณใหม่ตอนอัปเกรด:
curl -s https://qm.weekday100.com/api/v1/connectors \ | node -e 'JSON.parse(require("fs").readFileSync(0)).connectors .filter(c => c.id === "snippet").forEach(c => console.log(c.installPinned))'
ซึ่งจะพิมพ์ tag ที่ใส่ digest ไว้แล้ว:
<script async src="https://qm.weekday100.com/snippet/qm.js"
integrity="sha256-<hash>"
crossorigin="anonymous"
data-qm-server="https://qm.weekday100.com"
data-qm-room="checkout"></script>
ถ้าอยากเทียบคำตอบนั้นกับไฟล์จริงแทนที่จะเชื่อเลย ให้ hash ไฟล์ที่โหลดมาเอง ผลต้องตรงกับ field integrity ข้างบน:
curl -s https://qm.weekday100.com/snippet/qm.js | \
openssl dgst -sha256 -binary | openssl base64 -A
เมื่อใช้ SRI qm.js ที่ถูกแก้จะไม่ยอมรัน และเพราะ snippet ปล่อยผ่านเมื่อพัง snippet ที่ไม่ได้รันแปลว่าผู้เข้าชมผ่านได้ ไม่ใช่เว็บถูกล็อก แต่ก็แปลว่าถ้าลืมอัปเดต hash หลังอัปเกรด คุณจะเสียคิวไปแบบเงียบ ๆ จึงต้องอ่าน integrity ใหม่ทุกครั้งที่ deploy
- ไม่ต้องทำอะไร ไฟล์จะเปลี่ยนก็ต่อเมื่อคุณอัปเกรด deployment ของคุณเอง
ข้อจำกัดและ roadmap
ส่วนที่ผลิตภัณฑ์นี้ยังตามหลังเจ้าตลาด (CrowdHandler, Queue-it) เขียนไว้ตรง ๆ ให้คุณตัดสินใจโดยเห็นข้อดีข้อเสียจริงตรงหน้า ไม่ใช่มาเจอเอาตอนเปิดขาย
1. edge connector หนึ่งตัว ไม่ใช่สี่ตัว
ออกแล้ว ดู Installable connectors: Cloudflare Worker (edge), @queue-manager/node (npm), WordPress plugin และ custom template ของ Google Tag Manager ที่มีช่อง Server URL และ Room ID แบบตรวจค่าให้ ทั้งสี่ตัวสร้างและเปิดให้โหลดโดย queue server ของคุณเอง เวอร์ชันที่ operator ติดตั้งจึงตรงกับ contract ที่ server นั้นใช้เสมอ และทั้งสี่ตัวถาม server ว่ามี room นี้ไหม ก่อนการติดตั้งจะไปถึง traffic จริง กับดัก room id พิมพ์ผิดจึงถูกปิดในทุกช่องทางที่เราออกให้
จังหวะที่ตรวจต่างกันไปตามช่องทาง เพราะแต่ละช่องทางต่างกัน:
- CLI ของ npm, หน้าตั้งค่าของ WordPress และ script deploy ของ Worker ต่างก็อ่าน
/api/v1/verifyและไม่ยอมทำต่อถ้าพิมพ์ผิด - tag manager ไม่มีขั้นติดตั้งให้ปฏิเสธ tag ที่ publish แล้วก็เริ่มทำงานเลย template ของ GTM จึงตรวจตอน Preview ซึ่งเป็นขั้นที่ operator ทำก่อน publish แล้วบอกใน debug console ว่า room รับเว็บนั้นหรือไม่ ไม่ได้รันตอนผู้เข้าชมเปิดหน้า sandbox ของมันอ่าน body ของ response ไม่ได้ จึงถาม
/api/v1/verify.gifซึ่งคืนภาพ pixel สำหรับ room ที่ server นี้ให้บริการได้ และโหลดไม่สำเร็จสำหรับกรณีอื่น นั่นคือผลที่ tag มองเห็นได้ Preview เป็นคำเตือน ไม่ใช่ด่านกั้น: ยัง publish container ที่ room ผิดได้ แค่ไม่เงียบอีกต่อไป
สิ่งที่ยังขาดเมื่อเทียบกับเจ้าตลาด:
- ที่ edge มีแค่ Cloudflare Queue-it มี connector สำหรับ Cloudflare, Akamai, AWS CloudFront และ Google Service Extensions ส่วน CrowdHandler มี Cloudflare, CloudFront, Akamai และโหมด DNS ที่ไม่ต้องเขียนโค้ด เรามี Cloudflare ร้านค้าที่ใช้ Akamai หรือ CloudFront อยู่ด้านหน้า port โค้ด ~300 บรรทัดเดียวกันไปเองได้ (โครงอยู่ใน Porting to another edge และ source เป็นไฟล์เดียว) แต่นั่นเป็นงานของเขาเอง ไม่ใช่ผลิตภัณฑ์ที่ติดตั้งได้ นี่คือช่องว่าง ด้านการเชื่อมต่อที่ใหญ่ที่สุดที่เหลืออยู่
- โหมด DNS ที่คุณ host เอง ไม่ใช่ที่เรา host ให้ โหมด inline วางคิวไว้หน้าเว็บ โดยไม่ต้องมีโค้ดบนเว็บเลย: DNS ของเว็บชี้มาที่ queue ที่คุณ deploy (ผ่าน CDN หรือ proxy ที่ถอด TLS ให้ เพราะ server นี้พูด HTTP ธรรมดา) และคิวส่งผู้เข้าชมที่ผ่านแล้วต่อไปที่
proxyOriginของ room (ดู Operations guide → Two deployments) สิ่งที่ CrowdHandler มีแต่เราไม่มีคือปลายทาง CNAME ที่เขา host ให้: สิ่งที่ DNS ของคุณชี้ไป คือ server ที่คุณดูแลเอง และถ้ามันล่ม เว็บก็ล่มด้วย เว้นแต่คุณตั้งหน้า failover ของ CDN ไว้ - ยังไม่อยู่ใน marketplace การ publish ขึ้น npm, คลัง plugin ของ WordPress และ GTM Community Template Gallery เป็นขั้นตอนออก release ที่ต้องมีคนทำด้วยบัญชีเหล่านั้น ระหว่างนี้ให้ติดตั้งจาก URL
/connectors/...บน server ของคุณเอง (คำสั่งและ sha256 ของไฟล์ทุกตัวอยู่ในหน้าต่าง Install ของ room)npm updateไม่ขยับ dependency ที่เป็น URL ให้อัปเกรดด้วยการรันคำสั่งติดตั้งที่มีเวอร์ชันอีกครั้ง - มีแค่ Cloudflare, Node, WordPress และ GTM ยังไม่มี Magento/Adobe Commerce, Shopify, Salesforce Commerce Cloud และ SDK สำหรับ PHP/.NET/Java/Python connector ที่เขียนเอง ในเอกสารนี้ (PHP, โครง nginx) ยังใช้กับกรณีเหล่านั้นได้ และยังเป็นของคุณที่ต้องดูแลเอง
- ไม่มี hook ของแพลตฟอร์มที่เราไม่ได้เขียน checkout ของ Shopify บนแพ็กเกจที่ไม่ใช่ Plus และอะไรก็ตามที่ไม่ยอมรับ script tag หรือชั้น middleware อยู่นอกขอบเขตที่ทำได้
ข้อดีอีกด้านที่มีจริง: contract /api/v1 ที่ตรึงไว้ ทำให้ connector ที่คุณไม่เคยอัปเกรด ยังทำงานต่อได้ ไม่พังเงียบ ๆ (ดู API versioning) และสิ่งที่คุณติดตั้ง คือโค้ดที่อ่านได้ครบทุกบรรทัด ไม่มี runtime ของผู้ขายรันบน server ของคุณ และไม่มี origin ของบุคคลที่สามอยู่บนเส้นทางสำคัญของคุณ
2. targeting รวมศูนย์แล้ว แต่ภาษาของกฎถูกออกแบบให้เล็กโดยตั้งใจ
การกำหนดเป้าหมาย URL แบบรวมศูนย์ ออกแล้ว ดู URL targeting ขอบเขตเก็บไว้ที่ room server บังคับใช้ทุกครั้งที่ตรวจ ส่งให้ connector ผ่าน /api/v1/targeting และแสดงในหน้าของ room พร้อมจำนวนครั้งที่แต่ละกฎตรง สิ่งที่ยังขาดแคบกว่านั้น และควรบอกตรง ๆ:
- ใช้ glob ไม่ใช่ regex มีให้แค่
*และ!ข้างหน้า Queue-it และ CrowdHandler รับ regular expression ได้ทั้งคู่ เราเลือกแบบนี้โดยตั้งใจ: regex ที่ operator เขียนเอง จะรันทุกครั้งที่ตรวจที่ประตู และ pattern ที่ย้อนหาแบบไม่รู้จบ (backtracking) ที่วางไว้ตอนตีสอง คือเว็บล่มที่ทำตัวเอง ถ้าต้องการ "/product/\d{6}แต่ไม่เอา/product/\d{6}/reviews" ให้เขียนเป็นกฎ glob สองข้อ หรือทำใน connector ของคุณ - กำหนดเป้าหมายได้จาก URL อย่างเดียว ไม่มีเงื่อนไขประเทศ อุปกรณ์ cookie header หรือเปอร์เซ็นต์ของ traffic trigger ของ Queue-it ใช้สิ่งเหล่านี้ได้
- กฎเป็นของแต่ละ room ใช้ร่วมกันไม่ได้ ถ้ามีสิบห้า room ที่ต้องยกเว้น
/healthก็ต้องเขียนกฎนั้นสิบห้าครั้ง ไม่มีคลังกฎกลาง
3. ช่องว่างอื่น ๆ ที่ต้องยอมรับตรง ๆ
- คิวหนึ่งอยู่ใน region เดียว
node server.jsหลายตัวใช้คิวเดียวกันหลัง load balancer โดยคิวอยู่ใน Valkey ตัวเดียว และ journal ของมันอยู่ใน Postgres ตัวเดียว (ดู Operations guide → Several instances) instance ตัวหนึ่งพังจึงไม่ทำให้คิวหาย แต่ทุกตัวพึ่ง Valkey และ Postgres ชุดเดียวนั้นใน region เดียว เจ้าตลาดให้บริการเป็น SaaS ทั่วโลกหลาย region พร้อม CDN ของตัวเองอยู่ด้านหน้า ส่วนความหน่วงของคิวคุณ และขอบเขตความเสียหายเมื่อมันพัง ขึ้นกับ region นั้น การปล่อยผ่านเมื่อพัง (snippet) และหน้า failover ของ CDN (inline) เป็นแค่ทางบรรเทา ไม่ได้แทนระบบสำรองหลาย region นี่คือช่องว่างที่ใหญ่ที่สุดที่เหลืออยู่ และเป็นเรื่องโครงสร้างระบบ ไม่ใช่ความสามารถที่ขาดไป - มี operator key และ role แต่ไม่มี SSO key ที่มีชื่อพร้อม role
owner/operator/viewerการเพิกถอนที่มีผลทันที และบันทึกการตรวจสอบ (audit trail) แบบเขียนต่อท้ายอย่างเดียว ออกแล้ว ดู Operations guide → Operators, roles and the audit trail ยังไม่มี single sign-on แบบ SAML/OIDC ไม่มีการสร้างบัญชีอัตโนมัติแบบ SCIM และไม่มี MFA องค์กรที่กำหนดให้ สิทธิ์เข้าถึงต้องจัดการผ่านผู้ให้บริการยืนยันตัวตน (identity provider) จึงยังผ่านข้อนี้ไม่ได้/api/admin/healthรายงานaccess.sso: falseให้รู้เลย ไม่ต้องไปค้นพบเอง - รายงานเป็นข้อมูลย้อนหลัง ไม่มีการตั้งเวลา เก็บข้อมูลรายชั่วโมงและรายวันของแต่ละ room ไว้
HISTORY_RETAIN_DAYSวัน (default 90) และ export เป็น JSON หรือ CSV ได้จาก/api/admin/reportsสิ่งที่ไม่มี: รายงานทางอีเมลหรือตามตารางเวลา การส่งข้อมูลเข้า warehouse และการแจ้งเตือนจากแนวโน้มย้อนหลัง ค่า percentile ของเวลารอในข้อมูลเหล่านั้นเป็นค่าประมาณจากการสุ่มตัวอย่าง (reservoir) และติดป้ายบอกไว้ในทุก response - ให้คะแนนบอท ไม่ใช่ผลิตภัณฑ์จัดการบอท traffic ถูกให้คะแนนจากสิ่งที่ server เห็นได้ ได้แก่ client อัตโนมัติที่บอกตัวเอง header ของ browser ที่ขาดไป ที่อยู่ forwarded ที่ปลอมมา และจังหวะที่สม่ำเสมอแบบเครื่องจักร โหมด
enforceจะไม่ให้ที่ในคิวเมื่อคะแนนเกินเกณฑ์ แต่ไม่เคยกันการเข้าเว็บของคุณ สิ่งที่มันไม่มี: ไม่มี device fingerprinting ไม่มี JS challenge ไม่มี CAPTCHA ไม่มีข้อมูลชื่อเสียงของ proxy/VPN และไม่มี room แบบเชิญเท่านั้นหรือ 2FA บนหน้ารอ เกณฑ์ถูกปรับไว้ให้สัญญาณทุกแบบที่ที่อยู่ที่ใช้ร่วมกัน (NAT ของบริษัท, CGNAT) สร้างขึ้นได้เอง รวมกันแล้วไม่ถึงเกณฑ์บล็อก ซึ่งย่อมแปลว่า botnet ที่กระจายบาง ๆ ไปตามที่อยู่บ้านและใช้ browser จริง จะจับไม่ได้ ความยุติธรรมต่อกรณีนั้นยังมาจากโครงสร้างระบบ: ticket แบบมาก่อนได้ก่อน (FIFO), pass ที่ลงลายเซ็นด้วย HMAC, door session ที่ใช้ได้ครั้งเดียว และหนึ่งที่ใน pre-queue ต่อหนึ่งผู้ใช้
คำถามที่พบบ่อย (FAQ)
snippet ทำให้หน้าเว็บช้าลงไหม? ไม่ มันโหลดแบบ async (ไม่บล็อกการอ่านหรือการแสดงผลหน้า) และการตรวจรันอยู่เบื้องหลัง ผู้เข้าชมที่ต้องต่อคิวอาจเห็นหน้าเว็บเสี้ยววินาทีก่อนถูก redirect ใส่ tag ไว้อันดับแรกใน <head> (ก่อน script อื่นของคุณ) เพื่อให้ช่วงนี้สั้นที่สุด ถ้าต้องกั้นเด็ดขาดแบบไม่เห็นหน้าเลย ให้ใช้การตรวจฝั่ง server หรือ Cloudflare Worker edge connector แบบนั้นผู้เข้าชมที่ต้องต่อคิวจะไม่ได้รับหน้าที่คุ้มกันเลย
ผู้เข้าชมเห็นอะไรระหว่างรอ? หน้ารอที่เราให้บริการที่ /w/<roomId>: ลำดับในคิวแบบสด เวลารอโดยประมาณที่ตรงไปตรงมา และ redirect อัตโนมัติเมื่อถึงคิว ปรับแต่งแบรนด์ได้ทีละ room ผ่าน admin API หรือ dashboard
บอกผู้เข้าชมได้ไหมว่าเกิดอะไรขึ้นระหว่างมีเหตุ? ได้ ตั้ง branding.message ของ room ข้อความจะขึ้นบนหน้ารอในกรอบของมันเองพร้อมเวลา ("Message from the team — Updated 10:40 PM") ใน ทุก สถานะ รวมถึงตอนนับถอยหลังการเปิดขายตามเวลา และถูกส่งผ่าน SSE stream ที่เปิดอยู่แล้วไปถึงทุกคนที่รออยู่ภายในหนึ่ง heartbeat ไม่มีใครต้อง reload ลบข้อความในช่องนี้ ข้อความก็หายไปแบบเดียวกัน
ปิดแท็บแล้วผู้เข้าชมจะเสียที่ในคิวไหม? ไม่ token ใน localStorage/cookie เก็บ ticket ไว้ เข้าคิวใหม่ด้วย token เดิมก็ได้ลำดับเดิม ผู้เข้าชมที่ล้าง storage (หรือเปลี่ยน browser) จะเริ่มใหม่ที่ท้ายคิว
ลบ script tag ใน DevTools แล้วข้ามคิวได้ไหม? ถ้าเชื่อมต่อแค่ฝั่ง client ผู้ใช้ที่เข้าใจเทคนิคจะดู HTML ของหน้าได้ เหมือนผลิตภัณฑ์หน้ารอแบบ JavaScript tag ทุกตัว สิ่งที่เขา ทำไม่ได้ คือปลอม pass token (ลงลายเซ็น HMAC ฝั่ง server) ถ้าการเปิดขายไหนเรื่องนี้สำคัญ ให้เพิ่มการตรวจฝั่ง server แล้วหน้านั้นจะเข้าไม่ได้เลยถ้าไม่มี pass ที่ใช้ได้
pass ใช้ได้นานแค่ไหน? ตาม passedTtlSec ของแต่ละ room ค่า default 600 วินาที ภายในช่วงนี้ผู้เข้าชมเปิดหน้าที่คุ้มกันได้อิสระ หมดเวลาแล้ว การตรวจครั้งถัดไปจะให้เขาต่อคิวใหม่
ใช้ room เดียวหรือหลาย room? room เดียวคุ้มกันกี่หน้าก็ได้ที่ใช้ tag เดียวกัน ใช้หลาย room (หลาย tag) เมื่อแต่ละส่วนต้องการ อัตราปล่อยคนแยกกัน เช่น checkout ที่ 120/นาที และ ticket-drop ที่ 20/นาที pass ใช้ได้กับ room ที่ออกให้เท่านั้น pass ของ room หนึ่งไม่เคยเปิดอีก room ได้ ซึ่งกลายเป็นกับดักทันทีที่หน้ารอของ room หนึ่งส่งผู้เข้าชมไปหน้าที่ติด tag ของ room อื่น การจับคู่ผิดแบบนี้ไม่มี error ใด ๆ แค่ผู้เข้าชมต้องต่อคิวสองรอบ ดู Point each room's waiting room at its own protected pages
ใช้กับ tag manager (GTM ฯลฯ) ได้ไหม? ได้ มี Custom HTML tag สำเร็จรูปอยู่ใน Google Tag Manager ด้านบน snippet รับมือกรณีที่ใช้ document.currentScript ไม่ได้ไว้แล้ว แต่ tag manager มักโหลดช้ากว่า tag ที่ใส่ตรง ๆ ช่วงที่หน้าจอกะพริบก่อน redirect จึงนานขึ้น
ถ้ามี cache/CDN อยู่หน้าเว็บล่ะ? การตรวจรันใน browser ของผู้เข้าชม HTML ที่ cache ไว้จึงไม่มีปัญหา ผู้เข้าชมทุกคนยังถูกตรวจ แค่อย่า cache หน้าที่มี query string qm_token snippet ลบมันทิ้งฝั่ง client อยู่แล้ว แต่เพื่อความปลอดภัย ให้ตั้ง CDN ไม่เอา qm_token/qm_return มาใช้ใน cache key
รองรับ browser ไหนบ้าง? browser ที่อัปเดตตัวเองทุกตัว (Chrome, Edge, Firefox, Safari ≥ 12) snippet ใช้ fetch + AbortController และถอยไปใช้ XHR ได้ ถ้าใช้ localStorage ไม่ได้ (เช่น โหมดท่องเว็บส่วนตัวบางแบบ) ก็ใช้ cookie ธรรมดาแทน ไม่รองรับ Internet Explorer
มีการเก็บข้อมูลส่วนบุคคลไหม? ไม่มี cookie ของบุคคลที่สาม ไม่มีการติดตามข้ามเว็บ ไม่มีรหัสโฆษณา ไม่มีการขายหรือแบ่งข้อมูลให้ใคร pass token มีแค่ room id หมายเลข ticket และเวลาที่ออก พร้อมลายเซ็น
แต่ queue server ประมวลผลข้อมูลของผู้เข้าชมสองอย่างที่ประกาศความเป็นส่วนตัวของคุณต้องระบุไว้: IP address และ User-Agent มันเก็บค่า digest แบบทางเดียวที่ตัดสั้นแล้วของทั้งสองอย่าง (และใน events journal เก็บตัว IP เอง) เพื่อจุดประสงค์เดียว คือพิสูจน์ว่าใครเป็นเจ้าของ ticket pass token เดินทางใน URL จึงไปอยู่ใน access log ของคุณ ถ้าไม่ผูกไว้แบบนี้ ใครที่อ่าน log เจอสักบรรทัด ก็เอาที่ในคิวของผู้เข้าชมไปได้ ข้อมูลนี้เป็น first-party ของ queue server ไม่เคยถูกใช้สร้างโปรไฟล์ และอยู่ในฐานข้อมูล Postgres ของ queue server ตราบเท่าที่คุณเก็บข้อมูลนั้นไว้
ตาม GDPR IP address เป็นข้อมูลส่วนบุคคล จึงต้องระบุเรื่องนี้ในประกาศของคุณ กลไก และเหตุผลที่แต่ละชั้นมีอยู่ อยู่ใน Operations guide → How a pass is bound to a visitor