GraphQL client & auth
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”apps/web/lib/graphql.ts — ฟังก์ชัน gqlFetch ที่ type ครบตัวเดียว สร้างบน native fetch API ที่ทุกหน้าและทุก mutation ในคอร์สนี้เรียกใช้ พร้อม type ที่ใช้ร่วมกันอย่าง Post/Tag/Comment/PostPage/Author ที่สะท้อน schema ของ GraphQL จาก GraphQL API ควบคู่กันไป apps/web/lib/auth.ts — ฟังก์ชันเล็ก ๆ สามตัว getToken/setToken/clearToken ที่ห่อ JWT ของ admin ไว้ใน localStorage
Frontend init ติดตั้ง graphql-request ไว้เป็น placeholder ก่อนที่โมดูลนี้จะตัดสินใจจริงว่าฝั่ง frontend จะคุยกับ API อย่างไร บทเรียนนี้คือจุดตัดสินใจ และคำตอบคือ native fetch ไม่ใช่ graphql-request ถือเป็นการแก้ทิศทางที่บันทึกไว้ชัดเจน ไม่ใช่การขัดกันเองแบบเงียบ ๆ ตัว graphql-request แทบไม่ต่างจาก wrapper บาง ๆ ที่ POST { query, variables } ไปที่ endpoint เดียวแล้ว parse JSON กลับมา ซึ่งตรงกับสิ่งที่ gqlFetch ด้านล่างทำเป๊ะ ๆ ของที่สร้างมาจนถึงตอนนี้ไม่เสียเปล่า แต่ตัว package ไม่จำเป็นอีกต่อไปเมื่อมี lib/graphql.ts แล้ว รัน npm uninstall graphql-request หลังบทเรียนนี้ได้เลยอย่างปลอดภัย ส่วน react-markdown กับ remark-gfm ยังต้องเก็บไว้ เพราะ Public Blog ต้องใช้เรนเดอร์เนื้อหา Markdown ของโพสต์
เหตุผลที่เลือกใช้ fetch ตรง ๆ แทน GraphQL client library ลงมาที่สองเรื่องที่คอร์สนี้ต้องการจริง
อย่างแรกคือ bundle size gqlFetch ยาวไม่ถึง 50 บรรทัดและไม่มี runtime dependency เลย จึงไม่เพิ่มอะไรใน client bundle ที่ admin dashboard ส่งออกไป ส่วน machinery เรื่อง cache และ query deduplication ของ GraphQL client เต็มรูปแบบจะถูกส่งไปที่ browser แม้แต่กับ server component ของบล็อกสาธารณะ ซึ่งไม่เคยต้องใช้ client-side cache เลย เพราะรันแค่ครั้งเดียวต่อ request (หรือต่อรอบ revalidate ของ ISR) บนเซิร์ฟเวอร์
อย่างที่สองคือ RSC-friendliness Server Component await fetch(...) ได้ตรง ๆ ใน body โดยไม่ต้องตั้งค่าอะไร ขณะที่ client library เต็มรูปแบบมักต้องมี client instance แบบ singleton และถ้าเกินกว่า query พื้นฐานสุด ๆ ก็ต้องมี React context Provider ซึ่งตัว Provider เองเป็น Client Component จึงต้องใส่ 'use client' แล้วบังคับให้ทุกคอมโพเนนต์ที่อยู่ใต้ลงไปใน tree ยอมรับขอบเขตนั้นตาม ลบล้างการแบ่ง server-by-default ที่ App Router & layout เพิ่งวางไว้โดยตรง
gqlFetch ยังทำอีกเรื่องที่ควรพูดให้ชัด: เมื่อรันบนเซิร์ฟเวอร์ จะตั้งค่าเริ่มต้นเป็น cache: 'no-store' เว้นแต่ผู้เรียกจะส่ง revalidate มาเอง นี่คือการออกแบบให้ต้องเลือกเข้าร่วม ไม่ใช่การมองข้าม mutation ของ admin ไม่ควรโดนแคชแบบเงียบ ๆ เด็ดขาด และหน้าที่ยังไม่ได้ตัดสินใจเรื่อง revalidation window ของตัวเอง ก็ไม่ควรได้ ISR มาโดยบังเอิญแค่เพราะเรียก gqlFetch จาก Server Component หน้าไหนอยากได้ ISR ต้องขอเอง
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Typed fetch wrapper (ที่เราใช้) เทียบกับ Apollo Client Apollo ให้ normalized in-memory cache ที่ใช้ร่วมกันได้ทุกคอมโพเนนต์ที่ query entity เดียวกัน, ตัวช่วย optimistic-update, DevTools, และ GraphQL subscription ล้วนเป็นความสามารถจริง แต่คอร์สนี้ไม่ต้องใช้เลยสำหรับ client สองตัว (บล็อกสาธารณะ, admin dashboard) ที่ไม่ได้แชร์ live cache ข้ามคอมโพเนนต์ที่ไม่เกี่ยวกัน การใช้ Apollo ใน App Router ยังต้องต่อสาย @apollo/client-react-streaming (คำตอบของ Apollo เองสำหรับการเชื่อม RSC) หรือห่อ tree ด้วย ApolloProvider แบบ 'use client' ซึ่งไม่ว่าทางไหนก็เพิ่มการตั้งค่าจริงจังและ runtime ฝั่ง client ที่ไม่เล็ก แม้แต่กับหน้าที่เป็น server-rendered read ล้วน ๆ ส่วน gqlFetch มีต้นทุนแค่ไฟล์เดียวที่อ่านจบได้ในหนึ่งนาที เสียบเข้ากับ Data Cache ในตัวของ Next ตรง ๆ ผ่าน next: { revalidate, tags } โดยไม่ต้องตั้งค่าเพิ่ม และทำงานเหมือนกันทุกประการไม่ว่าจะเรียกจาก Server Component หรือ Client Component เพราะข้างใต้คือ fetch เฉย ๆ ต้นทุนจริงคือไม่มีการแคชอัตโนมัติข้ามคอมโพเนนต์ในการเรนเดอร์เดียวกัน สองคอมโพเนนต์ที่เรียก gqlFetch แยกกันสำหรับโพสต์เดียวกันจะยิง network request สองครั้ง ขณะที่ normalized cache ของ Apollo จะยุบให้เหลือครั้งเดียว และไม่มี type ที่ generate มาจาก schema ดังนั้น Post/Tag/Comment ด้านล่างจึงดูแลด้วยมือคู่กับ API แทนที่จะผลิตจากขั้นตอน codegen สำหรับโปรเจกต์คอร์สสองไคลเอนต์ การแลกนั้นคุ้มค่า แอปที่ใหญ่กว่าที่มีหลายคอมโพเนนต์อ่านข้อมูลที่ทับซ้อนกันเองอย่างอิสระจะรู้สึกถึง cache ที่หายไปมากกว่านี้
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”สร้าง apps/web/lib/graphql.ts:
export const API_URL = process.env.NEXT_PUBLIC_API_URL!;
export interface GqlOptions { token?: string; revalidate?: number; tags?: string[];}
export interface Author { displayName: string;}
export interface Post { id: string; title: string; slug: string; body: string; excerpt?: string; coverImage?: string; status: 'draft' | 'published'; tags: string[]; publishedAt?: string; author: Author;}
export interface Tag { id: string; name: string; slug: string;}
export interface Comment { id: string; authorName: string; body: string; createdAt: string;}
export interface PostPage { items: Post[]; total: number; page: number; pageSize: number;}
interface GqlError { message: string;}
interface GqlResponse<T> { data?: T; errors?: GqlError[];}
export async function gqlFetch<T>( query: string, variables?: Record<string, unknown>, opts: GqlOptions = {},): Promise<T> { const headers: Record<string, string> = { 'Content-Type': 'application/json', }; if (opts.token) { headers.Authorization = `Bearer ${opts.token}`; }
const init: RequestInit = { method: 'POST', headers, body: JSON.stringify({ query, variables }), };
if (opts.revalidate !== undefined || opts.tags) { init.next = { ...(opts.revalidate !== undefined ? { revalidate: opts.revalidate } : {}), ...(opts.tags ? { tags: opts.tags } : {}), }; } else { init.cache = 'no-store'; }
const res = await fetch(API_URL, init); const json = (await res.json()) as GqlResponse<T>;
if (json.errors && json.errors.length > 0) { throw new Error(json.errors[0].message); } if (!json.data) { throw new Error('GraphQL response had no data'); } return json.data;}API_URL = process.env.NEXT_PUBLIC_API_URL!— prefixNEXT_PUBLIC_จาก Repo layout คือสิ่งที่ทำให้อ่านได้จากโค้ดฝั่ง client ด้วย ไม่ใช่แค่เซิร์ฟเวอร์ ส่วน non-null assertion เชื่อว่า.env.localตั้งค่าไว้แล้ว ซึ่ง Frontend init ทำไปเรียบร้อยPost/Tag/Comment/PostPage/Authorเขียนด้วยมือให้สะท้อน schema จาก GraphQL API ทีละฟิลด์ props ของหน้าและคอมโพเนนต์ในโมดูลถัดไปทุกโมดูล import type พวกนี้จากไฟล์นี้โดยตรงแทนที่จะประกาศซ้ำAuthorizationheader เป็นแบบมีเงื่อนไข — เพิ่มเข้ามาก็ต่อเมื่อผู้เรียกส่งtokenมา ตรงกับรูปแบบที่lib/auth.tsด้านล่างสร้างไว้ให้การเรียกฝั่ง admin ส่วนการเรียกจากบล็อกสาธารณะไม่เคยส่ง token มาเลยinit.nextถูกตั้งค่าเฉพาะเมื่อผู้เรียกขอเท่านั้น การส่ง{ revalidate: 60 }คือการให้ Server Component เข้าร่วม Data Cache ของ Next เป็นเวลา 60 วินาที — ISR ของจริง การไม่ส่งอะไรเลยจะตกไปที่cache: 'no-store'ดังนั้น mutation หรือการอ่านของ admin จึงไม่มีวันถูกแคชโดยบังเอิญ- Error โยนออกมา ไม่ใช่คืน
undefinedGraphQL response สามารถเป็น HTTP200แต่ยังพก arrayerrorsแทนdataได้gqlFetchเช็คกรณีนี้อย่างชัดเจนแล้วโยน error ด้วยข้อความจริงจาก GraphQL error boundary ของ Server Component ที่เรียก (หรือtry/catchรอบ mutation ใน Client Component) จึงเห็นข้อความที่อ่านรู้เรื่อง แทนที่จะพังเงียบ ๆ ด้วยundefined.somethingลึกลงไป มีอยู่กรณีหนึ่งที่ยังไม่จัดการ: response ที่ไม่ใช่ 2xx จากอะไรสักอย่างที่คั่นอยู่หน้า API (เช่น proxy timeout) แล้วคืน HTML แทน JSON จะโยน error ข้างในres.json()กลายเป็น parse error ทั่วไปแทนที่จะเป็น GraphQL error เป็นช่องว่างที่ยอมรับได้ในคอร์สนี้ และเป็นจุดที่ควร harden สำหรับ deployment จริง
สร้าง apps/web/lib/auth.ts:
const TOKEN_KEY = 'devblog_token';
export function getToken(): string | null { if (typeof window === 'undefined') { return null; } return window.localStorage.getItem(TOKEN_KEY);}
export function setToken(token: string): void { if (typeof window === 'undefined') { return; } window.localStorage.setItem(TOKEN_KEY, token);}
export function clearToken(): void { if (typeof window === 'undefined') { return; } window.localStorage.removeItem(TOKEN_KEY);}typeof window === 'undefined'ป้องกันทุกฟังก์ชัน —lib/auth.tsimport ได้จากทุกที่ รวมถึง Server Component ที่ไม่มีวันรันใน browser เลย ถ้าไม่มีการป้องกันนี้window.localStorageจะโยน error ระหว่างการเรนเดอร์บนเซิร์ฟเวอร์และทำให้หน้าพัง ที่เป็นจุดพลาดเรื่อง hydration เดียวกันกับที่ convention ของคอร์สนี้เองเตือนไว้- การแลกที่พูดตรง ๆ:
localStorageอ่านได้โดย JavaScript ใด ๆ ที่รันอยู่บนหน้านั้น รวมถึงของผู้โจมตีด้วย ถ้า DevBlog เคยมีบั๊ก XSS — cookie แบบhttpOnlyที่เก็บ token จะไม่มีความเสี่ยงนั้น เพราะ JavaScript ไม่มีวันอ่านค่าของ cookie แบบhttpOnlyได้เลย DevBlog ใช้localStorageอยู่ดีเพราะฝั่งหน้าบ้านและ API เป็นแอปที่ deploy แยกกันสองตัวโดยไม่มีเส้นทางSet-Cookieแบบ shared-domain ให้พึ่งพา และเพราะ admin ใน Module 10 เป็นพื้นผิวเดียวที่แตะ token นี้เลย deployment จริงที่เติบโตเกินกว่าโปรเจกต์สำหรับสอนจะขยับไปทาง JWT อายุสั้นบวก refresh token ที่เก็บใน cookie แบบhttpOnlyและ CSP แบบscript-src 'self'ที่เข้มงวด — ทิศทางที่คอร์สนี้พูดตรง ๆ ว่าจำเป็น แทนที่จะแกล้งทำเป็นว่าไม่ต้องการ
Server Component ที่เรียก gqlFetch — นี่คือรูปแบบที่ Public Blog สร้างขึ้นจริงในภายหลัง ตรงนี้แค่พอพิสูจน์ว่าเลเยอร์ fetch ทำงานได้ตั้งแต่ต้นจนจบ:
// app/page.tsx — a Server Component (no 'use client' — this file never runs in the browser)import { gqlFetch } from '@/lib/graphql';import type { PostPage } from '@/lib/graphql';
const POSTS_QUERY = ` query Posts($status: PostStatus, $page: Int, $pageSize: Int) { posts(status: $status, page: $page, pageSize: $pageSize) { items { id title slug excerpt coverImage tags publishedAt author { displayName } } total page pageSize } }`;
export default async function HomePage() { const { posts } = await gqlFetch<{ posts: PostPage }>( POSTS_QUERY, { status: 'PUBLISHED', page: 1, pageSize: 10 }, { revalidate: 60 }, );
return ( <ul> {posts.items.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> );}status: 'PUBLISHED' ตรงกับชื่อ enum ของ GraphQL ของ PostStatus จาก Draft → published ไม่ใช่ string ตัวพิมพ์เล็ก — enum เดียวกัน เขียนแบบเดียวกับที่ posts(status: DRAFT) เขียนไว้แล้วในตัวอย่างของบทเรียนนั้นเอง
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”ให้ API รันอยู่ (npm run start:dev ใน apps/api) และมีโพสต์ที่ publish แล้วอย่างน้อยหนึ่งโพสต์จาก Verify section ของโมดูลก่อนหน้า วาง snippet HomePage ด้านบนลงใน apps/web/app/page.tsx แล้ว:
cd apps/webnpm run devเปิด http://localhost:3000 — ชื่อโพสต์ที่ publish แล้วควรปรากฏในรายการธรรมดา นั่นยืนยันว่า gqlFetch ไปถึง API, parse response 200 ที่มี data ได้ และเรนเดอร์ทั้งหมดบนเซิร์ฟเวอร์
ตอนนี้พิสูจน์ว่า error path เป็นของจริง ไม่ใช่แค่ทฤษฎี: พิมพ์ฟิลด์ผิดใน POSTS_QUERY ชั่วคราว (เปลี่ยน title เป็น titlee) บันทึก แล้วรีโหลด error overlay ของ dev ของ Next ควรแสดงข้อความ error จริงของ GraphQL — ประมาณ Cannot query field "titlee" on type "Post". — ที่โผล่มาตรงจากบรรทัด throw new Error(json.errors[0].message) ด้านบน ไม่ใช่การพังทั่วไป แก้คำผิดกลับคืนหลังจากเห็นแล้ว
getToken/setToken/clearToken ยังไม่มี UI ให้กดเลย Admin จะสร้างฟอร์ม login ที่เรียกใช้ทั้งสามตัวใน Module 10 ตอนนี้สิ่งเดียวที่ควรยืนยันคือการ import lib/auth.ts จากที่ไหนก็ตามไม่ทำให้หน้าที่เพิ่งโหลดพัง ซึ่งพิสูจน์ว่า guard typeof window ทำหน้าที่ได้จริงระหว่างการเรนเดอร์บนเซิร์ฟเวอร์
gqlFetch<T> คือฟังก์ชันเดียวที่ทุกหน้าและทุก mutation ในคอร์สนี้เรียกใช้: native fetch แบบ POST ไปที่ NEXT_PUBLIC_API_URL, Authorization header ที่เป็นตัวเลือก, next: { revalidate, tags } ที่เลือกเข้าร่วมอย่างชัดเจนสำหรับ ISR, cache: 'no-store' เป็นค่าเริ่มต้นในกรณีอื่น และการโยน Error ที่พก error message จริงของ GraphQL ทุกครั้งที่ response กลับมาพร้อม errors แทน data lib/auth.ts ห่อ JWT ของ admin ไว้ในตัวช่วย localStorage สามตัว แต่ละตัวป้องกันไม่ให้รันระหว่างการเรนเดอร์บนเซิร์ฟเวอร์ พร้อมการแลกระหว่าง localStorage กับ cookie แบบ httpOnly ที่พูดตรง ๆ แทนที่จะกลบเกลื่อน graphql-request จาก Frontend init ไม่จำเป็นอีกต่อไปและปลอดภัยที่จะ uninstall ส่วน react-markdown/remark-gfm ยังคงอยู่สำหรับ Public Blog
ถัดไป: Styling →