Skip to content

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

javascript
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

javascript
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

javascript
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

javascript
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 — ApolloProviderLocaleProviderApp สำคัญเพราะ 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)

javascript
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)

javascript
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)

javascript
{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

โหมดmodedevtoolbailTerserPlugin
production'production''source-map' (ถ้า shouldUseSourceMap)trueminify เปิด
development'development''cheap-module-source-map'falseminify ปิด

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-nativereact-native-web (มีใน jest.moduleNameMapper ด้วย ที่ vtrc-web/package.json:160)
  • ไม่มี @/ หรือ path alias อื่น — ทุก import ใช้ relative path

Plugin สำคัญ

Pluginหน้าที่
CleanWebpackPluginล้าง build/ ก่อน build
HtmlWebpackPlugininject 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

html
<!-- 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

json
"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 buildvtrc-web/scripts/build.js (212 บรรทัด)production build เรียก configFactory('production')
npm testvtrc-web/scripts/test.jsjest 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:

javascript
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;   // 512 KB
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;    // 1 MB

bundle ปัจจุบันเกิน threshold ทั้งคู่ — ทุกครั้งที่ build จะมี warning รายงานขนาด รายละเอียดใน 4.1.3


Docker build — 2 stages

vtrc-web/Dockerfile:1-19 (สั้น แต่ load-bearing)

dockerfile
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_แบบแจ้งรายการเพื่อการหักลดหย่อนภาษี.pdf

Stage 1 — build bundle

  • ใช้ node:14.20 (legacy — Node 14 EOL เมษายน 2566 แล้ว)
  • npm install --legacy-peer-deps เพราะ AntD v3 ขัดกับ @ant-design/icons v4 ใน dependency tree
  • npm 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

BranchPipeline
feature/cicd, develop, uat, prodbuild-front-end-productionbuild-dockerpush-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       ← เฉพาะ prod

REACT_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-34checkLinkURL() เป็น runtime env resolver

javascript
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/graphqlprod
https://vtrcbackoffice.redcross.or.th/https://vtrcapi.redcross.or.th/api/graphqlprod-backoffice (เดียวกัน)
https://uat-vtrc.redcross.or.th/https://uat-vtrcapi.redcross.or.th/api-uat/graphqlUAT
http://localhost:3000/https://uat-vtrcapi.redcross.or.th/api-uat/graphqldefault สำหรับ dev
http://192.168.0.105:3000/https://uat-vtrcapi.redcross.or.th/api-uat/graphqldev (LAN IP)
origin อื่น ๆundefinedapp จะ 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)

ข้อสังเกตสำคัญ

  1. ตรวจด้วย window.location.href.includes(...) — เป็น substring match ไม่ใช่ exact origin ถ้าเปิดผ่าน IP ตรง ๆ หรือ domain สำรองที่ไม่ match จะได้ undefined ทำให้ new ApolloClient({ link: ...undefined... }) ทำงานผิดพลาด
  2. ใน source code มี comment endpoints สำรอง (backup-site/graphql, IP ภายใน 10.188.128.162:8081, 192.168.0.137:8081) แสดงว่าเคยมีการสลับ endpoint ฉุกเฉินในอดีต
  3. ไม่ใช้ NODE_ENV หรือ REACT_APP_* — เพราะ bundle ตัวเดียวใช้กับทุก env
  4. 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

javascript
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 apolloClient
request ──► errorLink ──► authLink ──► uploadLink ──► server
LinkCitationหน้าที่
errorLinkvtrc-web/src/config/apollo.js:69-164จับ GraphQL error แยกตาม extensions.code
authLinkvtrc-web/src/config/apollo.js:48-67แปะ Authorization: Bearer [token] + apiKey: [key]
uploadLinkvtrc-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 — เป็นทางเลือกที่เคยพิจารณาแต่ปิดไว้


vtrc-web/src/config/apollo.js:48-67

javascript
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
		}
	}
})
HeaderCitationหน้าที่
Authorization: Bearer [token]getToken().token ใน vtrc-web/src/utils/auth.jsJWT ของ user ปัจจุบัน
apiKey: [key]atob(apiKeyNews()) — base64 decodedevice 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


vtrc-web/src/config/apollo.js:69-164 — เป็นพฤติกรรม error handling ที่จัดการที่เดียวทั้งระบบ

javascript
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_EXPIREDrefresh token ผ่าน apolloFetch แล้ว retryvtrc-web/src/config/apollo.js:71-101
TOKEN_INVALIDalert + logout + redirect /login (หน่วง 2 วินาที)vtrc-web/src/config/apollo.js:107-112
SESSION_EXPIRED, SESSION_INVALIDalert + logout + redirect /loginvtrc-web/src/config/apollo.js:113-121
AUTH_LIMITalert + logout + redirect /loginvtrc-web/src/config/apollo.js:122-128
PROFILE_INVALID, VERIFY_CODE_INVALIDmessage.error + remainingCount ถ้ามีvtrc-web/src/config/apollo.js:129-136
INTERNAL_SERVER_ERRORalert "พบข้อผิดพลาด กรุณาติดต่อผู้ดูแลระบบ" + logoutvtrc-web/src/config/apollo.js:137-142
PROFILE_NOT_FOUNDalert "ไม่พบข้อมูล" + logoutvtrc-web/src/config/apollo.js:143-148
INVALID_PASSWORD_5_TIMESalert + logoutvtrc-web/src/config/apollo.js:149-154
อื่น ๆErrorMessageFormAPIApollo() แมป code → ข้อความไทยvtrc-web/src/config/apollo.js:155-162

ทุก 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

javascript
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

  1. ถ้า vtrc-api ตอบ TOKEN_EXPIRED → เรียก Queries.refreshToken ผ่าน apolloFetch (ไม่ใช่ apolloClient เพื่อไม่ให้วน errorLink)
  2. ถ้า refresh สำเร็จ → setToken เก็บ token ใหม่ + update header + retry operation เดิมผ่าน forward(operation)
  3. ถ้า 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

javascript
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-35setToken + getToken

javascript
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

isRemembertoken เก็บที่refreshToken เก็บที่isRemember เก็บที่
truelocalStoragelocalStoragelocalStorage (เสมอ)
false หรือ undefinedsessionStoragesessionStoragelocalStorage (เสมอ)
  • localStorage เก็บข้าม tab และอยู่ข้ามวัน
  • sessionStorage เก็บเฉพาะ tab ปัจจุบัน ปิด tab แล้วหาย
  • isRemember flag เก็บใน localStorage เสมอเพื่อให้รู้ว่าต้องอ่าน token จากที่ไหนเวลา reload

clearAllStorage() (vtrc-web/src/utils/auth.js:99-102) — logout ทำครั้งเดียว ล้างทั้งสอง storage เพราะไม่รู้ว่า user ตอนนี้ใช้ localStorage หรือ sessionStorage

javascript
export const clearAllStorage = () => {
	localStorage.clear()
	sessionStorage.clear()
}

helpers อื่นใน utils/auth.js

นอกจาก token storage ยังมี helpers ที่ใช้บ่อย

helperCitationหน้าที่
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

helperendpoint ตัวอย่าง (prod)ใช้ที่ไหน
checkLinkURL() (auth.js)https://vtrcapi.redcross.or.th/base URL ของ vtrc-api (REST)
UrlMeeting()https://vtrcdev.redcross.or.thmeeting service
UrlAbstainTime()https://vtrcdev.redcross.or.thabstain time service
UrleventLog().../log-event-api/api/v1/logEventevent 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/v1common service (duplicate)

ข้อควรระวัง URL resolvers

  1. UrlMeeting() และ UrlAbstainTime() ใน prod ชี้ไป vtrcdev.redcross.or.th (ผิดปกติ — น่าจะเป็น dev ที่ใช้จริงใน prod) ต้อง verify กับทีมก่อนแก้
  2. checkLinkUrlVtrcCustom() prod ชี้ไป uat-vtrcapi... (ชัดเจนว่าเป็น bug) — แต่ถ้าเรียกใช้ไม่ได้จะรู้ตัว
  3. มี 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 ให้ตรวจสอบตามลำดับ

  1. เปิด DevTools → Console ดู error ถ้าไม่เห็นอะไรเลย → อาจเป็นเพราะ vtrc-web/src/containers/App/index.js:15 ปิด console ไว้
  2. comment บรรทัด console.log = ... = () => {} ใน App/index.js:15 ชั่วคราว แล้ว reload
  3. ดู source map ใน DevTools → Sources เพื่อหาบรรทัดที่ error จริง
  4. ถ้าเป็น 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 ใช้ HOC withApollo ที่ deprecated ใน Apollo v3
  • AntD v3 ใช้ LocaleProvider + Form.create HOC ที่หายไปใน v4
  • React 17 เปลี่ยน event delegation ทำให้บาง component ทำงานผิดปกติ

ตอนเปลี่ยน endpoint

ห้ามเพิ่ม .env หรือ REACT_APP_* — ให้แก้ checkLinkURL() ใน vtrc-web/src/config/apollo.js:17-34 ตาม pattern เดิม

ตอน debug "request ไม่ถึง server"

ตรวจสอบตามลำดับ

  1. ดู origin ใน address bar — ตรงกับที่ checkLinkURL() รองรับไหม
  2. ดู Network tab — request ไป endpoint ที่คาดไว้ไหม
  3. ดู request headers — มี Authorization และ apiKey ครบไหม
  4. ดู response — ถ้าเป็น extensions.code ให้ดูในตาราง errorLink ว่าควรเกิดอะไรขึ้น

ตอนเจอ "token refresh แล้ววนลูป"

อาจเป็นเพราะ apolloFetch เองก็ได้รับ TOKEN_EXPIRED แล้ววนกลับมา — ตรวจดูว่า apolloFetch.use() (vtrc-web/src/config/apollo.js:40-46) ใส่ apiKey แล้วหรือยัง และ refreshToken ที่ส่งไปยังใช้ได้ไหม

ตอนเพิ่ม error code ใหม่

  1. เพิ่มใน vtrc-api ฝั่ง resolver ก่อน (throw new ApolloError(..., { code: 'NEW_CODE' }))
  2. เพิ่ม mapping ใน vtrc-web/src/utils/checkError.js เพื่อแมป code → ข้อความไทย
  3. ถ้า 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 จะไม่เป็นปัญหา

javascript
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