The Session Singleton
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”package @mosaic/session: store เดียวที่ใช้ร่วมกันสำหรับ “ใคร sign in อยู่” เป็น mock (ชื่ออะไรก็ sign in ได้ state อยู่ใน localStorage ไม่มี auth จริง) พร้อม API เล็ก ๆ ที่ตั้งใจออกแบบ:
import { getSession, signIn, signOut, subscribe } from '@mosaic/session';
getSession(); // -> { userId, name } | nullsignIn('Ada'); // sets the session, persists it, notifies everyonesignOut(); // clears itconst off = subscribe((session) => { /* react to changes */ });off(); // stop listeningส่วนสำคัญไม่ใช่ตัวโค้ด — แต่คือการที่ package นี้เป็น Module Federation singleton micro-frontend ทุกตัว import instance ตัวเดียวกัน จึงมี session เดียวเป๊ะ ๆ บนหน้าเว็บ sign in ครั้งเดียวแล้วทั้งแอปเห็นตรงกัน session ยังประกาศทุกการเปลี่ยนแปลงบน @mosaic/bus เป็น auth:changed เพื่อให้แม้แต่โค้ดที่ไม่ import session ก็ react ได้
identity คือ state ชิ้นเดียวที่กินขอบเขตทุก remote จริง ๆ catalog อาจทักคุณด้วยชื่อ, cart ต้องรู้ว่า cart นี้ของใคร, shell แสดงปุ่ม sign-in/out ใน SPA เดี่ยวนี่คือ context provider ตัวเดียว แต่ข้าม remote ที่ deploy อิสระนี่คือกับดัก: ถ้า remote แต่ละตัวเก็บ copy ของ “current user” ของตัวเอง ค่าจะ drift ออกจากกัน — shell คิดว่าคุณคือ Ada แต่ cart ยังคิดว่าคุณเป็น guest
สอง property แก้เรื่องนั้น:
- One instance. session เป็น store ระดับ module (
let current) ถ้า remote แต่ละตัว bundle copy ของตัวเอง แต่ละตัวก็มีcurrentของตัวเอง และการ sign in ในตัวหนึ่งไม่แตะตัวอื่น การ mark@mosaic/sessionเป็น singleton ใน Module Federation บังคับให้ทั้งหน้าใช้ copy เดียวร่วมกัน — วินัยเดียวกับที่ bus ต้องการ ด้วยเหตุผลเดียวกัน - Two ways to react. โค้ดที่ import package
subscribe()ได้และรับ objectSessionเต็ม ๆ ส่วนโค้ดที่ไม่อยากพึ่ง package เลย — custom element ธรรมดา, Astro content island — ฟังauth:changedบน bus แทนได้ session emit ทั้งสองทาง
เราตั้งใจให้เป็น mock auth จริง (token, refresh, httpOnly cookie) เป็นหัวข้อใหญ่ที่จะกลบบทเรียนตัวจริง ซึ่งคือ การ share state ข้าม MFE localStorage ให้ persistence ข้าม reload โดยไม่ต้องมี backend และการสลับไปใช้ provider จริงทีหลังก็ไม่เปลี่ยนรูปทรงของ shared singleton
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”A shared singleton session vs. each remote fetching /me itself
- Pros: source of truth เดียว — ไม่มี drift ระหว่าง remote ไม่มี fetch ซ้ำซ้อน มีที่เดียวให้ sign in และ out remote อ่าน current user แบบ synchronous ด้วย
getSession()แทนที่จะ await request ของตัวเอง - Cons: ทุก remote ตอนนี้ share runtime dependency และต้องเห็นตรงกันเรื่อง version (singleton mismatch จะ warn หรือ break) session กลายเป็นจุด coordination — breaking change ต่อ API นี้กระเพื่อมไปทุก consumer
A localStorage mock vs. real cookie/token auth
- Pros: ไม่ต้องมี backend, persist ข้าม reload ทันที, เข้าใจง่ายมากตอนคุณกำลังเรียนเรื่อง composition API (
getSession/signIn/signOut/subscribe) มีรูปทรงเดียวกับที่ store จริงจะ expose - Cons: ไม่ secure และไม่จริง —
localStorageอ่านได้โดย script ตัวไหนก็ได้ และไม่มี server verify อะไรเลย นี่เป็นตัวแทนของ identity ไม่ใช่ authentication; production ต้องมี provider จริงหลัง interface เดียวกัน
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”1. packages/session/package.json
หัวข้อที่มีชื่อว่า “1. packages/session/package.json”พึ่ง bus เพราะต้องประกาศ auth:changed
{ "name": "@mosaic/session", "version": "0.0.0", "type": "module", "main": "./src/index.ts", "types": "./src/index.ts", "exports": { ".": "./src/index.ts" }, "dependencies": { "@mosaic/bus": "workspace:*" }}2. packages/session/src/index.ts
หัวข้อที่มีชื่อว่า “2. packages/session/src/index.ts”store ระดับ module บวก subscriber ทุกการเปลี่ยนแปลง persist, แจ้ง local subscriber, และ emit บน bus
import { bus } from '@mosaic/bus';
export type Session = { userId: string; name: string } | null;
const STORAGE_KEY = 'mosaic:session';
// Module-level state. Because @mosaic/session is a singleton (step 3),// this `current` is the ONE session the whole page shares.let current: Session = load();const subscribers = new Set<(session: Session) => void>();
function load(): Session { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? (JSON.parse(raw) as Session) : null; } catch { return null; // SSR or private-mode: fall back to signed-out }}
function set(next: Session): void { current = next; if (next) localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); else localStorage.removeItem(STORAGE_KEY);
// 1) tell in-process subscribers (they get the whole Session) subscribers.forEach((cb) => cb(current)); // 2) announce on the bus for anyone who didn't import this package bus.emit('auth:changed', { userId: next?.userId ?? null, name: next?.name ?? null, });}
export function getSession(): Session { return current;}
export function signIn(name: string): Session { // Mock: any non-empty name signs in. Real auth slots in behind this call. set({ userId: `u_${name.toLowerCase().replace(/\s+/g, '-')}`, name }); return current;}
export function signOut(): void { set(null);}
export function subscribe(cb: (session: Session) => void): () => void { subscribers.add(cb); return () => subscribers.delete(cb);}3. Share it as a singleton (vite.config.ts)
หัวข้อที่มีชื่อว่า “3. Share it as a singleton (vite.config.ts)”เพิ่ม @mosaic/session — คู่กับ bus ที่ต้องพึ่ง — เข้าไปใน shared ทั้งใน host และ remote ทุกตัว เป็น singleton:
federation({ name: 'shell', // ...remotes / exposes... shared: { react: { singleton: true }, 'react-dom': { singleton: true }, '@mosaic/bus': { singleton: true }, '@mosaic/session': { singleton: true }, },});Svelte cart list '@mosaic/session': { singleton: true } ด้วยเช่นกัน จึงอ่าน current ตัวเดียวกันกับที่ shell เขียน
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”type-check package:
pnpm --filter @mosaic/session exec tsc --noEmitคาดหวัง: ไม่มี output
จากนั้นลองใช้ใน browser เริ่ม shell (pnpm --filter shell dev) เปิด console:
const s = await import('@mosaic/session');const off = s.subscribe((sess) => console.log('session ->', sess));
s.signIn('Ada');// session -> { userId: 'u_ada', name: 'Ada' }
s.getSession(); // { userId: 'u_ada', name: 'Ada' }ตอนนี้ reload หน้า แล้วรัน (await import('@mosaic/session')).getSession() — ยัง return Ada พิสูจน์ persistence ของ localStorage จากนั้น:
s.signOut();// session -> nulloff();คุณควรเห็น auth:changed fire ด้วยถ้าเพิ่ม bus.on('auth:changed', console.log) ก่อน sign in — ยืนยันว่าทั้งสองเส้นทางแจ้งเตือนทำงาน
สุดท้าย build workspace:
pnpm -r buildคาดหวัง: ทุก package และแอป build ผ่าน; ไม่มี singleton-version warning ใน shell console ตอน runtime
Check your understanding:
- ทำไม
@mosaic/sessionต้องเป็น singleton? อธิบาย drift ที่เกิดขึ้นเป๊ะ ๆ ถ้า remote สองตัวต่าง bundle copy ของตัวเอง set()แจ้ง subscriber และ emitauth:changedบน bus แต่ละเส้นทางไว้เพื่อใคร และทำไมเก็บไว้ทั้งคู่?- ทำไม
localStorageเป็น mock ที่รับได้ในที่นี้แต่ไม่ใช่ authentication จริง? อะไรที่ยังคงเดิมเมื่อคุณเปลี่ยนไปใช้ provider จริง? getSession()return แบบ synchronous ส่วน fetch/meจริงจะเป็น async singleton ให้อะไรกับคุณที่ทำให้การอ่านแบบ synchronous ถูกต้อง?
คุณสร้าง @mosaic/session: mock store ที่ backed ด้วย localStorage พร้อม getSession/signIn/signOut/subscribe share เป็น Module Federation singleton เพื่อให้ทั้งหน้ามี identity เดียว และประกาศทุกการเปลี่ยนแปลงบน bus เป็น auth:changed ต่อไปเอาไปใช้ข้าม framework: Sharing Across MFEs → ให้ remote อ่าน session และ react ต่อการ sign in จาก shell