@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 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/client-plugin`, and apps through
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/client-plugin`, and apps through
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
- 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_TARGET_COHORTS_PER_RELEASE, NIL_UUID, NUMERIC_COHORT_SIZE, RELEASE_CATALOG_FALLBACK_POLICY, RELEASE_CATALOG_SCHEMA_VERSION, 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 };
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.20",
3
+ "version": "1.0.0-rc.21",
4
4
  "type": "module",
5
- "description": "Data formats and pure computations shared by the Hot Updater app and server.",
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",