Request Pipeline
Middleware & Sessions แสดง sequence() กับ context.locals ไปแล้ว บทเรียนนี้พิสูจน์ pipeline ทั้งหมด ตั้งแต่ต้นจนจบ ด้วย log จริงจาก build ที่รันอยู่จริง: middleware สองตัว, page หนึ่งตัว, endpoint หนึ่งตัว, route ที่ชนกันสามตัว, การเรียก Astro.rewrite() และ error ที่ throw ออกมา — log จากข้างในทั้งหมด curl จากข้างนอกทั้งหมด
import { defineMiddleware, sequence } from 'astro:middleware';
const logStep = defineMiddleware(async (context, next) => { console.log(`[pipeline] middleware A: before next() for ${context.url.pathname}`); const response = await next(); console.log(`[pipeline] middleware A: after next() for ${context.url.pathname}, status=${response.status}`); return response;});
const logStep2 = defineMiddleware(async (context, next) => { console.log(`[pipeline] middleware B: before next() for ${context.url.pathname}`); const response = await next(); console.log(`[pipeline] middleware B: after next() for ${context.url.pathname}`); return response;});
export const onRequest = sequence(logStep, logStep2);route สามตัวที่สร้างมาให้ชนกันโดยเฉพาะ แต่ละตัว log เมื่อตัวเองเป็นตัวที่ match:
---export const prerender = false;console.log('[pipeline] STATIC route matched: /probe-req/collide/static');---<p>static</p>---export const prerender = false;console.log('[pipeline] DYNAMIC [id] route matched:', Astro.params.id);---<p>dynamic {Astro.params.id}</p>---export const prerender = false;console.log('[pipeline] REST [...rest] route matched:', Astro.params.rest);---<p>rest {Astro.params.rest}</p>build ด้วย @astrojs/node โหมด standalone แล้วรันจริง (node dist/server/entry.mjs) แล้วยิงด้วย curl จริง:
$ curl -s http://127.0.0.1:5004/probe-req/collide/static -w "\nHTTP %{http_code}\n"<!DOCTYPE html><p>static</p>HTTP 200
$ curl -s http://127.0.0.1:5004/probe-req/collide/somethingelse -w "\nHTTP %{http_code}\n"<!DOCTYPE html><p>dynamic somethingelse</p>HTTP 200
$ curl -s http://127.0.0.1:5004/probe-req/collide/a/b -w "\nHTTP %{http_code}\n"<!DOCTYPE html><p>rest a/b</p>HTTP 200และ server log จริงของสาม request นั้น ตามลำดับ:
[pipeline] middleware A: before next() for /probe-req/collide/static[pipeline] middleware B: before next() for /probe-req/collide/static[pipeline] STATIC route matched: /probe-req/collide/static[pipeline] middleware B: after next() for /probe-req/collide/static[pipeline] middleware A: after next() for /probe-req/collide/static, status=200[pipeline] middleware A: before next() for /probe-req/collide/somethingelse[pipeline] middleware B: before next() for /probe-req/collide/somethingelse[pipeline] DYNAMIC [id] route matched: somethingelse[pipeline] middleware B: after next() for /probe-req/collide/somethingelse[pipeline] middleware A: after next() for /probe-req/collide/somethingelse, status=200[pipeline] middleware A: before next() for /probe-req/collide/a/b[pipeline] middleware B: before next() for /probe-req/collide/a/b[pipeline] REST [...rest] route matched: a/b[pipeline] middleware B: after next() for /probe-req/collide/a/b[pipeline] middleware A: after next() for /probe-req/collide/a/b, status=200(Real, captured from a single node dist/server/entry.mjs run with @astrojs/node’s standalone mode — the same run this lesson’s Astro.rewrite() and error-handling logs below come from too, hit with six real curl requests in one sequence, in the order they appear across this lesson.)
สองข้อเท็จจริง ที่พิสูจน์ตรง ๆ:
sequence(A, B)ซ้อนกัน ไม่ได้ต่อกันแบบเรียง. ทุก request logA before→B before→ (route handler) →B after→A after—Aห่อBไว้ ตามลำดับเป๊ะ ๆ ที่ระบุในsequence(...)middleware ที่อยู่ก่อนในsequence()ได้รันโค้ดทั้งก่อนและหลังทุก middleware หลังจากมัน ไม่ใช่แค่ “ก่อน”- route priority คือ static > dynamic > rest พิสูจน์ด้วยการชนกันจริง:
/collide/staticmatch static file แม้[id].astroจะ match ได้เหมือนกัน;/collide/somethingelse(segment เดียว ไม่มี static file สำหรับมัน) ตกไปที่[id].astro;/collide/a/b(สอง segment —[id]match สอง segment ไม่ได้เลย) ตกไปถึง[...rest].astroนี่คือ ordering เป๊ะ ๆ ที่ Dynamic Routes ระบุไว้ — พิสูจน์ที่นี่ด้วยไฟล์สามตัวที่แข่งกัน request เดียวกันจริง
sequenceDiagram participant Client participant Adapter as "@astrojs/node handler" participant MW as middleware sequence(A, B) participant Router as route matching participant Handler as page / endpoint / action Client->>Adapter: HTTP request Adapter->>MW: build context, call onRequest MW->>MW: A before -> B before MW->>Router: next() reaches routing Router->>Router: static > dynamic > rest priority Router->>Handler: matched route's module Handler-->>MW: render result / Response MW->>MW: B after -> A after MW-->>Adapter: final Response Adapter-->>Client: HTTP response
Astro.rewrite(): ผ่าน pipeline ซ้อนกันเต็มรูปแบบ
หัวข้อที่มีชื่อว่า “Astro.rewrite(): ผ่าน pipeline ซ้อนกันเต็มรูปแบบ”การเรียก Astro.rewrite('/probe-req/reqpage/') จากข้างใน frontmatter ของ page ไม่ได้แค่สลับ HTML — มันเข้า routing ใหม่และ รัน middleware chain ทั้งหมดซ้ำ แบบซ้อนกัน สำหรับ URL ใหม่ พิสูจน์ด้วย request จริงไปยัง page ที่ทำแบบนี้เป๊ะ:
---export const prerender = false;console.log('[pipeline] rewrite-source-req: about to call Astro.rewrite');return Astro.rewrite('/probe-req/reqpage/');---$ curl -s http://127.0.0.1:5004/probe-req/rewrite-source -w "\nHTTP %{http_code}\n"<!DOCTYPE html><html><body><h1>req page</h1></body></html>HTTP 200server log จริงของ request นั้นตัวเดียว:
[pipeline] middleware A: before next() for /probe-req/rewrite-source[pipeline] middleware B: before next() for /probe-req/rewrite-source[pipeline] rewrite-source-req: about to call Astro.rewrite[pipeline] middleware A: before next() for /probe-req/reqpage/[pipeline] middleware B: before next() for /probe-req/reqpage/[pipeline] page component: rendering /probe-req/reqpage/[pipeline] middleware B: after next() for /probe-req/reqpage/[pipeline] middleware A: after next() for /probe-req/reqpage/, status=200[pipeline] middleware B: after next() for /probe-req/rewrite-source[pipeline] middleware A: after next() for /probe-req/rewrite-source, status=200อ่าน section กลางอีกที: middleware A/B: before next() สำหรับ /probe-req/reqpage/ ปรากฏ ซ้อนอยู่ข้างใน call A/B ของ request เดิมที่ยังเปิดอยู่ — request เดิมไม่จบ next() ของตัวเองจนกว่า pipeline ที่รันซ้ำเต็มสำหรับ path ที่ rewrite ไปจะเสร็จ นี่มีผลจริงในทางปฏิบัติ: middleware ที่ทำ setup ต่อ request (เปิด database transaction, เริ่ม timer) รัน สองครั้ง สำหรับ request ที่ถูก rewrite หนึ่งครั้ง — พิสูจน์ตรง ๆ ข้างบน ไม่ใช่การเดา
Error handling: throw ก็ trigger การผ่านซ้อนกันเหมือนกัน
หัวข้อที่มีชื่อว่า “Error handling: throw ก็ trigger การผ่านซ้อนกันเหมือนกัน”page ที่ throw พฤติกรรมคล้าย rewrite — Astro ดักจับ error แล้วเข้า pipeline ใหม่ข้างในสำหรับหน้า 500 ของไซต์ แทนที่จะ return error response เปล่า ๆ:
---export const prerender = false;console.log('[pipeline] boom page: about to throw');throw new Error('deliberate boom for the request-pipeline lesson');---<p>never rendered</p>---export const prerender = false;console.log('[pipeline] 500.astro rendered, error:', Astro.props.error?.message);Astro.response.status = 500;---<p>server error</p>$ curl -s http://127.0.0.1:5004/probe-req/boom -w "\nHTTP %{http_code}\n"<!DOCTYPE html><p>server error</p>HTTP 500server log จริง — สังเกต middleware A: before next() for /probe-req/boom ปรากฏ สองครั้ง:
[pipeline] middleware A: before next() for /probe-req/boom[pipeline] middleware B: before next() for /probe-req/boom[pipeline] boom page: about to throw10:28:42 [ERROR] Error: deliberate boom for the request-pipeline lesson at ... (real stack trace, pointing at the exact throw line)[pipeline] middleware A: before next() for /probe-req/boom[pipeline] middleware B: before next() for /probe-req/boom[pipeline] 500.astro rendered, error: deliberate boom for the request-pipeline lesson[pipeline] middleware B: after next() for /probe-req/boom[pipeline] middleware A: after next() for /probe-req/boom, status=500Astro.props.error?.message ข้างใน 500.astro ได้รับ message จริงของ Error ที่ throw ออกมาเป๊ะ — พิสูจน์ข้างบน ไม่ใช่ placeholder และ request ไปยัง route ที่ไม่มีอยู่จริง (ไม่มีการ throw เลย) ยัง resolve ผ่าน middleware wrapping แบบเดียวกันเป๊ะ ครั้งเดียว ลงเอยที่ 404.astro:
$ curl -s http://127.0.0.1:5004/probe-req/does-not-exist -w "\nHTTP %{http_code}\n"<!DOCTYPE html><p>not found: /probe-req/does-not-exist</p>HTTP 404[pipeline] middleware A: before next() for /probe-req/does-not-exist[pipeline] middleware B: before next() for /probe-req/does-not-exist[pipeline] 404.astro rendered for /probe-req/does-not-exist[pipeline] middleware B: after next() for /probe-req/does-not-exist[pipeline] middleware A: after next() for /probe-req/does-not-exist, status=404ความต่างระหว่างสองแบบนี้: 404 จริง (ไม่มี route match เลย) resolve ไปที่ 404.astro ใน pass เดียว — ไม่มี handler “เดิม” ที่รันแล้ว fail ดังนั้นไม่มีอะไรให้ซ้อน error ที่ถูก throw ต่างหากคือตัวที่ trigger การเข้า pipeline ใหม่ข้างในโดยเฉพาะ (พิสูจน์ข้างบนด้วย middleware log ที่ซ้ำ) เพราะ Astro ต้องละทิ้ง request ที่กำลังทำอยู่แล้วส่งการควบคุมไปที่หน้า 500 แทน
กฎ private-file ของ route ที่เจอระหว่างทาง
หัวข้อที่มีชื่อว่า “กฎ private-file ของ route ที่เจอระหว่างทาง”การ build route ของบทเรียนนี้ไว้ใต้ directory ที่ชื่อตรงกับ convention src/pages/__lessons/<module>__<lesson>/ ของ harness คอร์สนี้เอง ทำให้ทุก route ข้างในนั้น 404 สม่ำเสมอ — รวมถึงตัวที่ไม่มี typo เลยด้วยซ้ำ สาเหตุจริงพบตรง ๆ ใน source ที่ติดตั้งไว้ (isPublicRoute ของ astro/dist/core/util.js):
// astro/dist/core/util.js — isPublicRoute (trimmed)function isPublicRoute(file, config) { const parts = normalizedDir.split("/").slice(1); for (const part of parts) { if (part.startsWith("_")) return false; } return true;}path segment ไหนก็ตาม ใต้ src/pages/ ที่ขึ้นต้นด้วย _ — ไฟล์ หรือ directory ที่บรรจุมัน — ทำให้ isPage()/isEndpoint() return false ให้มัน ดังนั้นมันไม่ถูก route เลย (ไม่ใช่แค่ 404 — router ไม่นับว่ามันเป็น page เลยด้วยซ้ำ) นี่คือข้อมูลจริงที่มีประโยชน์เวลาอ่าน route tree: folder ที่ขึ้นต้นด้วย underscore ใต้ src/pages/ (convention ทั่วไปสำหรับ “helper component ที่อยู่ใกล้ route ของมันแต่ไม่ใช่ route เอง”) มองไม่เห็นจาก routing เลย ไม่ว่าจะลึกแค่ไหน
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”- comment อธิบาย
sequence(a, b, c)ว่า “รันตามลำดับ ทีละตัว” ไหม? พิสูจน์ข้างบน: มัน ซ้อนกัน —aห่อbห่อcดังนั้น code ของaหลังnext()ของตัวเองรันสุดท้าย หลังทุกอย่างที่ซ้อนข้างในเสร็จหมดแล้ว ไม่ใช่ “ตัวถัดไปในคิว” - code สมมติว่า setup code ต่อ request ของ middleware รันแค่ครั้งเดียวไหม? พิสูจน์ข้างบนด้วย
Astro.rewrite(): request ที่ถูก rewrite (หรือ throw แล้วไปหน้า error) เข้า middleware chain เต็มซ้ำเป็นครั้งที่สอง สำหรับ request เดิมตัวเดียวกัน - การ “แก้” route ที่ชนกันแค่เปลี่ยนชื่อหรือย้ายไฟล์ โดยไม่เช็คว่า priority จริง (static > dynamic > rest พิสูจน์ข้างบน) แก้ให้ถูกอยู่แล้วหรือเปล่าไหม? บางครั้งสิ่งที่ต้องแก้คือความเข้าใจว่าไฟล์ไหน ควร ชนะ ไม่ใช่ทำให้ตัวใดตัวหนึ่งหายไป
- page หรือ component ถูกวางไว้ใต้ directory ที่ชื่อขึ้นต้นด้วย
_ข้างในsrc/pages/โดยคาดว่ายังเข้าถึงเป็น route ได้ไหม? พิสูจน์ข้างบน: segment ที่ขึ้นต้นด้วย underscore ตรงไหนก็ตามใน path ทำให้มันหลุดจาก routing ทั้งหมด
🐛 หา bug ในโค้ดนี้
import { defineMiddleware, sequence } from 'astro:middleware';
const timing = defineMiddleware(async (context, next) => {const start = Date.now();const response = await next();// "logs how long the whole request took, including anything downstream"console.log(`${context.url.pathname} took ${Date.now() - start}ms`);return response;});
const auth = defineMiddleware(async (context, next) => {context.locals.user = await getUser(context);return next();});
export const onRequest = sequence(timing, auth);เฉลย
claim ของ comment เกี่ยวกับ timing ถูกต้องในตัวเอง — แต่ ลำดับ ใน sequence(timing, auth) แปลว่า timing ห่อ auth และถ้า request เดียวกันนี้ถูก rewrite (Astro.rewrite(...)) หรือลงเอยที่ error-page path pipeline ที่ซ้อนกันทั้งหมด — รวมถึงงานจริงของ auth ที่ derive context.locals.user ใหม่ — รันเป็นครั้งที่สองข้างใน next() call ตัวเดียวนั้น และการวัดครั้งเดียวของ timing รวมทั้งสอง pass เข้าด้วยกันเงียบ ๆ ไม่ใช่แบบที่ log message บอกเป็นนัย ยังมี ordering bug ที่ลึกกว่านั้น: auth รันหลัง timing เริ่ม clock แต่ก่อน page จริง — โอเคในที่นี้ แต่ถ้า middleware ทีหลังต้องใช้ context.locals.user เพื่อตัดสินว่าจะ rewrite หรือไม่ auth ต้องมาก่อนใน list ของ sequence(...) เพราะ middleware เห็นแค่ค่า context.locals ที่ middleware ที่รันก่อนมันใน pass เดียวกันตั้งไว้เท่านั้น
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”request ไม่ใช่ “middleware แล้วค่อย page” — มันคือ call stack ที่ซ้อนกัน ตรงตามชื่อ sequence() เป๊ะ ๆ: middleware แต่ละตัวห่อทุกอย่างหลังจากมัน และอะไรก็ตามที่ทำให้ Astro route ใหม่ข้างใน (error ที่ throw, Astro.rewrite()) ไม่ข้าม stack นั้น มันเข้าไปใหม่ ซ้อนลึกอีกชั้น สำหรับ target ใหม่ การอ่าน pipeline code ให้ดีคือถามว่า สำหรับ request นี้ “จริง ๆ มันรันมากกว่าหนึ่งครั้งข้างใน request เดิมได้ไหม” — แล้วพิสูจน์มัน แบบที่บทเรียนนี้เพิ่งทำ ไม่ใช่สมมติว่า console.log fire แค่ครั้งเดียวเสมอ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| Static route | Dynamic ([id]) |
Rest ([...rest]) |
|
|---|---|---|---|
| match อะไร | literal path เดียวเป๊ะ | segment เดียวของ path เป๊ะ | segment ที่เหลือกี่ตัวก็ได้ |
| Priority | สูงสุด — ชนะทุกการชน | กลาง | ต่ำสุด — ชนะแค่เมื่อไม่มีอะไรเจาะจงกว่า match |
| เหมาะกับ | route ที่ห้าม match ผิดโดยเด็ดขาด (health check, webhook) | resource เดียวด้วย id/slug | catch-all (CMS-driven page tree, proxy) |
Error ที่ throw → 500.astro |
404 จริง → 404.astro |
|
|---|---|---|
| middleware pass | สองครั้ง — pass เดิม (ที่ถูกยกเลิก) บวก re-entry เต็มสำหรับ error page (พิสูจน์ข้างบน) | ครั้งเดียว — ไม่เคยมี handler แรกให้ยกเลิก |
Astro.props.error |
Error ที่ throw จริง |
ไม่เกี่ยวข้อง |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
sequence(a, b)แปลว่า a รันแล้ว b รัน เหมือนสองขั้นตอนอิสระต่อกัน” พิสูจน์ว่าผิดข้างบน: มันซ้อนกัน code ของaหลังawait next()รันหลังจากb(และ route) เสร็จสมบูรณ์แล้ว — ใกล้เคียงกับการเรียก function ที่ห่อกันมากกว่า list เรียง - “setup code ของ middleware รันครั้งเดียวเป๊ะต่อ request” พิสูจน์ว่าผิดข้างบน:
Astro.rewrite()และ error ที่ throw ทำให้เกิด pass ที่สองผ่าน middleware chain ทั้งหมดจริงและพิสูจน์ได้ สำหรับ request เดิมตัวเดียวกัน - “route ที่ชนกันแก้ด้วยไฟล์ไหนถูกสร้างก่อน หรือเรียงตัวอักษร” พิสูจน์ข้างบนด้วยไฟล์สามตัวที่สร้างมาให้ชนกันโดยเฉพาะ: priority เป็นกฎตายตัว — static แล้ว dynamic แล้ว rest — ไม่ว่าไฟล์ไหนถูกสร้างก่อนหรือชื่ออะไร
- “folder ที่ขึ้นต้นด้วย underscore ใต้
src/pages/แค่ทำให้ route ของมัน ‘ซ่อน’ จาก sitemap แต่ยังใช้งานได้ถ้ารู้ URL” พิสูจน์ว่าผิดข้างบน ตรง ๆ จาก source จริงของisPublicRoute: มันทำให้ router ไม่ลงทะเบียนเป็น page/endpoint เลย — URL 404 จริง ไม่ใช่แค่ไม่ถูกลิสต์