เอกสาร Queue Manager

ที่อยู่ในหน้านี้ถูกแทนด้วยที่อยู่ของ server นี้เอง https://qm.weekday100.com

Queue Manager — คู่มือการเชื่อมต่อ (Integration Guide)

ใส่ script tag บรรทัดเดียว แล้วหน้าไหนของเว็บคุณก็มีหน้ารอคิว (virtual waiting room) คุ้มกันได้ การตั้งค่าพื้นฐานไม่ต้อง build ไม่ต้องติดตั้ง npm package และไม่ต้องแก้ backend ถ้ามีหน้าที่ห้ามใครเข้าเด็ดขาดหากไม่มี pass ที่ใช้ได้ ให้ใช้วิธีตรวจฝั่ง server ซึ่งอธิบายไว้ด้านล่าง

(pass คือสิทธิ์เข้าหน้าเว็บที่ผู้เข้าชมได้รับเมื่อรอคิวเสร็จ)


เริ่มต้นอย่างรวดเร็ว (60 วินาที)

  1. สร้าง 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

  1. ใส่โค้ดนี้ใน <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 นั้นที่ใช้งานจริง แค่นี้ก็เสร็จ ผลที่ได้:

script นี้เป็น async จึงไม่ทำให้หน้าเว็บแสดงผลช้าลง ขนาดที่ส่งจริงผ่านเครือข่าย วัดจาก deployment นี้:

Encodingจำนวน byte ที่ส่งจริง
Content-Encoding: br (Chrome, Edge, Firefox, Safari 16.4+)5,220
Content-Encoding: gzip6,114
Content-Encoding: deflate6,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 ถามคิว GET /api/check: มี pass ที่ใช้ได้ไหม ต้องรอ ผ่าน 3 ห้องรอ เห็นลำดับและเวลารอ หน้าโหลดตามปกติ ไม่ต้องรอ 4 ถึงคิว ตามลำดับบัตรคิว 5 กลับไปหน้าเดิม พร้อม pass ที่ลงนามแล้ว (qm_token) 6 tag เก็บ pass ไว้ ตรวจครั้งถัดไปได้ pass: เข้าเว็บได้

สรุปเป็นคำพูด: (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 หรือปล่อยเขาออกจากคิว) หน้ารอจะเลือกปลายทางตามลำดับนี้:

  1. qm_return คือหน้าที่ผู้เข้าชมอยู่ตอนที่ snippet (หรือ middleware ของคุณ) ส่งเขาไปหน้ารอ ถ้ามีค่านี้ จะใช้ค่านี้เสมอ: snippet บนหน้านั้นจะรับ token ไปเก็บ แล้วลบออกจากช่อง address
  2. targetUrl ของ room ใช้เฉพาะตอนที่ผู้เข้าชมเข้าหน้ารอ ตรง ๆ (ลิงก์ที่ bookmark ไว้ URL ที่มีคนแชร์มา ไม่มี qm_return) ให้ตั้งเป็นหน้าที่มี snippet ถ้า URL ที่มี token ไปลงหน้าที่ไม่มี snippet ?qm_token= จะค้างให้เห็นในช่อง address และถูกบันทึกใน access log ของเว็บนั้น
  3. หน้าที่ผู้เข้าชมมาจาก (referring page) เป็นทางสุดท้าย ใช้กับปุ่ม "Continue" ที่ผู้เข้าชมกดเองเท่านั้น ไม่มีการส่งต่ออัตโนมัติ

ตั้ง targetUrl ให้ room ที่ใช้ snippet คุ้มกันอยู่แล้วได้ ไม่มีปัญหา ผู้เข้าชมที่ snippet ส่งมายังกลับไปหน้าเดิมที่มา targetUrl ใช้กับคนที่เข้าหน้ารอตรง ๆ เท่านั้น

ชี้ waiting room ของแต่ละ room ไปยังหน้าที่ถูกป้องกันของ room นั้นเอง

⚠️ pass ใช้ได้กับ room ที่ออกให้เท่านั้น pass จาก room A ใช้ไม่ได้เลยกับหน้าที่ snippet ตรวจ room B และจะไม่มีอะไรเตือนคุณว่าตั้งผิด เพราะทั้งสองฝั่งทำงานถูกตามที่ออกแบบ: หน้ารอของ room A ให้ผู้เข้าชมต่อคิว ปล่อยเขาออกจากคิว แล้วส่งไปที่ targetUrl พร้อม ?qm_token=<pass for A> snippet บนหน้านั้นตั้งไว้เป็น data-qm-room="B" จึงตรวจ room B ผู้เข้าชมไม่มี pass ของ B จึงถูกส่งไปต่อคิวของ room B ผลคือผู้เข้าชมต้องรอสองรอบ หรือเด้งไปมาระหว่างสอง room ส่วนคิวของ room A ดูปกติและว่าง เพราะมันปล่อยคนออกจริง ๆ ไม่มี error ไม่มีข้อความใน console ไม่มี request ที่ล้มเหลว pass นั้นใช้ได้จริง แต่ใช้ได้กับประตูที่ไม่มีใครยืนรออยู่ กับดักนี้มักเจอในสามแบบ: | สิ่งที่คุณต่อไว้ | สิ่งที่เกิดขึ้นจริง | |---|---| | targetUrl ของ room A → หน้าที่ติด tag data-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 เว็บนี้อ่านคำตอบของคิวได้ไหม ไม่อยู่ในรายการ: ไม่ได้คำตอบ ผู้เข้าชมข้ามคิวไปเงียบๆ server คิวและห้องรอ returnOrigins ส่งผู้เข้าชมกลับไป qm_return ได้ไหม ไม่อยู่ในรายการ: ไป targetUrl ของห้องแทน (หรือค้างอยู่) กลับถึงเว็บของคุณ พร้อม pass เว็บของ targetUrl อยู่ในทั้งสองรายการ tagOrigins เป็น null: ใช้ returnOrigins returnOrigins เกินจำเป็น: เสี่ยง open redirect

อ่านจากบนลงล่าง: หน้าที่มี 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 ไม่ได้ ผลคือ:

นี่คือเหตุที่การลืมใส่เว็บในรายการนี้อันตราย: 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:

  1. Fail: tag บนเว็บนั้นอ่านคำตอบของ queue ไม่ได้ ปุ่ม Allow \<origin\> เพิ่มเว็บนั้นเข้า tagOrigins อย่างเดียว
  2. 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

Attributesrc parameterต้องใส่ไหมค่า defaultคำอธิบาย
data-qm-serverserver=ไม่¹origin ของ src ของ tag เองorigin (scheme + host + port) ของ Queue Manager ที่คุณ deploy เช่น https://qm.weekday100.com มี slash ต่อท้ายก็ได้
data-qm-roomroom=ต้อง—room id ที่ใช้ตรวจหน้านี้
data-qm-timeouttimeout=ไม่2000รอผลการตรวจที่ประตู (door check) กี่มิลลิวินาที ถ้าเกินนี้จะเลิกรอและปล่อยผู้เข้าชมผ่าน
data-qm-spaspa=ไม่—ใส่ auto ตรงตัว (ตัวพิมพ์เล็กหรือใหญ่ก็ได้) เพื่อให้ตรวจใหม่เองทุกครั้งที่เปลี่ยนหน้าฝั่ง client (history.pushState / replaceState / popstate) ค่าอื่นทุกค่าคือปิด ดู Single-page apps

¹ ไม่ใส่ได้เฉพาะตอนที่ qm.js ถูกโหลด จาก Queue Manager ที่คุณ deploy เอง ซึ่งเป็นการติดตั้งแบบปกติ tag จะคุยกับ origin ที่มันถูกโหลดมา มีสองกรณีที่ไม่ใส่แล้วพัง และพังคนละแบบ:

ช่องทาง 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 ที่อยู่คู่กัน และพังแบบเงียบทั้งสองทาง:

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 ต้องเป็นแบบใดแบบหนึ่ง:

ดังนั้นไฟล์ที่เปลี่ยนชื่อ (/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คืออะไรมาเมื่อไร
sessionboolean บอกสถานะ ("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 ไปทั้งหมด

ผลที่ต้องวางแผนไว้สองข้อ:

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 ที่เปลี่ยนได้ขณะระบบทำงาน ไม่ได้ฝังอยู่ในโค้ด

รูปแบบกฎ บรรทัดละหนึ่งกฎ:

คุณเขียนความหมาย
/checkoutpath นี้ตรงตัวเท่านั้น (/checkout/ เป็น path คนละตัว)
/checkout*path นี้ และทุกอย่างที่อยู่ใต้หรือต่อท้ายมัน
*/cartpath ใดก็ได้ที่ลงท้ายด้วย /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 ที่มีอยู่แล้วไม่ถูกเปลี่ยน

แต่ละจุดเชื่อมต่อรู้ขอบเขตได้อย่างไร

หน้าที่อยู่นอกขอบเขตจะได้ {"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:

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)

tag ถาม /api/check รอคำตอบนานสุด 2 วินาที คำตอบคือ pass หน้าเว็บโหลดตามปกติ คำตอบคือ queue หรือ 429 (ยุ่ง) ส่งไปห้องรอ ไม่ได้คำตอบที่ใช้ได้: fail open หน้าโหลดโดยไม่เข้าคิว เมื่อ หมดเวลา, network หรือ CORS ผิดพลาด, 5xx, คำตอบไม่ใช่ JSON หรือตั้งค่า tag ผิด

สรุปสั้น ๆ: ได้คำตอบ 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 ไปได้ สิ่งที่จำกัดความเสียหายไว้:

กรณีแย่ที่สุดคือผู้เข้าชมที่ 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 นี้:

ถ้าไม่มี 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 ไปใช้ อ่านสองเรื่องที่พลาดง่ายนี้ก่อน:

  1. ต้องรับรองผู้เข้าชม (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 และวนไม่รู้จบ ให้ใช้:

ตั้งเฉพาะเมื่อทุก request ผ่าน proxy นั้นจริง ๆ เท่านั้น ไม่เช่นนั้น client เขียน header เหล่านี้เองได้ ทั้งสอง connector จะเตือนครั้งเดียวเมื่อเห็น X-Forwarded-For โดยที่ไม่ได้ตั้งทั้งสองอย่าง

  1. การเชื่อมต่อแบบ 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);
}

ไม่ว่าใช้แพลตฟอร์มไหน ต้องทำให้ครบทุกข้อ:

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 แบบไหน:

ทำไม 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

<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 ไว้จะได้คำตอบแบบเดิม ตรงตามที่เขียนโค้ดไว้ ไม่ได้รับแบบใหม่โดยไม่รู้ตัว

นโยบาย:

# 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 ที่การเชื่อมต่อบนเว็บของลูกค้าได้รับจริง:

codeStatusเมื่อไร
room_not_found404server นี้ไม่มี room นี้: พิมพ์ผิดใน data-qm-room หรือ room ถูกลบไปแล้ว
invalid_token401ตรวจ token ไม่ผ่าน หรือไม่ได้ส่ง token มาในที่ที่ต้องมี ให้ทิ้ง token แล้วเข้าคิวใหม่
stale_ticket409ticket เป็นของคิวรุ่นก่อน (room ถูกรีเซ็ต) ให้ทิ้งแล้วเข้าคิวใหม่
rate_limited429เกินโควตาต่อที่อยู่: 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_id400ไม่มี roomId หรือไม่ใช่ string ที่ไม่ว่าง (body ของ POST /api/join, query ของ GET /api/verify)
invalid_token_field400มี token ใน body ของ POST /api/join หรือ POST /api/notify แต่ไม่ใช่ string
blocked_by_protection403เฉพาะ POST /api/join: ระบบกันบอทของ room อยู่ในโหมด enforce และให้คะแนน request นี้ว่าเป็นบอท จึงไม่ได้ที่ในคิว X-QM-Protection บอกคะแนน ไม่ใช่การจำกัดอัตรา ลองใหม่ก็ไม่ช่วย
queue_identity_limit429POST /api/join: ที่อยู่นี้ถือที่ในคิวครบ queueMaxPerIp ของ room แล้ว (default 16) และไม่ได้ส่งที่ไหนมาเลย ไม่ได้อะไรกลับไปที่ใช้ได้ Retry-After: 30
prequeue_identity_limit429POST /api/join: ผู้ใช้รายนี้ถือที่ใน pre-queue ของการเปิดขายตามเวลาครบ preQueueMaxPerIp แล้ว Retry-After: 30
prequeue_full503pre-queue นั้นเต็มแล้ว ลองใหม่
storage_unavailable503server บันทึกการเข้าคิวลง disk ไม่ได้ จึงไม่ยอมแจก ticket ที่อาจลืมทีหลัง ลองใหม่
sse_capacity503/events (หรือ /api/events): server เปิด stream อยู่ครบ SSE_MAX_TOTAL แล้ว Retry-After: 5 ระหว่างนั้นให้เรียก /api/status เป็นระยะแทน
sse_per_ip_limit429/events: ที่อยู่นี้เปิด stream อยู่ครบ SSE_PER_IP แล้ว Retry-After: 5
unauthorized401ต้องมี key แต่ไม่มีหรือผิด เช่น /api/check?ip=&agent= ที่ไม่มี PUBLIC_API_KEY
credential_in_query401/api/check?ip=&agent= ที่ส่ง key มาเป็น ?key= และไม่มี header Authorization รับ key ได้เฉพาะแบบ Authorization: Bearer <key>
edge_required403request ไม่ได้มาผ่าน Cloudflare zone ที่ server นี้ deploy อยู่ข้างหลัง (ตั้ง EDGE_SECRET ไว้ และ request ไม่มี header ที่ Cloudflare ใส่ให้) ผู้เข้าชมแก้เองไม่ได้ และลองใหม่ก็ไม่ช่วย ถ้าเป็นการเปิดหน้าใน browser จะได้หน้า 403 แบบเรียบ ๆ แทน
server_saturated503process รับ connection เต็มเพดานแล้ว จึงตัด request ที่ไม่ใช่การโหลดหน้าทิ้ง (การโหลดหน้าจะได้หน้า offline แทน) Retry-After: 5
uri_too_long414URL ของ request ยาวเกิน 4096 byte
bad_request, request_aborted400อ่าน URL ไม่ออก หรือ body ของ request ขาดกลางทาง
not_inline_host404path /__qm/… บน host ที่ไม่ได้อยู่หน้า inline room ใดเลย
internal500server ผิดพลาดโดยไม่คาดคิด ข้อความเป็น internal error เสมอ ลองใหม่
email_notify_disabled404POST /api/notify บน server ที่ operator ไม่ได้ตั้ง EMAIL_NOTIFY ความสามารถนี้ปิดไว้เป็นค่าเริ่มต้น หน้ารอซ่อนปุ่มของมันไว้ จะเจอ code นี้ก็ต่อเมื่อเรียก route นี้ตรง ๆ
invalid_email400ที่อยู่ที่ส่งมาให้ POST /api/notify ไม่ใช่อีเมล
ticket_ended409POST /api/notify สำหรับบัตรคิวที่สิ้นสุดแล้ว (ผ่านเข้าไปแล้ว ถูก eject ถูก purge หรือช่วงเวลาเข้าหมดไป) ไม่มีที่ในแถวให้ผูกอีเมลไว้อีก ให้เข้าคิวใหม่ก่อน การถอนอีเมล (email ว่าง) รับเสมอ
invalid_json, invalid_body400body ของ request ผิดรูปแบบ
payload_too_large413body ใหญ่เกินขีดจำกัด
method_not_allowed405ใช้ HTTP method ผิดกับ path ใน contract header Allow บอก method ที่ถูก
not_found404server นี้ไม่มี 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)

วิธีตรึงเวอร์ชัน เรียงจากเข้มที่สุด:

  1. copy qm.js ไปไว้ในระบบจัดการไฟล์ของคุณเอง มันไม่มี dependency และไม่ต้อง build เสิร์ฟจากเว็บของคุณเอง (first-party) จะไม่ต้องโหลดข้าม origin เลย และใช้วิธี cache-busting ปกติของคุณจัดการการอัปเกรดได้
  2. 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

  1. ไม่ต้องทำอะไร ไฟล์จะเปลี่ยนก็ต่อเมื่อคุณอัปเกรด 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 พิมพ์ผิดจึงถูกปิดในทุกช่องทางที่เราออกให้

จังหวะที่ตรวจต่างกันไปตามช่องทาง เพราะแต่ละช่องทางต่างกัน:

สิ่งที่ยังขาดเมื่อเทียบกับเจ้าตลาด:

ข้อดีอีกด้านที่มีจริง: contract /api/v1 ที่ตรึงไว้ ทำให้ connector ที่คุณไม่เคยอัปเกรด ยังทำงานต่อได้ ไม่พังเงียบ ๆ (ดู API versioning) และสิ่งที่คุณติดตั้ง คือโค้ดที่อ่านได้ครบทุกบรรทัด ไม่มี runtime ของผู้ขายรันบน server ของคุณ และไม่มี origin ของบุคคลที่สามอยู่บนเส้นทางสำคัญของคุณ

2. targeting รวมศูนย์แล้ว แต่ภาษาของกฎถูกออกแบบให้เล็กโดยตั้งใจ

การกำหนดเป้าหมาย URL แบบรวมศูนย์ ออกแล้ว ดู URL targeting ขอบเขตเก็บไว้ที่ room server บังคับใช้ทุกครั้งที่ตรวจ ส่งให้ connector ผ่าน /api/v1/targeting และแสดงในหน้าของ room พร้อมจำนวนครั้งที่แต่ละกฎตรง สิ่งที่ยังขาดแคบกว่านั้น และควรบอกตรง ๆ:

3. ช่องว่างอื่น ๆ ที่ต้องยอมรับตรง ๆ

คำถามที่พบบ่อย (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