@hot-updater/react-native 1.0.0-rc.15 → 1.0.0-rc.16
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/lib/commonjs/httpClient.js +107 -12
- package/lib/commonjs/httpClient.js.map +1 -1
- package/lib/commonjs/httpClient.spec.js +190 -0
- package/lib/commonjs/httpClient.spec.js.map +1 -1
- package/lib/commonjs/index.js +13 -2
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/index.spec.js +101 -0
- package/lib/commonjs/index.spec.js.map +1 -1
- package/lib/commonjs/notifyAppReadyInsights.transport.spec.js +48 -1
- package/lib/commonjs/notifyAppReadyInsights.transport.spec.js.map +1 -1
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/uuidv7.js +41 -0
- package/lib/commonjs/uuidv7.js.map +1 -0
- package/lib/commonjs/uuidv7.spec.js +30 -0
- package/lib/commonjs/uuidv7.spec.js.map +1 -0
- package/lib/module/httpClient.js +107 -12
- package/lib/module/httpClient.js.map +1 -1
- package/lib/module/httpClient.spec.js +191 -1
- package/lib/module/httpClient.spec.js.map +1 -1
- package/lib/module/index.js +13 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/index.spec.js +102 -1
- package/lib/module/index.spec.js.map +1 -1
- package/lib/module/notifyAppReadyInsights.transport.spec.js +48 -1
- package/lib/module/notifyAppReadyInsights.transport.spec.js.map +1 -1
- package/lib/module/sdkVersion.js +1 -1
- package/lib/module/uuidv7.js +36 -0
- package/lib/module/uuidv7.js.map +1 -0
- package/lib/module/uuidv7.spec.js +30 -0
- package/lib/module/uuidv7.spec.js.map +1 -0
- package/lib/typescript/commonjs/httpClient.d.ts +6 -0
- package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/commonjs/uuidv7.d.ts +10 -0
- package/lib/typescript/commonjs/uuidv7.d.ts.map +1 -0
- package/lib/typescript/commonjs/wrap.types.d.ts +7 -2
- package/lib/typescript/commonjs/wrap.types.d.ts.map +1 -1
- package/lib/typescript/module/httpClient.d.ts +6 -0
- package/lib/typescript/module/httpClient.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/uuidv7.d.ts +10 -0
- package/lib/typescript/module/uuidv7.d.ts.map +1 -0
- package/lib/typescript/module/wrap.types.d.ts +7 -2
- package/lib/typescript/module/wrap.types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/httpClient.spec.ts +219 -3
- package/src/httpClient.ts +157 -14
- package/src/index.spec.ts +70 -1
- package/src/index.ts +17 -2
- package/src/notifyAppReadyInsights.transport.spec.ts +58 -0
- package/src/sdkVersion.ts +1 -1
- package/src/uuidv7.spec.ts +25 -0
- package/src/uuidv7.ts +38 -0
- package/src/wrap.types.ts +9 -2
package/src/httpClient.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { fetchJSON, FetchJSONResponseError } from "./fetchJSON";
|
|
|
10
10
|
import { fetchReleaseCatalogWithCache } from "./releaseCatalogCache";
|
|
11
11
|
import { HOT_UPDATER_SDK_VERSION } from "./sdkVersion";
|
|
12
12
|
import type { HotUpdaterBaseURL } from "./types";
|
|
13
|
+
import { createUUIDv7 } from "./uuidv7";
|
|
13
14
|
|
|
14
15
|
export interface ReleaseCatalogRequest {
|
|
15
16
|
readonly platform: "ios" | "android";
|
|
@@ -66,6 +67,12 @@ export interface HotUpdaterHttpSession {
|
|
|
66
67
|
params: ReleaseCatalogRequest,
|
|
67
68
|
) => Promise<ReleaseCatalog>;
|
|
68
69
|
resolveArtifact: (params: ArtifactRequest) => Promise<ArtifactInfo>;
|
|
70
|
+
/**
|
|
71
|
+
* Posts an Insights event under a client `eventId`, retrying a network
|
|
72
|
+
* error, timeout, 429 or 5xx for up to three attempts in all. Resolves once
|
|
73
|
+
* the event is delivered or left to background retries; rejects when its
|
|
74
|
+
* first attempt fails for good, such as with a 400.
|
|
75
|
+
*/
|
|
69
76
|
sendInsightsEvent: (params: InsightsEventParams) => Promise<void>;
|
|
70
77
|
}
|
|
71
78
|
|
|
@@ -150,10 +157,53 @@ const requireArtifactProtocolV1 = (info: ArtifactInfo): ArtifactInfo => {
|
|
|
150
157
|
return info;
|
|
151
158
|
};
|
|
152
159
|
|
|
153
|
-
|
|
160
|
+
/** Attempts per Insights event, the first one included. */
|
|
161
|
+
const INSIGHTS_MAX_ATTEMPTS = 3;
|
|
162
|
+
/** Wait before the second attempt; it doubles for each later attempt. */
|
|
163
|
+
const INSIGHTS_RETRY_BASE_DELAY_MS = 1000;
|
|
164
|
+
/** Longest wait between attempts, a server's `Retry-After` included. */
|
|
165
|
+
const INSIGHTS_RETRY_MAX_DELAY_MS = 30000;
|
|
166
|
+
|
|
167
|
+
type InsightsAttemptFailure = {
|
|
168
|
+
readonly error: Error;
|
|
169
|
+
/** A network error, timeout, 429 or 5xx may pass on a later attempt. */
|
|
170
|
+
readonly retryable: boolean;
|
|
171
|
+
readonly retryAfterMs: number | null;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Reads `Retry-After` as delay-seconds, the form a server under load sends;
|
|
176
|
+
* an HTTP-date falls back to the backoff.
|
|
177
|
+
*/
|
|
178
|
+
const parseRetryAfterMs = (value: string | null): number | null => {
|
|
179
|
+
const seconds = value?.trim();
|
|
180
|
+
return seconds && /^\d+$/.test(seconds) ? Number(seconds) * 1000 : null;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const getRetryDelayMs = (
|
|
184
|
+
failedAttempt: number,
|
|
185
|
+
retryAfterMs: number | null,
|
|
186
|
+
): number =>
|
|
187
|
+
Math.min(
|
|
188
|
+
retryAfterMs ??
|
|
189
|
+
// Jitter spreads the retries of installations that failed together.
|
|
190
|
+
INSIGHTS_RETRY_BASE_DELAY_MS *
|
|
191
|
+
2 ** (failedAttempt - 1) *
|
|
192
|
+
(0.5 + Math.random()),
|
|
193
|
+
INSIGHTS_RETRY_MAX_DELAY_MS,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const wait = (ms: number) =>
|
|
197
|
+
new Promise<void>((resolve) => {
|
|
198
|
+
setTimeout(resolve, ms);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/** Makes one POST, with its own timeout, and settles with its failure. */
|
|
202
|
+
const postInsightsEvent = async (
|
|
154
203
|
baseURL: string,
|
|
155
204
|
params: InsightsEventParams,
|
|
156
|
-
|
|
205
|
+
eventId: string,
|
|
206
|
+
): Promise<InsightsAttemptFailure | null> => {
|
|
157
207
|
const controller = new AbortController();
|
|
158
208
|
const timeoutId = setTimeout(() => {
|
|
159
209
|
controller.abort();
|
|
@@ -165,6 +215,7 @@ const sendInsightsEvent = async (
|
|
|
165
215
|
appVersion: params.appVersion,
|
|
166
216
|
channel: params.channel,
|
|
167
217
|
cohort: params.cohort,
|
|
218
|
+
eventId,
|
|
168
219
|
fingerprintHash: params.fingerprintHash,
|
|
169
220
|
fromBundleId: params.fromBundleId,
|
|
170
221
|
...(params.fromReleaseId === undefined
|
|
@@ -190,22 +241,109 @@ const sendInsightsEvent = async (
|
|
|
190
241
|
signal: controller.signal,
|
|
191
242
|
});
|
|
192
243
|
|
|
193
|
-
if (response.status
|
|
194
|
-
|
|
244
|
+
if (response.status === 204) return null;
|
|
245
|
+
return {
|
|
246
|
+
error: new Error(
|
|
195
247
|
`Expected HTTP 204 from /events, received ${response.status}`,
|
|
196
|
-
)
|
|
197
|
-
|
|
248
|
+
),
|
|
249
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
250
|
+
retryAfterMs: parseRetryAfterMs(response.headers.get("Retry-After")),
|
|
251
|
+
};
|
|
198
252
|
} catch (error: unknown) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
253
|
+
// fetch rejects only when no response arrived (a timeout or a network
|
|
254
|
+
// error), so a later attempt may still get through.
|
|
255
|
+
return {
|
|
256
|
+
error:
|
|
257
|
+
error instanceof Error && error.name === "AbortError"
|
|
258
|
+
? new Error("Request timed out")
|
|
259
|
+
: error instanceof Error
|
|
260
|
+
? error
|
|
261
|
+
: new Error(String(error)),
|
|
262
|
+
retryable: true,
|
|
263
|
+
retryAfterMs: null,
|
|
264
|
+
};
|
|
203
265
|
} finally {
|
|
204
266
|
clearTimeout(timeoutId);
|
|
205
267
|
}
|
|
206
268
|
};
|
|
207
269
|
|
|
208
|
-
|
|
270
|
+
type InsightsEventSender = (
|
|
271
|
+
baseURL: string,
|
|
272
|
+
params: InsightsEventParams,
|
|
273
|
+
) => Promise<void>;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Sends Insights events one at a time and retries each in the background.
|
|
277
|
+
*
|
|
278
|
+
* The server keeps an installation's latest report by arrival, so a later
|
|
279
|
+
* event waits behind an earlier one's retries; otherwise a retried
|
|
280
|
+
* UPDATE_APPLIED could land after the UPDATE_DOWNLOADED that followed it.
|
|
281
|
+
* Callers still wait only for first attempts, as they did before retries, so
|
|
282
|
+
* startup, readiness and `updateBundle()` never wait out a backoff: once an
|
|
283
|
+
* event backs off, every waiting caller is released, and a failure after that
|
|
284
|
+
* only warns.
|
|
285
|
+
*/
|
|
286
|
+
const createInsightsEventSender = (): InsightsEventSender => {
|
|
287
|
+
let queue = Promise.resolve();
|
|
288
|
+
let retrying = false;
|
|
289
|
+
const waiting = new Set<() => void>();
|
|
290
|
+
|
|
291
|
+
return (baseURL, params) => {
|
|
292
|
+
// One ID for every attempt, so the server counts a retried POST once.
|
|
293
|
+
const eventId = createUUIDv7();
|
|
294
|
+
|
|
295
|
+
return new Promise<void>((resolve, reject) => {
|
|
296
|
+
let released = false;
|
|
297
|
+
const release = () => {
|
|
298
|
+
released = true;
|
|
299
|
+
waiting.delete(release);
|
|
300
|
+
resolve();
|
|
301
|
+
};
|
|
302
|
+
if (retrying) release();
|
|
303
|
+
else waiting.add(release);
|
|
304
|
+
|
|
305
|
+
queue = queue.then(async () => {
|
|
306
|
+
try {
|
|
307
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
308
|
+
const failure = await postInsightsEvent(baseURL, params, eventId);
|
|
309
|
+
if (failure === null) {
|
|
310
|
+
release();
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
if (!failure.retryable || attempt === INSIGHTS_MAX_ATTEMPTS) {
|
|
314
|
+
waiting.delete(release);
|
|
315
|
+
if (released) {
|
|
316
|
+
console.warn(
|
|
317
|
+
`[HotUpdater] Insights ${params.type} event was not delivered:`,
|
|
318
|
+
failure.error,
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
reject(failure.error);
|
|
322
|
+
}
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
retrying = true;
|
|
326
|
+
for (const releaseWaiting of waiting) releaseWaiting();
|
|
327
|
+
await wait(getRetryDelayMs(attempt, failure.retryAfterMs));
|
|
328
|
+
}
|
|
329
|
+
} catch (error: unknown) {
|
|
330
|
+
// A failed request settles in postInsightsEvent, so only a bug gets
|
|
331
|
+
// here. A rejected queue would leave every later event, and a caller
|
|
332
|
+
// such as `updateBundle()` waiting on one, pending forever.
|
|
333
|
+
waiting.delete(release);
|
|
334
|
+
if (!released) reject(error);
|
|
335
|
+
} finally {
|
|
336
|
+
retrying = false;
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
};
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const createSession = (
|
|
344
|
+
baseURL: string,
|
|
345
|
+
sendInsightsEvent: InsightsEventSender,
|
|
346
|
+
): HotUpdaterHttpSession => ({
|
|
209
347
|
fetchReleaseCatalog: async (params): Promise<ReleaseCatalog> => {
|
|
210
348
|
const channelKey = encodeChannelKey(params.channel);
|
|
211
349
|
let strategyValue: string;
|
|
@@ -270,6 +408,11 @@ const createSession = (baseURL: string): HotUpdaterHttpSession => ({
|
|
|
270
408
|
/** Creates the private HTTP client used by HotUpdater.init and HotUpdater.wrap. */
|
|
271
409
|
export const createHttpClient = (
|
|
272
410
|
baseURL: HotUpdaterBaseURL,
|
|
273
|
-
): HotUpdaterHttpClient =>
|
|
274
|
-
|
|
275
|
-
|
|
411
|
+
): HotUpdaterHttpClient => {
|
|
412
|
+
// Shared by every session, since each report opens its own session.
|
|
413
|
+
const sendInsightsEvent = createInsightsEventSender();
|
|
414
|
+
return {
|
|
415
|
+
createSession: async () =>
|
|
416
|
+
createSession(await resolveBaseURL(baseURL), sendInsightsEvent),
|
|
417
|
+
};
|
|
418
|
+
};
|
package/src/index.spec.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
import type { HotUpdaterInitOptions, HotUpdaterOptions } from "./wrap";
|
|
4
4
|
|
|
@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => {
|
|
|
31
31
|
getInstallId: vi.fn(() => "install-id"),
|
|
32
32
|
getManifest: vi.fn(() => null),
|
|
33
33
|
getMinBundleId: vi.fn(() => "min-bundle-id"),
|
|
34
|
+
getPersistedUserIdentity: vi.fn(() => ({})),
|
|
34
35
|
init: vi.fn(),
|
|
35
36
|
isChannelSwitched: vi.fn(() => false),
|
|
36
37
|
notifyAppReady: vi.fn(() => ({ status: "UNCHANGED" as const })),
|
|
@@ -71,6 +72,7 @@ vi.mock("./native", () => ({
|
|
|
71
72
|
getInstallId: mocks.getInstallId,
|
|
72
73
|
getManifest: mocks.getManifest,
|
|
73
74
|
getMinBundleId: mocks.getMinBundleId,
|
|
75
|
+
getPersistedUserIdentity: mocks.getPersistedUserIdentity,
|
|
74
76
|
isChannelSwitched: mocks.isChannelSwitched,
|
|
75
77
|
notifyAppReady: mocks.notifyAppReady,
|
|
76
78
|
reload: mocks.reload,
|
|
@@ -99,6 +101,11 @@ describe("HotUpdater client initialization", () => {
|
|
|
99
101
|
mocks.wrap.mockImplementation((Component: unknown) => Component);
|
|
100
102
|
});
|
|
101
103
|
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
vi.unstubAllGlobals();
|
|
106
|
+
vi.restoreAllMocks();
|
|
107
|
+
});
|
|
108
|
+
|
|
102
109
|
it("exposes the console ID through the existing getter only", async () => {
|
|
103
110
|
const HotUpdater = await importHotUpdater();
|
|
104
111
|
|
|
@@ -178,6 +185,68 @@ describe("HotUpdater client initialization", () => {
|
|
|
178
185
|
);
|
|
179
186
|
});
|
|
180
187
|
|
|
188
|
+
it.each([
|
|
189
|
+
{ dev: true, insights: undefined, expected: false },
|
|
190
|
+
{ dev: true, insights: true, expected: false },
|
|
191
|
+
{ dev: true, insights: false, expected: false },
|
|
192
|
+
{ dev: true, insights: { debug: true }, expected: true },
|
|
193
|
+
{ dev: false, insights: { debug: true }, expected: true },
|
|
194
|
+
{ dev: false, insights: { debug: false }, expected: true },
|
|
195
|
+
{ dev: false, insights: false, expected: false },
|
|
196
|
+
] as const)(
|
|
197
|
+
"resolves insights $insights to $expected when __DEV__ is $dev",
|
|
198
|
+
async ({ dev, insights, expected }) => {
|
|
199
|
+
vi.stubGlobal("__DEV__", dev);
|
|
200
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
201
|
+
const HotUpdater = await importHotUpdater();
|
|
202
|
+
|
|
203
|
+
HotUpdater.init({ baseURL: "https://updates.example.com", insights });
|
|
204
|
+
HotUpdater.wrap({
|
|
205
|
+
baseURL: "https://updates.example.com",
|
|
206
|
+
insights,
|
|
207
|
+
updateStrategy: "appVersion",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
expect(mocks.init).toHaveBeenCalledWith(
|
|
211
|
+
expect.objectContaining({ insights: expected }),
|
|
212
|
+
);
|
|
213
|
+
expect(mocks.wrap).toHaveBeenCalledWith(
|
|
214
|
+
expect.objectContaining({ insights: expected }),
|
|
215
|
+
);
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
it.each([
|
|
220
|
+
{ dev: true, reports: false },
|
|
221
|
+
{ dev: false, reports: true },
|
|
222
|
+
])(
|
|
223
|
+
"gates manual checks and downloads on the build type (__DEV__: $dev)",
|
|
224
|
+
async ({ dev, reports }) => {
|
|
225
|
+
vi.stubGlobal("__DEV__", dev);
|
|
226
|
+
const sendInsightsEvent = vi.fn(async () => undefined);
|
|
227
|
+
mocks.createHttpClient.mockReturnValue({
|
|
228
|
+
createSession: vi.fn(async () => ({ sendInsightsEvent })),
|
|
229
|
+
} as never);
|
|
230
|
+
mocks.updateBundle.mockResolvedValueOnce(true);
|
|
231
|
+
const HotUpdater = await importHotUpdater();
|
|
232
|
+
HotUpdater.init({ baseURL: "https://updates.example.com" });
|
|
233
|
+
|
|
234
|
+
await HotUpdater.checkForUpdate({ updateStrategy: "appVersion" });
|
|
235
|
+
await HotUpdater.updateBundle({
|
|
236
|
+
assets: {},
|
|
237
|
+
bundleId: "next-bundle-id",
|
|
238
|
+
manifestFileHash: "manifest-hash",
|
|
239
|
+
manifestUrl: "https://updates.example.com/manifest.json",
|
|
240
|
+
status: "UPDATE",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(mocks.checkForUpdate).toHaveBeenCalledWith(
|
|
244
|
+
expect.objectContaining({ insights: reports }),
|
|
245
|
+
);
|
|
246
|
+
expect(sendInsightsEvent).toHaveBeenCalledTimes(reports ? 1 : 0);
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
|
|
181
250
|
it("requires baseURL and rejects the removed resolver shape", async () => {
|
|
182
251
|
const HotUpdater = await importHotUpdater();
|
|
183
252
|
|
package/src/index.ts
CHANGED
|
@@ -93,6 +93,21 @@ type HotUpdaterWrap = {
|
|
|
93
93
|
(options: HotUpdaterOptions): ReturnType<typeof wrap>;
|
|
94
94
|
};
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Debug builds report only with `insights: { debug: true }`, so development
|
|
98
|
+
* sessions do not mix into production Insights. `__DEV__` is defined only in
|
|
99
|
+
* a React Native bundle; anywhere else, such as unit tests, counts as release.
|
|
100
|
+
*/
|
|
101
|
+
const resolveInsights = (
|
|
102
|
+
insights: HotUpdaterInitOptions["insights"],
|
|
103
|
+
): boolean => {
|
|
104
|
+
if (insights === false) return false;
|
|
105
|
+
const isDebugBuild = typeof __DEV__ !== "undefined" && __DEV__;
|
|
106
|
+
return (
|
|
107
|
+
!isDebugBuild || (typeof insights === "object" && insights?.debug === true)
|
|
108
|
+
);
|
|
109
|
+
};
|
|
110
|
+
|
|
96
111
|
/**
|
|
97
112
|
* Creates a HotUpdater client instance with all update management methods.
|
|
98
113
|
* This function is called once on module initialization to create a singleton instance.
|
|
@@ -161,7 +176,7 @@ function createHotUpdaterClient() {
|
|
|
161
176
|
const { baseURL, ...rest } = autoOptions;
|
|
162
177
|
return {
|
|
163
178
|
...rest,
|
|
164
|
-
insights: rest.insights
|
|
179
|
+
insights: resolveInsights(rest.insights),
|
|
165
180
|
client: createHttpClient(baseURL),
|
|
166
181
|
};
|
|
167
182
|
}
|
|
@@ -181,7 +196,7 @@ function createHotUpdaterClient() {
|
|
|
181
196
|
const { baseURL, ...baseURLRest } = rest;
|
|
182
197
|
return {
|
|
183
198
|
...baseURLRest,
|
|
184
|
-
insights: baseURLRest.insights
|
|
199
|
+
insights: resolveInsights(baseURLRest.insights),
|
|
185
200
|
client: createHttpClient(baseURL),
|
|
186
201
|
};
|
|
187
202
|
}
|
|
@@ -17,6 +17,9 @@ vi.mock("react-native", () => ({
|
|
|
17
17
|
const failureCases = [
|
|
18
18
|
{ label: "400", responseStatus: 400 },
|
|
19
19
|
{ label: "404", responseStatus: 404 },
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
const retryableFailureCases = [
|
|
20
23
|
{ label: "500", responseStatus: 500 },
|
|
21
24
|
{
|
|
22
25
|
error: Object.assign(new Error("request aborted"), { name: "AbortError" }),
|
|
@@ -135,4 +138,59 @@ describe("automatic notifyAppReady transport failures", () => {
|
|
|
135
138
|
}
|
|
136
139
|
},
|
|
137
140
|
);
|
|
141
|
+
|
|
142
|
+
it.each(retryableFailureCases)(
|
|
143
|
+
"retries a $label failure in the background without delaying UNCHANGED readiness",
|
|
144
|
+
async (failureCase) => {
|
|
145
|
+
stubNotifyFrame();
|
|
146
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
147
|
+
const onError = vi.fn();
|
|
148
|
+
const onNotifyAppReady = vi.fn();
|
|
149
|
+
const fetchMock = vi.fn<typeof fetch>(async () => {
|
|
150
|
+
if ("error" in failureCase) {
|
|
151
|
+
throw failureCase.error;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return new Response(null, { status: failureCase.responseStatus });
|
|
155
|
+
});
|
|
156
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
157
|
+
Reflect.set(globalThis, "HotUpdater", {
|
|
158
|
+
SDK_VERSION: "test-sdk-version",
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
const [{ createHttpClient }, { init }] = await Promise.all([
|
|
163
|
+
import("./httpClient"),
|
|
164
|
+
import("./wrap"),
|
|
165
|
+
]);
|
|
166
|
+
const client = createHttpClient(
|
|
167
|
+
"https://updates.example.test/hot-updater",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
init({ insights: true, client, onError, onNotifyAppReady });
|
|
171
|
+
await vi.runOnlyPendingTimersAsync();
|
|
172
|
+
|
|
173
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(onNotifyAppReady).toHaveBeenCalledWith({
|
|
175
|
+
status: "UNCHANGED",
|
|
176
|
+
});
|
|
177
|
+
expect(warn).not.toHaveBeenCalled();
|
|
178
|
+
|
|
179
|
+
await vi.runAllTimersAsync();
|
|
180
|
+
|
|
181
|
+
const eventIds = fetchMock.mock.calls.map(
|
|
182
|
+
([, request]) => JSON.parse(String(request?.body)).eventId,
|
|
183
|
+
);
|
|
184
|
+
expect(eventIds).toHaveLength(3);
|
|
185
|
+
expect(new Set(eventIds).size).toBe(1);
|
|
186
|
+
expect(onError).not.toHaveBeenCalled();
|
|
187
|
+
expect(warn).toHaveBeenCalledExactlyOnceWith(
|
|
188
|
+
"[HotUpdater] Insights UNCHANGED event was not delivered:",
|
|
189
|
+
expect.any(Error),
|
|
190
|
+
);
|
|
191
|
+
} finally {
|
|
192
|
+
warn.mockRestore();
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
);
|
|
138
196
|
});
|
package/src/sdkVersion.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.
|
|
1
|
+
export const HOT_UPDATER_SDK_VERSION = "1.0.0-rc.16";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isUUIDv7 } from "@hot-updater/core";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { createUUIDv7 } from "./uuidv7";
|
|
5
|
+
|
|
6
|
+
describe("createUUIDv7", () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.useRealTimers();
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it.each([
|
|
13
|
+
{ label: "Web Crypto", crypto: globalThis.crypto },
|
|
14
|
+
{ label: "no Web Crypto, as in React Native", crypto: undefined },
|
|
15
|
+
])("leads with the 48-bit millisecond time using $label", ({ crypto }) => {
|
|
16
|
+
vi.useFakeTimers({ now: 0x0192_3456_789a });
|
|
17
|
+
vi.stubGlobal("crypto", crypto);
|
|
18
|
+
|
|
19
|
+
const id = createUUIDv7();
|
|
20
|
+
|
|
21
|
+
expect(isUUIDv7(id)).toBe(true);
|
|
22
|
+
expect(id.startsWith("01923456-789a-7")).toBe(true);
|
|
23
|
+
expect(createUUIDv7()).not.toBe(id);
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/uuidv7.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
type RandomValuesSource = {
|
|
2
|
+
readonly getRandomValues?: (array: Uint8Array) => Uint8Array;
|
|
3
|
+
};
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates an RFC 9562 UUIDv7: a 48-bit millisecond timestamp, then random bits.
|
|
7
|
+
*
|
|
8
|
+
* `createUUIDv7` from `@hot-updater/plugin-core` calls `crypto.getRandomValues`
|
|
9
|
+
* unconditionally, but React Native has no Web Crypto unless the app installs a
|
|
10
|
+
* polyfill. An Insights event ID only has to tell one event from another so a
|
|
11
|
+
* retried POST is counted once, so `Math.random` is enough without one.
|
|
12
|
+
*/
|
|
13
|
+
export const createUUIDv7 = (): string => {
|
|
14
|
+
const bytes = new Uint8Array(16);
|
|
15
|
+
const { crypto } = globalThis as { crypto?: RandomValuesSource };
|
|
16
|
+
if (typeof crypto?.getRandomValues === "function") {
|
|
17
|
+
crypto.getRandomValues(bytes);
|
|
18
|
+
} else {
|
|
19
|
+
for (let index = 0; index < bytes.length; index += 1) {
|
|
20
|
+
bytes[index] = Math.floor(Math.random() * 256);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Division, not bit shifts: shifts truncate to 32 bits, and a millisecond
|
|
25
|
+
// timestamp already needs 41.
|
|
26
|
+
let timestamp = Date.now();
|
|
27
|
+
for (let index = 5; index >= 0; index -= 1) {
|
|
28
|
+
bytes[index] = timestamp % 256;
|
|
29
|
+
timestamp = Math.floor(timestamp / 256);
|
|
30
|
+
}
|
|
31
|
+
bytes[6] = 0x70 | (bytes[6]! & 0x0f);
|
|
32
|
+
bytes[8] = 0x80 | (bytes[8]! & 0x3f);
|
|
33
|
+
|
|
34
|
+
const hex = Array.from(bytes, (byte) =>
|
|
35
|
+
byte.toString(16).padStart(2, "0"),
|
|
36
|
+
).join("");
|
|
37
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
38
|
+
};
|
package/src/wrap.types.ts
CHANGED
|
@@ -30,9 +30,15 @@ interface CommonHotUpdaterOptions {
|
|
|
30
30
|
/**
|
|
31
31
|
* Sends app-ready transitions and same-Bundle Release adoptions to the
|
|
32
32
|
* configured server. Defaults to `true`; set to `false` to disable client
|
|
33
|
-
* reporting.
|
|
33
|
+
* reporting. Debug builds (`__DEV__`) send nothing unless you pass
|
|
34
|
+
* `{ debug: true }`, so development sessions stay out of production data.
|
|
34
35
|
*/
|
|
35
|
-
insights?:
|
|
36
|
+
insights?:
|
|
37
|
+
| boolean
|
|
38
|
+
| {
|
|
39
|
+
/** Also report from debug builds. Defaults to `false`. */
|
|
40
|
+
debug?: boolean;
|
|
41
|
+
};
|
|
36
42
|
/** Base URL of a server exposing the Hot Updater v1 client HTTP protocol. */
|
|
37
43
|
baseURL: HotUpdaterBaseURL;
|
|
38
44
|
requestHeaders?: Record<string, string>;
|
|
@@ -54,6 +60,7 @@ export type HotUpdaterInitOptions = CommonHotUpdaterOptions;
|
|
|
54
60
|
export type HotUpdaterOptions = AutoUpdateOptions;
|
|
55
61
|
|
|
56
62
|
type InternalCommonOptions = {
|
|
63
|
+
/** Whether this runtime reports, with the debug-build default applied. */
|
|
57
64
|
insights?: boolean;
|
|
58
65
|
client: HotUpdaterHttpClient;
|
|
59
66
|
requestHeaders?: Record<string, string>;
|