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/src/push.ts ADDED
@@ -0,0 +1,239 @@
1
+ import type {LeiaoConfig, PushOptions} from './core/config';
2
+ import type {LeiaoModule} from './core/module';
3
+ import {collectDeviceProfile, registerDevice} from './device';
4
+ import {getDeviceId, ready as deviceReady} from './identity';
5
+ import {updates} from './updates';
6
+ import {request} from './core/request';
7
+ import {readJson, writeJson} from './storage';
8
+
9
+ const PUSH_FILE = 'leiao-push-state';
10
+
11
+ export type PushMessage = {
12
+ id: string;
13
+ title: string;
14
+ body: string;
15
+ data: Record<string, string>;
16
+ extras?: Record<string, string>;
17
+ type?: string;
18
+ imageUrl?: string;
19
+ deepLink?: string;
20
+ sentAt: string;
21
+ };
22
+
23
+ export type PushListener = (message: PushMessage) => void;
24
+
25
+ export type VendorTokenInput = {
26
+ channel?: string;
27
+ vendorToken?: string;
28
+ fcmToken?: string;
29
+ apnsToken?: string;
30
+ webPush?: unknown;
31
+ };
32
+
33
+ function native(): {AppState?: {addEventListener: Function}} | null {
34
+ try {
35
+ return require('react-native');
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ class PushModule implements LeiaoModule {
42
+ private options: PushOptions = {};
43
+ private started = false;
44
+ private timer: ReturnType<typeof setInterval> | null = null;
45
+ private resumeBound = false;
46
+ private seen = new Set<string>();
47
+ private listeners = new Set<PushListener>();
48
+ private alias = '';
49
+ private tags: string[] = [];
50
+ private vendor: VendorTokenInput = {};
51
+ userId = '';
52
+
53
+ configure(config: LeiaoConfig): void {
54
+ this.options = config.push && typeof config.push === 'object' ? config.push : {};
55
+ if (this.options.userId) {
56
+ this.userId = this.options.userId;
57
+ }
58
+ }
59
+
60
+ reset() {
61
+ this.stop();
62
+ this.started = false;
63
+ this.resumeBound = false;
64
+ this.seen.clear();
65
+ this.alias = '';
66
+ this.tags = [];
67
+ this.vendor = {};
68
+ this.userId = '';
69
+ this.options = {};
70
+ }
71
+
72
+ detectBrand(): string {
73
+ return detectBrand();
74
+ }
75
+
76
+ onMessage(listener: PushListener): () => void {
77
+ this.listeners.add(listener);
78
+ return () => {
79
+ this.listeners.delete(listener);
80
+ };
81
+ }
82
+
83
+ async start(): Promise<string> {
84
+ await this.restore();
85
+ const id = await this.register();
86
+ if (this.started) {
87
+ return id;
88
+ }
89
+ this.started = true;
90
+ this.armPoll();
91
+ const rn = native();
92
+ if (!this.resumeBound && rn?.AppState) {
93
+ this.resumeBound = true;
94
+ rn.AppState.addEventListener('change', (state: string) => {
95
+ if (state === 'active' && this.started) {
96
+ this.armPoll();
97
+ this.poll().catch(() => undefined);
98
+ }
99
+ if ((state === 'background' || state === 'inactive') && this.timer) {
100
+ clearInterval(this.timer);
101
+ this.timer = null;
102
+ }
103
+ });
104
+ }
105
+ return id;
106
+ }
107
+
108
+ stop(): void {
109
+ this.started = false;
110
+ if (this.timer) {
111
+ clearInterval(this.timer);
112
+ this.timer = null;
113
+ }
114
+ }
115
+
116
+ getDeviceId(): string {
117
+ return getDeviceId();
118
+ }
119
+
120
+ async ensureDeviceId(): Promise<string> {
121
+ return deviceReady();
122
+ }
123
+
124
+ async setAlias(alias: string): Promise<void> {
125
+ this.alias = String(alias || '').trim();
126
+ await this.persist();
127
+ await this.register();
128
+ }
129
+
130
+ async setTags(tags: string[]): Promise<void> {
131
+ this.tags = tags.map(item => String(item || '').trim()).filter(Boolean);
132
+ await this.persist();
133
+ await this.register();
134
+ }
135
+
136
+ async setVendorToken(input: VendorTokenInput): Promise<void> {
137
+ this.vendor = {...this.vendor, ...input};
138
+ await this.persist();
139
+ await this.register();
140
+ }
141
+
142
+ async register(): Promise<string> {
143
+ let bundleVersion = 0;
144
+ try {
145
+ bundleVersion = await updates.getInstalledVersion();
146
+ } catch {
147
+ bundleVersion = 0;
148
+ }
149
+ const deviceId = await registerDevice({
150
+ userId: this.userId || this.options.userId || '',
151
+ alias: this.alias,
152
+ tags: this.tags,
153
+ vendorChannel: this.vendor.channel || '',
154
+ vendorToken: this.vendor.vendorToken || '',
155
+ fcmToken: this.vendor.fcmToken || '',
156
+ apnsToken: this.vendor.apnsToken || '',
157
+ webPush: this.vendor.webPush,
158
+ bundleVersion,
159
+ });
160
+ await this.poll();
161
+ return deviceId;
162
+ }
163
+
164
+ async poll(): Promise<PushMessage[]> {
165
+ const deviceId = getDeviceId();
166
+ if (!deviceId) {
167
+ return [];
168
+ }
169
+ const result = await request<{messages: PushMessage[]}>('/v1/push/inbox', {
170
+ query: {deviceId},
171
+ });
172
+ const messages = result.messages || [];
173
+ const fresh = messages.filter(item => !this.seen.has(item.id));
174
+ if (fresh.length) {
175
+ const ids = fresh.map(item => item.id);
176
+ for (const item of fresh) {
177
+ this.seen.add(item.id);
178
+ for (const listener of this.listeners) {
179
+ listener(item);
180
+ }
181
+ this.report(item.id, 'arrive').catch(() => undefined);
182
+ }
183
+ await request('/v1/push/ack', {
184
+ method: 'POST',
185
+ body: {deviceId, messageIds: ids},
186
+ }).catch(() => undefined);
187
+ }
188
+ return messages;
189
+ }
190
+
191
+ async click(messageId: string): Promise<void> {
192
+ await this.report(messageId, 'click');
193
+ }
194
+
195
+ private async report(messageId: string, type: 'arrive' | 'click'): Promise<void> {
196
+ const deviceId = getDeviceId();
197
+ if (!deviceId || !messageId) {
198
+ return;
199
+ }
200
+ await request('/v1/push/events', {
201
+ method: 'POST',
202
+ body: {deviceId, messageId, type},
203
+ });
204
+ }
205
+
206
+ private async restore() {
207
+ const stored = await readJson<{alias?: string; tags?: string[]; vendor?: VendorTokenInput}>(PUSH_FILE, {});
208
+ if (stored.alias && !this.alias) {
209
+ this.alias = String(stored.alias);
210
+ }
211
+ if ((!this.tags.length) && Array.isArray(stored.tags)) {
212
+ this.tags = stored.tags.map(item => String(item || '').trim()).filter(Boolean);
213
+ }
214
+ if (stored.vendor && typeof stored.vendor === 'object') {
215
+ this.vendor = {...stored.vendor, ...this.vendor};
216
+ }
217
+ }
218
+
219
+ private persist() {
220
+ return writeJson(PUSH_FILE, {alias: this.alias, tags: this.tags, vendor: this.vendor});
221
+ }
222
+
223
+ private armPoll(): void {
224
+ if (this.timer) {
225
+ clearInterval(this.timer);
226
+ }
227
+ const ms = Math.max(this.options.pollMs || 8000, 3000);
228
+ this.timer = setInterval(() => {
229
+ this.poll().catch(() => undefined);
230
+ }, ms);
231
+ }
232
+ }
233
+
234
+ export const push = new PushModule();
235
+
236
+ export function detectBrand(): string {
237
+ return collectDeviceProfile().brand;
238
+ }
239
+
package/src/runtime.ts ADDED
@@ -0,0 +1,124 @@
1
+ import {getConfig, resetConfig, setConfig, type LeiaoConfig, type ModuleName} from './core/config';
2
+ import type {LeiaoModule} from './core/module';
3
+ import {analytics} from './analytics';
4
+ import {auth} from './auth';
5
+ import {updates} from './updates';
6
+ import {getDeviceProfile, registerDevice, watchNetwork} from './device';
7
+ import {configureIdentity, getDeviceId, ready as deviceReady, resetIdentity} from './identity';
8
+ import {push} from './push';
9
+ import {clearMemoryStorage} from './storage';
10
+
11
+ export type InitResult = {
12
+ deviceId: string;
13
+ userId: string;
14
+ };
15
+
16
+ const all: Record<ModuleName, LeiaoModule & {start?: () => unknown; reset?: () => void}> = {
17
+ hotUpdate: updates,
18
+ push,
19
+ analytics,
20
+ auth,
21
+ };
22
+
23
+ let resumeBound = false;
24
+
25
+ async function retry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
26
+ let last: Error | undefined;
27
+ for (let index = 0; index < attempts; index += 1) {
28
+ try {
29
+ return await fn();
30
+ } catch (error) {
31
+ last = error instanceof Error ? error : new Error(String(error));
32
+ if (index < attempts - 1) {
33
+ await new Promise(resolve => setTimeout(resolve, 200 * (index + 1)));
34
+ }
35
+ }
36
+ }
37
+ throw last || new Error('请求失败');
38
+ }
39
+
40
+ async function startModules(selected: ModuleName[]) {
41
+ if (selected.includes('auth')) {
42
+ await retry(() => auth.anonymous());
43
+ }
44
+ await retry(() => registerDevice({userId: auth.userId}));
45
+ if (selected.includes('analytics')) {
46
+ analytics.start();
47
+ }
48
+ if (selected.includes('push')) {
49
+ await retry(() => push.start());
50
+ }
51
+ if (selected.includes('hotUpdate')) {
52
+ updates.start();
53
+ }
54
+ }
55
+
56
+ function bindResume() {
57
+ if (resumeBound) {
58
+ return;
59
+ }
60
+ resumeBound = true;
61
+ try {
62
+ const rn = require('react-native') as {AppState?: {addEventListener: Function}};
63
+ rn?.AppState?.addEventListener('change', (state: string) => {
64
+ if (state !== 'active') {
65
+ return;
66
+ }
67
+ registerDevice({userId: auth.userId}).catch(() => undefined);
68
+ analytics.flush().catch(() => undefined);
69
+ });
70
+ } catch {
71
+ if (typeof document === 'undefined') {
72
+ return;
73
+ }
74
+ document.addEventListener('visibilitychange', () => {
75
+ if (!document.hidden) {
76
+ registerDevice({userId: auth.userId}).catch(() => undefined);
77
+ analytics.flush().catch(() => undefined);
78
+ }
79
+ });
80
+ }
81
+ }
82
+
83
+ export function resetLeiao() {
84
+ analytics.reset();
85
+ auth.reset();
86
+ push.reset();
87
+ updates.reset();
88
+ resetIdentity();
89
+ resetConfig();
90
+ clearMemoryStorage();
91
+ resumeBound = false;
92
+ }
93
+
94
+ export const Leiao = {
95
+ init(config: LeiaoConfig): Promise<InitResult> {
96
+ analytics.reset();
97
+ auth.reset();
98
+ push.reset();
99
+ updates.reset();
100
+ const next = setConfig(config);
101
+ configureIdentity(next);
102
+ const selected = next.modules || [];
103
+ for (const name of selected) {
104
+ all[name]?.configure(next);
105
+ }
106
+ return deviceReady().then(async deviceId => {
107
+ watchNetwork();
108
+ await retry(() => registerDevice());
109
+ await startModules(selected);
110
+ bindResume();
111
+ return {deviceId, userId: auth.userId};
112
+ });
113
+ },
114
+ reset: resetLeiao,
115
+ ready: deviceReady,
116
+ getDeviceId,
117
+ getDeviceProfile,
118
+ getUserId: () => auth.userId,
119
+ getConfig,
120
+ updates,
121
+ push,
122
+ analytics,
123
+ auth,
124
+ };
package/src/storage.ts ADDED
@@ -0,0 +1,82 @@
1
+ function webStorage() {
2
+ try {
3
+ return (
4
+ (globalThis as unknown as {localStorage?: {getItem(key: string): string | null; setItem(key: string, value: string): void}})
5
+ .localStorage || null
6
+ );
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ function blobUtil() {
13
+ try {
14
+ return require('react-native-blob-util').default as {
15
+ fs: {dirs: {DocumentDir?: string}; exists: (path: string) => Promise<boolean>; readFile: Function; writeFile: Function};
16
+ };
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ const memory = new Map<string, string>();
23
+
24
+ export function clearMemoryStorage() {
25
+ memory.clear();
26
+ }
27
+
28
+ export async function readText(name: string): Promise<string> {
29
+ const storage = webStorage();
30
+ if (storage) {
31
+ return storage.getItem(name) || '';
32
+ }
33
+ const util = blobUtil();
34
+ const dir = util?.fs.dirs.DocumentDir;
35
+ if (!util || !dir) {
36
+ return memory.get(name) || '';
37
+ }
38
+ const file = `${dir}/${name}`;
39
+ try {
40
+ if (!(await util.fs.exists(file))) {
41
+ return '';
42
+ }
43
+ return String(await util.fs.readFile(file, 'utf8')).trim();
44
+ } catch {
45
+ return '';
46
+ }
47
+ }
48
+
49
+ export async function writeText(name: string, value: string): Promise<void> {
50
+ const storage = webStorage();
51
+ if (storage) {
52
+ storage.setItem(name, value);
53
+ return;
54
+ }
55
+ const util = blobUtil();
56
+ const dir = util?.fs.dirs.DocumentDir;
57
+ if (!util || !dir) {
58
+ memory.set(name, value);
59
+ return;
60
+ }
61
+ try {
62
+ await util.fs.writeFile(`${dir}/${name}`, value, 'utf8');
63
+ } catch {
64
+ memory.set(name, value);
65
+ }
66
+ }
67
+
68
+ export async function readJson<T>(name: string, fallback: T): Promise<T> {
69
+ const raw = await readText(name);
70
+ if (!raw) {
71
+ return fallback;
72
+ }
73
+ try {
74
+ return JSON.parse(raw) as T;
75
+ } catch {
76
+ return fallback;
77
+ }
78
+ }
79
+
80
+ export async function writeJson(name: string, value: unknown): Promise<void> {
81
+ await writeText(name, JSON.stringify(value));
82
+ }