leiao 1.0.0

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.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "leiao",
3
+ "version": "1.0.0",
4
+ "description": "Leiao client SDK",
5
+ "license": "MIT",
6
+ "author": "leiao",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "react-native": "src/index.ts",
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./core": "./src/core/index.ts",
14
+ "./updates": "./src/updates/index.ts",
15
+ "./push": "./src/push.ts",
16
+ "./analytics": "./src/analytics.ts",
17
+ "./auth": "./src/auth.ts"
18
+ },
19
+ "files": ["src"],
20
+ "peerDependencies": {
21
+ "react": "^19.2.0",
22
+ "react-native": "^0.87.0",
23
+ "react-native-blob-util": "*",
24
+ "react-native-ota-hot-update": "^2.4.4"
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "react": {"optional": true},
28
+ "react-native": {"optional": true},
29
+ "react-native-blob-util": {"optional": true},
30
+ "react-native-ota-hot-update": {"optional": true}
31
+ }
32
+ }
@@ -0,0 +1,261 @@
1
+ import {getConfig, type LeiaoConfig} from './core/config';
2
+ import type {LeiaoModule} from './core/module';
3
+ import {collectDeviceProfile} from './device';
4
+ import {request} from './core/request';
5
+ import {getDeviceId, getIdentity, ready as deviceReady} from './identity';
6
+ import {readJson, writeJson} from './storage';
7
+
8
+ export const ReservedEvents = {
9
+ install: 'app_install',
10
+ start: 'app_start',
11
+ end: 'app_end',
12
+ update: 'app_update',
13
+ login: 'app_login',
14
+ logout: 'app_logout',
15
+ screen: 'app_screen',
16
+ } as const;
17
+
18
+ type Queued = {
19
+ event: string;
20
+ props: Record<string, unknown>;
21
+ deviceId: string;
22
+ userId: string;
23
+ platform: string;
24
+ appVersion: string;
25
+ brand: string;
26
+ manufacturer: string;
27
+ model: string;
28
+ osName: string;
29
+ osVersion: string;
30
+ locale: string;
31
+ timezone: string;
32
+ network: string;
33
+ sessionId: string;
34
+ durationMs?: number;
35
+ ts: string;
36
+ };
37
+
38
+ const QUEUE_FILE = 'leiao-event-queue';
39
+ const SUPER_FILE = 'leiao-super-props';
40
+ const MAX_QUEUE = 200;
41
+ const FLUSH_AT = 10;
42
+ const FLUSH_MS = 2000;
43
+
44
+ function native(): {AppState?: {addEventListener: Function}} | null {
45
+ try {
46
+ return require('react-native');
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function newSessionId() {
53
+ const cryptoObj = (globalThis as unknown as {crypto?: {randomUUID?: () => string}}).crypto;
54
+ const uuid = cryptoObj?.randomUUID
55
+ ? cryptoObj.randomUUID()
56
+ : `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
57
+ return `ses_${uuid.replace(/-/g, '')}`;
58
+ }
59
+
60
+ class AnalyticsModule implements LeiaoModule {
61
+ private userId = '';
62
+ private started = false;
63
+ private sessionOpen = false;
64
+ private sessionId = '';
65
+ private sessionStarted = 0;
66
+ private superProps: Record<string, unknown> = {};
67
+ private queue: Queued[] = [];
68
+ private flushing = false;
69
+ private timer: ReturnType<typeof setTimeout> | null = null;
70
+
71
+ configure(_config: LeiaoConfig): void {}
72
+
73
+ reset() {
74
+ this.userId = '';
75
+ this.started = false;
76
+ this.sessionOpen = false;
77
+ this.sessionId = '';
78
+ this.sessionStarted = 0;
79
+ this.superProps = {};
80
+ this.queue = [];
81
+ this.flushing = false;
82
+ if (this.timer) {
83
+ clearTimeout(this.timer);
84
+ this.timer = null;
85
+ }
86
+ }
87
+
88
+ identify(userId: string) {
89
+ this.userId = userId;
90
+ }
91
+
92
+ set(props: Record<string, unknown>) {
93
+ this.superProps = {...this.superProps, ...props};
94
+ writeJson(SUPER_FILE, this.superProps).catch(() => undefined);
95
+ }
96
+
97
+ unset(key: string) {
98
+ const next = {...this.superProps};
99
+ delete next[key];
100
+ this.superProps = next;
101
+ writeJson(SUPER_FILE, this.superProps).catch(() => undefined);
102
+ }
103
+
104
+ login(provider: string, extra: Record<string, unknown> = {}) {
105
+ this.track(ReservedEvents.login, {provider, ...extra}).catch(() => undefined);
106
+ }
107
+
108
+ screen(name: string, extra: Record<string, unknown> = {}) {
109
+ return this.track(ReservedEvents.screen, {name, ...extra});
110
+ }
111
+
112
+ async track(event: string, props: Record<string, unknown> = {}) {
113
+ const config = getConfig();
114
+ const profile = collectDeviceProfile();
115
+ const deviceId = getDeviceId() || (await deviceReady());
116
+ const durationMs = Number(props.durationMs || 0) || 0;
117
+ this.queue.push({
118
+ event,
119
+ props: {
120
+ ...this.superProps,
121
+ ...props,
122
+ sessionId: this.sessionId,
123
+ network: profile.network,
124
+ },
125
+ deviceId,
126
+ userId: this.userId,
127
+ platform: profile.platform,
128
+ appVersion: config.appVersion,
129
+ brand: profile.brand,
130
+ manufacturer: profile.manufacturer,
131
+ model: profile.model,
132
+ osName: profile.osName,
133
+ osVersion: profile.osVersion,
134
+ locale: profile.locale,
135
+ timezone: profile.timezone,
136
+ network: profile.network,
137
+ sessionId: this.sessionId,
138
+ durationMs,
139
+ ts: new Date().toISOString(),
140
+ });
141
+ if (this.queue.length > MAX_QUEUE) {
142
+ this.queue = this.queue.slice(-MAX_QUEUE);
143
+ }
144
+ writeJson(QUEUE_FILE, this.queue).catch(() => undefined);
145
+ if (this.queue.length >= FLUSH_AT) {
146
+ await this.flush();
147
+ return;
148
+ }
149
+ this.scheduleFlush();
150
+ }
151
+
152
+ async flush() {
153
+ if (this.flushing || !this.queue.length) {
154
+ return;
155
+ }
156
+ this.flushing = true;
157
+ if (this.timer) {
158
+ clearTimeout(this.timer);
159
+ this.timer = null;
160
+ }
161
+ const batch = this.queue.slice(0, 50);
162
+ this.queue = this.queue.slice(batch.length);
163
+ try {
164
+ await request('/v1/track', {method: 'POST', body: {events: batch}});
165
+ writeJson(QUEUE_FILE, this.queue).catch(() => undefined);
166
+ } catch {
167
+ this.queue = batch.concat(this.queue).slice(0, MAX_QUEUE);
168
+ writeJson(QUEUE_FILE, this.queue).catch(() => undefined);
169
+ this.scheduleFlush();
170
+ } finally {
171
+ this.flushing = false;
172
+ if (this.queue.length >= FLUSH_AT) {
173
+ this.flush().catch(() => undefined);
174
+ }
175
+ }
176
+ }
177
+
178
+ start() {
179
+ if (this.started) {
180
+ return;
181
+ }
182
+ this.started = true;
183
+ void this.boot();
184
+ }
185
+
186
+ private async boot() {
187
+ const [queued, stored] = await Promise.all([
188
+ readJson<Queued[]>(QUEUE_FILE, []),
189
+ readJson<Record<string, unknown>>(SUPER_FILE, {}),
190
+ ]);
191
+ this.queue = Array.isArray(queued) ? queued.slice(-MAX_QUEUE) : [];
192
+ this.superProps = stored && typeof stored === 'object' ? stored : {};
193
+ const found = getIdentity();
194
+ const current = getConfig().appVersion;
195
+ if (found?.isNew) {
196
+ await this.track(ReservedEvents.install, {first: true});
197
+ } else if (found?.previousVersion && found.previousVersion !== current) {
198
+ await this.track(ReservedEvents.update, {from: found.previousVersion, to: current});
199
+ }
200
+ this.openSession(true);
201
+ this.listenLifecycle();
202
+ await this.flush();
203
+ }
204
+
205
+ private openSession(cold: boolean) {
206
+ if (this.sessionOpen) {
207
+ return;
208
+ }
209
+ this.sessionOpen = true;
210
+ this.sessionId = newSessionId();
211
+ this.sessionStarted = Date.now();
212
+ this.track(ReservedEvents.start, {cold, sessionId: this.sessionId}).catch(() => undefined);
213
+ }
214
+
215
+ private closeSession() {
216
+ if (!this.sessionOpen) {
217
+ return;
218
+ }
219
+ this.sessionOpen = false;
220
+ const durationMs = Math.max(0, Date.now() - this.sessionStarted);
221
+ this.track(ReservedEvents.end, {sessionId: this.sessionId, durationMs}).catch(() => undefined);
222
+ this.flush().catch(() => undefined);
223
+ }
224
+
225
+ private scheduleFlush() {
226
+ if (this.timer) {
227
+ return;
228
+ }
229
+ this.timer = setTimeout(() => {
230
+ this.timer = null;
231
+ this.flush().catch(() => undefined);
232
+ }, FLUSH_MS);
233
+ }
234
+
235
+ private listenLifecycle() {
236
+ const rn = native();
237
+ if (rn?.AppState) {
238
+ rn.AppState.addEventListener('change', (state: string) => {
239
+ if (state === 'background' || state === 'inactive') {
240
+ this.closeSession();
241
+ }
242
+ if (state === 'active') {
243
+ this.openSession(false);
244
+ }
245
+ });
246
+ return;
247
+ }
248
+ if (typeof document === 'undefined') {
249
+ return;
250
+ }
251
+ document.addEventListener('visibilitychange', () => {
252
+ if (document.hidden) {
253
+ this.closeSession();
254
+ return;
255
+ }
256
+ this.openSession(false);
257
+ });
258
+ }
259
+ }
260
+
261
+ export const analytics = new AnalyticsModule();
package/src/auth.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type {LeiaoConfig} from './core/config';
2
+ import type {LeiaoModule} from './core/module';
3
+ import {request} from './core/request';
4
+ import {analytics, ReservedEvents} from './analytics';
5
+ import {ready as deviceReady, getDeviceId} from './identity';
6
+ import {push} from './push';
7
+
8
+ class AuthModule implements LeiaoModule {
9
+ userId = '';
10
+
11
+ configure(config: LeiaoConfig): void {
12
+ const fromPush = config.push && typeof config.push === 'object' ? config.push.userId : '';
13
+ if (fromPush) {
14
+ this.userId = fromPush;
15
+ }
16
+ }
17
+
18
+ reset() {
19
+ this.userId = '';
20
+ }
21
+
22
+ async anonymous() {
23
+ const deviceId = await deviceReady();
24
+ const data = await request<{userId: string}>('/v1/auth/anonymous', {
25
+ method: 'POST',
26
+ body: {deviceId},
27
+ });
28
+ this.bind(data.userId);
29
+ return data;
30
+ }
31
+
32
+ async sendOtp(target: string, channel: 'sms' | 'email' = 'sms') {
33
+ return request('/v1/auth/otp/send', {method: 'POST', body: {target, channel}});
34
+ }
35
+
36
+ async verifyOtp(target: string, code: string, channel: 'sms' | 'email' = 'sms') {
37
+ const data = await request<{userId: string; provider: string}>('/v1/auth/otp/verify', {
38
+ method: 'POST',
39
+ body: {target, code, channel, deviceId: getDeviceId()},
40
+ });
41
+ this.bind(data.userId, data.provider || 'otp');
42
+ return data;
43
+ }
44
+
45
+ async wechat(code: string) {
46
+ const data = await request<{userId: string}>('/v1/auth/wechat', {
47
+ method: 'POST',
48
+ body: {code, deviceId: getDeviceId()},
49
+ });
50
+ this.bind(data.userId, 'wechat');
51
+ return data;
52
+ }
53
+
54
+ async apple(identityToken: string) {
55
+ const data = await request<{userId: string}>('/v1/auth/apple', {
56
+ method: 'POST',
57
+ body: {identityToken, deviceId: getDeviceId()},
58
+ });
59
+ this.bind(data.userId, 'apple');
60
+ return data;
61
+ }
62
+
63
+ async google(idToken: string) {
64
+ const data = await request<{userId: string}>('/v1/auth/google', {
65
+ method: 'POST',
66
+ body: {idToken, deviceId: getDeviceId()},
67
+ });
68
+ this.bind(data.userId, 'google');
69
+ return data;
70
+ }
71
+
72
+ async logout() {
73
+ await analytics.track(ReservedEvents.logout).catch(() => undefined);
74
+ const data = await request<{userId: string}>('/v1/auth/logout', {
75
+ method: 'POST',
76
+ body: {deviceId: getDeviceId()},
77
+ });
78
+ this.bind(data.userId);
79
+ return data;
80
+ }
81
+
82
+ private bind(userId: string, provider?: string) {
83
+ this.userId = userId;
84
+ analytics.identify(userId);
85
+ if (provider) {
86
+ analytics.login(provider);
87
+ }
88
+ push.userId = userId;
89
+ push.register().catch(() => undefined);
90
+ }
91
+ }
92
+
93
+ export const auth = new AuthModule();
@@ -0,0 +1,122 @@
1
+ export type HotUpdateOptions = {
2
+ /** 热更环境,只能是 production / staging,和安装渠道 channel 不是一回事。 */
3
+ channel?: 'production' | 'staging';
4
+ /** 热更安装完是否自动重启加载新包,默认 true。 */
5
+ restart?: boolean;
6
+ };
7
+
8
+ export type PushOptions = {
9
+ userId?: string;
10
+ deviceId?: string;
11
+ pollMs?: number;
12
+ };
13
+
14
+ export type ModuleName = 'hotUpdate' | 'push' | 'analytics' | 'auth';
15
+
16
+ export type LeiaoConfig = {
17
+ serverUrl: string;
18
+ appKey?: string;
19
+ app?: string;
20
+ appVersion: string;
21
+ channel?: string;
22
+ deviceId?: string;
23
+ clientId?: string;
24
+ timeoutMs?: number;
25
+ modules?: ModuleName[];
26
+ hotUpdate?: boolean | HotUpdateOptions;
27
+ push?: boolean | PushOptions;
28
+ analytics?: boolean;
29
+ auth?: boolean;
30
+ };
31
+
32
+ let current: LeiaoConfig | null = null;
33
+
34
+ function nativePlatform() {
35
+ try {
36
+ const rn = require('react-native') as {Platform?: {OS: string}};
37
+ return rn?.Platform?.OS || '';
38
+ } catch {
39
+ return '';
40
+ }
41
+ }
42
+
43
+ function enabled(value: unknown, fallback: boolean) {
44
+ if (value === false) {
45
+ return false;
46
+ }
47
+ if (value === undefined) {
48
+ return fallback;
49
+ }
50
+ return true;
51
+ }
52
+
53
+ export function resolveModules(config: LeiaoConfig): ModuleName[] {
54
+ if (config.modules?.length) {
55
+ return config.modules;
56
+ }
57
+ const native = nativePlatform() === 'ios' || nativePlatform() === 'android';
58
+ const names: ModuleName[] = [];
59
+ if (enabled(config.hotUpdate, native)) {
60
+ names.push('hotUpdate');
61
+ }
62
+ if (enabled(config.push, true)) {
63
+ names.push('push');
64
+ }
65
+ if (enabled(config.analytics, true)) {
66
+ names.push('analytics');
67
+ }
68
+ if (enabled(config.auth, true)) {
69
+ names.push('auth');
70
+ }
71
+ return names;
72
+ }
73
+
74
+ function resolveHotUpdate(config: LeiaoConfig): LeiaoConfig['hotUpdate'] {
75
+ if (config.hotUpdate === false) {
76
+ return undefined;
77
+ }
78
+ const fromInit =
79
+ config.channel === 'production' || config.channel === 'staging' ? config.channel : undefined;
80
+ const given = config.hotUpdate === true || config.hotUpdate === undefined ? {} : config.hotUpdate;
81
+ const channel = given.channel || fromInit || 'production';
82
+ if (channel !== 'production' && channel !== 'staging') {
83
+ throw new Error('hotUpdate.channel 只能是 production 或 staging');
84
+ }
85
+ return {...given, channel};
86
+ }
87
+
88
+ function resolveTimeout(value: unknown) {
89
+ const n = Number(value);
90
+ if (!Number.isFinite(n) || n <= 0) {
91
+ return 12000;
92
+ }
93
+ return Math.min(60000, Math.max(1000, Math.round(n)));
94
+ }
95
+
96
+ export function setConfig(config: LeiaoConfig): LeiaoConfig {
97
+ const appKey = String(config.appKey || config.app || '').trim();
98
+ if (!config.serverUrl || !appKey || !config.appVersion) {
99
+ throw new Error('Leiao.init 需要 serverUrl、appKey、appVersion');
100
+ }
101
+ current = {
102
+ ...config,
103
+ timeoutMs: resolveTimeout(config.timeoutMs),
104
+ appKey,
105
+ modules: resolveModules(config),
106
+ serverUrl: config.serverUrl.replace(/\/$/, ''),
107
+ hotUpdate: resolveHotUpdate(config),
108
+ push: config.push === true ? {} : config.push === false ? undefined : config.push,
109
+ };
110
+ return current;
111
+ }
112
+
113
+ export function resetConfig() {
114
+ current = null;
115
+ }
116
+
117
+ export function getConfig(): LeiaoConfig {
118
+ if (!current) {
119
+ throw new Error('请先调用 Leiao.init({ serverUrl, appKey, appVersion })');
120
+ }
121
+ return current;
122
+ }
@@ -0,0 +1,4 @@
1
+ export type {HotUpdateOptions, LeiaoConfig, ModuleName, PushOptions} from './config';
2
+ export {getConfig, setConfig} from './config';
3
+ export {request} from './request';
4
+ export type {LeiaoModule} from './module';
@@ -0,0 +1,5 @@
1
+ import type {LeiaoConfig} from './config';
2
+
3
+ export type LeiaoModule = {
4
+ configure(config: LeiaoConfig): void;
5
+ };
@@ -0,0 +1,84 @@
1
+ import {getConfig} from './config';
2
+
3
+ export type RequestOptions = Omit<RequestInit, 'body'> & {
4
+ query?: Record<string, string | number | boolean | undefined | null>;
5
+ body?: RequestInit['body'] | Record<string, unknown>;
6
+ };
7
+
8
+ export async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
9
+ const config = getConfig();
10
+ const {query, headers, body, ...init} = options;
11
+ const url = buildUrl(config.serverUrl, path, query);
12
+ const merged = new Headers(headers);
13
+ merged.set('x-app-key', config.appKey || '');
14
+ let payload = body;
15
+
16
+ if (body && typeof body === 'object' && !(body instanceof ArrayBuffer) && !isFetchBody(body)) {
17
+ merged.set('content-type', merged.get('content-type') || 'application/json');
18
+ payload = JSON.stringify(body);
19
+ }
20
+
21
+ let lastError: Error | null = null;
22
+ for (let attempt = 0; attempt <= 2; attempt += 1) {
23
+ const controller = new AbortController();
24
+ const timer = setTimeout(() => controller.abort(), config.timeoutMs || 12000);
25
+ try {
26
+ const response = await fetch(url, {
27
+ ...init,
28
+ headers: merged,
29
+ body: payload as RequestInit['body'],
30
+ signal: controller.signal,
31
+ });
32
+ const text = await response.text();
33
+ let data: {error?: string} = {};
34
+ try {
35
+ data = text ? (JSON.parse(text) as {error?: string}) : {};
36
+ } catch {
37
+ data = {};
38
+ }
39
+ if (!response.ok) {
40
+ const error = Object.assign(new Error(String(data.error || `请求失败 (${response.status})`)), {status: response.status});
41
+ throw error;
42
+ }
43
+ if (response.status === 204) {
44
+ return undefined as T;
45
+ }
46
+ return data as T;
47
+ } catch (error) {
48
+ lastError = error instanceof Error ? error : new Error(String(error));
49
+ const status = Number((error as {status?: number}).status || 0);
50
+ if (status >= 400 && status < 500 && status !== 429) {
51
+ throw lastError;
52
+ }
53
+ if (attempt < 2) {
54
+ await new Promise(resolve => setTimeout(resolve, 400 * (attempt + 1)));
55
+ }
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
60
+ throw lastError || new Error('请求失败');
61
+ }
62
+
63
+ function buildUrl(serverUrl: string, path: string, query?: RequestOptions['query']): string {
64
+ const url = path.startsWith('http') ? path : `${serverUrl}${path.startsWith('/') ? '' : '/'}${path}`;
65
+ if (!query) {
66
+ return url;
67
+ }
68
+ const params = new URLSearchParams();
69
+ for (const [key, value] of Object.entries(query)) {
70
+ if (value != null && value !== '') {
71
+ params.set(key, String(value));
72
+ }
73
+ }
74
+ const qs = params.toString();
75
+ return qs ? `${url}${url.includes('?') ? '&' : '?'}${qs}` : url;
76
+ }
77
+
78
+ function isFetchBody(value: object): boolean {
79
+ return (
80
+ (typeof FormData !== 'undefined' && value instanceof FormData) ||
81
+ (typeof Blob !== 'undefined' && value instanceof Blob) ||
82
+ (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams)
83
+ );
84
+ }