4.1.1 · Bootstrap + Webpack build + Apollo Client
บทนี้เล่าว่า vtrc-web เริ่มต้นที่ไฟล์ใด ผ่านขั้นตอนไหนก่อนถึงหน้าจอผู้ใช้ สร้าง bundle สำหรับ production อย่างไร และส่ง request ไปยัง vtrc-api อย่างไร
จุดที่ทำให้ vtrc-web แตกต่างจาก React app ทั่วไปคือ — endpoint เลือกตอน runtime ตาม URL ที่ผู้ใช้เปิด ไม่ใช่ตอน build ทำให้ bundle ตัวเดียวใช้ได้กับทุก env
ถ้า DevOps แจ้งว่า "หน้าขาว", "bundle ใหญ่เกิน", หรือ "request ไม่ถึง server" — บทนี้คือจุดเริ่มต้นสำหรับการตรวจสอบ
Entry point — vtrc-web/src/index.js
vtrc-web/src/index.js:1-12 เป็นการ import + ตั้ง moment locale
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { ApolloProvider } from 'react-apollo';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';
import apolloClient from './config/apollo';
import { LocaleProvider } from 'antd';
import th_TH from 'antd/lib/locale/provider/th_TH';
import moment from 'moment';
import 'moment/locale/th';
moment.locale('th');ลำดับสำคัญ — import moment/locale/th ก่อนแล้วค่อยเรียก moment.locale('th') (vtrc-web/src/index.js:10-12) ตั้ง locale ทั้งระบบเป็นไทย ตรงนี้เป็น global side effect ไม่ใช่ per-component
vtrc-web/src/index.js:14-45 เก็บฟังก์ชัน format/year เดิม แล้ว override longDateFormat ของ th
const _format = moment.fn.format;
// override format ของ moment ให้แสดงปีเป็น พ.ศ. — ถูก comment ไว้
// moment.fn.format = function (f) {
// if (!f) return _format.call(this);
// return _format.call(this, f.replace(/YYYY/g, this.year() + 543));
// };
// override year ให้เป็น พ.ศ. — ถูก comment ไว้
// moment.fn.year = function (y) { ... };
moment.updateLocale('th', {
longDateFormat: {
LT: 'HH:mm',
LTS: 'HH:mm:ss',
L: 'DD/MM/YYYY',
LL: 'D MMMM YYYY',
LLL: 'D MMMM YYYY เวลา HH:mm',
LLLL: 'วันddddที่ D MMMM YYYY เวลา HH:mm'
}
});ข้อสังเกตสำคัญ — override format/year แบบ +543 ถูก comment ไว้ทั้งคู่ การแปลง พ.ศ. ไม่ได้ทำที่นี่ ทำที่ helper vtrc-web/src/utils/formatdate.js (441 บรรทัด) แทน ถ้าจะเปลี่ยนพฤติกรรม พ.ศ. ต้องแก้ที่ formatdate.js ไม่ใช่ uncomment ตรงนี้
vtrc-web/src/index.js:46-76 ตรวจ OS + browser แล้วแปะ class บน <html> สำหรับเขียน CSS workaround
const ua = navigator.userAgent || "";
const platform = navigator.platform || "";
const isWindows = /Win(dows)?/.test(platform) || /Windows/.test(ua);
const isMac = /Mac/.test(platform) || /Macintosh/.test(ua) || /Mac OS/.test(ua);
const isChromium =
/Chrome\/\d+/.test(ua) &&
!/Edg\/\d+/.test(ua) &&
!/OPR\/\d+/.test(ua) &&
!/Brave\//.test(ua);
if (isWindows) document.documentElement.classList.add("os-windows");
if (isMac) document.documentElement.classList.add("os-macos");
if (isChromium) document.documentElement.classList.add("browser-chrome");
if (isWindows && isChromium) document.documentElement.classList.add("win-chrome");
if (isMac && isChromium) document.documentElement.classList.add("mac-chrome");class เหล่านี้ใช้ใน CSS เพื่อทำ workaround สำหรับ font rendering / scroll behavior ที่ต่างกันระหว่าง Windows-Chrome กับ macOS-Chrome
vtrc-web/src/index.js:80-93 เป็น root component + render
class Root extends Component {
render() {
return (
<ApolloProvider client={apolloClient}>
<LocaleProvider locale={th_TH}>
<App />
</LocaleProvider>
</ApolloProvider>
);
}
}
ReactDOM.render(<Root />, document.getElementById('root'));
serviceWorker.unregister();ลำดับการ wrap — ApolloProvider → LocaleProvider → App สำคัญเพราะ App และ component ลูกต้องการ this.props.client จาก ApolloProvider และ AntD message/Modal ต้องการ locale จาก LocaleProvider ถ้าสลับลำดับจะทำให้ component ลูกเข้าถึง Apollo client ไม่ได้
ข้อสังเกตเรื่อง index.js
- ไม่มี
<ErrorBoundary>ระดับ root — runtime error ใน component ลูกจะทำให้หน้าขาวทันที <LocaleProvider>ของ AntD v3 ต่างจาก<ConfigProvider>ของ v4 ห้ามสับเปลี่ยน (AntD v4 เปลี่ยนชื่อ component)serviceWorker.unregister()ปิด CRA service worker แม้ webpack ยัง generate SW ไว้ ฝั่งแอปไม่ register จึงไม่ทำงานmoment.updateLocale('th', ...)เป็น global side effect — เปลี่ยน locale ของ moment ทุกจุดในแอป
Root component — vtrc-web/src/containers/App/index.js
vtrc-web/src/containers/App/index.js:1-36 เป็น root router + auth gate (function component ตัวเดียวใน repo ที่ไม่ใช้ class + withApollo)
import React from 'react'
import { BrowserRouter, Switch, Route, Redirect } from 'react-router-dom'
import { isLoggedIn } from '../../utils/auth'
import Login from '../../pages/user/Login'
import ConfirmUserA1 from '../../pages/user/ConfirmUserA1'
import ConfirmUserA2 from '../../pages/user/ConfirmUserA2'
import SetPasswordsFirstTime from '../../pages/user/SetPasswordsFirstTime'
import SiderLayout from '../../components/user/SiderLayout'
import ForgetPassword from '../../pages/user/ForgetPassword'
import NotFound from '../../pages/user/NotFound'
function App() {
console.log = console.warn = console.error = () => { };
return (
<BrowserRouter>
<Switch>
<Route path="/login" exact component={Login} />
<Route path="/verify/user/A1" exact component={ConfirmUserA1} />
<Route path="/verify/user/A2" exact component={ConfirmUserA2} />
<Route path="/forgetpassword" exact component={ForgetPassword} />
<Route path="/passwords/first/time" exact component={SetPasswordsFirstTime} />
{isLoggedIn() && <Route path="/" component={SiderLayout} />}
{!isLoggedIn() && <Redirect to="/login" />}
<Route component={NotFound} />
</Switch>
</BrowserRouter>
)
}
export default Appส่วนสำคัญ 3 จุด
1. ปิด console ทั้งหมด (บรรทัด 15)
console.log = console.warn = console.error = () => { };เป็น global side effect ที่ทำให้ debug ฝั่ง production ได้ยาก — ทุก console.* ถูกทำให้เป็น no-op ตั้งแต่แอปเริ่ม ถ้าจะ debug ฝั่ง browser ต้อง comment บรรทัดนี้ชั่วคราว หรือใช้ debugger statement + DevTools
2. Auth gate ตัวเดียวคือ isLoggedIn() (บรรทัด 27-28)
{isLoggedIn() && <Route path="/" component={SiderLayout} />}
{!isLoggedIn() && <Redirect to="/login" />}isLoggedIn() จาก vtrc-web/src/utils/auth.js ตรวจว่ามี token + refreshToken ครบทั้งคู่ใน storage รายละเอียด routing/auth gate ใน 4.1.2
3. Public routes แยกต่างหาก (บรรทัด 20-26)
5 path ที่ไม่ต้อง login — /login, /verify/user/A1, /verify/user/A2, /forgetpassword, /passwords/first/time เป็น onboarding + auth flow ถ้า path ไม่ match และไม่ login → <Redirect to="/login" /> ถ้า path ไม่ match และ login อยู่ → <Route component={NotFound} />
Webpack build — CRA ejected
vtrc-web/config/webpack.config.js (~672 บรรทัด) เป็น CRA ที่ eject แล้ว แก้ไขได้ทุกอย่าง แต่ต้องรับผิดชอบการ upgrade เอง
โหมดการ build
| โหมด | mode | devtool | bail | TerserPlugin |
|---|---|---|---|---|
| production | 'production' | 'source-map' (ถ้า shouldUseSourceMap) | true | minify เปิด |
| development | 'development' | 'cheap-module-source-map' | false | minify ปิด |
Entry + output
entry: paths.appIndexJs (webpack.config.js)
output: paths.appBuild + static/js/[name].[contenthash:8].js (prod)
static/js/bundle.js (dev)ไม่มี code splitting ระดับ route — splitChunks แยก vendor (chunks: 'all') แต่ไม่มี React.lazy ทำให้ทุก page อยู่ใน main bundle ผลกระทบ bundle ใหญ่ (~3-4 MB JS gzip) รายละเอียดใน 4.1.3
Alias
react-native→react-native-web(มีในjest.moduleNameMapperด้วย ที่vtrc-web/package.json:160)- ไม่มี
@/หรือ path alias อื่น — ทุก import ใช้ relative path
Plugin สำคัญ
| Plugin | หน้าที่ |
|---|---|
CleanWebpackPlugin | ล้าง build/ ก่อน build |
HtmlWebpackPlugin | inject bundle เข้า public/index.html |
WorkboxWebpackPlugin.GenerateSW | สร้าง service worker (แต่ฝั่งแอป unregister) |
IgnorePlugin(/^\.\/locale$/, /moment$/) | ตัด moment locale ที่ไม่ใช้ ลดขนาด bundle |
public/index.html
vtrc-web/public/index.html:32-41 — Google Analytics (GA4 id G-LKXD2EBDZ4) ฝังตรงใน HTML template แทนที่จะใช้ library แยก โหลดก่อน bundle
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-LKXD2EBDZ4"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-LKXD2EBDZ4');
</script>
<title>VTRC Web App</title>meta cache-control ตั้ง no-cache + expires: 0 + pragma: no-cache (public/index.html:6, 13-15) เพื่อบังคับให้ browser ดู bundle เวอร์ชันใหม่เสมอ
package.json scripts
vtrc-web/package.json:112-116 — มีเพียง 3 scripts มาตรฐาน CRA
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"test": "node scripts/test.js"
}| สคริปต์ | ที่มา | หน้าที่ |
|---|---|---|
npm start (หรือ yarn start) | vtrc-web/scripts/start.js (167 บรรทัด) | dev server + HMR เรียก configFactory('development') |
npm run build | vtrc-web/scripts/build.js (212 บรรทัด) | production build เรียก configFactory('production') |
npm test | vtrc-web/scripts/test.js | jest runner (ไม่มี test จริงใน repo — test files เป็น CRA default) |
ทั้ง start.js และ build.js require config/env ก่อนเริ่ม เพื่อโหลด .env*
package.json ระบุ packageManager ผ่านไฟล์ yarn.lock (ใช้ yarn) — workspace rule: vtrc-web/ ใช้ yarn, ทุก repo อื่นใช้ npm
Build warning threshold
vtrc-web/scripts/build.js:36-37:
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024; // 512 KB
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024; // 1 MBbundle ปัจจุบันเกิน threshold ทั้งคู่ — ทุกครั้งที่ build จะมี warning รายงานขนาด รายละเอียดใน 4.1.3
Docker build — 2 stages
vtrc-web/Dockerfile:1-19 (สั้น แต่ load-bearing)
FROM node:14.20 AS stage-1
WORKDIR /usr/src/app
ADD . .
RUN npm install --legacy-peer-deps
RUN npm run build
FROM nginx:1.24.0 AS stage-2
COPY ./nginx-config/default.conf /etc/nginx/conf.d/
COPY --from=stage-1 usr/src/app/build/ /usr/share/nginx/html/build/
COPY --from=stage-1 usr/src/app/src/assets/images/07_ไฟล์แนบ_แนวปฏิบัติสำหรับผู้ได้รับอุบัติ.pdf /usr/share/nginx/html/build/assets/07_ไฟล์แนบ_แนวปฏิบัติสำหรับผู้ได้รับอุบัติ.pdf
COPY --from=stage-1 usr/src/app/src/assets/images/16_แบบแจ้งรายการเพื่อการหักลดหย่อนภาษี.pdf /usr/share/nginx/html/build/assets/16_แบบแจ้งรายการเพื่อการหักลดหย่อนภาษี.pdfStage 1 — build bundle
- ใช้
node:14.20(legacy — Node 14 EOL เมษายน 2566 แล้ว) npm install --legacy-peer-depsเพราะ AntD v3 ขัดกับ@ant-design/iconsv4 ใน dependency treenpm run buildสร้าง bundle ในbuild/
Stage 2 — nginx serve
nginx:1.24.0- copy
default.confจากnginx-config/ - copy
build/ไป/usr/share/nginx/html/build/ - copy PDF แนบ 2 ไฟล์ (ชื่อไทย) แยกต่างหาก เพราะเก็บใน
src/assets/images/ไม่ได้ถูก webpack processed
nginx config
vtrc-web/nginx-config/default.conf — server block หลัก
- listen port 80
try_files $uri $uri/ /index.html— SPA fallback- root
/usr/share/nginx/html/build
CI/CD — bitbucket-pipelines.yml
vtrc-web/bitbucket-pipelines.yml (~180 บรรทัด) — branch trigger เดียวกัน 4 branch
| Branch | Pipeline |
|---|---|
feature/cicd, develop, uat, prod | build-front-end-production → build-docker → push-image-to-registry |
ขั้นตอนหลัก — (1) build bundle ใน node image (2) build Docker image (3) push ไป registry โดย tag ด้วย commit short hash + :latest
env ทำงานอย่างไร
vtrc-web/config/env.js (~90 บรรทัด) เป็น CRA env loader มาตรฐาน โหลดตามลำดับ
.env ← ทุก env
.env.local ← ทุก env (ไม่ commit)
.env.development ← เฉพาะ dev
.env.production ← เฉพาะ prodREACT_APP_* จะถูก expose ผ่าน DefinePlugin
ข้อเท็จจริงเกี่ยวกับ env
ไม่พบ .env ใน workspace — env-specific ทั้งหมดทำงานที่ runtime ผ่าน window.location.href ใน checkLinkURL() bundle ตัวเดียวใช้กับทุก env เปลี่ยนเฉพาะ origin ที่เปิด
Apollo Client + checkLinkURL() — runtime env resolver
จุดที่ทำให้ vtrc-web แตกต่างจาก React app ทั่วไป — endpoint เลือกตอน runtime ตาม URL ที่ผู้ใช้เปิด ไม่ใช่ตอน build
vtrc-web/src/config/apollo.js:17-34 — checkLinkURL() เป็น runtime env resolver
const checkLinkURL = () =>{
if(window.location.href.includes("https://vtrc.redcross.or.th/")){
return "https://vtrcapi.redcross.or.th/api/graphql"
// return "https://vtrcapi.redcross.or.th/backup-site/graphql"
// return "https://uat-vtrcapi.redcross.or.th/api-uat/graphql"
}else if(window.location.href.includes("https://uat-vtrc.redcross.or.th/")){
// return "https://vtrcapi.redcross.or.th/api/graphql"
// return "https://vtrcapi.redcross.or.th/backup-site/graphql"
return "https://uat-vtrcapi.redcross.or.th/api-uat/graphql"
}else if(window.location.href.includes("http://localhost:3000/") || window.location.href.includes("http://192.168.0.105:3000/")){
// return "http://10.188.128.162:8081/api-uat/graphql"
// return "https://vtrcapi.redcross.or.th/api/graphql"
// return "https://vtrcapi.redcross.or.th/backup-site/graphql"
return "https://uat-vtrcapi.redcross.or.th/api-uat/graphql"
// return "http://192.168.0.137:8081/api-uat/graphql"
}
}ตารางการ mapping
| Origin ที่ผู้ใช้เปิด | GraphQL endpoint | หมายเหตุ |
|---|---|---|
https://vtrc.redcross.or.th/ | https://vtrcapi.redcross.or.th/api/graphql | prod |
https://vtrcbackoffice.redcross.or.th/ | https://vtrcapi.redcross.or.th/api/graphql | prod-backoffice (เดียวกัน) |
https://uat-vtrc.redcross.or.th/ | https://uat-vtrcapi.redcross.or.th/api-uat/graphql | UAT |
http://localhost:3000/ | https://uat-vtrcapi.redcross.or.th/api-uat/graphql | default สำหรับ dev |
http://192.168.0.105:3000/ | https://uat-vtrcapi.redcross.or.th/api-uat/graphql | dev (LAN IP) |
| origin อื่น ๆ | undefined | app จะ crash ตอนสร้าง ApolloClient |
หมายเหตุ — 4 endpoint ที่ active มีเพียง 2 ค่าจริง (vtrcapi.redcross.or.th/api/graphql prod + uat-vtrcapi.redcross.or.th/api-uat/graphql UAT) แต่เข้าถึงได้จาก 5 origin (รวม backoffice + localhost + LAN IP)
ข้อสังเกตสำคัญ
- ตรวจด้วย
window.location.href.includes(...)— เป็น substring match ไม่ใช่ exact origin ถ้าเปิดผ่าน IP ตรง ๆ หรือ domain สำรองที่ไม่ match จะได้undefinedทำให้new ApolloClient({ link: ...undefined... })ทำงานผิดพลาด - ใน source code มี comment endpoints สำรอง (
backup-site/graphql, IP ภายใน10.188.128.162:8081,192.168.0.137:8081) แสดงว่าเคยมีการสลับ endpoint ฉุกเฉินในอดีต - ไม่ใช้
NODE_ENVหรือREACT_APP_*— เพราะ bundle ตัวเดียวใช้กับทุก env vtrcbackoffice.redcross.or.th(prod-backoffice origin) ไม่ได้ระบุในcheckLinkURL()ของvtrc-webแต่ vtrc-backoffice app มีcheckLinkURL()ของตัวเองที่ชี้ไป endpoint เดียวกัน
กฎเหล็กเมื่อเพิ่ม env
ถ้าต้องการรองรับ origin ใหม่ ให้แก้ checkLinkURL() ตาม pattern เดิม — ห้าม สร้างกลไก .env คู่แข่ง เพราะจะทำให้มีสองทางเลือก endpoint และสับสน
โครงสร้าง Apollo Client
vtrc-web/src/config/apollo.js:166-184 — สร้าง Apollo Client + ตั้ง defaultOptions
const apolloClient = new ApolloClient({
link: ApolloLink.from([errorLink, authLink, uploadLink]),
cache: new InMemoryCache()
})
// cache-and-network
apolloClient.defaultOptions = {
watchQuery: {
fetchPolicy: 'no-cache',
// fetchPolicy: 'cache-and-network',
errorPolicy: 'ignore'
},
query: {
fetchPolicy: 'no-cache',
// fetchPolicy: 'cache-and-network',
errorPolicy: 'all'
}
}
export default apolloClientLink chain 3 ตัว
request ──► errorLink ──► authLink ──► uploadLink ──► server| Link | Citation | หน้าที่ |
|---|---|---|
errorLink | vtrc-web/src/config/apollo.js:69-164 | จับ GraphQL error แยกตาม extensions.code |
authLink | vtrc-web/src/config/apollo.js:48-67 | แปะ Authorization: Bearer [token] + apiKey: [key] |
uploadLink | vtrc-web/src/config/apollo.js:38 (createUploadLink) | link สุดท้ายส่งไป server รองรับ multipart สำหรับ upload ไฟล์ |
fetchPolicy: 'no-cache' เป็นค่า default
ทุก query และ watchQuery ใช้ no-cache — cache ของ Apollo แทบไม่ถูกใช้ ทุกครั้งที่เรียกดูข้อมูลจะดึงใหม่จาก server
ผลกระทบ
- ข้อมูลเสมอภาคสดทุกครั้ง (good)
- ไม่ต้อง invalidate cache (good)
- ความรู้สึกว่าแอปช้าเวลาเปลี่ยนหน้าเพราะดึงข้อมูลใหม่ทุกครั้ง (bad)
ใน source code มี comment // fetchPolicy: 'cache-and-network' อยู่ใต้บรรทัด no-cache — เป็นทางเลือกที่เคยพิจารณาแต่ปิดไว้
authLink — แปะ headers 2 ตัว
vtrc-web/src/config/apollo.js:48-67
const authLink = setContext((_, { headers }) => {
const { token } = getToken()
const API_DEVICE_KEY = atob(apiKeyNews())
if (token) {
return {
headers: {
...headers,
Authorization: `Bearer ${token}`,
apiKey : API_DEVICE_KEY
}
}
}
return {
headers: {
apiKey : API_DEVICE_KEY
}
}
})| Header | Citation | หน้าที่ |
|---|---|---|
Authorization: Bearer [token] | getToken().token ใน vtrc-web/src/utils/auth.js | JWT ของ user ปัจจุบัน |
apiKey: [key] | atob(apiKeyNews()) — base64 decode | device key ที่ระบุ APP_TYPE: WEB |
ถ้ายังไม่ login (ไม่มี token) จะส่งแค่ apiKey อย่างเดียว — สำหรับ query สาธารณะเช่น refreshToken, generateCaptcha, sendVerifyCode
apiKey เป็น base64 ไม่ใช่ encryption
apiKeyNews() ใน vtrc-web/src/utils/apiKey.js (12 บรรทัด) คืน string base64 ที่ถูก hardcoded ตาม origin — ถ้า origin เป็น prod จะได้ key prod ถ้าเป็น uat จะได้ key uat
base64 decode ด้วย atob() ได้ค่าจริงทันที — ทุกคนที่เปิด DevTools จะเห็น key จริงได้ จึงไม่ใช่การเข้ารหัส แต่เป็นการ encode ไม่ให้ตรวจจับได้ง่ายเวลา grep bundle
รายละเอียดด้านความปลอดภัยใน 4.1.3
errorLink — แยกตาม extensions.code
vtrc-web/src/config/apollo.js:69-164 — เป็นพฤติกรรม error handling ที่จัดการที่เดียวทั้งระบบ
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors && graphQLErrors[0].extensions.code === 'TOKEN_EXPIRED') {
return new Observable(async observer => {
// ... refresh token แล้ว retry ...
})
}
if(graphQLErrors){
if(graphQLErrors && graphQLErrors[0].extensions.code === 'TOKEN_INVALID'){
// alert + logout + redirect /login (หน่วง 2 วินาที)
}else if(... === 'SESSION_EXPIRED' || ... === 'SESSION_INVALID'){
// logout
}else if(... === 'AUTH_LIMIT'){
// logout
}else if(... === 'PROFILE_INVALID' || ... === 'VERIFY_CODE_INVALID'){
// message.error + remainingCount ถ้ามี
}else if(... === 'INTERNAL_SERVER_ERROR'){
// logout
}else if(... === 'PROFILE_NOT_FOUND'){
// logout
}else if(... === 'INVALID_PASSWORD_5_TIMES'){
// logout
}else{
// default → ErrorMessageFormAPIApollo()
}
}
})ตาราง error code → พฤติกรรม
| code | พฤติกรรม | Citation |
|---|---|---|
TOKEN_EXPIRED | refresh token ผ่าน apolloFetch แล้ว retry | vtrc-web/src/config/apollo.js:71-101 |
TOKEN_INVALID | alert + logout + redirect /login (หน่วง 2 วินาที) | vtrc-web/src/config/apollo.js:107-112 |
SESSION_EXPIRED, SESSION_INVALID | alert + logout + redirect /login | vtrc-web/src/config/apollo.js:113-121 |
AUTH_LIMIT | alert + logout + redirect /login | vtrc-web/src/config/apollo.js:122-128 |
PROFILE_INVALID, VERIFY_CODE_INVALID | message.error + remainingCount ถ้ามี | vtrc-web/src/config/apollo.js:129-136 |
INTERNAL_SERVER_ERROR | alert "พบข้อผิดพลาด กรุณาติดต่อผู้ดูแลระบบ" + logout | vtrc-web/src/config/apollo.js:137-142 |
PROFILE_NOT_FOUND | alert "ไม่พบข้อมูล" + logout | vtrc-web/src/config/apollo.js:143-148 |
INVALID_PASSWORD_5_TIMES | alert + logout | vtrc-web/src/config/apollo.js:149-154 |
| อื่น ๆ | ErrorMessageFormAPIApollo() แมป code → ข้อความไทย | vtrc-web/src/config/apollo.js:155-162 |
กฎเหล็กของ errorLink
ทุก GraphQL error ทำงานที่นี่ที่เดียว — ห้าม เพิ่ม try/catch หรือ .catch() รอบ mutation เพื่อแสดงข้อความเอง (ยกเว้นกรณีที่ต้องการ field-level form error ใช้ ErrorMessageFormAPIApollo(graphQLErrors[0]) ตรง ๆ)
Token refresh flow (code TOKEN_EXPIRED)
vtrc-web/src/config/apollo.js:71-101 — ลำดับการ refresh token
if (graphQLErrors && graphQLErrors[0].extensions.code === 'TOKEN_EXPIRED') {
return new Observable(async observer => {
const oldHeaders = operation.getContext().headers
const { errors, data } = await apolloFetch({
query: Queries.refreshToken,
variables: {
refreshToken: getToken().refreshToken,
token: getToken().token
},
})
if (!errors) {
setToken(data.refreshToken)
operation.setContext({
headers: {
...oldHeaders,
Authorization: `Bearer ${data.refreshToken.accessToken}`
}
})
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer)
}
forward(operation).subscribe(subscriber)
} else if (errors && errors[0].extensions.code === 'TOKEN_INVALID') {
alertMessage("error",graphQLErrors[0].message)
clearAllStorage()
window.location.href = '/login'
}
})
}algorithm
- ถ้า vtrc-api ตอบ
TOKEN_EXPIRED→ เรียกQueries.refreshTokenผ่านapolloFetch(ไม่ใช่ apolloClient เพื่อไม่ให้วน errorLink) - ถ้า refresh สำเร็จ →
setTokenเก็บ token ใหม่ + update header + retry operation เดิมผ่านforward(operation) - ถ้า refresh fail ด้วย
TOKEN_INVALID→ logout + redirect/login
ข้อควรระวัง — ใช้ apolloFetch (createApolloFetch จาก apollo-fetch library, ที่ vtrc-web/src/config/apollo.js:37) ไม่ใช่ apolloClient เพราะ apolloClient จะวนกลับมาที่ errorLink ทำให้เกิด infinite loop
apolloFetch.use(...) (ที่ vtrc-web/src/config/apollo.js:40-46) เป็น middleware ที่แปะ apiKey header ให้ apolloFetch ด้วย ไม่เช่นนั้น refresh request จะถูก vtrc-api ปฏิเสธเพราะขาด device key
apolloFetch.use(({ request, options }, next) => {
const API_DEVICE_KEY = atob(apiKeyNews())
options.headers = {
apiKey: API_DEVICE_KEY
};
next();
});Token storage — utils/auth.js
vtrc-web/src/utils/auth.js:4-35 — setToken + getToken
export const setToken = ({ accessToken, refreshToken }, isRemember) => {
if (isRemember === undefined) {
isRemember = localStorage.getItem('isRemember')
}
if (isRemember) {
localStorage.setItem('token', accessToken)
if(refreshToken){
localStorage.setItem('refreshToken', refreshToken)
}
localStorage.setItem('isRemember', 'true')
} else {
sessionStorage.setItem('token', accessToken)
if(refreshToken){
sessionStorage.setItem('refreshToken', refreshToken)
}
}
}
export const getToken = () => {
if (localStorage.getItem('isRemember')) {
return {
token: localStorage.getItem('token'),
refreshToken: localStorage.getItem('refreshToken')
}
} else {
return {
token: sessionStorage.getItem('token'),
refreshToken: sessionStorage.getItem('refreshToken')
}
}
}กฎการเลือก storage
isRemember | token เก็บที่ | refreshToken เก็บที่ | isRemember เก็บที่ |
|---|---|---|---|
true | localStorage | localStorage | localStorage (เสมอ) |
false หรือ undefined | sessionStorage | sessionStorage | localStorage (เสมอ) |
- localStorage เก็บข้าม tab และอยู่ข้ามวัน
- sessionStorage เก็บเฉพาะ tab ปัจจุบัน ปิด tab แล้วหาย
isRememberflag เก็บใน localStorage เสมอเพื่อให้รู้ว่าต้องอ่าน token จากที่ไหนเวลา reload
clearAllStorage() (vtrc-web/src/utils/auth.js:99-102) — logout ทำครั้งเดียว ล้างทั้งสอง storage เพราะไม่รู้ว่า user ตอนนี้ใช้ localStorage หรือ sessionStorage
export const clearAllStorage = () => {
localStorage.clear()
sessionStorage.clear()
}helpers อื่นใน utils/auth.js
นอกจาก token storage ยังมี helpers ที่ใช้บ่อย
| helper | Citation | หน้าที่ |
|---|---|---|
isLoggedIn() | vtrc-web/src/utils/auth.js:91-97 | ตรวจ token + refreshToken ครบทั้งคู่ — ใช้ใน App/index.js:27-28 |
getLsId() | vtrc-web/src/utils/auth.js:104-106 | อ่าน lsid (login session id) |
getIsTrial() | vtrc-web/src/utils/auth.js:108-110 | อ่าน IsTrial flag |
getSessionID() | vtrc-web/src/utils/auth.js:112-114 | อ่าน sessionID |
getOrganization() | vtrc-web/src/utils/auth.js:116-119 | คืน string คงที่ (hardcoded UUID) |
appVersion() | vtrc-web/src/utils/auth.js:170-172 | คืน '1.8.0' (hardcoded) |
versionWeb() | vtrc-web/src/utils/auth.js:174-182 | คืน label ตาม origin ('Web App version' หรือ 'UAT Web App version') |
URL resolver สำหรับ REST services อื่น
นอกจาก GraphQL endpoint (checkLinkURL() ใน apollo.js) ยังมี URL resolver สำหรับ REST services อื่นใน vtrc-web/src/utils/auth.js
| helper | endpoint ตัวอย่าง (prod) | ใช้ที่ไหน |
|---|---|---|
checkLinkURL() (auth.js) | https://vtrcapi.redcross.or.th/ | base URL ของ vtrc-api (REST) |
UrlMeeting() | https://vtrcdev.redcross.or.th | meeting service |
UrlAbstainTime() | https://vtrcdev.redcross.or.th | abstain time service |
UrleventLog() | .../log-event-api/api/v1/logEvent | event log service |
workforce() | .../workforce-api/api/restful/ | workforce service |
urlCommon() | .../vtrc-common/api/v1/ | common service |
urlBenefit() | .../benefit-api/api/v1/ | benefit service |
checkLinkUrlVtrcCustom() | .../vtrc-common/api/v1 | common service (duplicate) |
ข้อควรระวัง URL resolvers
UrlMeeting()และUrlAbstainTime()ใน prod ชี้ไปvtrcdev.redcross.or.th(ผิดปกติ — น่าจะเป็น dev ที่ใช้จริงใน prod) ต้อง verify กับทีมก่อนแก้checkLinkUrlVtrcCustom()prod ชี้ไปuat-vtrcapi...(ชัดเจนว่าเป็น bug) — แต่ถ้าเรียกใช้ไม่ได้จะรู้ตัว- มี helper ซ้ำซ้อน (
checkLinkURLใน apollo.js กับ auth.js คนละตัว) — ดูให้ดีว่าใช้ตัวไหน
refreshToken.js — wrapper สำหรับ axios 401
vtrc-web/src/config/refreshToken.js (35 บรรทัด) — เป็น promise-based wrapper รอ apolloClient.query ด้วย Queries.refreshToken ใช้ตอนที่ axios interceptor พบ 401 (ดู axios-bo.js)
เคล็ดลับตอนทำงานจริง
ตอน debug "หน้าขาว"
อย่าเพิ่งโทษ network ให้ตรวจสอบตามลำดับ
- เปิด DevTools → Console ดู error ถ้าไม่เห็นอะไรเลย → อาจเป็นเพราะ
vtrc-web/src/containers/App/index.js:15ปิด console ไว้ - comment บรรทัด
console.log = ... = () => {}ในApp/index.js:15ชั่วคราว แล้ว reload - ดู source map ใน DevTools → Sources เพื่อหาบรรทัดที่ error จริง
- ถ้าเป็น runtime error ที่ไม่มี ErrorBoundary → ทุก component บนหน้าจอจะ unmount
ตอน build แล้วเจอ warning ขนาด bundle
warning threshold ตั้งไว้ที่ 512 KB (gzip) — bundle ปัจจุบันใหญ่กว่ามาก รายละเอียดและแนวทางแก้ใน 4.1.3
ตอน upgrade dependency
ห้าม upgrade React เป็น 17/18, Apollo เป็น v3, หรือ AntD ข้าม major โดยไม่ได้รับมอบหมาย — เป็น breaking change เพราะ
react-apollo@3.1.5ใช้ HOCwithApolloที่ deprecated ใน Apollo v3- AntD v3 ใช้
LocaleProvider+Form.createHOC ที่หายไปใน v4 - React 17 เปลี่ยน event delegation ทำให้บาง component ทำงานผิดปกติ
ตอนเปลี่ยน endpoint
ห้ามเพิ่ม .env หรือ REACT_APP_* — ให้แก้ checkLinkURL() ใน vtrc-web/src/config/apollo.js:17-34 ตาม pattern เดิม
ตอน debug "request ไม่ถึง server"
ตรวจสอบตามลำดับ
- ดู origin ใน address bar — ตรงกับที่
checkLinkURL()รองรับไหม - ดู Network tab — request ไป endpoint ที่คาดไว้ไหม
- ดู request headers — มี
AuthorizationและapiKeyครบไหม - ดู response — ถ้าเป็น
extensions.codeให้ดูในตาราง errorLink ว่าควรเกิดอะไรขึ้น
ตอนเจอ "token refresh แล้ววนลูป"
อาจเป็นเพราะ apolloFetch เองก็ได้รับ TOKEN_EXPIRED แล้ววนกลับมา — ตรวจดูว่า apolloFetch.use() (vtrc-web/src/config/apollo.js:40-46) ใส่ apiKey แล้วหรือยัง และ refreshToken ที่ส่งไปยังใช้ได้ไหม
ตอนเพิ่ม error code ใหม่
- เพิ่มใน vtrc-api ฝั่ง resolver ก่อน (
throw new ApolloError(..., { code: 'NEW_CODE' })) - เพิ่ม mapping ใน
vtrc-web/src/utils/checkError.jsเพื่อแมป code → ข้อความไทย - ถ้า behavior ไม่ใช่ "แสดงข้อความ" ให้เพิ่ม
else ifใน errorLink (vtrc-web/src/config/apollo.js:106-163)
ห้ามแยก handling ออกไปที่ .catch() ของแต่ละ mutation
ตอนที่ query ช้าเพราะ no-cache
บาง query ที่ไม่ควรเปลี่ยนบ่อย (เช่น dropdown master data) สามารถเปลี่ยน fetchPolicy เฉพาะตัวเป็น 'cache-first' ได้ — แต่ต้องแน่ใจว่า cache invalidation จะไม่เป็นปัญหา
this.props.client.query({
query: Queries.dropdownListProvince,
fetchPolicy: 'cache-first', // override default 'no-cache'
variables: { ... }
})อย่าอ่าน localStorage โดยตรงใน component
ใช้ getToken(), setToken(), clearAllStorage() จาก vtrc-web/src/utils/auth.js เสมอ — ถ้าอ่าน localStorage.getItem('token') โดยตรงจะไม่รองรับกรณีที่ user เลือก isRemember: false (เก็บใน sessionStorage)
ขั้นตอนถัดไป
ไป 4.1.2 Routing + auth gate + Component patterns + GraphQL catalog →