@hot-updater/protocol 1.0.0-rc.20 → 1.0.0-rc.21
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/README.md +50 -0
- package/dist/index.cjs +346 -3
- package/dist/index.d.cts +100 -3
- package/dist/index.d.mts +100 -3
- package/dist/index.mjs +339 -4
- package/package.json +7 -3
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @hot-updater/protocol
|
|
2
|
+
|
|
3
|
+
The device-safe, zero-dependency package for what crosses a boundary:
|
|
4
|
+
|
|
5
|
+
- the data formats and pure computations that the app and the server share,
|
|
6
|
+
where both sides must get the same answer;
|
|
7
|
+
- the contracts between a host and its extensions on the device, such as the
|
|
8
|
+
client plugin contract, and the reference host that runs them.
|
|
9
|
+
|
|
10
|
+
It runs anywhere: Hermes, Node, Workers, and Deno. Code that only the server
|
|
11
|
+
or the CLI uses belongs in `@hot-updater/plugin-core` or
|
|
12
|
+
`@hot-updater/server`.
|
|
13
|
+
|
|
14
|
+
## What belongs here
|
|
15
|
+
|
|
16
|
+
Code moves into this package only when all three hold:
|
|
17
|
+
|
|
18
|
+
1. It crosses a boundary: a format or computation both the app and the server
|
|
19
|
+
run, or a contract a host on the device offers its extensions.
|
|
20
|
+
2. It does no I/O of its own: what reaches the device or the network, such as
|
|
21
|
+
the plugin host's `fetch` and storage, the caller passes in. It uses no Node
|
|
22
|
+
or React Native API and keeps no global state.
|
|
23
|
+
3. It needs no dependency. This package has none; what it takes from another
|
|
24
|
+
package, such as verkit's version parsing, is bundled into `dist`.
|
|
25
|
+
|
|
26
|
+
It holds the domain types, the release catalog format and its scope rules,
|
|
27
|
+
the check a client runs on a fetched release catalog (`parseReleaseCatalog`),
|
|
28
|
+
the rollout cohort math, bundle artifacts, the bundle id format (`uuid`), the
|
|
29
|
+
client plugin contract, `canonicalizeAppVersion`, and `resolveBaseURL`. The
|
|
30
|
+
device's update client and doctor's server checks both run
|
|
31
|
+
`parseReleaseCatalog`, so they accept the same catalogs. Client
|
|
32
|
+
plugin authors import `defineClientPlugin` and its types from here.
|
|
33
|
+
`createPluginHost` is the host the SDK runs client plugins on: a device SDK
|
|
34
|
+
passes it the platform, the device's storage, and `fetch`, and
|
|
35
|
+
`@hot-updater/test-utils/react-native` passes it fakes.
|
|
36
|
+
|
|
37
|
+
## Boundary with `@hot-updater/plugin-core`
|
|
38
|
+
|
|
39
|
+
`@hot-updater/plugin-core` is the kit that extends the server: the storage and
|
|
40
|
+
database adapter contracts, the server plugin authoring API, and their
|
|
41
|
+
helpers. It runs wherever the server runs, in Node, Workers, Deno, or Lambda,
|
|
42
|
+
and depends on this package, never the reverse. The app never imports it:
|
|
43
|
+
React Native imports this package and the built-in Insights client, never
|
|
44
|
+
plugin-core or the server.
|
|
45
|
+
|
|
46
|
+
`src/package.spec.ts` checks that this package has no dependencies and imports
|
|
47
|
+
only what its build bundles. An oxlint rule refuses `@hot-updater/plugin-core`
|
|
48
|
+
and `@hot-updater/server` imports in `packages/protocol/src`,
|
|
49
|
+
`packages/react-native/src`, and the Insights client in
|
|
50
|
+
`plugins/insights/src/client`.
|
package/dist/index.cjs
CHANGED
|
@@ -1,4 +1,130 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region ../../node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/comparison-DenM3wCn.js
|
|
3
|
+
const LETTER_DASH_NUMBER = "[a-zA-Z0-9-]";
|
|
4
|
+
const NUMERIC_IDENTIFIER = String.raw`0|[1-9]\d*`;
|
|
5
|
+
const NUMERIC_IDENTIFIER_LOOSE = String.raw`\d+`;
|
|
6
|
+
const NON_NUMERIC_IDENTIFIER = String.raw`\d*[a-zA-Z-]${LETTER_DASH_NUMBER}*`;
|
|
7
|
+
const MAIN_VERSION = String.raw`(${NUMERIC_IDENTIFIER})\.(${NUMERIC_IDENTIFIER})\.(${NUMERIC_IDENTIFIER})`;
|
|
8
|
+
const MAIN_VERSION_LOOSE = String.raw`(${NUMERIC_IDENTIFIER_LOOSE})\.(${NUMERIC_IDENTIFIER_LOOSE})\.(${NUMERIC_IDENTIFIER_LOOSE})`;
|
|
9
|
+
const PRERELEASE_IDENTIFIER = `(?:${NON_NUMERIC_IDENTIFIER}|${NUMERIC_IDENTIFIER})`;
|
|
10
|
+
const PRERELEASE_IDENTIFIER_LOOSE = `(?:${NON_NUMERIC_IDENTIFIER}|${NUMERIC_IDENTIFIER_LOOSE})`;
|
|
11
|
+
const PRERELEASE = String.raw`(?:-(${PRERELEASE_IDENTIFIER}(?:\.${PRERELEASE_IDENTIFIER})*))`;
|
|
12
|
+
const PRERELEASE_LOOSE = String.raw`(?:-?(${PRERELEASE_IDENTIFIER_LOOSE}(?:\.${PRERELEASE_IDENTIFIER_LOOSE})*))`;
|
|
13
|
+
const BUILD_IDENTIFIER = `${LETTER_DASH_NUMBER}+`;
|
|
14
|
+
const BUILD = String.raw`(?:\+(${BUILD_IDENTIFIER}(?:\.${BUILD_IDENTIFIER})*))`;
|
|
15
|
+
const FULL_PLAIN = `v?${MAIN_VERSION}${PRERELEASE}?${BUILD}?`;
|
|
16
|
+
const LOOSE_PLAIN = String.raw`[v=\s]*${MAIN_VERSION_LOOSE}${PRERELEASE_LOOSE}?${BUILD}?`;
|
|
17
|
+
const XRANGE_IDENTIFIER = String.raw`${NUMERIC_IDENTIFIER}|x|X|\*`;
|
|
18
|
+
const XRANGE_IDENTIFIER_LOOSE = String.raw`${NUMERIC_IDENTIFIER_LOOSE}|x|X|\*`;
|
|
19
|
+
String.raw`[v=\s]*(${XRANGE_IDENTIFIER})(?:\.(${XRANGE_IDENTIFIER})(?:\.(${XRANGE_IDENTIFIER})(?:${PRERELEASE})?${BUILD}?)?)?`;
|
|
20
|
+
String.raw`[v=\s]*(${XRANGE_IDENTIFIER_LOOSE})(?:\.(${XRANGE_IDENTIFIER_LOOSE})(?:\.(${XRANGE_IDENTIFIER_LOOSE})(?:${PRERELEASE_LOOSE})?${BUILD}?)?)?`;
|
|
21
|
+
String.raw`(?:\^)`;
|
|
22
|
+
const COERCE_PLAIN = String.raw`(^|[^\d])(\d{1,${16}})(?:\.(\d{1,${16}}))?(?:\.(\d{1,${16}}))?`;
|
|
23
|
+
const COERCE = String.raw`${COERCE_PLAIN}(?:$|[^\d])`;
|
|
24
|
+
const COERCE_FULL = String.raw`${COERCE_PLAIN}(?:${PRERELEASE})?(?:${BUILD})?(?:$|[^\d])`;
|
|
25
|
+
function makeSafeRegexSource(source) {
|
|
26
|
+
const replacements = [
|
|
27
|
+
[String.raw`\s`, 1],
|
|
28
|
+
[String.raw`\d`, 256],
|
|
29
|
+
[LETTER_DASH_NUMBER, 250]
|
|
30
|
+
];
|
|
31
|
+
for (const [token, maximum] of replacements) source = source.split(`${token}*`).join(`${token}{0,${maximum}}`).split(`${token}+`).join(`${token}{1,${maximum}}`);
|
|
32
|
+
return source;
|
|
33
|
+
}
|
|
34
|
+
function safeRegex(source, flags) {
|
|
35
|
+
return new RegExp(makeSafeRegexSource(source), flags);
|
|
36
|
+
}
|
|
37
|
+
const FULL = safeRegex(`^${FULL_PLAIN}$`);
|
|
38
|
+
const LOOSE = safeRegex(`^${LOOSE_PLAIN}$`);
|
|
39
|
+
const NUMERIC$1 = /^\d+$/;
|
|
40
|
+
function formatComparableVersion(version) {
|
|
41
|
+
const base = `${version.major}.${version.minor}.${version.patch}`;
|
|
42
|
+
return version.prerelease?.length ? `${base}-${version.prerelease.join(".")}` : base;
|
|
43
|
+
}
|
|
44
|
+
function formatFullVersion(version) {
|
|
45
|
+
const comparable = formatComparableVersion(version);
|
|
46
|
+
return version.build?.length ? `${comparable}+${version.build.join(".")}` : comparable;
|
|
47
|
+
}
|
|
48
|
+
function parse(version, options = {}) {
|
|
49
|
+
if (typeof version !== "string") return version;
|
|
50
|
+
if (version.length > 256) throw new TypeError(`Version exceeds the maximum length of 256 characters`);
|
|
51
|
+
const match = version.trim().match(options.loose ? LOOSE : FULL);
|
|
52
|
+
if (!match) throw new TypeError(`Invalid version syntax: ${version}`);
|
|
53
|
+
const major = Number(match[1]);
|
|
54
|
+
const minor = Number(match[2]);
|
|
55
|
+
const patch = Number(match[3]);
|
|
56
|
+
if (major > Number.MAX_SAFE_INTEGER || major < 0) throw new TypeError(`Invalid major version: ${match[1]}`);
|
|
57
|
+
if (minor > Number.MAX_SAFE_INTEGER || minor < 0) throw new TypeError(`Invalid minor version: ${match[2]}`);
|
|
58
|
+
if (patch > Number.MAX_SAFE_INTEGER || patch < 0) throw new TypeError(`Invalid patch version: ${match[3]}`);
|
|
59
|
+
const prerelease = match[4] ? match[4].split(".").map((identifier) => {
|
|
60
|
+
if (NUMERIC$1.test(identifier)) {
|
|
61
|
+
const numeric = Number(identifier);
|
|
62
|
+
if (numeric >= 0 && numeric < Number.MAX_SAFE_INTEGER) return numeric;
|
|
63
|
+
}
|
|
64
|
+
return identifier;
|
|
65
|
+
}) : void 0;
|
|
66
|
+
return {
|
|
67
|
+
build: match[5]?.split("."),
|
|
68
|
+
major,
|
|
69
|
+
minor,
|
|
70
|
+
patch,
|
|
71
|
+
prerelease
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function tryParse(version, options = {}) {
|
|
75
|
+
try {
|
|
76
|
+
return parse(version, options);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
//#region ../../node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/version-CQ98ZBpL.js
|
|
83
|
+
const COERCE_EXACT = safeRegex(COERCE);
|
|
84
|
+
const COERCE_FULL_EXACT = safeRegex(COERCE_FULL);
|
|
85
|
+
safeRegex(`^${PRERELEASE}$`);
|
|
86
|
+
safeRegex(`^${PRERELEASE_LOOSE}$`);
|
|
87
|
+
function normalizeFull(version, options = {}) {
|
|
88
|
+
const parsed = tryParse(version, options);
|
|
89
|
+
return parsed ? formatFullVersion(parsed) : null;
|
|
90
|
+
}
|
|
91
|
+
function coerce(value, options = {}) {
|
|
92
|
+
if (typeof value === "object") return value;
|
|
93
|
+
const input = typeof value === "number" ? String(value) : value;
|
|
94
|
+
let match = null;
|
|
95
|
+
if (options.rtl) {
|
|
96
|
+
const expression = safeRegex(options.includePrerelease ? COERCE_FULL : COERCE, "g");
|
|
97
|
+
let next;
|
|
98
|
+
while ((next = expression.exec(input)) && (!match || match.index + match[0].length !== input.length)) {
|
|
99
|
+
if (!match || next.index + next[0].length !== match.index + match[0].length) match = next;
|
|
100
|
+
expression.lastIndex = next.index + next[1].length + next[2].length;
|
|
101
|
+
}
|
|
102
|
+
} else match = (options.includePrerelease ? COERCE_FULL_EXACT : COERCE_EXACT).exec(input);
|
|
103
|
+
if (!match) return null;
|
|
104
|
+
const major = match[2];
|
|
105
|
+
return tryParse(`${major}.${match[3] || "0"}.${match[4] || "0"}${options.includePrerelease && match[5] ? `-${match[5]}` : ""}${options.includePrerelease && match[6] ? `+${match[6]}` : ""}`, options);
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/appVersion.ts
|
|
109
|
+
/**
|
|
110
|
+
* The form of an app version that the device and the server compare:
|
|
111
|
+
* `major.minor.patch` of the first version in `appVersion`, such as `1.4.0`
|
|
112
|
+
* for `v1.4`, or null when it holds none. Prerelease and build parts are
|
|
113
|
+
* dropped.
|
|
114
|
+
*/
|
|
115
|
+
function canonicalizeAppVersion(appVersion) {
|
|
116
|
+
const version = coerce(appVersion);
|
|
117
|
+
return version ? normalizeFull(version) : null;
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/baseURL.ts
|
|
121
|
+
/** Resolves a functional baseURL and drops trailing slashes. */
|
|
122
|
+
const resolveBaseURL = async (baseURL) => {
|
|
123
|
+
const resolvedBaseURL = typeof baseURL === "function" ? await baseURL() : baseURL;
|
|
124
|
+
if (!resolvedBaseURL) throw new Error("baseURL function must return a non-empty string");
|
|
125
|
+
return resolvedBaseURL.replace(/\/+$/, "");
|
|
126
|
+
};
|
|
127
|
+
//#endregion
|
|
2
128
|
//#region src/bundleArtifacts.ts
|
|
3
129
|
const stripBundleArtifactMetadata = (metadata) => metadata;
|
|
4
130
|
const getManifestStorageUri = (bundle) => bundle.manifestStorageUri;
|
|
@@ -37,6 +163,164 @@ const getPatchStorageUri = (bundle) => getPrimaryPatch(bundle)?.patchStorageUri
|
|
|
37
163
|
/** Declares a client plugin; built-in and third-party plugins use the same contract. */
|
|
38
164
|
const defineClientPlugin = (plugin) => plugin;
|
|
39
165
|
//#endregion
|
|
166
|
+
//#region src/createPluginHost.ts
|
|
167
|
+
/** Storage values stay small, since native reads them on the JS thread. */
|
|
168
|
+
const MAX_STORAGE_VALUE_LENGTH = 65536;
|
|
169
|
+
/** Where a plugin's key lives in native storage. */
|
|
170
|
+
const pluginStorageKey = (pluginId, key) => `plugins/${encodeURIComponent(pluginId)}/${key}`;
|
|
171
|
+
const createPluginHost = (environment) => {
|
|
172
|
+
let config = null;
|
|
173
|
+
/** Hooks by plugin, so a plugin configured again is not set up twice. */
|
|
174
|
+
const setUpPlugins = /* @__PURE__ */ new WeakMap();
|
|
175
|
+
let activePlugins = [];
|
|
176
|
+
/** What plugins read and wrote, so a read follows a write before native does. */
|
|
177
|
+
const storageValues = /* @__PURE__ */ new Map();
|
|
178
|
+
const reportHookError = (message, error) => {
|
|
179
|
+
const report = new Error(`[HotUpdater] ${message}`, { cause: error });
|
|
180
|
+
try {
|
|
181
|
+
if (config?.onError) {
|
|
182
|
+
config.onError(report);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
} catch {}
|
|
186
|
+
console.warn(report.message, error);
|
|
187
|
+
};
|
|
188
|
+
const fetchFromServer = async (path, init = {}) => {
|
|
189
|
+
if (config === null) throw new Error("[HotUpdater] Plugins can fetch only after init or wrap.");
|
|
190
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//")) throw new TypeError("[HotUpdater] Plugin fetch takes a path relative to baseURL.");
|
|
191
|
+
const { requestHeaders, requestTimeout } = config;
|
|
192
|
+
const url = `${await resolveBaseURL(config.baseURL)}/${path.replace(/^\/+/, "")}`;
|
|
193
|
+
const headers = new Headers(requestHeaders);
|
|
194
|
+
new Headers(init.headers).forEach((value, name) => {
|
|
195
|
+
headers.set(name, value);
|
|
196
|
+
});
|
|
197
|
+
const controller = new AbortController();
|
|
198
|
+
const callerSignal = init.signal;
|
|
199
|
+
const abortFromCaller = () => controller.abort();
|
|
200
|
+
if (callerSignal?.aborted) controller.abort();
|
|
201
|
+
callerSignal?.addEventListener("abort", abortFromCaller);
|
|
202
|
+
const timeoutId = setTimeout(() => controller.abort(), requestTimeout ?? 5e3);
|
|
203
|
+
try {
|
|
204
|
+
return await environment.fetch(url, {
|
|
205
|
+
...init,
|
|
206
|
+
headers,
|
|
207
|
+
signal: controller.signal
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
if (error instanceof Error && error.name === "AbortError" && !callerSignal?.aborted) throw new Error("Request timed out");
|
|
211
|
+
throw error;
|
|
212
|
+
} finally {
|
|
213
|
+
clearTimeout(timeoutId);
|
|
214
|
+
callerSignal?.removeEventListener("abort", abortFromCaller);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const createStorage = (pluginId) => {
|
|
218
|
+
const scopedKey = (key) => {
|
|
219
|
+
if (typeof key !== "string" || key.length === 0) throw new TypeError("[HotUpdater] Plugin storage keys are non-empty strings.");
|
|
220
|
+
return pluginStorageKey(pluginId, key);
|
|
221
|
+
};
|
|
222
|
+
return {
|
|
223
|
+
get: (key) => {
|
|
224
|
+
const storageKey = scopedKey(key);
|
|
225
|
+
const cached = storageValues.get(storageKey);
|
|
226
|
+
if (cached !== void 0) return cached;
|
|
227
|
+
const value = environment.getStorageItem(storageKey);
|
|
228
|
+
storageValues.set(storageKey, value);
|
|
229
|
+
return value;
|
|
230
|
+
},
|
|
231
|
+
set: (key, value) => {
|
|
232
|
+
const storageKey = scopedKey(key);
|
|
233
|
+
if (value !== null && typeof value !== "string") throw new TypeError("[HotUpdater] Plugin storage values are strings or null.");
|
|
234
|
+
if (value !== null && value.length > MAX_STORAGE_VALUE_LENGTH) throw new RangeError("[HotUpdater] Plugin storage values are at most 64 KB.");
|
|
235
|
+
environment.setStorageItem(storageKey, value);
|
|
236
|
+
storageValues.set(storageKey, value);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
const createContext = (pluginId) => ({
|
|
241
|
+
fetch: fetchFromServer,
|
|
242
|
+
get installId() {
|
|
243
|
+
return environment.getInstallId();
|
|
244
|
+
},
|
|
245
|
+
platform: environment.platform,
|
|
246
|
+
get appVersion() {
|
|
247
|
+
return environment.getAppVersion();
|
|
248
|
+
},
|
|
249
|
+
get sdkVersion() {
|
|
250
|
+
return environment.sdkVersion;
|
|
251
|
+
},
|
|
252
|
+
isDebugBuild: environment.isDebugBuild,
|
|
253
|
+
getBundleId: () => environment.getBundleId(),
|
|
254
|
+
getChannel: () => environment.getChannel(),
|
|
255
|
+
getCohort: () => environment.getCohort(),
|
|
256
|
+
getFingerprintHash: () => environment.getFingerprintHash(),
|
|
257
|
+
storage: createStorage(pluginId),
|
|
258
|
+
now: () => environment.now()
|
|
259
|
+
});
|
|
260
|
+
const configurePlugins = (plugins, nextConfig) => {
|
|
261
|
+
const ids = /* @__PURE__ */ new Set();
|
|
262
|
+
for (const plugin of plugins ?? []) {
|
|
263
|
+
if (typeof plugin.id !== "string" || plugin.id.length === 0) throw new Error("[HotUpdater] A plugin id must be a non-empty string.");
|
|
264
|
+
if (ids.has(plugin.id)) throw new Error(`[HotUpdater] Two plugins use the id "${plugin.id}". Plugin ids must be unique.`);
|
|
265
|
+
ids.add(plugin.id);
|
|
266
|
+
}
|
|
267
|
+
config = nextConfig;
|
|
268
|
+
activePlugins = (plugins ?? []).map((plugin) => {
|
|
269
|
+
let hooks = setUpPlugins.get(plugin);
|
|
270
|
+
if (hooks === void 0) {
|
|
271
|
+
try {
|
|
272
|
+
hooks = plugin.setup(createContext(plugin.id)) ?? {};
|
|
273
|
+
} catch (error) {
|
|
274
|
+
reportHookError(`Plugin "${plugin.id}" failed in setup`, error);
|
|
275
|
+
hooks = {};
|
|
276
|
+
}
|
|
277
|
+
setUpPlugins.set(plugin, hooks);
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
plugin,
|
|
281
|
+
hooks
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
const hasPluginHook = (name) => activePlugins.some(({ hooks }) => typeof hooks[name] === "function");
|
|
286
|
+
const buildPluginEvent = (name, createPayload) => {
|
|
287
|
+
if (!hasPluginHook(name)) return null;
|
|
288
|
+
try {
|
|
289
|
+
return createPayload();
|
|
290
|
+
} catch (error) {
|
|
291
|
+
reportHookError(`Failed to build the ${name} event for plugins`, error);
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const dispatchPluginHook = (name, payload) => {
|
|
296
|
+
const settlements = [];
|
|
297
|
+
for (const { plugin, hooks } of activePlugins) {
|
|
298
|
+
const hook = hooks[name];
|
|
299
|
+
if (typeof hook !== "function") continue;
|
|
300
|
+
try {
|
|
301
|
+
const result = hook.call(hooks, payload);
|
|
302
|
+
if (result !== null && typeof result === "object" && typeof result.then === "function") settlements.push(Promise.resolve(result).then(() => void 0, (error) => {
|
|
303
|
+
reportHookError(`Plugin "${plugin.id}" failed in ${name}`, error);
|
|
304
|
+
}));
|
|
305
|
+
} catch (error) {
|
|
306
|
+
reportHookError(`Plugin "${plugin.id}" failed in ${name}`, error);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return settlements;
|
|
310
|
+
};
|
|
311
|
+
const emitPluginHook = (name, createPayload) => {
|
|
312
|
+
const payload = buildPluginEvent(name, createPayload);
|
|
313
|
+
if (payload !== null) dispatchPluginHook(name, payload);
|
|
314
|
+
};
|
|
315
|
+
return {
|
|
316
|
+
configurePlugins,
|
|
317
|
+
hasPluginHook,
|
|
318
|
+
buildPluginEvent,
|
|
319
|
+
dispatchPluginHook,
|
|
320
|
+
emitPluginHook
|
|
321
|
+
};
|
|
322
|
+
};
|
|
323
|
+
//#endregion
|
|
40
324
|
//#region src/rollout.ts
|
|
41
325
|
const NUMERIC_COHORT_SIZE = 1e3;
|
|
42
326
|
const DEFAULT_ROLLOUT_COHORT_COUNT = NUMERIC_COHORT_SIZE;
|
|
@@ -408,14 +692,65 @@ function parseReleaseCatalogScopeKey(scopeKey) {
|
|
|
408
692
|
return input;
|
|
409
693
|
}
|
|
410
694
|
//#endregion
|
|
411
|
-
//#region src/types.ts
|
|
412
|
-
const ARTIFACT_PROTOCOL_VERSION = 1;
|
|
413
|
-
//#endregion
|
|
414
695
|
//#region src/uuid.ts
|
|
415
696
|
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
416
697
|
const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
417
698
|
const isUUIDv7 = (value) => typeof value === "string" && UUID_V7_PATTERN.test(value);
|
|
418
699
|
//#endregion
|
|
700
|
+
//#region src/releaseCatalogWire.ts
|
|
701
|
+
/** The largest release catalog response body a client accepts, in UTF-8 bytes. */
|
|
702
|
+
const MAX_RELEASE_CATALOG_WIRE_BYTES = 528384;
|
|
703
|
+
/** The length of `value` in UTF-8 bytes, without an encoder the device may lack. */
|
|
704
|
+
const getUtf8ByteLength = (value) => {
|
|
705
|
+
let bytes = 0;
|
|
706
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
707
|
+
const code = value.charCodeAt(index);
|
|
708
|
+
if (code <= 127) bytes += 1;
|
|
709
|
+
else if (code <= 2047) bytes += 2;
|
|
710
|
+
else if (code >= 55296 && code <= 56319 && index + 1 < value.length && value.charCodeAt(index + 1) >= 56320 && value.charCodeAt(index + 1) <= 57343) {
|
|
711
|
+
bytes += 4;
|
|
712
|
+
index += 1;
|
|
713
|
+
} else bytes += 3;
|
|
714
|
+
}
|
|
715
|
+
return bytes;
|
|
716
|
+
};
|
|
717
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
718
|
+
const isValidReleaseDescriptor = (value) => {
|
|
719
|
+
if (value === null || typeof value !== "object") return false;
|
|
720
|
+
const descriptor = value;
|
|
721
|
+
return isUUIDv7(descriptor.releaseId) && (descriptor.kind === "BUNDLE" || descriptor.kind === "EMBEDDED") && (descriptor.kind === "BUNDLE" && typeof descriptor.bundleId === "string" || descriptor.kind === "EMBEDDED" && descriptor.bundleId === null) && Number.isSafeInteger(descriptor.rolloutCohortCount) && descriptor.rolloutCohortCount >= 0 && descriptor.rolloutCohortCount <= 1e3 && isStringArray(descriptor.targetCohorts) && descriptor.targetCohorts.length <= 100 && typeof descriptor.shouldForceUpdate === "boolean" && (descriptor.message === null || typeof descriptor.message === "string");
|
|
722
|
+
};
|
|
723
|
+
/** Whether the catalog is the expected scope's. */
|
|
724
|
+
const hasExpectedReleaseCatalogScope = (catalog, expected) => {
|
|
725
|
+
try {
|
|
726
|
+
const parsed = parseReleaseCatalogScopeKey(catalog.scopeKey);
|
|
727
|
+
return parsed.channelKey === expected.channelKey && parsed.platform === expected.platform && parsed.strategy === expected.strategy && (parsed.strategy === "APP_VERSION" || expected.strategy === "FINGERPRINT" && parsed.fingerprintHash === expected.fingerprintHash);
|
|
728
|
+
} catch {
|
|
729
|
+
return false;
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
/**
|
|
733
|
+
* Parses a release catalog response body as a client accepts it: within the
|
|
734
|
+
* wire limit, in the schema's shape, for the expected scope, and within the
|
|
735
|
+
* cohort limits. Any other body is null. The device's update client and
|
|
736
|
+
* doctor's server checks both run this, so they accept the same catalogs.
|
|
737
|
+
*/
|
|
738
|
+
const parseReleaseCatalog = (body, expectedScope) => {
|
|
739
|
+
if (getUtf8ByteLength(body) > MAX_RELEASE_CATALOG_WIRE_BYTES) return null;
|
|
740
|
+
try {
|
|
741
|
+
const catalog = JSON.parse(body);
|
|
742
|
+
if (catalog.schemaVersion !== 1 || typeof catalog.catalogId !== "string" || catalog.catalogId.length === 0 || typeof catalog.scopeKey !== "string" || !Number.isSafeInteger(catalog.generation) || (catalog.generation ?? 0) < 1 || typeof catalog.catalogHash !== "string" || !/^sha256:[0-9a-f]{64}$/.test(catalog.catalogHash) || catalog.fallbackPolicy !== "BUILTIN_IF_ACTIVE_INELIGIBLE" || !Array.isArray(catalog.releases) || !catalog.releases.every(isValidReleaseDescriptor) || catalog.rollbackReleases !== void 0 && (!Array.isArray(catalog.rollbackReleases) || !catalog.rollbackReleases.every(isValidReleaseDescriptor))) return null;
|
|
743
|
+
if (!hasExpectedReleaseCatalogScope(catalog, expectedScope)) return null;
|
|
744
|
+
if (new Set([...catalog.releases, ...catalog.rollbackReleases ?? []].flatMap((release) => release.targetCohorts)).size > 512) return null;
|
|
745
|
+
return catalog;
|
|
746
|
+
} catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region src/types.ts
|
|
752
|
+
const ARTIFACT_PROTOCOL_VERSION = 1;
|
|
753
|
+
//#endregion
|
|
419
754
|
exports.ARTIFACT_PROTOCOL_VERSION = ARTIFACT_PROTOCOL_VERSION;
|
|
420
755
|
exports.DEFAULT_ROLLOUT_COHORT_COUNT = DEFAULT_ROLLOUT_COHORT_COUNT;
|
|
421
756
|
exports.INVALID_COHORT_ERROR_MESSAGE = INVALID_COHORT_ERROR_MESSAGE;
|
|
@@ -423,6 +758,7 @@ exports.MAX_COHORT_LENGTH = MAX_COHORT_LENGTH;
|
|
|
423
758
|
exports.MAX_COMPILED_CATALOG_BYTES = MAX_COMPILED_CATALOG_BYTES;
|
|
424
759
|
exports.MAX_CRASHED_BUNDLES = MAX_CRASHED_BUNDLES;
|
|
425
760
|
exports.MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE = MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE;
|
|
761
|
+
exports.MAX_RELEASE_CATALOG_WIRE_BYTES = MAX_RELEASE_CATALOG_WIRE_BYTES;
|
|
426
762
|
exports.MAX_TARGET_COHORTS_PER_RELEASE = MAX_TARGET_COHORTS_PER_RELEASE;
|
|
427
763
|
exports.NIL_UUID = NIL_UUID;
|
|
428
764
|
exports.NUMERIC_COHORT_SIZE = NUMERIC_COHORT_SIZE;
|
|
@@ -431,6 +767,8 @@ exports.RELEASE_CATALOG_SCHEMA_VERSION = RELEASE_CATALOG_SCHEMA_VERSION;
|
|
|
431
767
|
exports.assertCanonicalChannelName = assertCanonicalChannelName;
|
|
432
768
|
exports.assessCatalogAcceptance = assessCatalogAcceptance;
|
|
433
769
|
exports.authorizeReleaseTransition = authorizeReleaseTransition;
|
|
770
|
+
exports.canonicalizeAppVersion = canonicalizeAppVersion;
|
|
771
|
+
exports.createPluginHost = createPluginHost;
|
|
434
772
|
exports.createReleaseCatalogScopeKey = createReleaseCatalogScopeKey;
|
|
435
773
|
exports.createReleaseSelectionContextHash = createReleaseSelectionContextHash;
|
|
436
774
|
exports.decodeChannelKey = decodeChannelKey;
|
|
@@ -449,6 +787,8 @@ exports.getPatchBaseFileHash = getPatchBaseFileHash;
|
|
|
449
787
|
exports.getPatchFileHash = getPatchFileHash;
|
|
450
788
|
exports.getPatchStorageUri = getPatchStorageUri;
|
|
451
789
|
exports.getRolledOutNumericCohorts = getRolledOutNumericCohorts;
|
|
790
|
+
exports.getUtf8ByteLength = getUtf8ByteLength;
|
|
791
|
+
exports.hasExpectedReleaseCatalogScope = hasExpectedReleaseCatalogScope;
|
|
452
792
|
exports.isCohortEligibleForUpdate = isCohortEligibleForUpdate;
|
|
453
793
|
exports.isCustomCohort = isCustomCohort;
|
|
454
794
|
exports.isNumericCohort = isNumericCohort;
|
|
@@ -458,6 +798,9 @@ exports.isValidCohort = isValidCohort;
|
|
|
458
798
|
exports.normalizeChannelName = normalizeChannelName;
|
|
459
799
|
exports.normalizeCohortValue = normalizeCohortValue;
|
|
460
800
|
exports.normalizeRolloutCohortCount = normalizeRolloutCohortCount;
|
|
801
|
+
exports.parseReleaseCatalog = parseReleaseCatalog;
|
|
461
802
|
exports.parseReleaseCatalogScopeKey = parseReleaseCatalogScopeKey;
|
|
803
|
+
exports.pluginStorageKey = pluginStorageKey;
|
|
804
|
+
exports.resolveBaseURL = resolveBaseURL;
|
|
462
805
|
exports.selectDesiredRelease = selectDesiredRelease;
|
|
463
806
|
exports.stripBundleArtifactMetadata = stripBundleArtifactMetadata;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
//#region src/appVersion.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The form of an app version that the device and the server compare:
|
|
4
|
+
* `major.minor.patch` of the first version in `appVersion`, such as `1.4.0`
|
|
5
|
+
* for `v1.4`, or null when it holds none. Prerelease and build parts are
|
|
6
|
+
* dropped.
|
|
7
|
+
*/
|
|
8
|
+
declare function canonicalizeAppVersion(appVersion: string): string | null;
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/baseURL.d.ts
|
|
11
|
+
/** The server's base URL, or a function that returns it, as the SDK takes it. */
|
|
12
|
+
type HotUpdaterBaseURL = string | (() => string | Promise<string>);
|
|
13
|
+
/** Resolves a functional baseURL and drops trailing slashes. */
|
|
14
|
+
declare const resolveBaseURL: (baseURL: HotUpdaterBaseURL) => Promise<string>;
|
|
15
|
+
//#endregion
|
|
1
16
|
//#region src/types.d.ts
|
|
2
17
|
type Platform = "ios" | "android";
|
|
3
18
|
type BundleMetadata = {
|
|
@@ -108,8 +123,7 @@ declare const getPatchStorageUri: (bundle: Pick<Bundle, "patches">) => string;
|
|
|
108
123
|
/**
|
|
109
124
|
* The client plugin contract. Built-in plugins, such as Insights, and
|
|
110
125
|
* third-party plugins use the same one, as server plugins do. Plugins import
|
|
111
|
-
* it through `@hot-updater/react-native
|
|
112
|
-
* `@hot-updater/react-native`.
|
|
126
|
+
* it from here, and apps through `@hot-updater/react-native`.
|
|
113
127
|
*
|
|
114
128
|
* Keep this module free of state and of classes that code checks with
|
|
115
129
|
* `instanceof`. A plugin can load it in one module format while the app loads
|
|
@@ -295,6 +309,63 @@ interface HotUpdaterClientPlugin {
|
|
|
295
309
|
/** Declares a client plugin; built-in and third-party plugins use the same contract. */
|
|
296
310
|
declare const defineClientPlugin: <const P extends HotUpdaterClientPlugin>(plugin: P) => P;
|
|
297
311
|
//#endregion
|
|
312
|
+
//#region src/createPluginHost.d.ts
|
|
313
|
+
type PluginHookName = keyof HotUpdaterClientHooks;
|
|
314
|
+
type PluginHookPayload<K extends PluginHookName> = Parameters<NonNullable<HotUpdaterClientHooks[K]>>[0];
|
|
315
|
+
/** The init or wrap settings plugins see through their context. */
|
|
316
|
+
interface PluginHostConfig {
|
|
317
|
+
readonly baseURL: HotUpdaterBaseURL;
|
|
318
|
+
readonly requestHeaders?: Record<string, string>;
|
|
319
|
+
readonly requestTimeout?: number;
|
|
320
|
+
readonly onError?: (error: unknown) => void;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* What plugins read from the app and the device. A device SDK backs it with
|
|
324
|
+
* its native module; `@hot-updater/test-utils/react-native` backs it with
|
|
325
|
+
* fakes.
|
|
326
|
+
*/
|
|
327
|
+
interface PluginHostEnvironment {
|
|
328
|
+
fetch(url: string, init: RequestInit): Promise<Response>;
|
|
329
|
+
readonly platform: "ios" | "android";
|
|
330
|
+
readonly isDebugBuild: boolean;
|
|
331
|
+
readonly sdkVersion: string;
|
|
332
|
+
getInstallId(): string;
|
|
333
|
+
getAppVersion(): string | null;
|
|
334
|
+
getBundleId(): string;
|
|
335
|
+
getChannel(): string;
|
|
336
|
+
getCohort(): string;
|
|
337
|
+
getFingerprintHash(): string | null;
|
|
338
|
+
getStorageItem(key: string): string | null;
|
|
339
|
+
setStorageItem(key: string, value: string | null): void;
|
|
340
|
+
now(): number;
|
|
341
|
+
}
|
|
342
|
+
interface PluginHost {
|
|
343
|
+
/**
|
|
344
|
+
* Sets up the plugins of an init or wrap call. A plugin passed to an
|
|
345
|
+
* earlier call keeps its hooks, and a plugin left out stops receiving
|
|
346
|
+
* events.
|
|
347
|
+
*/
|
|
348
|
+
configurePlugins(plugins: readonly HotUpdaterClientPlugin[] | undefined, config: PluginHostConfig): void;
|
|
349
|
+
/** Whether any plugin listens to this hook, so callers build events only when one does. */
|
|
350
|
+
hasPluginHook(name: PluginHookName): boolean;
|
|
351
|
+
/**
|
|
352
|
+
* Builds a hook's event when a plugin listens. A failure to build it is
|
|
353
|
+
* reported, not thrown, since reporting never changes an update.
|
|
354
|
+
*/
|
|
355
|
+
buildPluginEvent<K extends PluginHookName>(name: K, createPayload: () => PluginHookPayload<K> | null): PluginHookPayload<K> | null;
|
|
356
|
+
/**
|
|
357
|
+
* Calls a hook on every plugin that listens, without waiting for any. It
|
|
358
|
+
* returns when each asynchronous hook settles, for observers such as
|
|
359
|
+
* `@hot-updater/test-utils`; the SDK ignores it.
|
|
360
|
+
*/
|
|
361
|
+
dispatchPluginHook<K extends PluginHookName>(name: K, payload: PluginHookPayload<K>): readonly Promise<void>[];
|
|
362
|
+
/** Builds a hook's event when a plugin listens and calls the hook right away. */
|
|
363
|
+
emitPluginHook<K extends PluginHookName>(name: K, createPayload: () => PluginHookPayload<K> | null): void;
|
|
364
|
+
}
|
|
365
|
+
/** Where a plugin's key lives in native storage. */
|
|
366
|
+
declare const pluginStorageKey: (pluginId: string, key: string) => string;
|
|
367
|
+
declare const createPluginHost: (environment: PluginHostEnvironment) => PluginHost;
|
|
368
|
+
//#endregion
|
|
298
369
|
//#region src/releaseCatalog.d.ts
|
|
299
370
|
declare const RELEASE_CATALOG_SCHEMA_VERSION: 1;
|
|
300
371
|
declare const RELEASE_CATALOG_FALLBACK_POLICY: "BUILTIN_IF_ACTIVE_INELIGIBLE";
|
|
@@ -428,6 +499,32 @@ type ReleaseCatalogScopeKeyInput = {
|
|
|
428
499
|
declare function createReleaseCatalogScopeKey(input: ReleaseCatalogScopeKeyInput): string;
|
|
429
500
|
declare function parseReleaseCatalogScopeKey(scopeKey: string): ReleaseCatalogScopeKeyInput;
|
|
430
501
|
//#endregion
|
|
502
|
+
//#region src/releaseCatalogWire.d.ts
|
|
503
|
+
/** The largest release catalog response body a client accepts, in UTF-8 bytes. */
|
|
504
|
+
declare const MAX_RELEASE_CATALOG_WIRE_BYTES: number;
|
|
505
|
+
/** The scope a client asked for: the catalog it accepts must be this scope's. */
|
|
506
|
+
type ExpectedReleaseCatalogScope = {
|
|
507
|
+
readonly channelKey: string;
|
|
508
|
+
readonly platform: "ios" | "android";
|
|
509
|
+
} & ({
|
|
510
|
+
readonly strategy: "APP_VERSION";
|
|
511
|
+
readonly fingerprintHash?: never;
|
|
512
|
+
} | {
|
|
513
|
+
readonly strategy: "FINGERPRINT";
|
|
514
|
+
readonly fingerprintHash: string;
|
|
515
|
+
});
|
|
516
|
+
/** The length of `value` in UTF-8 bytes, without an encoder the device may lack. */
|
|
517
|
+
declare const getUtf8ByteLength: (value: string) => number;
|
|
518
|
+
/** Whether the catalog is the expected scope's. */
|
|
519
|
+
declare const hasExpectedReleaseCatalogScope: (catalog: Pick<ReleaseCatalog, "scopeKey">, expected: ExpectedReleaseCatalogScope) => boolean;
|
|
520
|
+
/**
|
|
521
|
+
* Parses a release catalog response body as a client accepts it: within the
|
|
522
|
+
* wire limit, in the schema's shape, for the expected scope, and within the
|
|
523
|
+
* cohort limits. Any other body is null. The device's update client and
|
|
524
|
+
* doctor's server checks both run this, so they accept the same catalogs.
|
|
525
|
+
*/
|
|
526
|
+
declare const parseReleaseCatalog: (body: string, expectedScope: ExpectedReleaseCatalogScope) => ReleaseCatalog | null;
|
|
527
|
+
//#endregion
|
|
431
528
|
//#region src/rollout.d.ts
|
|
432
529
|
declare const NUMERIC_COHORT_SIZE = 1000;
|
|
433
530
|
declare const DEFAULT_ROLLOUT_COHORT_COUNT = 1000;
|
|
@@ -448,4 +545,4 @@ declare function isCohortEligibleForUpdate(bundleId: string, cohort: string | nu
|
|
|
448
545
|
declare const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
449
546
|
declare const isUUIDv7: (value: unknown) => value is string;
|
|
450
547
|
//#endregion
|
|
451
|
-
export { ARTIFACT_PROTOCOL_VERSION, AppReadyResult, ArtifactAsset, ArtifactAssetFile, ArtifactInfo, Bundle, BundleDownloadedInfo, BundleMetadata, BundlePatchArtifact, CatalogAcceptance, CatalogHighWater, ChangedAssetPatch, DEFAULT_ROLLOUT_COHORT_COUNT, DesiredRelease, HotUpdaterClientContext, HotUpdaterClientHooks, HotUpdaterClientPlugin, HotUpdaterClientStorage, INVALID_COHORT_ERROR_MESSAGE, MAX_COHORT_LENGTH, MAX_COMPILED_CATALOG_BYTES, MAX_CRASHED_BUNDLES, MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE, MAX_TARGET_COHORTS_PER_RELEASE, ManifestArchive, NIL_UUID, NUMERIC_COHORT_SIZE, PersistedSelectionReceipt, Platform, RELEASE_CATALOG_FALLBACK_POLICY, RELEASE_CATALOG_SCHEMA_VERSION, Release, ReleaseCatalog, ReleaseCatalogDescriptor, ReleaseCatalogScopeKeyInput, ReleaseKind, ReleaseOperation, ReleaseSelectionContextInput, ReleaseSelectionInput, ReleaseStrategy, ReleaseTransitionAuthorization, ReleaseTransitionKind, SelectionKind, UpdateCheckResult, UpdateError, UpdateErrorReason, UpdateErrorResource, UpdateErrorStage, UpdateErrorTransport, UpdateStatus, UpdateStrategy, assertCanonicalChannelName, assessCatalogAcceptance, authorizeReleaseTransition, createReleaseCatalogScopeKey, createReleaseSelectionContextHash, decodeChannelKey, defineClientPlugin, encodeChannelKey, getAssetBaseStorageUri, getBundlePatch, getBundlePatches, getDefaultNumericCohort, getManifestFileHash, getManifestStorageUri, getNumericCohortRolloutPosition, getNumericCohortValue, getPatchBaseBundleId, getPatchBaseFileHash, getPatchFileHash, getPatchStorageUri, getRolledOutNumericCohorts, isCohortEligibleForUpdate, isCustomCohort, isNumericCohort, isReleaseEligibleForCohort, isUUIDv7, isValidCohort, normalizeChannelName, normalizeCohortValue, normalizeRolloutCohortCount, parseReleaseCatalogScopeKey, selectDesiredRelease, stripBundleArtifactMetadata };
|
|
548
|
+
export { ARTIFACT_PROTOCOL_VERSION, AppReadyResult, ArtifactAsset, ArtifactAssetFile, ArtifactInfo, Bundle, BundleDownloadedInfo, BundleMetadata, BundlePatchArtifact, CatalogAcceptance, CatalogHighWater, ChangedAssetPatch, DEFAULT_ROLLOUT_COHORT_COUNT, DesiredRelease, ExpectedReleaseCatalogScope, HotUpdaterBaseURL, HotUpdaterClientContext, HotUpdaterClientHooks, HotUpdaterClientPlugin, HotUpdaterClientStorage, INVALID_COHORT_ERROR_MESSAGE, MAX_COHORT_LENGTH, MAX_COMPILED_CATALOG_BYTES, MAX_CRASHED_BUNDLES, MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE, MAX_RELEASE_CATALOG_WIRE_BYTES, MAX_TARGET_COHORTS_PER_RELEASE, ManifestArchive, NIL_UUID, NUMERIC_COHORT_SIZE, PersistedSelectionReceipt, Platform, PluginHookName, PluginHookPayload, PluginHost, PluginHostConfig, PluginHostEnvironment, RELEASE_CATALOG_FALLBACK_POLICY, RELEASE_CATALOG_SCHEMA_VERSION, Release, ReleaseCatalog, ReleaseCatalogDescriptor, ReleaseCatalogScopeKeyInput, ReleaseKind, ReleaseOperation, ReleaseSelectionContextInput, ReleaseSelectionInput, ReleaseStrategy, ReleaseTransitionAuthorization, ReleaseTransitionKind, SelectionKind, UpdateCheckResult, UpdateError, UpdateErrorReason, UpdateErrorResource, UpdateErrorStage, UpdateErrorTransport, UpdateStatus, UpdateStrategy, assertCanonicalChannelName, assessCatalogAcceptance, authorizeReleaseTransition, canonicalizeAppVersion, createPluginHost, createReleaseCatalogScopeKey, createReleaseSelectionContextHash, decodeChannelKey, defineClientPlugin, encodeChannelKey, getAssetBaseStorageUri, getBundlePatch, getBundlePatches, getDefaultNumericCohort, getManifestFileHash, getManifestStorageUri, getNumericCohortRolloutPosition, getNumericCohortValue, getPatchBaseBundleId, getPatchBaseFileHash, getPatchFileHash, getPatchStorageUri, getRolledOutNumericCohorts, getUtf8ByteLength, hasExpectedReleaseCatalogScope, isCohortEligibleForUpdate, isCustomCohort, isNumericCohort, isReleaseEligibleForCohort, isUUIDv7, isValidCohort, normalizeChannelName, normalizeCohortValue, normalizeRolloutCohortCount, parseReleaseCatalog, parseReleaseCatalogScopeKey, pluginStorageKey, resolveBaseURL, selectDesiredRelease, stripBundleArtifactMetadata };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
//#region src/appVersion.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The form of an app version that the device and the server compare:
|
|
4
|
+
* `major.minor.patch` of the first version in `appVersion`, such as `1.4.0`
|
|
5
|
+
* for `v1.4`, or null when it holds none. Prerelease and build parts are
|
|
6
|
+
* dropped.
|
|
7
|
+
*/
|
|
8
|
+
declare function canonicalizeAppVersion(appVersion: string): string | null;
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/baseURL.d.ts
|
|
11
|
+
/** The server's base URL, or a function that returns it, as the SDK takes it. */
|
|
12
|
+
type HotUpdaterBaseURL = string | (() => string | Promise<string>);
|
|
13
|
+
/** Resolves a functional baseURL and drops trailing slashes. */
|
|
14
|
+
declare const resolveBaseURL: (baseURL: HotUpdaterBaseURL) => Promise<string>;
|
|
15
|
+
//#endregion
|
|
1
16
|
//#region src/types.d.ts
|
|
2
17
|
type Platform = "ios" | "android";
|
|
3
18
|
type BundleMetadata = {
|
|
@@ -108,8 +123,7 @@ declare const getPatchStorageUri: (bundle: Pick<Bundle, "patches">) => string;
|
|
|
108
123
|
/**
|
|
109
124
|
* The client plugin contract. Built-in plugins, such as Insights, and
|
|
110
125
|
* third-party plugins use the same one, as server plugins do. Plugins import
|
|
111
|
-
* it through `@hot-updater/react-native
|
|
112
|
-
* `@hot-updater/react-native`.
|
|
126
|
+
* it from here, and apps through `@hot-updater/react-native`.
|
|
113
127
|
*
|
|
114
128
|
* Keep this module free of state and of classes that code checks with
|
|
115
129
|
* `instanceof`. A plugin can load it in one module format while the app loads
|
|
@@ -295,6 +309,63 @@ interface HotUpdaterClientPlugin {
|
|
|
295
309
|
/** Declares a client plugin; built-in and third-party plugins use the same contract. */
|
|
296
310
|
declare const defineClientPlugin: <const P extends HotUpdaterClientPlugin>(plugin: P) => P;
|
|
297
311
|
//#endregion
|
|
312
|
+
//#region src/createPluginHost.d.ts
|
|
313
|
+
type PluginHookName = keyof HotUpdaterClientHooks;
|
|
314
|
+
type PluginHookPayload<K extends PluginHookName> = Parameters<NonNullable<HotUpdaterClientHooks[K]>>[0];
|
|
315
|
+
/** The init or wrap settings plugins see through their context. */
|
|
316
|
+
interface PluginHostConfig {
|
|
317
|
+
readonly baseURL: HotUpdaterBaseURL;
|
|
318
|
+
readonly requestHeaders?: Record<string, string>;
|
|
319
|
+
readonly requestTimeout?: number;
|
|
320
|
+
readonly onError?: (error: unknown) => void;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* What plugins read from the app and the device. A device SDK backs it with
|
|
324
|
+
* its native module; `@hot-updater/test-utils/react-native` backs it with
|
|
325
|
+
* fakes.
|
|
326
|
+
*/
|
|
327
|
+
interface PluginHostEnvironment {
|
|
328
|
+
fetch(url: string, init: RequestInit): Promise<Response>;
|
|
329
|
+
readonly platform: "ios" | "android";
|
|
330
|
+
readonly isDebugBuild: boolean;
|
|
331
|
+
readonly sdkVersion: string;
|
|
332
|
+
getInstallId(): string;
|
|
333
|
+
getAppVersion(): string | null;
|
|
334
|
+
getBundleId(): string;
|
|
335
|
+
getChannel(): string;
|
|
336
|
+
getCohort(): string;
|
|
337
|
+
getFingerprintHash(): string | null;
|
|
338
|
+
getStorageItem(key: string): string | null;
|
|
339
|
+
setStorageItem(key: string, value: string | null): void;
|
|
340
|
+
now(): number;
|
|
341
|
+
}
|
|
342
|
+
interface PluginHost {
|
|
343
|
+
/**
|
|
344
|
+
* Sets up the plugins of an init or wrap call. A plugin passed to an
|
|
345
|
+
* earlier call keeps its hooks, and a plugin left out stops receiving
|
|
346
|
+
* events.
|
|
347
|
+
*/
|
|
348
|
+
configurePlugins(plugins: readonly HotUpdaterClientPlugin[] | undefined, config: PluginHostConfig): void;
|
|
349
|
+
/** Whether any plugin listens to this hook, so callers build events only when one does. */
|
|
350
|
+
hasPluginHook(name: PluginHookName): boolean;
|
|
351
|
+
/**
|
|
352
|
+
* Builds a hook's event when a plugin listens. A failure to build it is
|
|
353
|
+
* reported, not thrown, since reporting never changes an update.
|
|
354
|
+
*/
|
|
355
|
+
buildPluginEvent<K extends PluginHookName>(name: K, createPayload: () => PluginHookPayload<K> | null): PluginHookPayload<K> | null;
|
|
356
|
+
/**
|
|
357
|
+
* Calls a hook on every plugin that listens, without waiting for any. It
|
|
358
|
+
* returns when each asynchronous hook settles, for observers such as
|
|
359
|
+
* `@hot-updater/test-utils`; the SDK ignores it.
|
|
360
|
+
*/
|
|
361
|
+
dispatchPluginHook<K extends PluginHookName>(name: K, payload: PluginHookPayload<K>): readonly Promise<void>[];
|
|
362
|
+
/** Builds a hook's event when a plugin listens and calls the hook right away. */
|
|
363
|
+
emitPluginHook<K extends PluginHookName>(name: K, createPayload: () => PluginHookPayload<K> | null): void;
|
|
364
|
+
}
|
|
365
|
+
/** Where a plugin's key lives in native storage. */
|
|
366
|
+
declare const pluginStorageKey: (pluginId: string, key: string) => string;
|
|
367
|
+
declare const createPluginHost: (environment: PluginHostEnvironment) => PluginHost;
|
|
368
|
+
//#endregion
|
|
298
369
|
//#region src/releaseCatalog.d.ts
|
|
299
370
|
declare const RELEASE_CATALOG_SCHEMA_VERSION: 1;
|
|
300
371
|
declare const RELEASE_CATALOG_FALLBACK_POLICY: "BUILTIN_IF_ACTIVE_INELIGIBLE";
|
|
@@ -428,6 +499,32 @@ type ReleaseCatalogScopeKeyInput = {
|
|
|
428
499
|
declare function createReleaseCatalogScopeKey(input: ReleaseCatalogScopeKeyInput): string;
|
|
429
500
|
declare function parseReleaseCatalogScopeKey(scopeKey: string): ReleaseCatalogScopeKeyInput;
|
|
430
501
|
//#endregion
|
|
502
|
+
//#region src/releaseCatalogWire.d.ts
|
|
503
|
+
/** The largest release catalog response body a client accepts, in UTF-8 bytes. */
|
|
504
|
+
declare const MAX_RELEASE_CATALOG_WIRE_BYTES: number;
|
|
505
|
+
/** The scope a client asked for: the catalog it accepts must be this scope's. */
|
|
506
|
+
type ExpectedReleaseCatalogScope = {
|
|
507
|
+
readonly channelKey: string;
|
|
508
|
+
readonly platform: "ios" | "android";
|
|
509
|
+
} & ({
|
|
510
|
+
readonly strategy: "APP_VERSION";
|
|
511
|
+
readonly fingerprintHash?: never;
|
|
512
|
+
} | {
|
|
513
|
+
readonly strategy: "FINGERPRINT";
|
|
514
|
+
readonly fingerprintHash: string;
|
|
515
|
+
});
|
|
516
|
+
/** The length of `value` in UTF-8 bytes, without an encoder the device may lack. */
|
|
517
|
+
declare const getUtf8ByteLength: (value: string) => number;
|
|
518
|
+
/** Whether the catalog is the expected scope's. */
|
|
519
|
+
declare const hasExpectedReleaseCatalogScope: (catalog: Pick<ReleaseCatalog, "scopeKey">, expected: ExpectedReleaseCatalogScope) => boolean;
|
|
520
|
+
/**
|
|
521
|
+
* Parses a release catalog response body as a client accepts it: within the
|
|
522
|
+
* wire limit, in the schema's shape, for the expected scope, and within the
|
|
523
|
+
* cohort limits. Any other body is null. The device's update client and
|
|
524
|
+
* doctor's server checks both run this, so they accept the same catalogs.
|
|
525
|
+
*/
|
|
526
|
+
declare const parseReleaseCatalog: (body: string, expectedScope: ExpectedReleaseCatalogScope) => ReleaseCatalog | null;
|
|
527
|
+
//#endregion
|
|
431
528
|
//#region src/rollout.d.ts
|
|
432
529
|
declare const NUMERIC_COHORT_SIZE = 1000;
|
|
433
530
|
declare const DEFAULT_ROLLOUT_COHORT_COUNT = 1000;
|
|
@@ -448,4 +545,4 @@ declare function isCohortEligibleForUpdate(bundleId: string, cohort: string | nu
|
|
|
448
545
|
declare const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
449
546
|
declare const isUUIDv7: (value: unknown) => value is string;
|
|
450
547
|
//#endregion
|
|
451
|
-
export { ARTIFACT_PROTOCOL_VERSION, AppReadyResult, ArtifactAsset, ArtifactAssetFile, ArtifactInfo, Bundle, BundleDownloadedInfo, BundleMetadata, BundlePatchArtifact, CatalogAcceptance, CatalogHighWater, ChangedAssetPatch, DEFAULT_ROLLOUT_COHORT_COUNT, DesiredRelease, HotUpdaterClientContext, HotUpdaterClientHooks, HotUpdaterClientPlugin, HotUpdaterClientStorage, INVALID_COHORT_ERROR_MESSAGE, MAX_COHORT_LENGTH, MAX_COMPILED_CATALOG_BYTES, MAX_CRASHED_BUNDLES, MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE, MAX_TARGET_COHORTS_PER_RELEASE, ManifestArchive, NIL_UUID, NUMERIC_COHORT_SIZE, PersistedSelectionReceipt, Platform, RELEASE_CATALOG_FALLBACK_POLICY, RELEASE_CATALOG_SCHEMA_VERSION, Release, ReleaseCatalog, ReleaseCatalogDescriptor, ReleaseCatalogScopeKeyInput, ReleaseKind, ReleaseOperation, ReleaseSelectionContextInput, ReleaseSelectionInput, ReleaseStrategy, ReleaseTransitionAuthorization, ReleaseTransitionKind, SelectionKind, UpdateCheckResult, UpdateError, UpdateErrorReason, UpdateErrorResource, UpdateErrorStage, UpdateErrorTransport, UpdateStatus, UpdateStrategy, assertCanonicalChannelName, assessCatalogAcceptance, authorizeReleaseTransition, createReleaseCatalogScopeKey, createReleaseSelectionContextHash, decodeChannelKey, defineClientPlugin, encodeChannelKey, getAssetBaseStorageUri, getBundlePatch, getBundlePatches, getDefaultNumericCohort, getManifestFileHash, getManifestStorageUri, getNumericCohortRolloutPosition, getNumericCohortValue, getPatchBaseBundleId, getPatchBaseFileHash, getPatchFileHash, getPatchStorageUri, getRolledOutNumericCohorts, isCohortEligibleForUpdate, isCustomCohort, isNumericCohort, isReleaseEligibleForCohort, isUUIDv7, isValidCohort, normalizeChannelName, normalizeCohortValue, normalizeRolloutCohortCount, parseReleaseCatalogScopeKey, selectDesiredRelease, stripBundleArtifactMetadata };
|
|
548
|
+
export { ARTIFACT_PROTOCOL_VERSION, AppReadyResult, ArtifactAsset, ArtifactAssetFile, ArtifactInfo, Bundle, BundleDownloadedInfo, BundleMetadata, BundlePatchArtifact, CatalogAcceptance, CatalogHighWater, ChangedAssetPatch, DEFAULT_ROLLOUT_COHORT_COUNT, DesiredRelease, ExpectedReleaseCatalogScope, HotUpdaterBaseURL, HotUpdaterClientContext, HotUpdaterClientHooks, HotUpdaterClientPlugin, HotUpdaterClientStorage, INVALID_COHORT_ERROR_MESSAGE, MAX_COHORT_LENGTH, MAX_COMPILED_CATALOG_BYTES, MAX_CRASHED_BUNDLES, MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE, MAX_RELEASE_CATALOG_WIRE_BYTES, MAX_TARGET_COHORTS_PER_RELEASE, ManifestArchive, NIL_UUID, NUMERIC_COHORT_SIZE, PersistedSelectionReceipt, Platform, PluginHookName, PluginHookPayload, PluginHost, PluginHostConfig, PluginHostEnvironment, RELEASE_CATALOG_FALLBACK_POLICY, RELEASE_CATALOG_SCHEMA_VERSION, Release, ReleaseCatalog, ReleaseCatalogDescriptor, ReleaseCatalogScopeKeyInput, ReleaseKind, ReleaseOperation, ReleaseSelectionContextInput, ReleaseSelectionInput, ReleaseStrategy, ReleaseTransitionAuthorization, ReleaseTransitionKind, SelectionKind, UpdateCheckResult, UpdateError, UpdateErrorReason, UpdateErrorResource, UpdateErrorStage, UpdateErrorTransport, UpdateStatus, UpdateStrategy, assertCanonicalChannelName, assessCatalogAcceptance, authorizeReleaseTransition, canonicalizeAppVersion, createPluginHost, createReleaseCatalogScopeKey, createReleaseSelectionContextHash, decodeChannelKey, defineClientPlugin, encodeChannelKey, getAssetBaseStorageUri, getBundlePatch, getBundlePatches, getDefaultNumericCohort, getManifestFileHash, getManifestStorageUri, getNumericCohortRolloutPosition, getNumericCohortValue, getPatchBaseBundleId, getPatchBaseFileHash, getPatchFileHash, getPatchStorageUri, getRolledOutNumericCohorts, getUtf8ByteLength, hasExpectedReleaseCatalogScope, isCohortEligibleForUpdate, isCustomCohort, isNumericCohort, isReleaseEligibleForCohort, isUUIDv7, isValidCohort, normalizeChannelName, normalizeCohortValue, normalizeRolloutCohortCount, parseReleaseCatalog, parseReleaseCatalogScopeKey, pluginStorageKey, resolveBaseURL, selectDesiredRelease, stripBundleArtifactMetadata };
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,129 @@
|
|
|
1
|
+
//#region ../../node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/comparison-DenM3wCn.js
|
|
2
|
+
const LETTER_DASH_NUMBER = "[a-zA-Z0-9-]";
|
|
3
|
+
const NUMERIC_IDENTIFIER = String.raw`0|[1-9]\d*`;
|
|
4
|
+
const NUMERIC_IDENTIFIER_LOOSE = String.raw`\d+`;
|
|
5
|
+
const NON_NUMERIC_IDENTIFIER = String.raw`\d*[a-zA-Z-]${LETTER_DASH_NUMBER}*`;
|
|
6
|
+
const MAIN_VERSION = String.raw`(${NUMERIC_IDENTIFIER})\.(${NUMERIC_IDENTIFIER})\.(${NUMERIC_IDENTIFIER})`;
|
|
7
|
+
const MAIN_VERSION_LOOSE = String.raw`(${NUMERIC_IDENTIFIER_LOOSE})\.(${NUMERIC_IDENTIFIER_LOOSE})\.(${NUMERIC_IDENTIFIER_LOOSE})`;
|
|
8
|
+
const PRERELEASE_IDENTIFIER = `(?:${NON_NUMERIC_IDENTIFIER}|${NUMERIC_IDENTIFIER})`;
|
|
9
|
+
const PRERELEASE_IDENTIFIER_LOOSE = `(?:${NON_NUMERIC_IDENTIFIER}|${NUMERIC_IDENTIFIER_LOOSE})`;
|
|
10
|
+
const PRERELEASE = String.raw`(?:-(${PRERELEASE_IDENTIFIER}(?:\.${PRERELEASE_IDENTIFIER})*))`;
|
|
11
|
+
const PRERELEASE_LOOSE = String.raw`(?:-?(${PRERELEASE_IDENTIFIER_LOOSE}(?:\.${PRERELEASE_IDENTIFIER_LOOSE})*))`;
|
|
12
|
+
const BUILD_IDENTIFIER = `${LETTER_DASH_NUMBER}+`;
|
|
13
|
+
const BUILD = String.raw`(?:\+(${BUILD_IDENTIFIER}(?:\.${BUILD_IDENTIFIER})*))`;
|
|
14
|
+
const FULL_PLAIN = `v?${MAIN_VERSION}${PRERELEASE}?${BUILD}?`;
|
|
15
|
+
const LOOSE_PLAIN = String.raw`[v=\s]*${MAIN_VERSION_LOOSE}${PRERELEASE_LOOSE}?${BUILD}?`;
|
|
16
|
+
const XRANGE_IDENTIFIER = String.raw`${NUMERIC_IDENTIFIER}|x|X|\*`;
|
|
17
|
+
const XRANGE_IDENTIFIER_LOOSE = String.raw`${NUMERIC_IDENTIFIER_LOOSE}|x|X|\*`;
|
|
18
|
+
String.raw`[v=\s]*(${XRANGE_IDENTIFIER})(?:\.(${XRANGE_IDENTIFIER})(?:\.(${XRANGE_IDENTIFIER})(?:${PRERELEASE})?${BUILD}?)?)?`;
|
|
19
|
+
String.raw`[v=\s]*(${XRANGE_IDENTIFIER_LOOSE})(?:\.(${XRANGE_IDENTIFIER_LOOSE})(?:\.(${XRANGE_IDENTIFIER_LOOSE})(?:${PRERELEASE_LOOSE})?${BUILD}?)?)?`;
|
|
20
|
+
String.raw`(?:\^)`;
|
|
21
|
+
const COERCE_PLAIN = String.raw`(^|[^\d])(\d{1,${16}})(?:\.(\d{1,${16}}))?(?:\.(\d{1,${16}}))?`;
|
|
22
|
+
const COERCE = String.raw`${COERCE_PLAIN}(?:$|[^\d])`;
|
|
23
|
+
const COERCE_FULL = String.raw`${COERCE_PLAIN}(?:${PRERELEASE})?(?:${BUILD})?(?:$|[^\d])`;
|
|
24
|
+
function makeSafeRegexSource(source) {
|
|
25
|
+
const replacements = [
|
|
26
|
+
[String.raw`\s`, 1],
|
|
27
|
+
[String.raw`\d`, 256],
|
|
28
|
+
[LETTER_DASH_NUMBER, 250]
|
|
29
|
+
];
|
|
30
|
+
for (const [token, maximum] of replacements) source = source.split(`${token}*`).join(`${token}{0,${maximum}}`).split(`${token}+`).join(`${token}{1,${maximum}}`);
|
|
31
|
+
return source;
|
|
32
|
+
}
|
|
33
|
+
function safeRegex(source, flags) {
|
|
34
|
+
return new RegExp(makeSafeRegexSource(source), flags);
|
|
35
|
+
}
|
|
36
|
+
const FULL = safeRegex(`^${FULL_PLAIN}$`);
|
|
37
|
+
const LOOSE = safeRegex(`^${LOOSE_PLAIN}$`);
|
|
38
|
+
const NUMERIC$1 = /^\d+$/;
|
|
39
|
+
function formatComparableVersion(version) {
|
|
40
|
+
const base = `${version.major}.${version.minor}.${version.patch}`;
|
|
41
|
+
return version.prerelease?.length ? `${base}-${version.prerelease.join(".")}` : base;
|
|
42
|
+
}
|
|
43
|
+
function formatFullVersion(version) {
|
|
44
|
+
const comparable = formatComparableVersion(version);
|
|
45
|
+
return version.build?.length ? `${comparable}+${version.build.join(".")}` : comparable;
|
|
46
|
+
}
|
|
47
|
+
function parse(version, options = {}) {
|
|
48
|
+
if (typeof version !== "string") return version;
|
|
49
|
+
if (version.length > 256) throw new TypeError(`Version exceeds the maximum length of 256 characters`);
|
|
50
|
+
const match = version.trim().match(options.loose ? LOOSE : FULL);
|
|
51
|
+
if (!match) throw new TypeError(`Invalid version syntax: ${version}`);
|
|
52
|
+
const major = Number(match[1]);
|
|
53
|
+
const minor = Number(match[2]);
|
|
54
|
+
const patch = Number(match[3]);
|
|
55
|
+
if (major > Number.MAX_SAFE_INTEGER || major < 0) throw new TypeError(`Invalid major version: ${match[1]}`);
|
|
56
|
+
if (minor > Number.MAX_SAFE_INTEGER || minor < 0) throw new TypeError(`Invalid minor version: ${match[2]}`);
|
|
57
|
+
if (patch > Number.MAX_SAFE_INTEGER || patch < 0) throw new TypeError(`Invalid patch version: ${match[3]}`);
|
|
58
|
+
const prerelease = match[4] ? match[4].split(".").map((identifier) => {
|
|
59
|
+
if (NUMERIC$1.test(identifier)) {
|
|
60
|
+
const numeric = Number(identifier);
|
|
61
|
+
if (numeric >= 0 && numeric < Number.MAX_SAFE_INTEGER) return numeric;
|
|
62
|
+
}
|
|
63
|
+
return identifier;
|
|
64
|
+
}) : void 0;
|
|
65
|
+
return {
|
|
66
|
+
build: match[5]?.split("."),
|
|
67
|
+
major,
|
|
68
|
+
minor,
|
|
69
|
+
patch,
|
|
70
|
+
prerelease
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function tryParse(version, options = {}) {
|
|
74
|
+
try {
|
|
75
|
+
return parse(version, options);
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region ../../node_modules/.pnpm/verkit@0.4.0/node_modules/verkit/dist/version-CQ98ZBpL.js
|
|
82
|
+
const COERCE_EXACT = safeRegex(COERCE);
|
|
83
|
+
const COERCE_FULL_EXACT = safeRegex(COERCE_FULL);
|
|
84
|
+
safeRegex(`^${PRERELEASE}$`);
|
|
85
|
+
safeRegex(`^${PRERELEASE_LOOSE}$`);
|
|
86
|
+
function normalizeFull(version, options = {}) {
|
|
87
|
+
const parsed = tryParse(version, options);
|
|
88
|
+
return parsed ? formatFullVersion(parsed) : null;
|
|
89
|
+
}
|
|
90
|
+
function coerce(value, options = {}) {
|
|
91
|
+
if (typeof value === "object") return value;
|
|
92
|
+
const input = typeof value === "number" ? String(value) : value;
|
|
93
|
+
let match = null;
|
|
94
|
+
if (options.rtl) {
|
|
95
|
+
const expression = safeRegex(options.includePrerelease ? COERCE_FULL : COERCE, "g");
|
|
96
|
+
let next;
|
|
97
|
+
while ((next = expression.exec(input)) && (!match || match.index + match[0].length !== input.length)) {
|
|
98
|
+
if (!match || next.index + next[0].length !== match.index + match[0].length) match = next;
|
|
99
|
+
expression.lastIndex = next.index + next[1].length + next[2].length;
|
|
100
|
+
}
|
|
101
|
+
} else match = (options.includePrerelease ? COERCE_FULL_EXACT : COERCE_EXACT).exec(input);
|
|
102
|
+
if (!match) return null;
|
|
103
|
+
const major = match[2];
|
|
104
|
+
return tryParse(`${major}.${match[3] || "0"}.${match[4] || "0"}${options.includePrerelease && match[5] ? `-${match[5]}` : ""}${options.includePrerelease && match[6] ? `+${match[6]}` : ""}`, options);
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/appVersion.ts
|
|
108
|
+
/**
|
|
109
|
+
* The form of an app version that the device and the server compare:
|
|
110
|
+
* `major.minor.patch` of the first version in `appVersion`, such as `1.4.0`
|
|
111
|
+
* for `v1.4`, or null when it holds none. Prerelease and build parts are
|
|
112
|
+
* dropped.
|
|
113
|
+
*/
|
|
114
|
+
function canonicalizeAppVersion(appVersion) {
|
|
115
|
+
const version = coerce(appVersion);
|
|
116
|
+
return version ? normalizeFull(version) : null;
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region src/baseURL.ts
|
|
120
|
+
/** Resolves a functional baseURL and drops trailing slashes. */
|
|
121
|
+
const resolveBaseURL = async (baseURL) => {
|
|
122
|
+
const resolvedBaseURL = typeof baseURL === "function" ? await baseURL() : baseURL;
|
|
123
|
+
if (!resolvedBaseURL) throw new Error("baseURL function must return a non-empty string");
|
|
124
|
+
return resolvedBaseURL.replace(/\/+$/, "");
|
|
125
|
+
};
|
|
126
|
+
//#endregion
|
|
1
127
|
//#region src/bundleArtifacts.ts
|
|
2
128
|
const stripBundleArtifactMetadata = (metadata) => metadata;
|
|
3
129
|
const getManifestStorageUri = (bundle) => bundle.manifestStorageUri;
|
|
@@ -36,6 +162,164 @@ const getPatchStorageUri = (bundle) => getPrimaryPatch(bundle)?.patchStorageUri
|
|
|
36
162
|
/** Declares a client plugin; built-in and third-party plugins use the same contract. */
|
|
37
163
|
const defineClientPlugin = (plugin) => plugin;
|
|
38
164
|
//#endregion
|
|
165
|
+
//#region src/createPluginHost.ts
|
|
166
|
+
/** Storage values stay small, since native reads them on the JS thread. */
|
|
167
|
+
const MAX_STORAGE_VALUE_LENGTH = 65536;
|
|
168
|
+
/** Where a plugin's key lives in native storage. */
|
|
169
|
+
const pluginStorageKey = (pluginId, key) => `plugins/${encodeURIComponent(pluginId)}/${key}`;
|
|
170
|
+
const createPluginHost = (environment) => {
|
|
171
|
+
let config = null;
|
|
172
|
+
/** Hooks by plugin, so a plugin configured again is not set up twice. */
|
|
173
|
+
const setUpPlugins = /* @__PURE__ */ new WeakMap();
|
|
174
|
+
let activePlugins = [];
|
|
175
|
+
/** What plugins read and wrote, so a read follows a write before native does. */
|
|
176
|
+
const storageValues = /* @__PURE__ */ new Map();
|
|
177
|
+
const reportHookError = (message, error) => {
|
|
178
|
+
const report = new Error(`[HotUpdater] ${message}`, { cause: error });
|
|
179
|
+
try {
|
|
180
|
+
if (config?.onError) {
|
|
181
|
+
config.onError(report);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
console.warn(report.message, error);
|
|
186
|
+
};
|
|
187
|
+
const fetchFromServer = async (path, init = {}) => {
|
|
188
|
+
if (config === null) throw new Error("[HotUpdater] Plugins can fetch only after init or wrap.");
|
|
189
|
+
if (/^[a-z][a-z\d+.-]*:/i.test(path) || path.startsWith("//")) throw new TypeError("[HotUpdater] Plugin fetch takes a path relative to baseURL.");
|
|
190
|
+
const { requestHeaders, requestTimeout } = config;
|
|
191
|
+
const url = `${await resolveBaseURL(config.baseURL)}/${path.replace(/^\/+/, "")}`;
|
|
192
|
+
const headers = new Headers(requestHeaders);
|
|
193
|
+
new Headers(init.headers).forEach((value, name) => {
|
|
194
|
+
headers.set(name, value);
|
|
195
|
+
});
|
|
196
|
+
const controller = new AbortController();
|
|
197
|
+
const callerSignal = init.signal;
|
|
198
|
+
const abortFromCaller = () => controller.abort();
|
|
199
|
+
if (callerSignal?.aborted) controller.abort();
|
|
200
|
+
callerSignal?.addEventListener("abort", abortFromCaller);
|
|
201
|
+
const timeoutId = setTimeout(() => controller.abort(), requestTimeout ?? 5e3);
|
|
202
|
+
try {
|
|
203
|
+
return await environment.fetch(url, {
|
|
204
|
+
...init,
|
|
205
|
+
headers,
|
|
206
|
+
signal: controller.signal
|
|
207
|
+
});
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error instanceof Error && error.name === "AbortError" && !callerSignal?.aborted) throw new Error("Request timed out");
|
|
210
|
+
throw error;
|
|
211
|
+
} finally {
|
|
212
|
+
clearTimeout(timeoutId);
|
|
213
|
+
callerSignal?.removeEventListener("abort", abortFromCaller);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
const createStorage = (pluginId) => {
|
|
217
|
+
const scopedKey = (key) => {
|
|
218
|
+
if (typeof key !== "string" || key.length === 0) throw new TypeError("[HotUpdater] Plugin storage keys are non-empty strings.");
|
|
219
|
+
return pluginStorageKey(pluginId, key);
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
get: (key) => {
|
|
223
|
+
const storageKey = scopedKey(key);
|
|
224
|
+
const cached = storageValues.get(storageKey);
|
|
225
|
+
if (cached !== void 0) return cached;
|
|
226
|
+
const value = environment.getStorageItem(storageKey);
|
|
227
|
+
storageValues.set(storageKey, value);
|
|
228
|
+
return value;
|
|
229
|
+
},
|
|
230
|
+
set: (key, value) => {
|
|
231
|
+
const storageKey = scopedKey(key);
|
|
232
|
+
if (value !== null && typeof value !== "string") throw new TypeError("[HotUpdater] Plugin storage values are strings or null.");
|
|
233
|
+
if (value !== null && value.length > MAX_STORAGE_VALUE_LENGTH) throw new RangeError("[HotUpdater] Plugin storage values are at most 64 KB.");
|
|
234
|
+
environment.setStorageItem(storageKey, value);
|
|
235
|
+
storageValues.set(storageKey, value);
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
const createContext = (pluginId) => ({
|
|
240
|
+
fetch: fetchFromServer,
|
|
241
|
+
get installId() {
|
|
242
|
+
return environment.getInstallId();
|
|
243
|
+
},
|
|
244
|
+
platform: environment.platform,
|
|
245
|
+
get appVersion() {
|
|
246
|
+
return environment.getAppVersion();
|
|
247
|
+
},
|
|
248
|
+
get sdkVersion() {
|
|
249
|
+
return environment.sdkVersion;
|
|
250
|
+
},
|
|
251
|
+
isDebugBuild: environment.isDebugBuild,
|
|
252
|
+
getBundleId: () => environment.getBundleId(),
|
|
253
|
+
getChannel: () => environment.getChannel(),
|
|
254
|
+
getCohort: () => environment.getCohort(),
|
|
255
|
+
getFingerprintHash: () => environment.getFingerprintHash(),
|
|
256
|
+
storage: createStorage(pluginId),
|
|
257
|
+
now: () => environment.now()
|
|
258
|
+
});
|
|
259
|
+
const configurePlugins = (plugins, nextConfig) => {
|
|
260
|
+
const ids = /* @__PURE__ */ new Set();
|
|
261
|
+
for (const plugin of plugins ?? []) {
|
|
262
|
+
if (typeof plugin.id !== "string" || plugin.id.length === 0) throw new Error("[HotUpdater] A plugin id must be a non-empty string.");
|
|
263
|
+
if (ids.has(plugin.id)) throw new Error(`[HotUpdater] Two plugins use the id "${plugin.id}". Plugin ids must be unique.`);
|
|
264
|
+
ids.add(plugin.id);
|
|
265
|
+
}
|
|
266
|
+
config = nextConfig;
|
|
267
|
+
activePlugins = (plugins ?? []).map((plugin) => {
|
|
268
|
+
let hooks = setUpPlugins.get(plugin);
|
|
269
|
+
if (hooks === void 0) {
|
|
270
|
+
try {
|
|
271
|
+
hooks = plugin.setup(createContext(plugin.id)) ?? {};
|
|
272
|
+
} catch (error) {
|
|
273
|
+
reportHookError(`Plugin "${plugin.id}" failed in setup`, error);
|
|
274
|
+
hooks = {};
|
|
275
|
+
}
|
|
276
|
+
setUpPlugins.set(plugin, hooks);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
plugin,
|
|
280
|
+
hooks
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
};
|
|
284
|
+
const hasPluginHook = (name) => activePlugins.some(({ hooks }) => typeof hooks[name] === "function");
|
|
285
|
+
const buildPluginEvent = (name, createPayload) => {
|
|
286
|
+
if (!hasPluginHook(name)) return null;
|
|
287
|
+
try {
|
|
288
|
+
return createPayload();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
reportHookError(`Failed to build the ${name} event for plugins`, error);
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const dispatchPluginHook = (name, payload) => {
|
|
295
|
+
const settlements = [];
|
|
296
|
+
for (const { plugin, hooks } of activePlugins) {
|
|
297
|
+
const hook = hooks[name];
|
|
298
|
+
if (typeof hook !== "function") continue;
|
|
299
|
+
try {
|
|
300
|
+
const result = hook.call(hooks, payload);
|
|
301
|
+
if (result !== null && typeof result === "object" && typeof result.then === "function") settlements.push(Promise.resolve(result).then(() => void 0, (error) => {
|
|
302
|
+
reportHookError(`Plugin "${plugin.id}" failed in ${name}`, error);
|
|
303
|
+
}));
|
|
304
|
+
} catch (error) {
|
|
305
|
+
reportHookError(`Plugin "${plugin.id}" failed in ${name}`, error);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return settlements;
|
|
309
|
+
};
|
|
310
|
+
const emitPluginHook = (name, createPayload) => {
|
|
311
|
+
const payload = buildPluginEvent(name, createPayload);
|
|
312
|
+
if (payload !== null) dispatchPluginHook(name, payload);
|
|
313
|
+
};
|
|
314
|
+
return {
|
|
315
|
+
configurePlugins,
|
|
316
|
+
hasPluginHook,
|
|
317
|
+
buildPluginEvent,
|
|
318
|
+
dispatchPluginHook,
|
|
319
|
+
emitPluginHook
|
|
320
|
+
};
|
|
321
|
+
};
|
|
322
|
+
//#endregion
|
|
39
323
|
//#region src/rollout.ts
|
|
40
324
|
const NUMERIC_COHORT_SIZE = 1e3;
|
|
41
325
|
const DEFAULT_ROLLOUT_COHORT_COUNT = NUMERIC_COHORT_SIZE;
|
|
@@ -407,12 +691,63 @@ function parseReleaseCatalogScopeKey(scopeKey) {
|
|
|
407
691
|
return input;
|
|
408
692
|
}
|
|
409
693
|
//#endregion
|
|
410
|
-
//#region src/types.ts
|
|
411
|
-
const ARTIFACT_PROTOCOL_VERSION = 1;
|
|
412
|
-
//#endregion
|
|
413
694
|
//#region src/uuid.ts
|
|
414
695
|
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
415
696
|
const UUID_V7_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
416
697
|
const isUUIDv7 = (value) => typeof value === "string" && UUID_V7_PATTERN.test(value);
|
|
417
698
|
//#endregion
|
|
418
|
-
|
|
699
|
+
//#region src/releaseCatalogWire.ts
|
|
700
|
+
/** The largest release catalog response body a client accepts, in UTF-8 bytes. */
|
|
701
|
+
const MAX_RELEASE_CATALOG_WIRE_BYTES = 528384;
|
|
702
|
+
/** The length of `value` in UTF-8 bytes, without an encoder the device may lack. */
|
|
703
|
+
const getUtf8ByteLength = (value) => {
|
|
704
|
+
let bytes = 0;
|
|
705
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
706
|
+
const code = value.charCodeAt(index);
|
|
707
|
+
if (code <= 127) bytes += 1;
|
|
708
|
+
else if (code <= 2047) bytes += 2;
|
|
709
|
+
else if (code >= 55296 && code <= 56319 && index + 1 < value.length && value.charCodeAt(index + 1) >= 56320 && value.charCodeAt(index + 1) <= 57343) {
|
|
710
|
+
bytes += 4;
|
|
711
|
+
index += 1;
|
|
712
|
+
} else bytes += 3;
|
|
713
|
+
}
|
|
714
|
+
return bytes;
|
|
715
|
+
};
|
|
716
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
717
|
+
const isValidReleaseDescriptor = (value) => {
|
|
718
|
+
if (value === null || typeof value !== "object") return false;
|
|
719
|
+
const descriptor = value;
|
|
720
|
+
return isUUIDv7(descriptor.releaseId) && (descriptor.kind === "BUNDLE" || descriptor.kind === "EMBEDDED") && (descriptor.kind === "BUNDLE" && typeof descriptor.bundleId === "string" || descriptor.kind === "EMBEDDED" && descriptor.bundleId === null) && Number.isSafeInteger(descriptor.rolloutCohortCount) && descriptor.rolloutCohortCount >= 0 && descriptor.rolloutCohortCount <= 1e3 && isStringArray(descriptor.targetCohorts) && descriptor.targetCohorts.length <= 100 && typeof descriptor.shouldForceUpdate === "boolean" && (descriptor.message === null || typeof descriptor.message === "string");
|
|
721
|
+
};
|
|
722
|
+
/** Whether the catalog is the expected scope's. */
|
|
723
|
+
const hasExpectedReleaseCatalogScope = (catalog, expected) => {
|
|
724
|
+
try {
|
|
725
|
+
const parsed = parseReleaseCatalogScopeKey(catalog.scopeKey);
|
|
726
|
+
return parsed.channelKey === expected.channelKey && parsed.platform === expected.platform && parsed.strategy === expected.strategy && (parsed.strategy === "APP_VERSION" || expected.strategy === "FINGERPRINT" && parsed.fingerprintHash === expected.fingerprintHash);
|
|
727
|
+
} catch {
|
|
728
|
+
return false;
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
/**
|
|
732
|
+
* Parses a release catalog response body as a client accepts it: within the
|
|
733
|
+
* wire limit, in the schema's shape, for the expected scope, and within the
|
|
734
|
+
* cohort limits. Any other body is null. The device's update client and
|
|
735
|
+
* doctor's server checks both run this, so they accept the same catalogs.
|
|
736
|
+
*/
|
|
737
|
+
const parseReleaseCatalog = (body, expectedScope) => {
|
|
738
|
+
if (getUtf8ByteLength(body) > MAX_RELEASE_CATALOG_WIRE_BYTES) return null;
|
|
739
|
+
try {
|
|
740
|
+
const catalog = JSON.parse(body);
|
|
741
|
+
if (catalog.schemaVersion !== 1 || typeof catalog.catalogId !== "string" || catalog.catalogId.length === 0 || typeof catalog.scopeKey !== "string" || !Number.isSafeInteger(catalog.generation) || (catalog.generation ?? 0) < 1 || typeof catalog.catalogHash !== "string" || !/^sha256:[0-9a-f]{64}$/.test(catalog.catalogHash) || catalog.fallbackPolicy !== "BUILTIN_IF_ACTIVE_INELIGIBLE" || !Array.isArray(catalog.releases) || !catalog.releases.every(isValidReleaseDescriptor) || catalog.rollbackReleases !== void 0 && (!Array.isArray(catalog.rollbackReleases) || !catalog.rollbackReleases.every(isValidReleaseDescriptor))) return null;
|
|
742
|
+
if (!hasExpectedReleaseCatalogScope(catalog, expectedScope)) return null;
|
|
743
|
+
if (new Set([...catalog.releases, ...catalog.rollbackReleases ?? []].flatMap((release) => release.targetCohorts)).size > 512) return null;
|
|
744
|
+
return catalog;
|
|
745
|
+
} catch {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/types.ts
|
|
751
|
+
const ARTIFACT_PROTOCOL_VERSION = 1;
|
|
752
|
+
//#endregion
|
|
753
|
+
export { ARTIFACT_PROTOCOL_VERSION, DEFAULT_ROLLOUT_COHORT_COUNT, INVALID_COHORT_ERROR_MESSAGE, MAX_COHORT_LENGTH, MAX_COMPILED_CATALOG_BYTES, MAX_CRASHED_BUNDLES, MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE, MAX_RELEASE_CATALOG_WIRE_BYTES, MAX_TARGET_COHORTS_PER_RELEASE, NIL_UUID, NUMERIC_COHORT_SIZE, RELEASE_CATALOG_FALLBACK_POLICY, RELEASE_CATALOG_SCHEMA_VERSION, assertCanonicalChannelName, assessCatalogAcceptance, authorizeReleaseTransition, canonicalizeAppVersion, createPluginHost, createReleaseCatalogScopeKey, createReleaseSelectionContextHash, decodeChannelKey, defineClientPlugin, encodeChannelKey, getAssetBaseStorageUri, getBundlePatch, getBundlePatches, getDefaultNumericCohort, getManifestFileHash, getManifestStorageUri, getNumericCohortRolloutPosition, getNumericCohortValue, getPatchBaseBundleId, getPatchBaseFileHash, getPatchFileHash, getPatchStorageUri, getRolledOutNumericCohorts, getUtf8ByteLength, hasExpectedReleaseCatalogScope, isCohortEligibleForUpdate, isCustomCohort, isNumericCohort, isReleaseEligibleForCohort, isUUIDv7, isValidCohort, normalizeChannelName, normalizeCohortValue, normalizeRolloutCohortCount, parseReleaseCatalog, parseReleaseCatalogScopeKey, pluginStorageKey, resolveBaseURL, selectDesiredRelease, stripBundleArtifactMetadata };
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hot-updater/protocol",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.21",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "The data formats and pure computations that Hot Updater's app and server share",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"main": "./dist/index.cjs",
|
|
8
8
|
"module": "./dist/index.mjs",
|
|
@@ -42,7 +42,11 @@
|
|
|
42
42
|
"access": "public"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@types/node": "^20"
|
|
45
|
+
"@types/node": "^20",
|
|
46
|
+
"verkit": "0.4.0"
|
|
47
|
+
},
|
|
48
|
+
"inlinedDependencies": {
|
|
49
|
+
"verkit": "0.4.0"
|
|
46
50
|
},
|
|
47
51
|
"scripts": {
|
|
48
52
|
"build": "tsdown",
|