Skip to content

Commit 4c1b0c7

Browse files
committed
improve endpoint
1 parent 8bc6527 commit 4c1b0c7

4 files changed

Lines changed: 432 additions & 103 deletions

File tree

src/__tests__/endpoint.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, mock, test } from 'bun:test';
2+
import { executeEndpointFallback } from '../endpoint';
3+
4+
const delay = (ms: number) =>
5+
new Promise<void>(resolve => {
6+
setTimeout(resolve, ms);
7+
});
8+
9+
describe('executeEndpointFallback', () => {
10+
test('uses a random configured endpoint first and stops after success', async () => {
11+
const tryEndpoint = mock(async (endpoint: string) => endpoint.toUpperCase());
12+
const getRemoteEndpoints = mock(async () => ['remote']);
13+
14+
const result = await executeEndpointFallback({
15+
configuredEndpoints: ['a', 'b', 'c'],
16+
getRemoteEndpoints,
17+
tryEndpoint,
18+
random: () => 0.5,
19+
});
20+
21+
expect(result.endpoint).toBe('b');
22+
expect(result.value).toBe('B');
23+
expect(tryEndpoint).toHaveBeenCalledTimes(1);
24+
expect(getRemoteEndpoints).not.toHaveBeenCalled();
25+
});
26+
27+
test('removes the failed first endpoint, merges remote endpoints, and picks the fastest success', async () => {
28+
const tryEndpoint = mock(async (endpoint: string) => {
29+
if (endpoint === 'a') {
30+
throw new Error('a failed');
31+
}
32+
if (endpoint === 'b') {
33+
await delay(30);
34+
return 'b-ok';
35+
}
36+
if (endpoint === 'c') {
37+
await delay(10);
38+
return 'c-ok';
39+
}
40+
await delay(20);
41+
return 'd-ok';
42+
});
43+
const getRemoteEndpoints = mock(async () => ['c', 'd', 'a']);
44+
45+
const result = await executeEndpointFallback({
46+
configuredEndpoints: ['a', 'b', 'c'],
47+
getRemoteEndpoints,
48+
tryEndpoint,
49+
random: () => 0,
50+
});
51+
52+
expect(result.endpoint).toBe('c');
53+
expect(result.value).toBe('c-ok');
54+
expect(getRemoteEndpoints).toHaveBeenCalledTimes(1);
55+
expect(tryEndpoint.mock.calls.map(call => call[0])).toEqual([
56+
'a',
57+
'b',
58+
'c',
59+
'd',
60+
]);
61+
});
62+
63+
test('repeats prune and retry when the retry round also fails', async () => {
64+
const tryEndpoint = mock(async (endpoint: string) => {
65+
if (endpoint === 'c') {
66+
await delay(5);
67+
return 'c-ok';
68+
}
69+
throw new Error(`${endpoint} failed`);
70+
});
71+
let remoteCallCount = 0;
72+
const getRemoteEndpoints = mock(async () => {
73+
remoteCallCount++;
74+
if (remoteCallCount === 1) {
75+
return ['b'];
76+
}
77+
return ['b', 'c'];
78+
});
79+
80+
const result = await executeEndpointFallback({
81+
configuredEndpoints: ['a', 'b'],
82+
getRemoteEndpoints,
83+
tryEndpoint,
84+
random: () => 0,
85+
});
86+
87+
expect(result.endpoint).toBe('c');
88+
expect(result.value).toBe('c-ok');
89+
expect(getRemoteEndpoints).toHaveBeenCalledTimes(2);
90+
expect(tryEndpoint.mock.calls.map(call => call[0])).toEqual(['a', 'b', 'c']);
91+
});
92+
});

src/client.ts

Lines changed: 129 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,18 @@ import {
1717
setLocalHashInfo,
1818
} from './core';
1919
import { PermissionsAndroid } from './permissions';
20-
import { CheckResult, ClientOptions, EventType, ProgressData } from './type';
20+
import {
21+
CheckResult,
22+
ClientOptions,
23+
EventType,
24+
ProgressData,
25+
UpdateServerConfig,
26+
} from './type';
2127
import {
2228
assertWeb,
29+
DEFAULT_FETCH_TIMEOUT_MS,
2330
emptyObj,
24-
enhancedFetch,
31+
fetchWithTimeout,
2532
info,
2633
joinUrls,
2734
log,
@@ -30,6 +37,7 @@ import {
3037
testUrls,
3138
} from './utils';
3239
import i18n from './i18n';
40+
import { dedupeEndpoints, executeEndpointFallback } from './endpoint';
3341

3442
const SERVER_PRESETS = {
3543
// cn
@@ -63,6 +71,19 @@ const defaultClientOptions: ClientOptions = {
6371
throwError: false,
6472
};
6573

74+
const cloneServerConfig = (
75+
server?: UpdateServerConfig,
76+
): UpdateServerConfig | undefined => {
77+
if (!server) {
78+
return undefined;
79+
}
80+
return {
81+
main: server.main,
82+
backups: server.backups ? [...server.backups] : undefined,
83+
queryUrls: server.queryUrls ? [...server.queryUrls] : undefined,
84+
};
85+
};
86+
6687
export const sharedState: {
6788
progressHandlers: Record<string, EmitterSubscription>;
6889
downloadedHash?: string;
@@ -110,7 +131,7 @@ export class Pushy {
110131

111132
constructor(options: ClientOptions, clientType?: 'Pushy' | 'Cresc') {
112133
this.clientType = clientType || 'Pushy';
113-
this.options.server = SERVER_PRESETS[this.clientType];
134+
this.options.server = cloneServerConfig(SERVER_PRESETS[this.clientType]);
114135

115136
i18n.setLocale(options.locale ?? this.clientType === 'Pushy' ? 'zh' : 'en');
116137

@@ -134,7 +155,10 @@ export class Pushy {
134155
setOptions = (options: Partial<ClientOptions>) => {
135156
for (const [key, value] of Object.entries(options)) {
136157
if (value !== undefined) {
137-
(this.options as any)[key] = value;
158+
(this.options as any)[key] =
159+
key === 'server'
160+
? cloneServerConfig(value as UpdateServerConfig)
161+
: value;
138162
if (key === 'logger') {
139163
this.loggerPromise.resolve();
140164
}
@@ -188,6 +212,90 @@ export class Pushy {
188212
getCheckUrl = (endpoint: string = this.options.server!.main) => {
189213
return `${endpoint}/checkUpdate/${this.options.appKey}`;
190214
};
215+
getConfiguredCheckEndpoints = () => {
216+
const { server } = this.options;
217+
if (!server) {
218+
return [];
219+
}
220+
return dedupeEndpoints([server.main, ...(server.backups || [])]);
221+
};
222+
getRemoteEndpoints = async () => {
223+
const { server } = this.options;
224+
if (!server?.queryUrls?.length) {
225+
return [];
226+
}
227+
try {
228+
const resp = await promiseAny(
229+
server.queryUrls.map(queryUrl =>
230+
fetchWithTimeout(queryUrl, {}, DEFAULT_FETCH_TIMEOUT_MS),
231+
),
232+
);
233+
const remoteEndpoints = await resp.json();
234+
log('fetch endpoints:', remoteEndpoints);
235+
if (Array.isArray(remoteEndpoints)) {
236+
const normalizedRemoteEndpoints = dedupeEndpoints(
237+
remoteEndpoints.filter(
238+
(endpoint): endpoint is string => typeof endpoint === 'string',
239+
),
240+
).filter(endpoint => endpoint !== server.main);
241+
server.backups = dedupeEndpoints([
242+
...(server.backups || []),
243+
...normalizedRemoteEndpoints,
244+
]).filter(endpoint => endpoint !== server.main);
245+
return normalizedRemoteEndpoints;
246+
}
247+
} catch (e) {
248+
log('failed to fetch endpoints from: ', server.queryUrls, e);
249+
}
250+
return [];
251+
};
252+
requestCheckResult = async (
253+
endpoint: string,
254+
fetchPayload: Parameters<typeof fetch>[1],
255+
) => {
256+
const resp = await fetchWithTimeout(
257+
this.getCheckUrl(endpoint),
258+
fetchPayload,
259+
DEFAULT_FETCH_TIMEOUT_MS,
260+
);
261+
262+
if (!resp.ok) {
263+
const respText = await resp.text();
264+
throw Error(
265+
this.t('error_http_status', {
266+
status: resp.status,
267+
statusText: respText,
268+
}),
269+
);
270+
}
271+
272+
return (await resp.json()) as CheckResult;
273+
};
274+
fetchCheckResult = async (fetchPayload: Parameters<typeof fetch>[1]) => {
275+
const { endpoint, value } = await executeEndpointFallback<CheckResult>({
276+
configuredEndpoints: this.getConfiguredCheckEndpoints(),
277+
getRemoteEndpoints: this.getRemoteEndpoints,
278+
tryEndpoint: async currentEndpoint => {
279+
try {
280+
return await this.requestCheckResult(currentEndpoint, fetchPayload);
281+
} catch (e) {
282+
log('check endpoint failed', currentEndpoint, e);
283+
throw e;
284+
}
285+
},
286+
onFirstFailure: ({ error }) => {
287+
this.report({
288+
type: 'errorChecking',
289+
message: this.t('error_cannot_connect_backup', {
290+
message: error.message,
291+
}),
292+
});
293+
},
294+
});
295+
296+
log('check endpoint success', endpoint);
297+
return value;
298+
};
191299
assertDebug = (matter: string) => {
192300
if (__DEV__ && !this.options.debug) {
193301
info(this.t('dev_debug_disabled', { matter }));
@@ -271,95 +379,40 @@ export class Pushy {
271379
},
272380
body,
273381
};
274-
let resp;
382+
const previousRespJson = this.lastRespJson;
275383
try {
276384
this.report({
277385
type: 'checking',
278386
message: this.options.appKey + ': ' + stringifyBody,
279387
});
280-
resp = await enhancedFetch(this.getCheckUrl(), fetchPayload);
281-
} catch (e: any) {
282-
this.report({
283-
type: 'errorChecking',
284-
message: this.t('error_cannot_connect_backup', { message: e.message }),
285-
});
286-
const backupEndpoints = await this.getBackupEndpoints().catch();
287-
if (backupEndpoints) {
288-
resp = await promiseAny(
289-
backupEndpoints.map(endpoint =>
290-
enhancedFetch(this.getCheckUrl(endpoint), fetchPayload),
291-
),
292-
).catch(() => {
293-
this.report({
294-
type: 'errorChecking',
295-
message: this.t('errorCheckingUseBackup'),
296-
});
297-
});
298-
} else {
299-
this.report({
300-
type: 'errorChecking',
301-
message: this.t('errorCheckingGetBackup'),
302-
});
303-
}
304-
}
305-
if (!resp) {
306-
this.report({
307-
type: 'errorChecking',
308-
message: this.t('error_cannot_connect_server'),
309-
});
310-
this.throwIfEnabled(Error('errorChecking'));
311-
return this.lastRespJson ? await this.lastRespJson : emptyObj;
312-
}
388+
const respJsonPromise = this.fetchCheckResult(fetchPayload);
389+
this.lastRespJson = respJsonPromise;
390+
const result: CheckResult = await respJsonPromise;
313391

314-
if (!resp.ok) {
315-
const respText = await resp.text();
316-
const errorMessage = this.t('error_http_status', {
317-
status: resp.status,
318-
statusText: respText,
319-
});
392+
log('checking result:', result);
393+
394+
return result;
395+
} catch (e: any) {
396+
this.lastRespJson = previousRespJson;
397+
const errorMessage =
398+
e?.message || this.t('error_cannot_connect_server');
320399
this.report({
321400
type: 'errorChecking',
322401
message: errorMessage,
323402
});
324-
this.throwIfEnabled(Error('errorChecking: ' + errorMessage));
325-
return this.lastRespJson ? await this.lastRespJson : emptyObj;
403+
this.throwIfEnabled(e);
404+
return previousRespJson ? await previousRespJson : emptyObj;
326405
}
327-
const respJsonPromise = resp.json() as Promise<CheckResult>;
328-
this.lastRespJson = respJsonPromise;
329-
330-
const result: CheckResult = await respJsonPromise;
331-
332-
log('checking result:', result);
333-
334-
return result;
335406
};
336407
getBackupEndpoints = async () => {
337408
const { server } = this.options;
338409
if (!server) {
339410
return [];
340411
}
341-
if (server.queryUrls) {
342-
try {
343-
const resp = await promiseAny(
344-
server.queryUrls.map(queryUrl => fetch(queryUrl)),
345-
);
346-
const remoteEndpoints = await resp.json();
347-
log('fetch endpoints:', remoteEndpoints);
348-
if (Array.isArray(remoteEndpoints)) {
349-
const backups = server.backups || [];
350-
const set = new Set(backups);
351-
for (const endpoint of remoteEndpoints) {
352-
set.add(endpoint);
353-
}
354-
if (set.size !== backups.length) {
355-
server.backups = Array.from(set);
356-
}
357-
}
358-
} catch (e: any) {
359-
log('failed to fetch endpoints from: ', server.queryUrls);
360-
}
361-
}
362-
return server.backups;
412+
const remoteEndpoints = await this.getRemoteEndpoints();
413+
return dedupeEndpoints([...(server.backups || []), ...remoteEndpoints]).filter(
414+
endpoint => endpoint !== server.main,
415+
);
363416
};
364417
downloadUpdate = async (
365418
updateInfo: CheckResult,

0 commit comments

Comments
 (0)