vncitizens-bridge
v1.0.1
Published
JWT bridge giữa Flutter InAppWebView và VNCitizens mini apps
Maintainers
Readme
vncitizens-bridge
JWT bridge chính thức giữa Flutter InAppWebView và VNCitizens mini apps.
Hoạt động với mọi framework: React, Next.js, Angular, Vue 3, PHP/Laravel, Vanilla JS.
Mục lục
- Kiến trúc tổng quan
- Yêu cầu hệ thống
- Phát hành bản mới (CI/CD)
- Tích hợp Flutter (phía app)
- Tích hợp mini app — React / Next.js
- Tích hợp mini app — Angular
- Tích hợp mini app — Vue 3
- Tích hợp mini app — PHP / Laravel
- Tích hợp mini app — Vanilla JS
- Deploy mini app
- Update bridge lên version mới
- API Reference
- Bảo mật
- Troubleshooting
1. Kiến trúc tổng quan
Flutter App
└─ MiniAppPage (InAppWebView)
└─ UserScript inject window.__VNCITIZENS__ = { token, platform, appVersion }
│
▼
Mini App (React / Angular / Vue / PHP / Vanilla)
└─ vncitizens-bridge đọc token → xoá window.__VNCITIZENS__
└─ createApiClient() — axios với auto Bearer + 401 refresh
│
├─ API call 200 OK → tiếp tục
└─ API call 401 → callHandler('refreshToken') → Flutter refresh
→ retry request → tiếp tụcNguyên tắc cốt lõi:
- Token chỉ sống trong memory — không bao giờ lưu
localStoragehaysessionStorage window.__VNCITIZENS__bị xoá ngay sau khi bridge đọc xong- Refresh token tự động deduplicate — N request 401 cùng lúc chỉ gọi Flutter 1 lần
- Mini app developer không cần biết gì về token — chỉ gọi
api.get(...)bình thường
2. Yêu cầu hệ thống
| Thành phần | Phiên bản tối thiểu | |---|---| | Flutter | 3.10+ | | flutter_inappwebview | 6.0+ | | Node.js | 18+ | | axios (peer dep) | 1.0+ | | Vite (cho React/Vue) | 5+ |
3. Phát hành bản mới (CI/CD)
Package này được phát hành chính thức lên npmjs.com.
3.1 Đăng nhập (Lần đầu)
npm login
# Nhập username: autrungtinhnghich
# Nhập password: ... (liên hệ lead)3.2 Build và Publish
# 1. Cài đặt dependencies
npm install
# 2. Build tất cả các bản phân phối (ESM, CJS, UMD)
npm run build
# 3. Publish lên npmjs (mặc định là public)
npm run publish:npm3.3 Kiểm tra bản mới
npm view vncitizens-bridge5. Tích hợp Flutter (phía app)
5.1 Thêm dependency
# pubspec.yaml
dependencies:
flutter_inappwebview: ^6.0.0flutter pub get5.2 Copy 2 file Dart vào project
Đặt vào packages/web_shell/lib/src/:
packages/web_shell/lib/src/
├── web_view_service.dart ← đăng ký JS handlers
└── mini_app_page.dart ← widget WebView wrapper5.3 Sử dụng MiniAppPage
// Trong router hoặc bất kỳ màn hình nào
import 'package:web_shell/src/mini_app_page.dart';
// Mở mini app khi user bấm menu
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => MiniAppPage(
url: 'https://app-a.vncitizens.vn',
title: 'Dịch vụ A',
// Lấy token từ AuthRepository của bạn
getToken: () => context.read<AuthRepository>().getValidToken(),
// Refresh token khi hết hạn
refreshToken: () => context.read<AuthRepository>().refreshToken(),
// Mini app yêu cầu navigate sang màn hình Flutter
onNavigate: (route, params) {
context.go(route, extra: params); // GoRouter
},
// Auth error không thể recover → logout
onAuthError: () {
context.read<AuthBloc>().add(LogoutEvent());
},
// Nhận custom events từ mini app (tuỳ chọn)
onEvent: (event, payload) {
debugPrint('Mini app event: $event, payload: $payload');
},
),
),
);6. Tích hợp mini app — React / Next.js
npm install vncitizens-bridge axios npm install -D vite @vitejs/plugin-react
### 6.2 Cấu hình Vite plugin
```javascript
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { vncBridgePlugin } from 'vncitizens-bridge/vite-plugin'
export default defineConfig({
plugins: [
react(),
vncBridgePlugin({
apiBaseUrl: 'https://api-a.vncitizens.vn', // URL backend của mini app này
appName: 'mini-app-a', // Tên định danh duy nhất
}),
],
})6.3 Import và dùng
// src/main.jsx
import { api, vncBridge } from 'virtual:vncitizens-bridge'
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
createRoot(document.getElementById('root')).render(
<App api={api} bridge={vncBridge} />
)// src/App.jsx — hoặc bất kỳ component nào
export default function App({ api, bridge }) {
const [data, setData] = React.useState([])
React.useEffect(() => {
// Token tự gắn, 401 tự refresh — không cần xử lý gì thêm
api.get('/orders').then(r => setData(r.data))
}, [])
return (
<div>
{/* Gọi navigate sang màn hình Flutter native */}
<button onClick={() => bridge.navigate('/profile')}>
Trang cá nhân
</button>
</div>
)
}7. Tích hợp mini app — Angular
7.1 Cài đặt
npm install vncitizens-bridge axios7.2 Tạo Service
// src/app/core/vnc-bridge.service.ts
import { Injectable } from '@angular/core'
import { VNCitizensBridge, createApiClient } from 'vncitizens-bridge'
import { AxiosInstance } from 'axios'
@Injectable({ providedIn: 'root' })
export class VncBridgeService {
private bridge = new VNCitizensBridge()
readonly api: AxiosInstance
constructor() {
this.api = createApiClient('https://api-b.vncitizens.vn')
}
get isFlutter() { return this.bridge.isFlutter }
getToken() { return this.bridge.getToken() }
navigate(route: string, params = {}) {
this.bridge.navigate(route, params)
}
}7.3 Đăng ký APP_INITIALIZER
// src/app/app.config.ts
import { ApplicationConfig, APP_INITIALIZER } from '@angular/core'
import { VncBridgeService } from './core/vnc-bridge.service'
export const appConfig: ApplicationConfig = {
providers: [
{
provide: APP_INITIALIZER,
useFactory: (bridge: VncBridgeService) => () => Promise.resolve(),
deps: [VncBridgeService],
multi: true,
},
],
}7.4 Dùng trong component
// src/app/features/orders/orders.component.ts
import { Component, inject, OnInit } from '@angular/core'
import { VncBridgeService } from '../../core/vnc-bridge.service'
@Component({ selector: 'app-orders', templateUrl: './orders.component.html' })
export class OrdersComponent implements OnInit {
private bridge = inject(VncBridgeService)
orders: any[] = []
async ngOnInit() {
const res = await this.bridge.api.get('/orders')
this.orders = res.data
}
}8. Tích hợp mini app — Vue 3
8.1 Cài đặt
npm install vncitizens-bridge axios8.2 Tạo Vue plugin
// src/plugins/vncBridge.js
import { VNCitizensBridge, createApiClient } from 'vncitizens-bridge'
const bridge = new VNCitizensBridge()
const api = createApiClient('https://api-c.vncitizens.vn')
export const vncBridgePlugin = {
install(app) {
app.provide('vncBridge', bridge)
app.provide('api', api)
app.config.globalProperties.$vncBridge = bridge
app.config.globalProperties.$api = api
},
}8.3 Đăng ký plugin
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import { vncBridgePlugin } from './plugins/vncBridge'
createApp(App).use(vncBridgePlugin).mount('#app')8.4 Dùng trong component
<!-- src/components/OrderList.vue -->
<script setup>
import { inject, onMounted, ref } from 'vue'
const api = inject('api')
const bridge = inject('vncBridge')
const orders = ref([])
onMounted(async () => {
const res = await api.get('/orders')
orders.value = res.data
})
</script>
<template>
<div>
<button @click="bridge.navigate('/profile')">Trang cá nhân</button>
<div v-for="order in orders" :key="order.id">{{ order.name }}</div>
</div>
</template>9. Tích hợp mini app — PHP / Laravel
PHP không dùng bundler nên sử dụng UMD bundle qua CDN nội bộ.
9.1 Upload UMD bundle lên CDN
# Sau khi build bridge, upload lên CDN/server
aws s3 cp dist/index.umd.js \
s3://your-bucket/vncitizens-bridge/1.0.0/index.umd.js \
--content-type "application/javascript" \
--cache-control "public, max-age=31536000, immutable"Hoặc copy vào Nginx static folder:
cp dist/index.umd.js /var/www/cdn/vncitizens-bridge/1.0.0/index.umd.js9.2 Thêm vào layout chính
<!-- resources/views/layouts/app.blade.php -->
<!DOCTYPE html>
<html>
<head>
<!-- 1. axios -->
<script src="https://cdn.jsdelivr.net/npm/axios@1/dist/axios.min.js"></script>
<!-- 2. VNCitizens Bridge UMD -->
<script src="https://cdn.vncitizens.vn/vncitizens-bridge/1.0.0/index.umd.js"></script>
<!-- 3. Khởi tạo bridge -->
<script>
const { vncBridge, createApiClient } = window.VNCitizensBridgeSDK
window.__VNC_BRIDGE__ = vncBridge
window.__VNC_API__ = createApiClient('https://api-d.vncitizens.vn')
</script>
</head>
<body>
@yield('content')
@stack('scripts')
</body>
</html>9.3 Dùng trong view
<!-- resources/views/orders/index.blade.php -->
@extends('layouts.app')
@section('content')
<div id="orders-list"></div>
@endsection
@push('scripts')
<script>
window.__VNC_API__.get('/orders').then(res => {
document.getElementById('orders-list').innerHTML =
res.data.map(o => `<div class="order">${o.name}</div>`).join('')
}).catch(console.error)
</script>
@endpush10. Tích hợp mini app — Vanilla JS
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/axios@1/dist/axios.min.js"></script>
<script src="https://cdn.vncitizens.vn/vncitizens-bridge/1.0.0/index.umd.js"></script>
</head>
<body>
<div id="app"></div>
<script type="module">
const { vncBridge, createApiClient } = window.VNCitizensBridgeSDK
const api = createApiClient('https://api-e.vncitizens.vn')
// Token đã tự gắn — gọi API bình thường
const res = await api.get('/data')
document.getElementById('app').textContent = JSON.stringify(res.data)
</script>
</body>
</html>11. Deploy mini app
11.1 Thêm scripts vào package.json
{
"scripts": {
"dev": "vite",
"build": "vite build",
"deploy:staging": "npm run build && bash scripts/deploy.sh staging",
"deploy:production": "npm run build && bash scripts/deploy.sh production"
}
}11.2 Script deploy
Tạo file scripts/deploy.sh:
#!/bin/bash
set -e
ENV=${1:-staging}
APP_NAME=$(node -p "require('./package.json').name")
BUILD_DIR="dist"
echo "🚀 Deploying $APP_NAME to $ENV..."
if [ "$ENV" = "production" ]; then
BUCKET="s3://your-bucket/apps/$APP_NAME"
CLOUDFRONT_ID="XXXXXXXXXXXXX"
# Upload static assets (cache vĩnh viễn)
aws s3 sync $BUILD_DIR $BUCKET \
--delete \
--exclude "index.html" \
--cache-control "public, max-age=31536000, immutable"
# Upload index.html (không cache — để update ngay)
aws s3 cp $BUILD_DIR/index.html $BUCKET/index.html \
--cache-control "no-cache, no-store, must-revalidate"
# Invalidate CloudFront
aws cloudfront create-invalidation \
--distribution-id $CLOUDFRONT_ID \
--paths "/$APP_NAME/*"
else
# Staging: rsync lên server
rsync -avz --delete $BUILD_DIR/ \
[email protected]:/var/www/apps/$APP_NAME/
fi
echo "✅ Done. Live: https://$APP_NAME.vncitizens.vn"11.3 Chạy deploy
# Staging
npm run deploy:staging
# Production
npm run deploy:production12. Update bridge lên version mới
# 1. Tại thư mục vncitizens-bridge:
# Tăng version theo chuẩn Semantic Versioning
npm version patch # 1.0.0 -> 1.0.1
# Build và publish
npm run publish:npm
# 2. Tại thư mục mini app:
npm update vncitizens-bridge
npm run build
# Deploy lại mini app
npm run deploy:production13. API Reference
VNCitizensBridge
class VNCitizensBridge {
constructor(options?: {
/** Danh sách origin được phép (dành cho bảo mật tương lai) */
allowedOrigins?: string[]
})
/** true nếu đang chạy trong Flutter WebView */
readonly isFlutter: boolean
/** Platform string từ Flutter ("flutter") */
readonly platform: string | null
/** Version của Flutter app */
readonly appVersion: string | null
/** Lấy JWT token hiện tại (đồng bộ) */
getToken(): string | null
/**
* Yêu cầu Flutter refresh JWT.
* Tự động dedup — N lần gọi đồng thời chỉ tạo 1 request.
*/
refresh(): Promise<string>
/** Yêu cầu Flutter navigate sang màn hình native */
navigate(route: string, params?: object): void
/** Gửi custom event lên Flutter */
emit(event: string, payload?: object): void
/**
* Đăng ký lắng nghe khi token thay đổi (sau khi refresh).
* Trả về hàm cleanup.
*/
onTokenUpdated(callback: (token: string) => void): () => void
/** Cập nhật JWT token thủ công (dành cho app tự quản lý auth) */
setToken(token: string | null): void
}
// Singleton — dùng chung toàn app
export const vncBridge: VNCitizensBridgecreateApiClient(baseURL, options?, bridge?)
function createApiClient(
baseURL: string,
options?: AxiosRequestConfig & { attachToken?: boolean },
bridge?: VNCitizensBridge
): AxiosInstanceTạo axios instance với:
- Request interceptor: tự gắn
Authorization: Bearer <token>(nếuattachToken !== false) - Response interceptor: bắt
401→ gọibridge.refresh()→ retry 1 lần
vncBridgePlugin(options) (Vite only)
function vncBridgePlugin(options: {
apiBaseUrl: string // URL backend của mini app
appName: string // Tên định danh (kebab-case)
}): PluginCung cấp virtual module virtual:vncitizens-bridge export { api, vncBridge }.
JS Handlers (Flutter phải đăng ký)
| Handler | Mô tả | Return |
|---|---|---|
| getToken | Lấy token hiện tại | { token, error } |
| refreshToken | Refresh JWT | { token, error } |
| onAuthError | Auth error không thể recover | null |
| navigate | Navigate màn hình Flutter | null |
| onEvent | Custom event từ mini app | null |
window.__VNCITIZENS__ (inject từ Flutter)
interface VNCitizensContext {
token: string // JWT access token
platform: string // "flutter"
appVersion: string // Flutter app version
timestamp: number // Unix timestamp lúc inject
}Quản lý Token thủ công (Tùy chọn)
Nếu bạn không muốn dùng createApiClient mặc định mà muốn tự quản lý token (ví dụ: đưa vào Redux/Pinia hoặc dùng fetch), bạn có thể sử dụng các API sau:
1. Lấy token và lắng nghe thay đổi
import { vncBridge } from 'vncitizens-bridge'
// Lấy token hiện tại
const token = vncBridge.getToken()
// Lắng nghe khi Flutter refresh token mới
vncBridge.onTokenUpdated((newToken) => {
console.log('Token mới:', newToken)
// Cập nhật vào store của bạn (Redux, Pinia, v.v.)
store.dispatch(setToken(newToken))
})
// Cập nhật token thủ công (Ví dụ: sau khi user login qua API riêng của mini app)
vncBridge.setToken('ey...xyz')2. Dùng với Axios riêng (Tự gắn header)
import { createApiClient, vncBridge } from 'vncitizens-bridge'
const api = createApiClient('https://api.example.com', {
attachToken: false // Tắt tự động gắn Authorization header
})
// Tự gắn interceptor theo ý mình
api.interceptors.request.use(config => {
const token = vncBridge.getToken()
config.headers['X-Custom-Auth'] = `Bearer ${token}`
return config
})3. Dùng với Native Fetch
import { vncBridge } from 'vncitizens-bridge'
async function fetchData(url) {
let token = vncBridge.getToken()
let response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
})
// Nếu bị 401, thử refresh và gọi lại
if (response.status === 401 && vncBridge.isFlutter) {
token = await vncBridge.refresh()
response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
})
}
return response.json()
}14. Bảo mật
| Quy tắc | Lý do |
|---|---|
| Không lưu token vào localStorage | XSS có thể đọc và đánh cắp |
| Xoá window.__VNCITIZENS__ ngay sau khi đọc | Giảm thời gian tồn tại trên window |
| Dùng AT_DOCUMENT_START | Đảm bảo token có trước khi bất kỳ script nào chạy |
| Validate origin trước khi gửi token | Chặn cross-origin token leak |
| Token là short-lived JWT (15–30 phút) | Giới hạn damage nếu bị leak |
| Retry chỉ 1 lần (_vncRetried flag) | Tránh refresh loop vô tận |
| Mini app chỉ chạy trên HTTPS | Bắt buộc cho production |
15. Troubleshooting
Token bị hết hạn nhưng không refresh
Kiểm tra:
- Flutter đã đăng ký handler
refreshTokenchưa? (WebViewService.registerHandlers()) AuthRepository.refreshToken()có throw exception không?- Backend có trả đúng
401(không phải403) không?
Nhiều request 401 cùng lúc gây nhiều lần refresh
Bridge đã xử lý tự động bằng #refreshPromise dedup. Nếu vẫn xảy ra, kiểm tra xem có đang dùng nhiều instance VNCitizensBridge hay không — chỉ nên dùng 1 singleton vncBridge.
License
MIT © VNCitizens Team
