@thanhphi/create-360-miniapp 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +328 -2
  3. package/bin/create-360-miniapp.cjs +54 -0
  4. package/lib/create.cjs +56 -0
  5. package/package.json +24 -4
  6. package/template/.eslintrc.cjs +1 -0
  7. package/template/.prettierrc.cjs +1 -0
  8. package/template/.watchmanconfig +1 -0
  9. package/template/App.tsx +2 -0
  10. package/template/README.md +215 -0
  11. package/template/__tests__/auth.test.ts +287 -0
  12. package/template/aliases.cjs +16 -0
  13. package/template/app.config.js +23 -0
  14. package/template/app.json +17 -0
  15. package/template/assets/.gitkeep +0 -0
  16. package/template/babel.config.js +7 -0
  17. package/template/declare/.gitkeep +0 -0
  18. package/template/declare/assets.d.ts +11 -0
  19. package/template/env/beta.json +16 -0
  20. package/template/env/prod.json +16 -0
  21. package/template/env/staging.json +16 -0
  22. package/template/gitignore +19 -0
  23. package/template/index.js +2 -0
  24. package/template/index.repack.js +14 -0
  25. package/template/jest.config.cjs +29 -0
  26. package/template/metro.config.js +6 -0
  27. package/template/package.json +88 -0
  28. package/template/pipelines/beta.yml +91 -0
  29. package/template/pipelines/production.yml +91 -0
  30. package/template/pipelines/staging.yml +91 -0
  31. package/template/pipelines/templates/build-and-zip.yml +89 -0
  32. package/template/pipelines/templates/upload-and-register.yml +89 -0
  33. package/template/react-native.config.js +3 -0
  34. package/template/rspack.config.cjs +53 -0
  35. package/template/scripts/clean-mini-app-output.js +9 -0
  36. package/template/scripts/sync-env.js +29 -0
  37. package/template/src/App.tsx +50 -0
  38. package/template/src/StandaloneApp.tsx +43 -0
  39. package/template/src/assets/icons/.gitkeep +1 -0
  40. package/template/src/assets/images/.gitkeep +1 -0
  41. package/template/src/assets/svg/.gitkeep +1 -0
  42. package/template/src/bootstrap.tsx +7 -0
  43. package/template/src/common/mini-app/provider.tsx +45 -0
  44. package/template/src/common/mini-app/types.ts +13 -0
  45. package/template/src/config/.gitkeep +0 -0
  46. package/template/src/constants/.gitkeep +0 -0
  47. package/template/src/hooks/.gitkeep +0 -0
  48. package/template/src/library/components/.gitkeep +0 -0
  49. package/template/src/library/networking/client.ts +53 -0
  50. package/template/src/library/networking/errors.ts +25 -0
  51. package/template/src/library/utils/responsive.ts +7 -0
  52. package/template/src/library/utils/storage/.gitkeep +0 -0
  53. package/template/src/modules/__MODULE__/assets/.gitkeep +0 -0
  54. package/template/src/modules/__MODULE__/components/ProfileCard.tsx +38 -0
  55. package/template/src/modules/__MODULE__/constants/.gitkeep +0 -0
  56. package/template/src/modules/__MODULE__/navigation/ModuleNavigator.tsx +18 -0
  57. package/template/src/modules/__MODULE__/redux/home-slice.ts +13 -0
  58. package/template/src/modules/__MODULE__/screens/home/hooks/use-home.ts +30 -0
  59. package/template/src/modules/__MODULE__/screens/home/index.tsx +100 -0
  60. package/template/src/modules/__MODULE__/services/profile.service.ts +32 -0
  61. package/template/src/modules/__MODULE__/types/.gitkeep +0 -0
  62. package/template/src/modules/auth/components/.gitkeep +0 -0
  63. package/template/src/modules/auth/constants/.gitkeep +0 -0
  64. package/template/src/modules/auth/hooks/use-auth.ts +55 -0
  65. package/template/src/modules/auth/navigation/AuthenticationNavigator.tsx +16 -0
  66. package/template/src/modules/auth/redux/auth-slice.ts +36 -0
  67. package/template/src/modules/auth/redux/selectors.ts +6 -0
  68. package/template/src/modules/auth/screens/LoginScreen.tsx +78 -0
  69. package/template/src/modules/auth/services/oidc.ts +64 -0
  70. package/template/src/modules/auth/services/standalone-session.ts +121 -0
  71. package/template/src/navigation/MainStack.tsx +46 -0
  72. package/template/src/redux/RootReducers.ts +8 -0
  73. package/template/src/redux/hooks.ts +5 -0
  74. package/template/src/redux/store.ts +10 -0
  75. package/template/src/services/application.ts +87 -0
  76. package/template/src/themes/colors/dark.ts +90 -0
  77. package/template/src/themes/colors/light.ts +90 -0
  78. package/template/src/themes/index.ts +38 -0
  79. package/template/src/themes/standalone.ts +6 -0
  80. package/template/src/themes/ui-geometry.ts +12 -0
  81. package/template/src/types/.gitkeep +0 -0
  82. package/template/tsconfig.json +65 -0
@@ -0,0 +1,215 @@
1
+ # __DISPLAY_NAME__
2
+
3
+ Mini app TypeScript chạy trong MySGR qua Re.Pack. Chạy độc lập bằng Expo là lựa chọn phục vụ phát triển và kiểm thử.
4
+
5
+ ## Thông tin project
6
+
7
+ | Cấu hình | Giá trị |
8
+ | --- | --- |
9
+ | npm package / thư mục | `__PACKAGE_NAME__` |
10
+ | Container Re.Pack | `__APP_NAME__` |
11
+ | Module được expose | `__APP_NAME__/MiniApp` |
12
+ | App ID trong catalog | `__APP_ID__` |
13
+ | App code | `__APP_CODE__` |
14
+ | Module nghiệp vụ | `src/modules/__MODULE__` |
15
+ | Port local | `__PORT__` |
16
+ | Bundle ID standalone | `__BUNDLE_ID__` |
17
+
18
+ Yêu cầu Node.js >=20.19.4, khuyến nghị Node 22 LTS. Bộ phiên bản được khai báo trong package.json: Expo 55, React Native 0.83.6, React 19.2.0, UI360 0.2.0, Unistyles 3.2.5 và Nitro 0.36.5. Không tự nâng riêng React/native dependencies nếu host chưa hỗ trợ phiên bản đó.
19
+
20
+ ## 1. Cài dependencies và chọn môi trường
21
+
22
+ ```sh
23
+ npm install
24
+ npm run env:staging
25
+ ```
26
+
27
+ Commit package-lock.json sau lần cài đầu. Các lần sau dùng npm ci.
28
+
29
+ Sửa env/staging.json, env/beta.json và env/prod.json:
30
+
31
+ - BASE_URL: URL gốc của backend.
32
+ - API_TIMEOUT_MS: timeout của request.
33
+ - AUTH: cấu hình OIDC khi chạy độc lập.
34
+
35
+ Script env sinh env/env-config.ts; không sửa file sinh bằng tay. Đổi môi trường cần restart dev server.
36
+
37
+ Tất cả giá trị env đi vào bundle. Không lưu client secret, mật khẩu hoặc token trong JSON. Secret deployment phải đặt trong CI.
38
+
39
+ ## 2. Chạy trong MySGR
40
+
41
+ **Hosted** nghĩa là mở mini app bên trong MySGR. Host cung cấp phiên đăng nhập và các native modules.
42
+
43
+ ```sh
44
+ npm run env:staging
45
+ npm run start:mf
46
+ ```
47
+
48
+ Đăng ký app phía host/catalog theo bảng đầu tài liệu, trỏ resolver tới dev server port __PORT__, sau đó mở app từ MySGR. CLI không tự sửa repository host hoặc dữ liệu backend.
49
+
50
+ Thiết bị thật cần IP LAN của máy phát triển:
51
+
52
+ ```sh
53
+ npm run start:mf:device
54
+ ```
55
+
56
+ Nếu cần reset cache dev server:
57
+
58
+ ```sh
59
+ npm run start:mf:reset
60
+ ```
61
+
62
+ Host phải truyền hostCapabilities theo miniapp-sdk. Base đã nối sẵn auth.getSnapshot/subscribe/refreshIfNeeded và closeMiniApp; màn hình/service không cần tự lưu token hoặc đăng nhập lại.
63
+
64
+ - Host quản lý refresh token và persistence.
65
+ - Mini app không persist token do host cấp.
66
+ - Đổi tài khoản làm đổi session và xóa dữ liệu phiên cũ.
67
+ - Refresh access token trong cùng session không reset dữ liệu nghiệp vụ.
68
+ - param_supper_app dùng cho dữ liệu mở app/thông báo, không thay cho auth.
69
+ - Thiếu hostCapabilities là lỗi cấu hình tích hợp; không tự chuyển sang standalone login.
70
+
71
+ ## 3. Chạy độc lập để phát triển
72
+
73
+ **Standalone** nghĩa là chạy app riêng, không nằm trong MySGR. Cần Xcode/CocoaPods cho iOS hoặc Android SDK/JDK cho Android; template không chạy bằng Expo Go.
74
+
75
+ Đăng ký OAuth native client với provider, cấu hình issuer/clientId/scopes và redirect URL trong env. Redirect mặc định có dạng __BUNDLE_ID__.auth://oauth/redirect.
76
+
77
+ ```sh
78
+ npm run env:staging
79
+ npx expo prebuild
80
+ npm run ios:staging
81
+ # Hoặc npm run android:staging
82
+ ```
83
+
84
+ Đã có development build thì dùng npm run start:staging để chạy Metro. Có các lệnh tương ứng với beta và prod.
85
+
86
+ Token standalone mặc định chỉ ở bộ nhớ; khởi động lại sẽ mất phiên. Scopes OIDC mẫu không tự cấp quyền API. Nếu backend yêu cầu token exchange, bổ sung flow đó trong service auth. Không dùng ID token thay API access token.
87
+
88
+ Đổi redirect URL hoặc thêm native dependency cần cập nhật native build.
89
+
90
+ ## 4. Nơi thêm code
91
+
92
+ ```text
93
+ env/ Cấu hình staging, beta, prod
94
+ pipelines/ CI và deployment Azure
95
+ src/
96
+ common/mini-app/ Provider, runtime, host integration
97
+ library/networking/ HTTP client
98
+ modules/auth/ Phiên đăng nhập
99
+ modules/__MODULE__/
100
+ screens/ Màn hình và hooks
101
+ navigation/ Routes của module
102
+ services/ Endpoints và mapping response
103
+ redux/ State nghiệp vụ
104
+ navigation/ Root navigator
105
+ services/application.ts Khởi tạo client/store/QueryClient
106
+ themes/ Palette, geometry, standalone setup
107
+ ```
108
+
109
+ Đặt tính năng mới trong src/modules/__MODULE__, đăng ký route trong navigator của module. Không đặt endpoints trong root provider.
110
+
111
+ ### API và dữ liệu mẫu
112
+
113
+ Màn Home chỉ gọi API sau khi nhấn **Tải hồ sơ**. Chỉnh PROFILE_PATH và mapping response trong src/modules/__MODULE__/services/profile.service.ts theo backend thật; endpoint mẫu là /me.
114
+
115
+ Lấy client qua useApplicationServices() trong component/hook, rồi truyền client vào service:
116
+
117
+ ```ts
118
+ import type { ApiClient } from '../../../library/networking/client';
119
+
120
+ export function getItems(api: ApiClient, signal?: AbortSignal) {
121
+ return api.get<{ id: string; name: string }[]>('/items', { signal });
122
+ }
123
+ ```
124
+
125
+ Ví dụ này giả định response là array trực tiếp; unwrap envelope trong service nếu backend trả cấu trúc khác. HTTP client tự gắn access token và refresh/retry tối đa một lần sau HTTP 401.
126
+
127
+ Mẫu useHome() có QueryClient, request cancellation và session trong query key; dùng làm mẫu cho dữ liệu cá nhân. Nếu backend báo hết hạn token trong HTTP 200, cấu hình isAuthExpired tại src/library/networking/client.ts.
128
+
129
+ ## 5. Màu và geometry
130
+
131
+ Unistyles phục vụ style của app; UIThemeProvider phục vụ component từ UI360.
132
+
133
+ - Hosted giữ nguyên mode và color từ runtime host.
134
+ - UI360 được bundle trong mini app, không share implementation với host.
135
+ - Geometry mặc định được tạo bằng factory UI360 tại src/themes/ui-geometry.ts. Màn mẫu và provider dùng cùng spacing/radius/typography, không yêu cầu host có những trường này.
136
+ - Palette local tại src/themes/colors/light.ts và dark.ts dùng để cấu hình standalone.
137
+ - Không mapping key màu legacy, không trộn palette và không ghi đè theme global khi hosted.
138
+ - Tách phiên bản UI360 không tự tách runtime Unistyles. Host vẫn cần có tên token màu mà app sử dụng.
139
+
140
+ Ví dụ style:
141
+
142
+ ```tsx
143
+ import { StyleSheet } from 'react-native-unistyles';
144
+ import { uiGeometry } from './themes/ui-geometry';
145
+
146
+ const { spacing, radius } = uiGeometry;
147
+ const styles = StyleSheet.create(({ color }) => ({
148
+ card: {
149
+ backgroundColor: color.background,
150
+ padding: spacing.xxl,
151
+ borderRadius: radius.lg,
152
+ },
153
+ }));
154
+ ```
155
+
156
+ Điều chỉnh import theo vị trí file. content1 là chữ nội dung, textPrimary là chữ trên primary; background là nền card, base3 là nền màn. Geometry đã scale theo thiết kế rộng 375, không nhân scale lại.
157
+
158
+ Để đổi geometry, cấu hình tại ui-geometry.ts. Override số truyền vào factory là dp và không bị scale lại. createUITheme/create360Theme chỉ tạo object; chỉ entry standalone mới gọi StyleSheet.configure qua src/themes/standalone.ts.
159
+
160
+ ## 6. Đóng gói remote
161
+
162
+ ```sh
163
+ npm run env:staging
164
+ npm run bundle:mf:android
165
+ npm run bundle:mf:ios
166
+ ```
167
+
168
+ Deploy các thư mục:
169
+
170
+ ```text
171
+ build/outputs/android/remotes/
172
+ build/outputs/ios/remotes/
173
+ ```
174
+
175
+ ZIP toàn bộ nội dung remotes của mỗi platform, gồm __APP_NAME__.container.js.bundle và các chunk, bỏ các file .map. Không chỉ upload container. Không dùng build/generated/<platform>/index.bundle để triển khai mini app.
176
+
177
+ Các lệnh này tạo JavaScript/assets, không tạo IPA/APK. Các native dependencies vẫn phải có trong binary host.
178
+
179
+ ## 7. CI và deployment
180
+
181
+ Azure DevOps có pipelines/staging.yml, beta.yml và production.yml. Mặc định deploy=false: cài dependencies, kiểm tra, build remote, ZIP và lưu artifacts.
182
+
183
+ Muốn bật deploy, cấu hình service connection/variable group và các biến:
184
+
185
+ | Biến | Mục đích |
186
+ | --- | --- |
187
+ | MINIAPP_AZURE_SUBSCRIPTION | Azure service connection |
188
+ | MINIAPP_STORAGE_ACCOUNT | Storage Account |
189
+ | MINIAPP_STORAGE_CONTAINER | Blob container |
190
+ | DB_HOST, DB_LOGIN, DB_DATABASE | PostgreSQL catalog |
191
+ | DB_PASSWORD | Mật khẩu, đánh dấu secret |
192
+
193
+ Kiểm tra appName/appId/appCode, envBuild/envFolder và miniAppVersion trong YAML. Agent deploy cần Azure CLI, psql và quyền mạng tới hạ tầng. Pipeline không tự tạo hạ tầng, schema database hoặc đăng ký app trong source host.
194
+
195
+ ## 8. Kiểm tra trước khi phát hành
196
+
197
+ ```sh
198
+ npm run typecheck
199
+ npm run lint
200
+ npm test
201
+ ```
202
+
203
+ Sau đó thử trên host: mở/đóng app, refresh token, đổi tài khoản, lỗi mạng, dialog/loading, theme và artifact hai platform.
204
+
205
+ | Vấn đề | Kiểm tra |
206
+ | --- | --- |
207
+ | Không tải remote | Container name, module name, URL/port và đủ chunk |
208
+ | Thiếu hostCapabilities | Props/cách host mở mini app |
209
+ | Không có spacing.xxl | Dùng uiGeometry/UIThemeProvider, không đọc geometry thiếu từ host |
210
+ | Màu thiếu hoặc không đúng | Token màu trong theme Unistyles đang hoạt động |
211
+ | Standalone không đăng nhập | OAuth client, redirect URL, scopes, native build |
212
+ | API vẫn trả 401 | API audience/scopes hoặc token exchange |
213
+ | Lỗi native module | Binary host có module/phiên bản cần dùng chưa |
214
+
215
+ Hướng dẫn tạo project mới và các CLI options: [@thanhphi/create-360-miniapp trên npm](https://www.npmjs.com/package/@thanhphi/create-360-miniapp).
@@ -0,0 +1,287 @@
1
+ import {
2
+ AuthError,
3
+ HttpError,
4
+ type HostCapabilities,
5
+ type SessionSnapshot,
6
+ } from '@thanhphi/miniapp-sdk';
7
+ import {
8
+ createStandaloneSession,
9
+ type OidcTokens,
10
+ } from '../src/modules/auth/services/standalone-session';
11
+ import { createApplicationServices } from '../src/services/application';
12
+ import { profileRequested } from '../src/modules/__MODULE__/redux/home-slice';
13
+ import { refresh as nativeRefresh } from 'react-native-app-auth';
14
+ import {
15
+ isOidcConfigured,
16
+ oidcOperations,
17
+ } from '../src/modules/auth/services/oidc';
18
+
19
+ jest.mock('react-native-app-auth', () => ({
20
+ authorize: jest.fn(),
21
+ refresh: jest.fn(),
22
+ }));
23
+ jest.mock('@env-config', () => ({
24
+ __esModule: true,
25
+ default: {
26
+ ENV: 'staging',
27
+ BASE_URL: 'https://api.example.test',
28
+ API_TIMEOUT_MS: 1000,
29
+ AUTH: { issuer: '', clientId: '', redirectUrl: '', scopes: ['openid'] },
30
+ },
31
+ }));
32
+
33
+ function deferred<T>() {
34
+ let resolve!: (value: T) => void;
35
+ let reject!: (error: unknown) => void;
36
+ const promise = new Promise<T>((yes, no) => {
37
+ resolve = yes;
38
+ reject = no;
39
+ });
40
+ return { promise, resolve, reject };
41
+ }
42
+
43
+ function testHost(initial: SessionSnapshot) {
44
+ let value = initial;
45
+ const listeners = new Set<(snapshot: SessionSnapshot) => void>();
46
+ const capabilities: HostCapabilities = {
47
+ auth: {
48
+ getSnapshot: () => ({ ...value }),
49
+ refreshIfNeeded: jest.fn(async () => value.accessToken),
50
+ subscribe(listener) {
51
+ listeners.add(listener);
52
+ return () => {
53
+ listeners.delete(listener);
54
+ };
55
+ },
56
+ },
57
+ closeMiniApp: jest.fn(),
58
+ };
59
+ return {
60
+ capabilities,
61
+ listeners,
62
+ emit(next: SessionSnapshot) {
63
+ value = next;
64
+ for (const listener of listeners) listener({ ...next });
65
+ },
66
+ };
67
+ }
68
+
69
+ describe('standalone session', () => {
70
+ test('rejects example OIDC values and maps revoked grants distinctly from network failures', async () => {
71
+ expect(
72
+ isOidcConfigured({
73
+ issuer: 'https://YOUR_OIDC_ISSUER',
74
+ clientId: 'YOUR_CLIENT_ID',
75
+ redirectUrl: 'sample://oauth',
76
+ scopes: ['openid'],
77
+ }),
78
+ ).toBe(false);
79
+ expect(
80
+ isOidcConfigured({
81
+ issuer: 'https://identity.example.test',
82
+ clientId: 'mobile',
83
+ redirectUrl: 'sample://oauth',
84
+ scopes: ['openid'],
85
+ }),
86
+ ).toBe(true);
87
+ const native = jest.mocked(nativeRefresh);
88
+ native.mockRejectedValueOnce({ code: 'invalid_grant' });
89
+ await expect(oidcOperations.refresh('revoked')).rejects.toMatchObject({
90
+ kind: 'expired',
91
+ });
92
+ native.mockRejectedValueOnce({ code: 'token_refresh_failed' });
93
+ await expect(oidcOperations.refresh('offline')).rejects.toMatchObject({
94
+ kind: 'unavailable',
95
+ });
96
+ });
97
+ test('coalesces a refresh and blocks its token write after local logout', async () => {
98
+ const response = deferred<OidcTokens>();
99
+ const refresh = jest.fn(() => response.promise);
100
+ const session = createStandaloneSession({
101
+ isConfigured: true,
102
+ authorize: async () => ({
103
+ accessToken: 'first-token',
104
+ refreshToken: 'private-refresh',
105
+ }),
106
+ refresh,
107
+ });
108
+ await session.actions.signIn();
109
+ const before = session.capabilities.auth.getSnapshot();
110
+ const first = session.capabilities.auth.refreshIfNeeded({
111
+ sessionId: before.sessionId,
112
+ failedAccessToken: before.accessToken,
113
+ });
114
+ const second = session.capabilities.auth.refreshIfNeeded({
115
+ sessionId: before.sessionId,
116
+ failedAccessToken: before.accessToken,
117
+ });
118
+ expect(first).toBe(second);
119
+ await Promise.resolve();
120
+ expect(refresh).toHaveBeenCalledTimes(1);
121
+ const checked = expect(first).rejects.toMatchObject({
122
+ kind: 'session-changed',
123
+ });
124
+ session.actions.signOut();
125
+ response.resolve({
126
+ accessToken: 'old-account-token',
127
+ refreshToken: 'old-refresh',
128
+ });
129
+ await checked;
130
+ expect(session.capabilities.auth.getSnapshot()).toEqual({
131
+ sessionId: null,
132
+ accessToken: null,
133
+ });
134
+ session.dispose();
135
+ });
136
+
137
+ test('blocks a late login result after logout', async () => {
138
+ const response = deferred<OidcTokens>();
139
+ const session = createStandaloneSession({
140
+ isConfigured: true,
141
+ authorize: () => response.promise,
142
+ refresh: jest.fn(),
143
+ });
144
+ const login = session.actions.signIn();
145
+ await Promise.resolve();
146
+ const checked = expect(login).rejects.toMatchObject({
147
+ kind: 'session-changed',
148
+ });
149
+ session.actions.signOut();
150
+ response.resolve({ accessToken: 'late-token' });
151
+ await checked;
152
+ expect(session.capabilities.auth.getSnapshot().accessToken).toBeNull();
153
+ session.dispose();
154
+ });
155
+
156
+ test('requires configured OIDC and never synthesizes credentials', async () => {
157
+ const authorize = jest.fn();
158
+ const session = createStandaloneSession({
159
+ isConfigured: false,
160
+ authorize,
161
+ refresh: jest.fn(),
162
+ });
163
+ await expect(session.actions.signIn()).rejects.toBeInstanceOf(AuthError);
164
+ expect(authorize).not.toHaveBeenCalled();
165
+ expect(session.capabilities.auth.getSnapshot()).toEqual({
166
+ sessionId: null,
167
+ accessToken: null,
168
+ });
169
+ session.dispose();
170
+ });
171
+ });
172
+
173
+ describe('application resources', () => {
174
+ const clients: ReturnType<typeof createApplicationServices>[] = [];
175
+ const originalFetch = globalThis.fetch;
176
+ beforeEach(() => {
177
+ globalThis.fetch = jest.fn();
178
+ });
179
+ afterEach(() => {
180
+ clients.splice(0).forEach(client => client.dispose());
181
+ globalThis.fetch = originalFetch;
182
+ });
183
+ const create = (host: HostCapabilities) => {
184
+ const client = createApplicationServices(host);
185
+ clients.push(client);
186
+ return client;
187
+ };
188
+
189
+ test('isolates instances; rotation retains caches; account change resets Redux and queries', () => {
190
+ const host = testHost({ sessionId: 'alice', accessToken: 'first-token' });
191
+ const first = create(host.capabilities);
192
+ const second = create(host.capabilities);
193
+ first.store.dispatch(profileRequested());
194
+ first.queryClient.setQueryData(['profile', 'alice'], { name: 'Alice' });
195
+ expect(second.store.getState().home.profileRequested).toBe(false);
196
+ expect(
197
+ second.queryClient.getQueryData(['profile', 'alice']),
198
+ ).toBeUndefined();
199
+ expect(first.store.getState().auth).not.toHaveProperty('accessToken');
200
+ expect(JSON.stringify(first.store.getState())).not.toContain('first-token');
201
+ const dispatched = jest.spyOn(first.store, 'dispatch');
202
+ host.emit({ sessionId: 'alice', accessToken: 'rotated-token' });
203
+ expect(JSON.stringify(dispatched.mock.calls)).not.toContain(
204
+ 'rotated-token',
205
+ );
206
+ expect(JSON.stringify(first.store.getState())).not.toContain('rotated-token');
207
+ expect(first.queryClient.getQueryData(['profile', 'alice'])).toEqual({
208
+ name: 'Alice',
209
+ });
210
+ expect(first.store.getState().home.profileRequested).toBe(true);
211
+ host.emit({ sessionId: 'bob', accessToken: 'bob-token' });
212
+ expect(
213
+ first.queryClient.getQueryData(['profile', 'alice']),
214
+ ).toBeUndefined();
215
+ expect(first.store.getState().home.profileRequested).toBe(false);
216
+ expect(first.store.getState().auth.sessionId).toBe('bob');
217
+ expect(JSON.stringify(first.store.getState())).not.toContain('bob-token');
218
+ });
219
+
220
+ test('dispose cancels a pending HTTP request, removes subscriptions and permits a fresh mount', async () => {
221
+ globalThis.fetch = jest.fn(() => new Promise<Response>(() => {}));
222
+ const host = testHost({ sessionId: 'alice', accessToken: 'alice-token' });
223
+ const first = create(host.capabilities);
224
+ const request = first.api.get('/me');
225
+ const checked = expect(request).rejects.toMatchObject({
226
+ kind: 'disposed',
227
+ } satisfies Partial<HttpError>);
228
+ await Promise.resolve();
229
+ first.dispose();
230
+ await checked;
231
+ expect(host.listeners.size).toBe(0);
232
+ expect(first.store.getState().auth.authenticated).toBe(false);
233
+ const next = create(host.capabilities);
234
+ expect(next.store.getState().auth.authenticated).toBe(true);
235
+ expect(next.store).not.toBe(first.store);
236
+ expect(host.listeners.size).toBe(1);
237
+ });
238
+
239
+ test('HTTP 401 retries once then clears user state when refresh still expires', async () => {
240
+ const unauthorized = {
241
+ status: 401,
242
+ ok: false,
243
+ headers: new Headers(),
244
+ url: '',
245
+ text: async () => '{}',
246
+ } as Response;
247
+ globalThis.fetch = jest.fn(async () => unauthorized);
248
+ const host = testHost({ sessionId: 'alice', accessToken: 'alice-token' });
249
+ const client = create(host.capabilities);
250
+ client.store.dispatch(profileRequested());
251
+ client.queryClient.setQueryData(['profile', 'alice'], { name: 'Alice' });
252
+ await expect(client.api.get('/me')).rejects.toMatchObject({
253
+ kind: 'expired',
254
+ });
255
+ expect(globalThis.fetch).toHaveBeenCalledTimes(2);
256
+ expect(client.store.getState().home.profileRequested).toBe(false);
257
+ expect(
258
+ client.queryClient.getQueryData(['profile', 'alice']),
259
+ ).toBeUndefined();
260
+ expect(client.store.getState().auth.authenticated).toBe(false);
261
+ expect(client.store.getState().auth.message).toContain('hết hạn');
262
+ });
263
+
264
+ test('all HTTP verbs inject the current host token and return parsed data', async () => {
265
+ const response = {
266
+ status: 200,
267
+ ok: true,
268
+ headers: new Headers(),
269
+ url: '',
270
+ text: async () => '{"ok":true}',
271
+ } as Response;
272
+ const fetch = jest.fn(async () => response);
273
+ globalThis.fetch = fetch;
274
+ const host = testHost({ sessionId: 'alice', accessToken: 'alice-token' });
275
+ const client = create(host.capabilities);
276
+ for (const method of ['get', 'post', 'put', 'patch', 'delete'] as const) {
277
+ await expect(client.api[method]('/me')).resolves.toEqual({ ok: true });
278
+ const [, init] = fetch.mock.calls[
279
+ fetch.mock.calls.length - 1
280
+ ] as unknown as [string, RequestInit];
281
+ expect(init.method).toBe(method.toUpperCase());
282
+ expect(new Headers(init.headers).get('Authorization')).toBe(
283
+ 'Bearer alice-token',
284
+ );
285
+ }
286
+ });
287
+ });
@@ -0,0 +1,16 @@
1
+ module.exports = {
2
+ "@": "./src",
3
+ "@auth": "./src/modules/auth",
4
+ "@feature": "./src/modules/__MODULE__",
5
+ "@assets": "./src/assets",
6
+ "@common": "./src/common",
7
+ "@components": "./src/library/components",
8
+ "@env-config": "./env/env-config",
9
+ "@modules": "./src/modules",
10
+ "@navigation": "./src/navigation",
11
+ "@networking": "./src/library/networking",
12
+ "@redux": "./src/redux",
13
+ "@storage": "./src/library/utils/storage",
14
+ "@theme": "./src/themes",
15
+ "@utils": "./src/library/utils"
16
+ };
@@ -0,0 +1,23 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ module.exports = ({ config }) => {
4
+ const selected = path.join(__dirname, 'env/selected.json');
5
+ const flavor =
6
+ process.env.APP_ENV ||
7
+ (fs.existsSync(selected)
8
+ ? JSON.parse(fs.readFileSync(selected, 'utf8')).flavor
9
+ : 'staging');
10
+ if (!['staging', 'beta', 'prod'].includes(flavor))
11
+ throw new Error('APP_ENV must be staging, beta or prod');
12
+ const input = JSON.parse(
13
+ fs.readFileSync(path.join(__dirname, 'env', `${flavor}.json`), 'utf8'),
14
+ );
15
+ const env = input.public || input;
16
+ return {
17
+ ...config,
18
+ plugins: [
19
+ ...(config.plugins || []),
20
+ ['react-native-app-auth', { redirectUrls: [env.AUTH.redirectUrl] }],
21
+ ],
22
+ };
23
+ };
@@ -0,0 +1,17 @@
1
+ {
2
+ "expo": {
3
+ "name": "__DISPLAY_NAME__",
4
+ "slug": "__PACKAGE_NAME__",
5
+ "version": "0.0.1",
6
+ "scheme": "__BUNDLE_ID__",
7
+ "ios": {
8
+ "bundleIdentifier": "__BUNDLE_ID__"
9
+ },
10
+ "android": {
11
+ "package": "__BUNDLE_ID__"
12
+ },
13
+ "plugins": [
14
+ "expo-dev-client"
15
+ ]
16
+ }
17
+ }
File without changes
@@ -0,0 +1,7 @@
1
+ module.exports = {
2
+ presets: ['babel-preset-expo'],
3
+ plugins: [['module-resolver', {
4
+ extensions: ['.ios.ts', '.android.ts', '.ts', '.ios.tsx', '.android.tsx', '.tsx', '.ios.js', '.android.js', '.js', '.json'],
5
+ alias: require('./aliases.cjs'),
6
+ }], ['react-native-unistyles/plugin', { root: 'src' }], 'react-native-worklets/plugin'],
7
+ };
File without changes
@@ -0,0 +1,11 @@
1
+ declare module '*.svg' {
2
+ import type { FunctionComponent } from 'react';
3
+ import type { SvgProps } from 'react-native-svg';
4
+ const Component: FunctionComponent<SvgProps>;
5
+ export default Component;
6
+ }
7
+ declare module '*.png' {
8
+ import type { ImageSourcePropType } from 'react-native';
9
+ const source: ImageSourcePropType;
10
+ export default source;
11
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "ENV": "beta",
3
+ "BASE_URL": "https://api.beta.example.com",
4
+ "API_TIMEOUT_MS": 15000,
5
+ "AUTH": {
6
+ "issuer": "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0",
7
+ "clientId": "YOUR_CLIENT_ID",
8
+ "redirectUrl": "__BUNDLE_ID__.auth://oauth/redirect",
9
+ "scopes": [
10
+ "openid",
11
+ "profile",
12
+ "offline_access",
13
+ "api://YOUR_API_CLIENT_ID/access_as_user"
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "ENV": "prod",
3
+ "BASE_URL": "https://api.prod.example.com",
4
+ "API_TIMEOUT_MS": 15000,
5
+ "AUTH": {
6
+ "issuer": "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0",
7
+ "clientId": "YOUR_CLIENT_ID",
8
+ "redirectUrl": "__BUNDLE_ID__.auth://oauth/redirect",
9
+ "scopes": [
10
+ "openid",
11
+ "profile",
12
+ "offline_access",
13
+ "api://YOUR_API_CLIENT_ID/access_as_user"
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "ENV": "staging",
3
+ "BASE_URL": "https://api.staging.example.com",
4
+ "API_TIMEOUT_MS": 15000,
5
+ "AUTH": {
6
+ "issuer": "https://login.microsoftonline.com/YOUR_TENANT_ID/v2.0",
7
+ "clientId": "YOUR_CLIENT_ID",
8
+ "redirectUrl": "__BUNDLE_ID__.auth://oauth/redirect",
9
+ "scopes": [
10
+ "openid",
11
+ "profile",
12
+ "offline_access",
13
+ "api://YOUR_API_CLIENT_ID/access_as_user"
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,19 @@
1
+ node_modules/
2
+ .expo/
3
+ build/
4
+ coverage/
5
+ *.tgz
6
+ .DS_Store
7
+ .env*
8
+ env/env-config.ts
9
+ env/selected.json
10
+ # Commit package-lock.json after npm install.
11
+ # Native projects are generated by npx expo prebuild; commit them if customizing native code.
12
+ ios/Pods/
13
+ ios/build/
14
+ android/.gradle/
15
+ android/app/build/
16
+ android/build/
17
+ android/local.properties
18
+ *.keystore
19
+ *.jks
@@ -0,0 +1,2 @@
1
+ import 'react-native-gesture-handler';
2
+ import './src/bootstrap';
@@ -0,0 +1,14 @@
1
+ import { Script, ScriptManager } from '@callstack/repack/client';
2
+
3
+ if (__DEV__) {
4
+ ScriptManager.shared.addResolver(
5
+ async scriptId => ({
6
+ url: Script.getDevServerURL(scriptId),
7
+ cache: false,
8
+ }),
9
+ { key: '__APP_NAME__-dev-chunks', priority: 1 },
10
+ );
11
+ }
12
+
13
+ // Keep app/native modules behind the federation bootstrap boundary.
14
+ import('./src/bootstrap');