Queue Manager — สเปกผลิตภัณฑ์ (Contract)
ระบบหน้ารอคิวเสมือน (virtual waiting room) และจัดการ traffic ระดับเดียวกับ CrowdHandler และ Queue-it
Stack (กำหนดตายตัว — ห้ามเปลี่ยน)
- Node.js >= 18 ตัวที่รันจริง (runtime) ต้องไม่มี dependency เลย: โค้ดใน
server.js,lib/,snippet/และpublic/ห้าม import package จากnode_modulesใช้ได้แค่ของที่มากับ node (node:http,node:crypto,node:fs, …) และdependenciesในpackage.jsonต้องว่างเสมอ - Vitest ใช้รัน test ตอนพัฒนาเท่านั้น (อยู่ใน
devDependenciesและเป็น npm package ตัวเดียวที่อนุญาต)npm test→vitest run,npm run test:watch→vitestตั้งค่าอยู่ในvitest.config.jsไฟล์ test ในtest/*.test.jsใช้ globals ของ Vitest (describe/it/expect/beforeAll/afterAll) จึงไม่มีไฟล์ test ไหน import มันเลย การ ship หรือ deploy ผลิตภัณฑ์ไม่ติดตั้งมัน - รันเป็น process เดียว:
node server.jsที่ port8080(envPORT) - ส่งข้อมูลแบบสด (realtime): ใช้ Server-Sent Events (SSE) ไม่ใช้ websocket
- เก็บข้อมูล: production ใช้
STORE=valkey+SIDESTORE=pg(NODE_ENV=productionไม่ยอมใช้แบบอื่น): คิวที่กำลังทำงานอยู่ใน Valkey ทุกการเปลี่ยนแปลงบันทึกลง journal ใน Postgres แยกตาม room และข้อมูลอื่นทั้งหมดอยู่ใน Postgres ข้อมูลไม่หายเมื่อ restart ส่วนSTORE=memory(development และ test) อยู่ใน process และไม่ใช้ดิสก์: ไม่เขียนอะไรลงดิสก์ และเริ่มจากว่างทุกครั้ง journal แบบไฟล์ (data/) ถูกเอาออกใน step 5 - ลำดับของ event (QM-449 ข้อบังคับสำหรับระบบจริง): event ของแต่ละ room ต้องมีเลขลำดับต่อ room ที่เพิ่มขึ้นเสมอ เพื่อให้ตอน replay ตรวจเจอได้ว่ามี event หายหรือสลับลำดับ Valkey store ทำส่วนนี้แล้ว: ทุกแถวของ journal ใน Postgres มี
seqของ room และ rebuild ตรวจเจอช่องว่างได้ memory store ไม่มี journal - ยืนยันตัวตน admin: env
ADMIN_KEY(ค่า defaultadmin-dev) ส่งเป็นAuthorization: Bearer <key> - ถ้าใช้ key ค่า default: เมื่อไม่ได้ตั้ง
HOSTserver จะ bind127.0.0.1และถ้าHOSTไม่ใช่ loopback server จะไม่ยอม start - credential ของ admin แบบอื่น: operator key ที่มีชื่อ (
qmo_…, roleowner | operator | viewerถ้า role ไม่พอได้403 insufficient_role), monitor token แบบอ่านอย่างเดียว และ session cookie ของ consoleqm_console(ดูด้านล่าง) - credential ที่ผิดถูกนับกับ
ADMIN_AUTH_FAIL_PER_MINที่อยู่ที่ถูกล็อกจะได้429 too_many_auth_failuresก่อนที่ server จะอ่าน credential ใด ๆ ดู OPERATIONS.md หัวข้อ Two keys, two blast radii และ Operators, roles and the audit trail - credential ใน query string จะไปอยู่ใน access log, ใน header
Refererและบน inline host (ที่/__qm/*ถูกเขียนใหม่ก่อนตรวจสิทธิ์) ไปอยู่ใน access log และ log ของ CDN ของ ลูกค้า ดังนั้นไม่มี key ไหนถูกรับจาก URL เลย - route ของ SSE เป็นที่เดียวที่
EventSourceส่ง header ไม่ได้:POST /api/admin/sse-ticket(Bearer) คืน ticket ที่ใช้ได้ครั้งเดียว อายุ 30 วินาที หมดทันทีที่ใช้ ใช้ได้เฉพาะกับ credential ที่ขอมัน และนำไปใช้เป็นGET /api/admin/events?ticket=<t> - ลิงก์ monitor ที่แชร์ได้เก็บ viewer token ไว้ใน fragment ของ URL (ส่วนหลัง
#) ซึ่งไม่มี browser ไหนส่งไปให้ server - Token:
base64url(JSON) "." base64url(HMAC-SHA256)ความลับหลักมาจาก envSECRET(ค่า default สุ่มใหม่ทุกครั้งที่เริ่ม จำเป็นเมื่อใช้SIDESTORE=pg) QM-352 มีสองแบบ ตรวจลายเซ็นทั้งสองแบบเสมอ:
| แบบ | ลงลายเซ็นเมื่อ | key และกฎ |
|---|---|---|
| Legacy | เป็นค่า default | HMAC key คือ SECRET ไม่มี kid รับได้ทุกจุดประสงค์ที่เนื้อหา (payload) ผ่านการตรวจ |
| v2 | TOKEN_SIGN_V2=1 | key แยกตามจุดประสงค์ = HKDF-SHA256(SECRET, salt ว่าง, info เป็น qm:visitor, qm:monitor, qm:session หรือ qm:operator, 32 byte) payload มี kid = hex(HKDF-SHA256(SECRET, salt ว่าง, qm:kid, 4 byte)) kid ที่ไม่รู้จักถูกปฏิเสธ ใช้ข้ามจุดประสงค์ไม่ได้เด็ดขาด |
SECRET_PREVIOUS ทำให้ SECRET ตัวก่อนยังตรวจผ่าน (ทั้งสองแบบ) ระหว่างเปลี่ยน key token ใหม่ใช้ SECRET ตัวตรวจของ operator ใช้กฎเดียวกัน และไม่เคยถูกลดจาก v2 ลงมา ดู OPERATIONS.md หัวข้อ Signing keys and rotating SECRET
- เนื้อหา (payload) ของ visitor token (จุดประสงค์
visitor):{r, n, g, iat}สำหรับที่ในคิว หรือ{r, q, g, iat}สำหรับที่ใน pre-queue มีnหรือqอย่างใดอย่างหนึ่งเท่านั้น ไม่มีทั้งคู่ v2 เพิ่มkid rroom idnหมายเลข ticket (จำนวนเต็ม >= 0 โดย0= pass แบบ bypass ที่ไม่ได้จองที่)qหมายเลขอ้างอิงใน pre-queue (จำนวนเต็ม >= 1 ไม่ใช่ลำดับ)gรุ่นของ room (generation, จำนวนเต็ม >= 1 room ที่ถูกลบแล้วสร้างใหม่ได้รุ่นใหม่ ticket เก่าจึงเอาที่ในคิวใหม่ไม่ได้)iatเวลาที่ออก เป็น epoch ms
ถูกปฏิเสธเมื่อเกิน TOKEN_MAX_AGE_SEC (default 86400 ต่ำสุด 60) นับจาก iat หรือเมื่อ iat อยู่ในอนาคตเกิน 60 วินาที token ไม่ได้ เก็บสถานะว่ากำลังรอหรือผ่านแล้ว สถานะนั้นอยู่ใน engine โดยใช้ r+n เป็นตัวค้น จุดประสงค์อื่น (monitor, session, operator) มี payload ของตัวเอง และใน v2 ไม่มีทางถูกรับเป็น visitor token
ความเป็นเจ้าของไฟล์ (builder แต่ละคนอยู่ในเลนของตัวเอง)
| Path | เจ้าของ |
|---|---|
server.js, lib/engine.js, lib/roomstore/, lib/token.js | ผู้สร้าง engine |
public/waiting.html (+ CSS/JS ในไฟล์) | ผู้สร้างหน้าของผู้เข้าชม |
public/admin.html (+ CSS/JS ในไฟล์) | ผู้สร้าง dashboard |
snippet/qm.js, INTEGRATION.md | ผู้สร้างส่วนเชื่อมต่อ |
test/*.test.js | ผู้สร้างแต่ละคนเพิ่มของตัวเอง |
PROGRESS.html, SPEC.md | ผู้ประสานงาน (orchestrator) เท่านั้น |
แนวคิดหลัก
- Room (หน้ารอหนึ่งห้อง): มี id, ชื่อ, URL ปลายทาง,
ratePerMinute(อัตราปล่อยคนออก),maxConcurrent(ไม่บังคับ) และสถานะที่ตั้งไว้:active | paused | bypass(bypass = ปิดคิว ทุกคนผ่าน) room ที่opensAtยังอยู่ในอนาคต จะแจ้งผู้เข้าชมด้วยสถานะ ที่มีผลจริงscheduledขณะที่สถานะที่ตั้งไว้ยังเป็นหนึ่งในสามค่านั้น รายการ room ฝั่ง admin แสดงทั้งสองค่า พร้อมopensAtและจำนวนpreQueue - Visitor token: ลงลายเซ็นแล้ว เก็บ room id, หมายเลข ticket หรือ หมายเลขอ้างอิงใน pre-queue, รุ่นของ room และเวลาที่ออก (payload ตามข้างบน) ticket นั้นกำลังรอหรือผ่านแล้วเป็นสถานะใน engine ไม่ได้อยู่ใน token
- Cookie ยืนยันเจ้าของ
qms_<roomId>: session id แบบสุ่มที่อ่านความหมายไม่ได้ (16–128 ตัวอักษร) ตั้งเป็น HttpOnly คู่กับ token ตอน/api/joinและผูกกับ ticket ใน engine (เก็บแค่ hash) token ที่ส่งมาจาก browser อื่นที่ไม่ได้ต่อคิวไว้ จะไม่ได้ที่ในคิวนั้น การ join ที่ไม่มี cookie นี้ จะได้ cookie ใหม่ ดู OPERATIONS.md หัวข้อ How a pass is bound to a visitor - ขอบเขตของ cookie คิว (QM-504, QM-526 ข้อบังคับ): cookie ต่อ room คือ
qm_<roomId>,qms_<roomId>และqm_dk_<roomId>ใช้Path=/ต่อไป ไม่แคบ Path ลง เพราะ connector บน origin ของลูกค้าอ่าน cookie เหล่านี้ที่Path=/และถ้าแคบลงจะเหลือ cookie ซ้ำกับของเดิมที่เป็นPath=/แทนที่จะทำแบบนั้น host ของคิวเก็บ cookie ไว้ไม่เกิน 8 room ต่อ browser (ROOM_COOKIE_CAP) ถ้าเกิน cookie ของ room ที่เก่าที่สุดจะถูกทำให้หมดอายุใน response เดียวกัน ความเสี่ยงที่ยอมรับ (QM-539): หน้าเว็บที่พาผู้เข้าชมผ่านเกิน 8 room ทำให้ cookie ของ room เก่าของผู้เข้าชมคนนั้นถูกลบได้ - รุ่นของ room (generation)
g: เพิ่มขึ้นเมื่อ room ถูกลบแล้วสร้างใหม่ token จากรุ่นก่อน: /api/joinไม่ยอมรับ (ผู้เรียกได้ที่ใหม่ในคิว)/api/notifyตอบ409 stale_ticket/api/statusและ event stream ตอบ{expired:true, reason:"room_reset"}- ความยุติธรรม: มาก่อนได้ก่อน (FIFO) ตามหมายเลข ticket อย่างเคร่งครัด กลับมาใหม่ได้ที่เดิม (token ใน cookie/localStorage คู่กับ cookie ยืนยันเจ้าของ) token หาย = ไปต่อท้ายคิว
- ต่ออายุ token (QM-391): การรออาจนานกว่าอายุ token (
TOKEN_MAX_AGE_SEC) และอายุ cookie ของคิว (QUEUE_COOKIE_MAX_AGE_SEC) โดยเฉพาะ pre-queue ที่opensAtอีกหลายวัน - เมื่อ visitor token ที่ส่งมามีอายุถึงครึ่งหนึ่งของค่าที่สั้นกว่าในสองค่านี้
/api/statusและ event stream จะลงลายเซ็นใหม่ให้ ที่เดิม ({r, n|q, g}กับiatใหม่ ลงลายเซ็นด้วยชุด key ที่ใช้อยู่ตอนนั้น v2 และkidจึงเป็นไปตามTOKEN_SIGN_V2) และตั้งทั้งqm_<roomId>และqms_<roomId>ใหม่ (session id เดิม) - ทำเฉพาะเมื่อครบทุกข้อ: room ยังมีอยู่และ
gเป็นรุ่นปัจจุบันของมัน; ที่นั้นยังรออยู่ (สถานะไม่ใช่expiredและไม่ใช่passedที่ใน pre-queue ก็นับ); และqms_<roomId>ของ request พิสูจน์ความเป็นเจ้าของที่นั้นใน engine ได้ fingerprint ตรงกันอย่างเดียวไม่มีสิทธิ์ต่ออายุ /api/statusคืน token ใหม่เป็นtokenstream ที่เปิดด้วย token ที่ถึงเวลาต่ออายุ จะส่งtokenมาในทุก frame และตั้ง cookie ใน response ของมัน stream ที่เปิดอยู่แล้ว ถ้า token ถึงเวลาต่ออายุ จะถูกปิด เพื่อให้ browser ต่อใหม่และได้ token ใหม่- หน้ารอเก็บ
tokenเฉพาะเมื่อมันระบุที่เดียวกับที่ถืออยู่แล้ว - token ที่เกิน
TOKEN_MAX_AGE_SECไปแล้วยังถูกปฏิเสธเหมือนเดิม การต่ออายุชุบชีวิตมันไม่ได้ - การปล่อยคนออก (outflow): engine ย้ายคนจากรอ→ผ่าน
ratePerMinuteคนต่อนาที อย่างสม่ำเสมอ (แบ่งเป็นช่วงวินาที ไม่ปล่อยทีละก้อน) ticket ที่ผ่านแล้วใช้เข้าเว็บปลายทางได้ภายในpassedTtlSec(default 600) - เปิดขายตามเวลา (scheduled drop): room ตั้ง
opensAtได้ - ก่อนเวลานั้นยังไม่มีคิวเลย คนที่เข้ามาจะถูกเก็บไว้ใน pre-queue (คิวก่อนเปิด) และได้หมายเลขอ้างอิง ที่ไม่บอกความหมาย ไม่ใช่หมายเลข ticket
- เมื่อถึง
opensAtpre-queue ถูกจัดเป็นลำดับ FIFO ด้วยการสลับลำดับแบบสุ่มระดับ crypto เวียนไปทีละผู้ใช้ (round-robin) เพื่อไม่ให้ที่ที่สองของผู้ใช้คนหนึ่งได้ลำดับก่อนที่แรกของผู้ใช้คนอื่น - ลำดับที่สุ่มได้ถูกบันทึกไว้ ถ้า restart คร่อมเวลาเปิด ระบบจะใช้ลำดับเดิมซ้ำ ไม่สุ่มใหม่
preQueueMaxPerIp(default 16,null= ไม่จำกัด, 1..100000) จำกัดจำนวนที่ต่อผู้ใช้ การ join ที่เกินได้429 prequeue_identity_limitโดยไม่ได้ token หมายเลขอ้างอิง หรือ cookie กลับไป pre-queue ที่เต็มเพดานได้503 prequeue_full- กฎที่ต้องเป็นจริงเสมอ: ลำดับที่มาถึงใน pre-queue ต้องไม่สัมพันธ์กับลำดับที่สุ่มได้ มาก่อนไม่ได้เปรียบอะไร
- Ticket ผี (ghost tickets) (QM-345): pass จะจองช่อง
maxConcurrentให้เฉพาะเจ้าของที่ระบบเห็นตัว (join,/api/statusหรือ heartbeat ของ/events) ภายในpresenceSec(default 30,null= ปิดกฎนี้) - ticket ของเจ้าของที่ไม่อยู่ ยังถูกปล่อยตามลำดับ FIFO แต่ pass ของมันไม่มีช่อง (เป็น pass ที่เลยช่วงรับสิทธิ์ แต่ยังใช้ที่ประตูได้ภายใน
passedTtlSec) และไม่นับเป็นการปล่อยคนออกตามเวลา - pass ที่เจ้าของยังไม่เคยรู้ว่าผ่านแล้ว จะคืนช่องเมื่อไม่เห็นตัวครบ
presenceSec - event
passใน log แสดง ticket เหล่านั้นในnsเพื่อให้การเล่นซ้ำ (replay) คืนค่าแบบไม่มีช่อง queueMaxPerIp(default 16,null= ไม่จำกัด, 1..100000) จำกัดจำนวนที่ในคิวที่กำลังรอต่อที่อยู่ของ client การ join ใหม่ที่เกินได้429 queue_identity_limitและไม่ได้อะไรที่ใช้ได้กลับไป- ticket ที่ไม่มีเจ้าของบันทึกไว้ (ผู้เรียกภายใน engine) ได้รับยกเว้นทั้งสองกฎ
HTTP API (contract — UI builder เขียนโค้ดตามนี้)
นโยบาย versioning
ทุก route ด้านล่างเรียกได้ที่ /api/v1/<route> (แบบหลัก ตรึงเวอร์ชันไว้) และ ที่ /api/<route> ที่ไม่มีเวอร์ชัน (ชื่อเรียกอีกชื่อที่ใช้ได้ตลอดไป ใช้ handler เดียวกัน ตอบเหมือนกันทุก byte) snippet ถูกติดตั้งบน origin ของคนอื่น ซึ่งอัปเกรดพร้อม server นี้ไม่ได้ มันจึงเรียก /api/v1/* และถ้า server ตอบ 404 กับ prefix นี้ จะถอยไปใช้ /api/* หนึ่งครั้งต่อหน้า
- การเปลี่ยนที่ทำให้ของเดิมพังได้ ออกเป็น
/api/v2/*ส่วน v1 คงรูปแบบเดิม field ที่เพิ่มแบบไม่บังคับ ยังอยู่ใน v1 ได้ - ชื่อเรียกแบบไม่มีเวอร์ชัน ไม่ได้ ถูกเลิกใช้ และจะไม่ถูกลบ มันตาม contract ใหม่ล่าสุดเสมอ การเชื่อมต่อที่ตรึงเวอร์ชันจึงห้ามใช้มัน
- ส่วนที่ไม่มีเวอร์ชัน (ไม่อยู่ใน contract):
/healthz,/w/:roomId,/snippet/*,/,/progress,/metrics,/events(ชื่อเรียกเปล่า ๆ ที่ใช้ได้ตลอดไปของ route ใน contract/api/eventsดู Visitor ด้านล่าง ที่ใส่ไว้ตรงนี้เพื่อให้ build ที่สร้างรายการ path จากหัวข้อนี้ไม่ทำมันหล่นหาย),/docs(+/docs/:nameซึ่งแสดงINTEGRATION.md,OPERATIONS.mdและไฟล์นี้ ให้คนที่เห็นแค่ server ที่รันอยู่ได้อ่าน),/docs/th(+/docs/th/:nameคู่มือชุดเดียวกัน เป็นภาษาไทยจากdocs/th/<file>) - ใช้ HTTP method ผิดกับ path ใน contract →
405+ headerAllow+{"code":"method_not_allowed"} - preflight
OPTIONSใช้การตัดสิน CORS เดียวกัน กับ method จริง ต้องไม่ประกาศ origin ที่ response จริงจะปฏิเสธ
Visitor
POST /api/join {roomId, token?}→{token, position, ahead, etaSec, state}(ตั้ง cookieqm_<roomId>ที่เก็บ token และ cookie ยืนยันเจ้าของแบบ HttpOnlyqms_<roomId>) token รุ่นเดียวกันใน body หรือ cookie ได้ที่เดิม (หมายเลขอ้างอิงใน pre-queue จะถูกแลกเป็น ticket ที่สุ่มได้เมื่อประตูเปิด) กรณีที่ถูกปฏิเสธ:429 rate_limited(JOIN_LIMIT_PER_MIN)400 invalid_room_id/invalid_token_field404 room_not_found403 blocked_by_protection(ระบบกันบอทของ room อยู่ในโหมด enforce)429 queue_identity_limit,429 prequeue_identity_limit503 prequeue_full503 storage_unavailable(บันทึกการ join ลง disk ไม่ได้ จึงไม่แจก ticket)GET /api/status?token=→{position, ahead, etaSec, state, passed, redirectUrl?}token มาจาก?token=(ไม่ใช้บน inline host) หรือ cookieqm_<roomId>ถ้าไม่มีอันไหนใช้ได้ →401 invalid_tokenGET /api/events?token=→ SSE: eventstatusที่มี JSON แบบเดียวกับ /api/status ส่งเมื่อมีการเปลี่ยนแปลง และส่ง heartbeat ทุก 5 วินาที มีเวอร์ชันเหมือน route อื่นใน contract (/api/v1/events) และ ยัง เรียกได้ที่/eventsเปล่า ๆ ซึ่งหน้ารอที่มากับระบบใช้อยู่ ชื่อนี้ใช้ได้ตลอดไป ไม่ใช่ของเก่า: หน้ารอทุกหน้าที่เปิดอยู่ใน browser ตอนนี้ถือEventSourceที่ชี้มาที่นี่ และมันต่อใหม่เองหลัง restart หรือ deploy ถ้าลบ path นี้ ผู้เข้าชมที่อยู่ในคิวตอนนั้นจะพัง ซึ่งเป็นกลุ่มคนที่ผลิตภัณฑ์นี้มีไว้เพื่อพวกเขา ขีดจำกัดของ stream:503 sse_capacityเมื่อครบSSE_MAX_TOTALstream และ429 sse_per_ip_limitเมื่อครบSSE_PER_IPต่อที่อยู่ ทั้งสองมีRetry-After: 5- เฟรม refresh (QM-469) เมื่อ token ของ stream ถึงเวลาต้อง sign ใหม่ (
TOKEN_REFRESH_AFTER_MSหลังiatของมัน คือครึ่งหนึ่งของค่าที่น้อยกว่าระหว่างTOKEN_MAX_AGE_SECกับQUEUE_COOKIE_MAX_AGE_SEC) และการ refresh นั้นจะได้รับอนุญาต server จะส่งevent: refreshที่มีข้อมูล{}แล้วปิด stream เพราะ stream ตั้ง cookie ไม่ได้หลังส่ง header ไปแล้ว หน้ารอจะเปิด stream ใหม่เองครั้งเดียว response ของ stream ใหม่จะ sign ที่ในคิวใหม่และตั้ง cookie ทั้งสองใหม่ จากนั้นเฟรมstatusจะมีtokenใหม่ติดไปด้วย การปิดจะช้ากว่าเวลาที่ถึงกำหนด ด้วย jitter ที่คงที่ต่อที่ในคิว:uint32_be(sha256("r|n|q|g|iat")[0..4]) % (floor(TOKEN_REFRESH_AFTER_MS / 10) + 1)ms คำนวณจากช่องใน payload ของ token (ช่องที่ token ไม่มี เช่นqของ ticket จะเป็นข้อความundefinedตามที่ JavaScript แสดง) จึงช้าได้ไม่เกิน +10% ของช่วงเวลานั้น และคนที่ join พร้อมกันด้วยiatเดียวกันจะไม่ต่อใหม่พร้อมกัน ระบบที่ port ไปต้องส่งเฟรมเดียวกันและใช้ jitter แบบเดียวกัน stream ที่การ refresh จะไม่ได้รับอนุญาต จะไม่ถูกปิดเพราะเรื่องนี้ GET /w/:roomId→ ส่ง waiting.html (ใส่ roomId + JSON ของแบรนด์เป็นwindow.QM_CONFIG)qm_returnที่นโยบายส่งกลับของ room ไม่อนุญาต จะถูกตัดทิ้ง ไม่ถูกใช้- style และ script หลักของหน้ารอเป็นไฟล์แยก ไม่ได้ฝังในหน้า (QM-467):
GET /assets/waiting-<hash>.cssและ/assets/waiting-<hash>.jsบน inline host อยู่ใต้/__qm/assets/…ด้วย<hash>คือ 22 ตัวแรกของ SHA-256 แบบ base64url ของไฟล์ ส่งพร้อมCache-Control: public, max-age=31536000, immutableprocess หนึ่งส่งได้เฉพาะ hash ของ build ตัวเอง ดังนั้นเมื่อมีหลาย instance หรือระหว่าง rolling deploy ทุก instance ต้องส่งได้ทุก hash ที่ instance ใดก็ตามยังแจกอยู่ ดู OPERATIONS.md หัวข้อ Waiting-page assets and live streams across a deploy - ผู้เข้าชมที่มาพร้อม token ที่ไม่เป็นที่ในคิวแล้ว จะได้
lapsedใน config นั้น:"expired"เมื่อช่วงเวลาเข้าของเขาเองหมด,"reset"เมื่อเป็นเพราะฝั่งเรา (eject, purge, รีเซ็ต room) และไม่มีค่านี้เมื่อไม่มีอะไรหาย ทั้งสอง deployment คำนวณค่านี้จากคำที่ engine ใช้บอกเหตุที่ปฏิเสธ และหน้ารอมีประโยคสำหรับแต่ละค่าในทุกภาษา ถ้าไม่มีค่านี้ ผู้เข้าชมจะได้ยินว่า ticket "could not be verified" ซึ่งบอกความผิดพลาดที่ไม่ได้เกิดขึ้น token ที่รู้ว่าหมดสภาพแล้ว จะถูกถือเหมือนไม่มี token ด้วย ระบบจึงออกที่ใหม่ที่หน้ารอสัญญาไว้ได้จริง รวมถึงผู้เข้าชมที่ไม่มี JavaScript ซึ่งไม่มีทางอื่นกู้คืน POST /api/notify {token?, email?}→ ลงทะเบียนแจ้งเตือนเมื่อถึงคิว (หรือถอนออก ถ้าemailว่างหรือไม่ได้ส่ง) ticket มาจาก token ใน body หรือ cookie ของคิว ไม่เคยมาจาก URL- การแจ้งเตือนเมื่อถึงคิวผูกกับ ticket ดังนั้นทุกครั้งที่ server เปลี่ยน ticket ให้ใหม่ มันจะย้ายข้อมูลไปด้วย ได้แก่ตอนสุ่มลำดับ pre-queue และตอน rejoin ด้วย ticket ที่ engine ไม่ยอมรับ client ส่ง token ที่ใช้ไม่ได้แล้วมาตอน rejoin ก็เพื่อเรื่องนี้โดยเฉพาะ ส่วน purge หรือ eject จะลบข้อมูลนี้ทิ้ง เพราะ operator ทิ้งที่ในคิวนั้นแล้ว
- ด้วยเหตุนี้
POST /api/joinจึงตอบnotifyด้วย เป็น{emailMasked, requestedAt, delivery}หรือnullบอกว่ามีอะไรผูกไว้กับที่ที่มันเพิ่งระบุ ถ้าEMAIL_NOTIFYปิดอยู่ จะไม่มี field นี้เลย หน้ารออ่าน "ไม่มี field" ว่า "ไม่มีการบอก" และอ่านnullว่า "ไม่ได้เก็บอะไรไว้" และเลิกบอกว่าบันทึกแล้วในเมื่อ server ไม่มีข้อมูลนั้น - ก่อนถึง
opensAt/api/joinและ/api/statusตอบ{state:"scheduled", scheduled:true, preQueued:true, preCount, opensAt, now}โดยpositionและaheadเป็น null และ token มีหมายเลขอ้างอิง pre-queueqแทน ticketn - error ทุกตัวฝั่งผู้เข้าชมเป็น
{error, code}ให้แยกกรณีด้วยcodeรายการเต็มของฝั่งผู้เข้าชม อยู่ใน INTEGRATION.md หัวข้อ Error codes - frame ไหนที่มีเวลาของ server ต้องมี
now(เวลา server เป็น epoch ms) คู่มาด้วย เช่นopensAtของการเปิดขายตามเวลา และmessageAtเมื่อ operator ตั้งข้อความ เวลาของ server เป็นเวลาเดียวที่ส่งถึงผู้เข้าชมโดยไม่อิงนาฬิกาของเครื่อง และหน้ารอแสดงทุกเวลาที่มันโชว์ ตามนาฬิกาของ เครื่องผู้เข้าชม (instant - (now - clientNow)) เพราะผู้เข้าชมเทียบกับนาฬิกานั้น ถ้าไม่มีnowเครื่องที่นาฬิกาเพี้ยนจะเห็นการนับถอยหลังที่แก้ความเพี้ยนแล้ว คู่กับ "doors open at" ที่ยังไม่แก้ และสองค่านี้ไม่ตรงกัน frame ที่ไม่มีเวลาของ server ไม่ต้องมีnow
Edge check (ใช้โดย snippet)
GET /api/check?roomId=&token=&url=→{action: "pass"|"queue", reason?, waitingRoomUrl?, clearToken?, sess?, targeting?, …}ได้ pass เมื่อ room เป็น bypass/ไม่ active หรือ ticket ของ token ผ่านแล้วและยังไม่หมดอายุ room ที่ไม่รู้จักได้200 {action:"pass", reason:"no_room"}(ปล่อยผ่านเมื่อพัง) และถูกนับให้ operator เห็นใน/api/admin/healthและ/metricsคำตอบpassที่ปล่อยใครเข้า จะตอบก็ต่อเมื่อ บันทึกการปล่อยลง disk แล้ว ถ้าบันทึกไม่ได้ได้503 storage_unavailableและการปล่อยนั้นถูกยกเลิก เพื่อให้ลองใหม่แล้วได้เข้า- กุญแจ door-session
sess: ได้มากับpassที่เปิด (หรือเอาคืน) door session และต้องส่งกลับมา ทุกครั้งที่ตรวจครั้งต่อไป ทางใดทางหนึ่ง: - header
X-QM-Session(snippet ใช้วิธีนี้ เพราะ header ไม่ทำให้ credential ไปอยู่ใน URL และ log) ?sess=- บน inline host ใช้ cookie แบบ HttpOnly
qm_dk_<roomId>
clearToken: true แปลว่าให้ลบ token และกุญแจที่เก็บไว้ก่อน redirect เพราะ body อาจมี sess บน room ที่มีนโยบาย origin /api/check (และ OPTIONS ของมัน) จะส่ง Access-Control-Allow-Origin ให้เฉพาะ origin ของ browser ที่อยู่ใน tag origins ของ room (tagOrigins ถ้าไม่มีใช้ returnOrigins บวก origin ของ targetUrl) ดู INTEGRATION.md หัวข้อ The door-session key
?ip=&agent=คือการรับรองตัวผู้เข้าชมจริง (connector ฝั่ง server และ edge) และต้องมีAuthorization: Bearer <PUBLIC_API_KEY>(หรือADMIN_KEY) ถ้าไม่มีได้401 unauthorizedหรือ401 credential_in_queryเมื่อส่ง key มาเป็น?key=การตรวจแบบ vouched ถูกจำกัดอัตรา ต่อที่อยู่ที่รับรองไว้ ไม่ใช่ต่อ colo ที่เรียกมาurl=คือหน้าที่กำลังเปิด มันตัดสิน ขอบเขต: หน้านี้เป็นของ room นี้หรือไม่ ถ้าไม่มี ใช้ headerRefererแทน ถ้าไม่มีทั้งคู่ ผลการตรวจเป็นunknownและ ส่งเข้าคิวไว้ก่อน (ไม่มีทางปล่อยให้ไม่มีการคุ้มกันแบบเงียบ ๆ)?targeting=1ใส่รายการกฎทั้งหมดของ room ใน response- หน้าที่กฎของ room ไม่รวมไว้ ตอบ
{action:"pass", reason:"out_of_scope"}โดยไม่ออกที่ในคิว targetingมีเฉพาะ room ที่มีขอบเขต:{rev, rules, scope:"in"|"out"|"unknown", rule, action}room ที่ไม่มีกฎจะไม่มี key นี้เลย และทำงานเหมือนก่อนมีการกำหนดเป้าหมาย URL ทุกอย่าง
Targeting (public ใช้โดย edge connector)
GET /api/targeting?roomId=→{roomId, rev, scoped, updatedAt, patterns:[{pattern, action}]}ขอบเขต URL ของ room เพื่อให้ connector แบบ Worker/Lambda/nginx ดึงกฎตอนรัน แทนการเขียนกฎตายตัวไว้ มีETag+304จึงเรียกถามบ่อย ๆ ได้โดยใช้ทรัพยากรน้อย
Install check และ connector registry (public)
GET /api/verify?roomId=&url=&origin=&connector=&version=→{ok, checks:[{id, status:"pass"|"fail"|"warn"|"info", title, detail?, fix?}], …}การตรวจก่อน deploy ที่ CLI หรือหน้าตั้งค่าของ connector ทุกตัวรันoriginถ้าไม่ส่ง ใช้ origin ของurlถ้าไม่มีอีก ใช้ headerOriginของ request- ส่ง Public API key แบบ
Bearerแล้วรายงานจะละเอียดขึ้น (ตรวจ key ด้วย) key ที่ส่งมาใน?key=จะถูกรายงานเป็นข้อที่ไม่ผ่าน - ไม่มี
roomId→400 invalid_room_id - การตรวจถูกบันทึกไว้ให้รายการ "connectors that checked in" ใน console ดู INTEGRATION.md หัวข้อ Installable connectors
GET /api/verify.gif?roomId=&origin=&connector=→ ผลเดียวกันแต่บอกเป็น status code สำหรับ sandbox ของ GTM ที่รู้ได้แค่ว่าภาพโหลดสำเร็จหรือไม่:200 image/gifเมื่อokไม่อย่างนั้น424 {ok:false, error:"verify_failed", checks}X-QM-Verifyบอก id ของข้อที่ไม่ผ่านCache-Control: no-storeGET /api/connectors→ registry: เวอร์ชันของ connector แต่ละตัว คำสั่ง install/upgrade/verify URL ดาวน์โหลด และ SHA-256 สำหรับตรวจความถูกต้อง ตามที่ server นี้ ให้บริการGET /api/connectors/wordpress/update→ ข้อมูลเดียวกันในรูปแบบที่ตัวอัปเดต plugin ของ WordPress ใช้ ตัวไฟล์อยู่ใต้/connectors/<name>/latest.{js,tgz,zip}(ไม่มีเวอร์ชัน)/api/verify,/api/verify.gifและ/api/connectorsใช้โควตาCHECK_LIMIT_PER_MINร่วมกัน (429 rate_limited)
Health (public ไม่ต้อง authenticate)
GET /healthz,GET /api/health→ ข้อมูลความพร้อม ตอบ503เมื่อ instance ไม่ready(เช่น บันทึกการ join ไม่ได้) นอกนั้น200GET /api/version→ ข้อมูลเดียวกัน ตอบ200เสมอ ไม่ถูกตัดทิ้งเมื่อ connection เต็มเพดาน ดู OPERATIONS.md หัวข้อ Readiness
Admin (ทั้งหมดอยู่ใต้ /api/admin/*; Bearer หรือ console session cookie)
POST /api/admin/session {key}พร้อม headerX-QM-CSRF: 1→ ลงชื่อเข้า console: ตั้งqm_console(HttpOnly,SameSite=Strict,Path=/api/admin, 12 ชม.) ซึ่งเป็นตัวอ้างอิง session ที่ลงลายเซ็นแล้วและไม่มี key อยู่ข้างใน ตอบ{ok, name, role, expiresInSec}DELETE /api/admin/session(header เดียวกัน) ลบ cookie และเพิกถอน session ฝั่ง server request ที่มี cookie แต่ไม่มี headerAuthorizationต้องส่งX-QM-CSRFในทุก method ที่ไม่ใช่GETไม่อย่างนั้นได้403 csrf_requiredrequest ที่มีAuthorizationถูกตัดสินจาก header นั้นอย่างเดียว ดู OPERATIONS.md หัวข้อ Two keys, two blast radii- สิทธิ์:
| ผู้ใช้ | ทำอะไรได้ |
|---|---|
viewer และ monitor token | อ่าน (rooms, events, metrics, health, security, reports, สถานะ alert, audit) แต่ monitor token อ่าน audit, visitors และ targeting ไม่ได้ |
operator | ทุกอย่างของ viewer + เปลี่ยนสถานะคิวและการตั้งค่า room |
owner/ADMIN_KEY | ทุกอย่างของ operator + จัดการ operator, ลบ room และสั่งหยุด server |
GET /api/admin/operators,POST /api/admin/operators {name, role}→ คืน key ครั้งเดียวDELETE /api/admin/operators/:idเพิกถอน ไม่มี route ไหนเปลี่ยน role ของ operator ที่มีอยู่แล้วGET /api/admin/audit→ ใครทำอะไร เมื่อไร จากที่ไหนGET /api/admin/security→ คะแนนการกันบอท และการปฏิเสธGET /api/admin/reports?roomId=&bucket=hour|day→ ข้อมูลย้อนหลัง (มี CSV ให้ใช้กับ BI)GET /api/admin/alerts→ สถานะ alertPOST /api/admin/alertsส่งการแจ้งเตือนทดสอบ (400 alerts_disabledถ้าไม่มีALERT_WEBHOOK_URL,502 webhook_failedถ้า webhook ปฏิเสธ)POST /api/admin/shutdown→{ok, stopping, uptimeSec}แล้วหยุดอย่างเรียบร้อย (วิธีหยุดที่เขียนไว้ในเอกสารสำหรับ Windows) เฉพาะownerPOST /api/admin/sse-ticket→ ticket ใช้ครั้งเดียว อายุ 30 วินาที สำหรับGET /api/admin/events?ticket=GET /api/admin/rooms→ รายการ room พร้อมสถิติสด{waiting, passedLastMin, rate, state, oldestWaitSec}estWaitSecคือเวลารอของผู้เข้าชมที่เข้าคิว ตอนนี้ และเป็นตัวเลขที่ room ยืนยันได้จริงเท่านั้น- room ที่ไม่ได้ปล่อยใครเลย (paused,
ratePerMinute: 0หรืออัตราปล่อยที่วัดได้เป็นศูนย์) รายงานnull(ไม่มีขอบเขต) ไม่ว่าจะมีคนรออยู่หรือไม่ - room ที่ประตูยังปิด รายงานเวลาที่เหลือก่อนเปิด บวกเวลาปล่อยคนที่ถือ ticket อยู่แล้ว ซึ่งเป็นผลรวมเดียวกับที่
/api/statusบอกผู้เข้าชมที่กำลังรอ พร้อมestWaitFloor: trueการสุ่มลำดับเป็นตัวตัดสินว่าที่ใน pre-queue ต้องรอนานกว่านั้นเท่าไร จึงไม่บวกอะไรเพิ่มให้ 0แปลว่า room จะปล่อยเข้าทันทีPOST /api/admin/roomsสร้าง/แก้ room field ที่เขียนได้มีเท่านี้พอดี:{id, name, targetUrl, proxyOrigin?, ratePerMinute, maxConcurrent?, passedTtlSec?, sessionTtlSec?, sessionMaxSec?, branding?, returnOrigins?, tagOrigins?, autotune?, protection?, autoActivate?, urlPatterns?, opensAt?, preQueueMaxPerIp?, queueMaxPerIp?, presenceSec?, state?, onBackendDown?}- เก้าตัวที่ไม่ได้ชัดเจนในตัว อธิบายไว้เพราะแต่ละตัวคือความสามารถทั้งชุดที่เข้าถึงได้ผ่าน body นี้เท่านั้น:
proxyOrigin: โหมด inline (ที่อยู่ภายในที่ server นี้ส่งผู้เข้าชมที่ผ่านแล้วต่อไป)sessionTtlSec/sessionMaxSec: ขีดจำกัดของ door sessionautotune: ตัวปรับอัตราปล่อยคนอัตโนมัติprotection: โหมดให้คะแนนบอท ({mode:"off"|"monitor"|"enforce"})autoActivate: เปิดใช้คิวอัตโนมัติเมื่อถึงเกณฑ์opensAtและpreQueueMaxPerIp: การเปิดขายตามเวลา และเพดานต่อผู้ใช้ของ pre-queuequeueMaxPerIpและpresenceSec: เพดานต่อที่อยู่ของคิวที่กำลังรอ และช่วงเวลายืนยันว่ายังอยู่ (ดู แนวคิดหลัก, Ghost tickets)state: ตั้ง active/paused/bypass ในการเขียนครั้งเดียวกับค่าอื่นonBackendDown("closed"ซึ่งเป็น default หรือ"open") คือสิ่งที่ gate ของ room แบบ inline ทำระหว่างที่ backend ล่ม (ติดต่อ Valkey ไม่ได้ หรือ room กำลังถูกสร้างใหม่จาก journal):closedตอบ browser navigation ด้วยหน้ารอใน retry mode (503+Retry-Afterไม่มีการรับคิว) และตอบ request อื่นด้วย503 storage_unavailableส่วนopenส่งต่อไปที่proxyOriginโดยไม่ผ่านคิว ค่าอื่นได้400 invalid_on_backend_downเมื่อใช้STORE=memoryค่านี้ถูกเก็บไว้ แต่ไม่มีผล เพราะไม่มี backend ให้หายredacted: trueถูกปฏิเสธด้วย400 redacted_shapeแทนที่จะถือเป็น field ที่ไม่รู้จัก room ที่อ่านผ่านลิงก์ monitor ที่แชร์ จะกลับมาโดยลบที่อยู่ภายใน รายชื่อ origin ที่อนุญาต และกฎ URL ออก ถ้าส่ง body นั้นกลับไปตรง ๆ ค่าทั้งหมดนั้นจะถูกล้าง error นี้จึงบอกเรื่องนั้นแทนการบอกชื่อ field- รับ
rateเป็นชื่อเรียกอีกชื่อของratePerMinuteเพราะGET /api/admin/roomsเรียก field เดียวกันนี้ว่าrateการอ่าน-แก้-เขียนกลับจึงไม่ต้องเปลี่ยนชื่อ ส่งทั้งสองได้ถ้าค่าตรงกัน ถ้าส่งทั้งสองแต่ค่าต่างกัน ได้400 conflicting_fieldชื่อ field ใดที่ไม่อยู่ในรายการได้400 unknown_fieldไม่ถูกข้ามแบบเงียบ ๆ เมื่อก่อนการพิมพ์ผิดตรงนี้ดูเหมือนแก้สำเร็จทุกอย่าง แต่ไม่มีอะไรเปลี่ยน returnOriginsคือที่ที่ผู้เข้าชมที่ถึงคิวแล้ว ถูกส่งไป ได้tagOriginsคือหน้าที่ อ่าน ผลการตรวจที่ประตูได้ (รายชื่อ CORS ของ browser) origin ของtargetUrlของ room อยู่ในทั้งสองเสมอtagOrigins: null(default) ใช้ตามreturnOriginsroom ที่ตั้งไว้ก่อนแยกสองค่านี้จึงเหมือนเดิม ต้องแยกกันเพราะผิดแล้วพังคนละทาง: นโยบายส่งกลับที่ผิดคือ open redirect ส่วน tag origin ที่ขาดไป ทำให้หน้านั้นเปิดโดยไม่ต้องต่อคิวเลยแบบเงียบ ๆurlPatternsคือขอบเขตของ room เป็นการตั้งค่าที่เปลี่ยนได้ขณะระบบทำงาน: ไม่เกิน 25 กฎ กฎละไม่เกิน 300 ตัวอักษร และ wildcard*ไม่เกิน 10 ตัว กฎขึ้นต้นด้วย/(path),*(ที่ไหนก็ได้) หรือhttps://host/pathเต็ม (ระบุ origin)!ข้างหน้าทำให้เป็นข้อยกเว้น ตัวพิมพ์เล็กใหญ่ถือว่าเหมือนกัน กฎที่ไม่มี?เทียบกับ path อย่างเดียว ข้อยกเว้นชนะเสมอ ไม่ว่าจะเรียงไว้ลำดับไหน กฎที่ผลขึ้นกับลำดับทำให้ operator พลาดง่าย ไม่ใช่ข้อดี[]แปลว่าไม่มีขอบเขต: ทุกหน้าที่มี tag ต้องต่อคิวbranding.messageคือข้อความของ operator ถึงผู้เข้าชมที่รออยู่ server ประทับเวลาbranding.messageAtเมื่อข้อความเปลี่ยน และหน้ารอแสดงข้อความในกรอบที่มีเวลากำกับ ใน ทุก สถานะ รวมถึงตอนนับถอยหลังของการเปิดขายตามเวลาDELETE /api/admin/rooms/:idPOST /api/admin/rooms/:id/state {state}(active/paused/bypass)POST /api/admin/rooms/:id/rate {ratePerMinute}(รับrateเป็นชื่อเรียกอีกชื่อ กฎเดียวกับข้างบน) มีผลทันทีPOST /api/admin/rooms/:id/flushปล่อย N คนเดี๋ยวนี้{count}POST /api/admin/rooms/:id/schedule {opensAt?, preQueueMaxPerIp?}ตั้ง/ล้างเวลาเปิดประตู ใช้เฉพาะ field ที่ส่งมา: ส่งpreQueueMaxPerIpอย่างเดียวคือปรับเพดาน และการเปิดขายยังตั้งเวลาไว้เหมือนเดิม ยกเลิกการเปิดขายต้องส่ง{"opensAt": null}อย่างชัดเจน body ที่ไม่มีทั้งสอง field ได้400 nothing_to_changeไม่ถูกข้ามแบบเงียบ ๆPOST /api/admin/rooms/:id/eject {ticket}เพิกถอน pass ปิด session ที่เปิดอยู่ แล้วส่งไปท้ายคิวPOST /api/admin/rooms/:id/purgeทิ้งคนที่รออยู่ทั้งหมด โดยไม่ปล่อยใครเข้า- ใช้กับ room ที่งานจบไปแล้ว: ระบบเก็บคิวไว้ข้ามทุกการ restart และเมื่อก่อนทางออกเดียวคือลบ room
- ไม่ออก pass และไม่บันทึกการปล่อยคน กราฟจึงไม่มียอดพุ่งที่ไม่ได้เกิดขึ้นจริง
- ticket ที่ถูกทิ้งอ่านได้เป็น
expiredส่วน room การตั้งค่า และประวัติยังอยู่ - pre-queue ของ room ที่ตั้งเวลาไว้ถูกล้างด้วย
- ตอบ
{purged, stats}เมื่อบันทึกการทิ้งลง disk แล้ว (503 storage_unavailableถ้าบันทึกไม่ได้)purgedนับรวมที่ใน pre-queue ซึ่งแจ้งแยกไว้เป็นpreQueueด้วย GET /api/admin/rooms/:id/visitors?ticket=หรือ?ref=→ ค้นผู้เข้าชมรายคนสำหรับงาน support (สถานะ ลำดับ อายุต่าง ๆ session การผูก เป็น digest ทางเดียวเท่านั้น ไม่มีตัวระบุดิบ)ref=แปลงรหัสสั้นที่หน้ารอแสดงให้ผู้เข้าชมเห็น ข้อความที่เขาอ่านให้ฟังทางโทรศัพท์จึงเป็นข้อความเดียวกับที่ใส่ในช่องค้น ถ้ารหัสตรงกับหลาย ticket จะคืนรายการ ticket ที่เป็นไปได้ ไม่เดาGET /api/admin/rooms/:id/targeting?url=→ กฎของ room พร้อม{matchCount, lastMatchedAt}ต่อกฎ และจำนวนการตรวจที่ไม่ตรงกฎไหน กับการตรวจที่มาโดยไม่มี URLurl=(ไม่บังคับ) คือ การลองดู (dry run): กฎไหนจะชนะ โดยไม่แตะตัวนับ กฎที่ไม่เคยตรงเลยคืออาการที่มองเห็นได้ของ room ที่ชี้ไปผิดหน้าGET /api/admin/events→ SSE สถิติทุก 2 วินาทีของทุก room{rooms:[...], totals, sparkline data}- เฟรม delta (QM-468) ส่งเฟรมเต็ม (ไม่มีคีย์
delta) ตอนต่อ และอีกครั้งใน tick ถัดไป หลังจากนั้น แต่ละ tick เป็น{delta:true, rooms:[room ที่เปลี่ยน], removed:[ids], totals, …}คือเฉพาะ room ที่สถิติเปลี่ยนตั้งแต่ tick ก่อน กับ id ของ room ที่หายไป และคีย์อื่นเหมือนเฟรมเต็ม room ที่เปลี่ยน แทนที่ของเดิม room ใหม่ต่อท้าย (ลำดับของ server คือลำดับที่สร้าง) room ที่ถูกลบก็เอาออก tick ที่ลำดับ room เปลี่ยนจะส่งเป็นเฟรมเต็ม (QM-480) client ที่ได้ delta โดยยังไม่มีเฟรมเต็มอยู่ก่อน ต้องต่อใหม่ นั่นคือวิธี resync GET /api/admin/metrics?roomId=&range=→ ข้อมูลตามเวลา (joins/min, passes/min, ความยาวคิว) สำหรับกราฟGET /api/admin/health→ การตั้งค่าที่มีผลจริง สถานะ storage คำเตือนเรื่องตั้งค่าผิดPOST /api/admin/monitor-token→ ออก credential monitor แบบอ่านอย่างเดียว (ดูได้อย่างเดียว ไม่ใช่ADMIN_KEYเด็ดขาด)GETแสดงลิงก์แชร์ที่ยังใช้ได้ (เป็นการอ่าน แต่ monitor token เองเรียกไม่ได้)DELETE ?id=เพิกถอนหนึ่งลิงก์ (?all=1ทั้งหมด)GET /metrics→ ข้อมูลแบบ Prometheus ต้องมี credential สำหรับอ่าน (monitor token,viewerขึ้นไป เพราะ room id และความยาวคิวเป็นข้อมูลลับทางธุรกิจ)GET /→ admin.htmlGET /progress→ PROGRESS.htmlGET /docs→ คู่มือ
เกณฑ์คุณภาพ (สิ่งที่ critic ใช้ตัดสิน)
- หน้ารอ: สงบ มีแบรนด์ ลำดับแบบสด + เวลารอที่ตรงไปตรงมา แถบความคืบหน้า ใช้ได้กับทุกคน (ลดการเคลื่อนไหว, aria-live) ใช้บนมือถือได้ refresh หรือปิดแท็บแล้วไม่หาย redirect เองเมื่อผ่าน หน้าไม่กระตุก รับมือตอน offline/ต่อใหม่ได้
- Dashboard: กราฟความยาวคิวแบบสด ปุ่มคุม room แต่ละห้องมีผลทันที เห็นคนแห่เข้ามาได้ในแวบเดียว ใช้ด้วยคีย์บอร์ดได้ ใช้ธีมมืดได้ ไม่มีข้อมูลปลอม
- Engine: join พร้อมกัน 10k ครั้งต้องไม่ล่มและไม่สลับลำดับ ต้องเป็น FIFO เสมอ เปลี่ยนอัตรากลางช่วงคนแห่ ต้องมีผล restart กลางช่วงคนแห่ต้องไม่มีอะไรหาย (
STORE=valkey) - ทุกส่วน: ไม่มี error ใน console ไม่มีปุ่มที่กดแล้วไม่ทำอะไร ไม่มีข้อความ lorem ipsum