@hot-updater/protocol 0.0.0-stage → 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/LICENSE +33 -0
- package/README.md +49 -2
- package/dist/index.cjs +806 -0
- package/dist/index.d.cts +548 -0
- package/dist/index.d.mts +548 -0
- package/dist/index.mjs +753 -0
- package/package.json +52 -3
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
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
|
|
128
|
+
//#region src/bundleArtifacts.ts
|
|
129
|
+
const stripBundleArtifactMetadata = (metadata) => metadata;
|
|
130
|
+
const getManifestStorageUri = (bundle) => bundle.manifestStorageUri;
|
|
131
|
+
const getManifestFileHash = (bundle) => bundle.manifestFileHash;
|
|
132
|
+
const getAssetBaseStorageUri = (bundle) => bundle.assetBaseStorageUri;
|
|
133
|
+
const isBundlePatchArtifact = (value) => {
|
|
134
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
135
|
+
const candidate = value;
|
|
136
|
+
return typeof candidate.baseBundleId === "string" && typeof candidate.baseFileHash === "string" && typeof candidate.patchFileHash === "string" && typeof candidate.patchStorageUri === "string";
|
|
137
|
+
};
|
|
138
|
+
const readBundlePatchArray = (patches) => {
|
|
139
|
+
if (!Array.isArray(patches)) return [];
|
|
140
|
+
return patches.filter(isBundlePatchArtifact);
|
|
141
|
+
};
|
|
142
|
+
const getBundlePatches = (bundle) => {
|
|
143
|
+
const patches = readBundlePatchArray(bundle.patches);
|
|
144
|
+
const seenBaseBundleIds = /* @__PURE__ */ new Set();
|
|
145
|
+
return patches.filter((patch) => {
|
|
146
|
+
if (seenBaseBundleIds.has(patch.baseBundleId)) return false;
|
|
147
|
+
seenBaseBundleIds.add(patch.baseBundleId);
|
|
148
|
+
return true;
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
const getBundlePatch = (bundle, baseBundleId) => {
|
|
152
|
+
return getBundlePatches(bundle).find((patch) => patch.baseBundleId === baseBundleId) ?? null;
|
|
153
|
+
};
|
|
154
|
+
const getPrimaryPatch = (bundle) => {
|
|
155
|
+
return getBundlePatches(bundle)[0] ?? null;
|
|
156
|
+
};
|
|
157
|
+
const getPatchBaseBundleId = (bundle) => getPrimaryPatch(bundle)?.baseBundleId ?? null;
|
|
158
|
+
const getPatchBaseFileHash = (bundle) => getPrimaryPatch(bundle)?.baseFileHash ?? null;
|
|
159
|
+
const getPatchFileHash = (bundle) => getPrimaryPatch(bundle)?.patchFileHash ?? null;
|
|
160
|
+
const getPatchStorageUri = (bundle) => getPrimaryPatch(bundle)?.patchStorageUri ?? null;
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/clientPlugin.ts
|
|
163
|
+
/** Declares a client plugin; built-in and third-party plugins use the same contract. */
|
|
164
|
+
const defineClientPlugin = (plugin) => plugin;
|
|
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
|
|
324
|
+
//#region src/rollout.ts
|
|
325
|
+
const NUMERIC_COHORT_SIZE = 1e3;
|
|
326
|
+
const DEFAULT_ROLLOUT_COHORT_COUNT = NUMERIC_COHORT_SIZE;
|
|
327
|
+
const MAX_COHORT_LENGTH = 64;
|
|
328
|
+
const INVALID_COHORT_ERROR_MESSAGE = `Invalid cohort. Use 1-1000 or a lowercase slug without spaces, up to 64 characters.`;
|
|
329
|
+
const CUSTOM_COHORT_PATTERN = /^[a-z0-9-]+$/;
|
|
330
|
+
function parseNumericCohortValue(cohort) {
|
|
331
|
+
if (!/^\d+$/.test(cohort)) return null;
|
|
332
|
+
const parsed = Number.parseInt(cohort, 10);
|
|
333
|
+
if (Number.isNaN(parsed) || parsed < 1 || parsed > 1e3) return null;
|
|
334
|
+
return parsed;
|
|
335
|
+
}
|
|
336
|
+
function positiveMod(value, modulus) {
|
|
337
|
+
return (value % modulus + modulus) % modulus;
|
|
338
|
+
}
|
|
339
|
+
function hashString(value) {
|
|
340
|
+
let hash = 0;
|
|
341
|
+
for (let i = 0; i < value.length; i++) {
|
|
342
|
+
const char = value.charCodeAt(i);
|
|
343
|
+
hash = (hash << 5) - hash + char;
|
|
344
|
+
hash |= 0;
|
|
345
|
+
}
|
|
346
|
+
return hash;
|
|
347
|
+
}
|
|
348
|
+
function gcd(a, b) {
|
|
349
|
+
let x = Math.abs(a);
|
|
350
|
+
let y = Math.abs(b);
|
|
351
|
+
while (y !== 0) {
|
|
352
|
+
const next = x % y;
|
|
353
|
+
x = y;
|
|
354
|
+
y = next;
|
|
355
|
+
}
|
|
356
|
+
return x;
|
|
357
|
+
}
|
|
358
|
+
function modularInverse(value, modulus) {
|
|
359
|
+
let t = 0;
|
|
360
|
+
let newT = 1;
|
|
361
|
+
let r = modulus;
|
|
362
|
+
let newR = positiveMod(value, modulus);
|
|
363
|
+
while (newR !== 0) {
|
|
364
|
+
const quotient = Math.floor(r / newR);
|
|
365
|
+
[t, newT] = [newT, t - quotient * newT];
|
|
366
|
+
[r, newR] = [newR, r - quotient * newR];
|
|
367
|
+
}
|
|
368
|
+
if (r > 1) throw new Error(`No modular inverse for ${value} mod ${modulus}`);
|
|
369
|
+
return positiveMod(t, modulus);
|
|
370
|
+
}
|
|
371
|
+
function getRolloutShuffleParameters(bundleId) {
|
|
372
|
+
let multiplier = positiveMod(hashString(`${bundleId}:multiplier`), 997);
|
|
373
|
+
if (multiplier === 0) multiplier = 1;
|
|
374
|
+
while (gcd(multiplier, NUMERIC_COHORT_SIZE) !== 1) {
|
|
375
|
+
multiplier = positiveMod(multiplier + 1, NUMERIC_COHORT_SIZE);
|
|
376
|
+
if (multiplier === 0) multiplier = 1;
|
|
377
|
+
}
|
|
378
|
+
const offset = positiveMod(hashString(`${bundleId}:offset`), NUMERIC_COHORT_SIZE);
|
|
379
|
+
return {
|
|
380
|
+
multiplier,
|
|
381
|
+
offset,
|
|
382
|
+
inverseMultiplier: modularInverse(multiplier, NUMERIC_COHORT_SIZE)
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
function normalizeRolloutCohortCount(rolloutCohortCount) {
|
|
386
|
+
if (rolloutCohortCount === null || rolloutCohortCount === void 0) return DEFAULT_ROLLOUT_COHORT_COUNT;
|
|
387
|
+
if (rolloutCohortCount <= 0) return 0;
|
|
388
|
+
if (rolloutCohortCount >= 1e3) return NUMERIC_COHORT_SIZE;
|
|
389
|
+
return Math.floor(rolloutCohortCount);
|
|
390
|
+
}
|
|
391
|
+
function normalizeCohortValue(cohort) {
|
|
392
|
+
const normalized = cohort.trim().toLowerCase();
|
|
393
|
+
const numericCohort = parseNumericCohortValue(normalized);
|
|
394
|
+
if (numericCohort !== null) return String(numericCohort);
|
|
395
|
+
return normalized;
|
|
396
|
+
}
|
|
397
|
+
function getNumericCohortValue(cohort) {
|
|
398
|
+
return parseNumericCohortValue(normalizeCohortValue(cohort));
|
|
399
|
+
}
|
|
400
|
+
function isNumericCohort(cohort) {
|
|
401
|
+
return getNumericCohortValue(cohort) !== null;
|
|
402
|
+
}
|
|
403
|
+
function isCustomCohort(cohort) {
|
|
404
|
+
const normalized = normalizeCohortValue(cohort);
|
|
405
|
+
return normalized.length > 0 && normalized.length <= 64 && !/^\d+$/.test(normalized) && CUSTOM_COHORT_PATTERN.test(normalized);
|
|
406
|
+
}
|
|
407
|
+
function isValidCohort(cohort) {
|
|
408
|
+
const normalized = normalizeCohortValue(cohort);
|
|
409
|
+
return isNumericCohort(normalized) || isCustomCohort(normalized);
|
|
410
|
+
}
|
|
411
|
+
function getDefaultNumericCohort(identifier) {
|
|
412
|
+
const cohortValue = positiveMod(hashString(identifier), NUMERIC_COHORT_SIZE) + 1;
|
|
413
|
+
return String(cohortValue);
|
|
414
|
+
}
|
|
415
|
+
function getNumericCohortRolloutPosition(bundleId, cohortValue) {
|
|
416
|
+
if (cohortValue < 1 || cohortValue > 1e3) throw new Error(`Invalid numeric cohort: ${cohortValue}`);
|
|
417
|
+
const { offset, inverseMultiplier } = getRolloutShuffleParameters(bundleId);
|
|
418
|
+
return positiveMod(inverseMultiplier * (cohortValue - 1 - offset), NUMERIC_COHORT_SIZE);
|
|
419
|
+
}
|
|
420
|
+
function getRolledOutNumericCohorts(bundleId, rolloutCohortCount) {
|
|
421
|
+
const normalizedRolloutCount = normalizeRolloutCohortCount(rolloutCohortCount);
|
|
422
|
+
if (normalizedRolloutCount <= 0) return [];
|
|
423
|
+
if (normalizedRolloutCount >= 1e3) return Array.from({ length: NUMERIC_COHORT_SIZE }, (_, index) => index + 1);
|
|
424
|
+
const { multiplier, offset } = getRolloutShuffleParameters(bundleId);
|
|
425
|
+
return Array.from({ length: normalizedRolloutCount }, (_, position) => positiveMod(multiplier * position + offset, NUMERIC_COHORT_SIZE)).map((zeroBasedCohort) => zeroBasedCohort + 1).sort((left, right) => left - right);
|
|
426
|
+
}
|
|
427
|
+
function isCohortEligibleForUpdate(bundleId, cohort, rolloutCohortCount, targetCohorts) {
|
|
428
|
+
const normalizedCohort = cohort === null || cohort === void 0 ? void 0 : normalizeCohortValue(cohort);
|
|
429
|
+
const normalizedTargetCohorts = targetCohorts?.map((targetCohort) => normalizeCohortValue(targetCohort)) ?? [];
|
|
430
|
+
if (normalizedCohort !== void 0 && normalizedTargetCohorts.includes(normalizedCohort)) return true;
|
|
431
|
+
const normalizedRolloutCount = normalizeRolloutCohortCount(rolloutCohortCount);
|
|
432
|
+
if (normalizedRolloutCount <= 0) return false;
|
|
433
|
+
if (normalizedCohort === void 0) return normalizedRolloutCount >= NUMERIC_COHORT_SIZE;
|
|
434
|
+
const numericCohort = getNumericCohortValue(normalizedCohort);
|
|
435
|
+
if (numericCohort === null) return false;
|
|
436
|
+
if (normalizedRolloutCount >= 1e3) return true;
|
|
437
|
+
return getNumericCohortRolloutPosition(bundleId, numericCohort) < normalizedRolloutCount;
|
|
438
|
+
}
|
|
439
|
+
//#endregion
|
|
440
|
+
//#region src/releaseCatalog.ts
|
|
441
|
+
const RELEASE_CATALOG_SCHEMA_VERSION = 1;
|
|
442
|
+
const RELEASE_CATALOG_FALLBACK_POLICY = "BUILTIN_IF_ACTIVE_INELIGIBLE";
|
|
443
|
+
const MAX_CRASHED_BUNDLES = 10;
|
|
444
|
+
const MAX_TARGET_COHORTS_PER_RELEASE = 100;
|
|
445
|
+
const MAX_DISTINCT_TARGET_COHORTS_PER_SCOPE = 512;
|
|
446
|
+
const MAX_COMPILED_CATALOG_BYTES = 262144;
|
|
447
|
+
const hashSelectionContext = (value) => {
|
|
448
|
+
let first = 2166136261;
|
|
449
|
+
let second = 2654435769;
|
|
450
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
451
|
+
const code = value.charCodeAt(index);
|
|
452
|
+
first = Math.imul(first ^ code, 16777619);
|
|
453
|
+
second = Math.imul(second ^ code, 2246822507);
|
|
454
|
+
}
|
|
455
|
+
return `v1:${(first >>> 0).toString(16).padStart(8, "0")}${(second >>> 0).toString(16).padStart(8, "0")}`;
|
|
456
|
+
};
|
|
457
|
+
function createReleaseSelectionContextHash(input) {
|
|
458
|
+
const crashedBundleIds = [...new Set(input.crashedBundleIds)].sort().slice(0, 10);
|
|
459
|
+
const canonical = JSON.stringify({
|
|
460
|
+
activeBundleId: input.activeBundleId ?? null,
|
|
461
|
+
activeReleaseId: input.activeReleaseId ?? null,
|
|
462
|
+
cohort: normalizeCohortValue(input.cohort ?? ""),
|
|
463
|
+
crashedBundleIds,
|
|
464
|
+
minimumReleaseId: input.minimumReleaseId,
|
|
465
|
+
selectorSchemaVersion: 1,
|
|
466
|
+
strategy: input.strategy,
|
|
467
|
+
strategyValue: input.strategyValue
|
|
468
|
+
});
|
|
469
|
+
return hashSelectionContext(canonical);
|
|
470
|
+
}
|
|
471
|
+
function isReleaseEligibleForCohort(release, cohort) {
|
|
472
|
+
if (isCohortEligibleForUpdate(release.releaseId, cohort, release.rolloutCohortCount, release.targetCohorts)) return true;
|
|
473
|
+
return cohort !== null && cohort !== void 0 && isCustomCohort(normalizeCohortValue(cohort)) && normalizeRolloutCohortCount(release.rolloutCohortCount) === 1e3;
|
|
474
|
+
}
|
|
475
|
+
function selectDesiredRelease(catalog, input) {
|
|
476
|
+
const crashedBundleIds = new Set(input.crashedBundleIds);
|
|
477
|
+
const activeReleaseId = input.activeReleaseId ?? null;
|
|
478
|
+
const hasActiveBundle = input.currentBundleId !== input.builtInBundleId;
|
|
479
|
+
const isSafeBundleRelease = (release) => release.kind === "BUNDLE" && release.bundleId !== null && !crashedBundleIds.has(release.bundleId) && release.bundleId >= input.minimumReleaseId;
|
|
480
|
+
const desiredBundle = (release, status) => ({
|
|
481
|
+
bundleId: release.bundleId,
|
|
482
|
+
kind: "BUNDLE",
|
|
483
|
+
release,
|
|
484
|
+
releaseId: release.releaseId,
|
|
485
|
+
status
|
|
486
|
+
});
|
|
487
|
+
for (const release of catalog.releases) {
|
|
488
|
+
if (!isReleaseEligibleForCohort(release, input.cohort)) continue;
|
|
489
|
+
if (activeReleaseId !== null && release.releaseId <= activeReleaseId || activeReleaseId === null && hasActiveBundle && (release.bundleId === null || release.bundleId <= input.currentBundleId)) continue;
|
|
490
|
+
if (isSafeBundleRelease(release)) return desiredBundle(release, "UPDATE");
|
|
491
|
+
if (release.bundleId !== null) continue;
|
|
492
|
+
return {
|
|
493
|
+
bundleId: input.builtInBundleId,
|
|
494
|
+
kind: "EMBEDDED",
|
|
495
|
+
release,
|
|
496
|
+
releaseId: release.releaseId,
|
|
497
|
+
status: "ROLLBACK"
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
if (activeReleaseId !== null || hasActiveBundle) {
|
|
501
|
+
const rollbackReleases = catalog.rollbackReleases ?? catalog.releases;
|
|
502
|
+
const current = rollbackReleases.find((release) => release.releaseId === activeReleaseId || activeReleaseId === null && release.bundleId === input.currentBundleId);
|
|
503
|
+
if (current !== void 0 && isSafeBundleRelease(current) && isReleaseEligibleForCohort(current, input.cohort)) return desiredBundle(current, "UPDATE");
|
|
504
|
+
const rollback = rollbackReleases.find((release) => isSafeBundleRelease(release) && (activeReleaseId !== null ? release.releaseId < activeReleaseId : release.bundleId < input.currentBundleId));
|
|
505
|
+
if (rollback !== void 0 && isSafeBundleRelease(rollback)) return desiredBundle(rollback, "ROLLBACK");
|
|
506
|
+
if (input.currentBundleId <= input.minimumReleaseId) return null;
|
|
507
|
+
}
|
|
508
|
+
if (catalog.fallbackPolicy !== "BUILTIN_IF_ACTIVE_INELIGIBLE") return null;
|
|
509
|
+
return {
|
|
510
|
+
bundleId: input.builtInBundleId,
|
|
511
|
+
kind: "BUILTIN",
|
|
512
|
+
release: null,
|
|
513
|
+
releaseId: null,
|
|
514
|
+
status: "ROLLBACK"
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
function assessCatalogAcceptance(incoming, highestSeen) {
|
|
518
|
+
if (highestSeen === null) return {
|
|
519
|
+
accepted: true,
|
|
520
|
+
shouldAdvanceHighWater: true
|
|
521
|
+
};
|
|
522
|
+
if (incoming.generation < highestSeen.generation) return {
|
|
523
|
+
accepted: false,
|
|
524
|
+
reason: "STALE_GENERATION"
|
|
525
|
+
};
|
|
526
|
+
if (incoming.generation === highestSeen.generation && incoming.catalogHash !== highestSeen.catalogHash) return {
|
|
527
|
+
accepted: false,
|
|
528
|
+
reason: "GENERATION_HASH_MISMATCH"
|
|
529
|
+
};
|
|
530
|
+
return {
|
|
531
|
+
accepted: true,
|
|
532
|
+
shouldAdvanceHighWater: incoming.generation > highestSeen.generation
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function authorizeReleaseTransition(input) {
|
|
536
|
+
const { active, desired, explicitScopeSwitch } = input;
|
|
537
|
+
if (active === null || active.scopeKey === null || active.generation === null) return {
|
|
538
|
+
authorized: true,
|
|
539
|
+
reason: "FIRST_AUTHENTICATED_SELECTION"
|
|
540
|
+
};
|
|
541
|
+
if (desired.catalogId !== active.catalogId || desired.scopeKey !== active.scopeKey) {
|
|
542
|
+
if (!explicitScopeSwitch) return {
|
|
543
|
+
authorized: false,
|
|
544
|
+
reason: "UNSOLICITED_SCOPE"
|
|
545
|
+
};
|
|
546
|
+
if (desired.releaseId === null || desired.kind === "BUILTIN") return {
|
|
547
|
+
authorized: false,
|
|
548
|
+
reason: "EMPTY_TARGET_SCOPE"
|
|
549
|
+
};
|
|
550
|
+
return {
|
|
551
|
+
authorized: true,
|
|
552
|
+
reason: "EXPLICIT_SCOPE_SWITCH"
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
if (desired.generation === null || desired.generation < active.generation) return {
|
|
556
|
+
authorized: false,
|
|
557
|
+
reason: "STALE_GENERATION"
|
|
558
|
+
};
|
|
559
|
+
if (desired.generation > active.generation) return {
|
|
560
|
+
authorized: true,
|
|
561
|
+
reason: "NEWER_POLICY"
|
|
562
|
+
};
|
|
563
|
+
if (desired.selectionContextHash !== active.selectionContextHash) return {
|
|
564
|
+
authorized: true,
|
|
565
|
+
reason: "CONTEXT_RESELECTION"
|
|
566
|
+
};
|
|
567
|
+
return {
|
|
568
|
+
authorized: false,
|
|
569
|
+
reason: "BACKWARD_NOT_AUTHORIZED"
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
//#endregion
|
|
573
|
+
//#region src/releaseCatalogScope.ts
|
|
574
|
+
const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
|
575
|
+
const MAX_CATALOG_SEGMENT_LENGTH = 255;
|
|
576
|
+
function assertCatalogSegment(value, name) {
|
|
577
|
+
if (value.length === 0 || value.length > MAX_CATALOG_SEGMENT_LENGTH || !/^[A-Za-z0-9._~-]+$/.test(value)) throw new Error(`${name} must be 1-${MAX_CATALOG_SEGMENT_LENGTH} URL-safe ASCII characters`);
|
|
578
|
+
}
|
|
579
|
+
function encodeUtf8(value) {
|
|
580
|
+
const bytes = [];
|
|
581
|
+
for (const codePointText of value) {
|
|
582
|
+
const codePoint = codePointText.codePointAt(0);
|
|
583
|
+
if (codePoint <= 127) bytes.push(codePoint);
|
|
584
|
+
else if (codePoint <= 2047) bytes.push(192 | codePoint >> 6, 128 | codePoint & 63);
|
|
585
|
+
else if (codePoint <= 65535) bytes.push(224 | codePoint >> 12, 128 | codePoint >> 6 & 63, 128 | codePoint & 63);
|
|
586
|
+
else bytes.push(240 | codePoint >> 18, 128 | codePoint >> 12 & 63, 128 | codePoint >> 6 & 63, 128 | codePoint & 63);
|
|
587
|
+
}
|
|
588
|
+
return bytes;
|
|
589
|
+
}
|
|
590
|
+
function decodeUtf8(bytes) {
|
|
591
|
+
let result = "";
|
|
592
|
+
for (let index = 0; index < bytes.length;) {
|
|
593
|
+
const first = bytes[index++];
|
|
594
|
+
let codePoint;
|
|
595
|
+
let continuationCount;
|
|
596
|
+
if (first <= 127) {
|
|
597
|
+
codePoint = first;
|
|
598
|
+
continuationCount = 0;
|
|
599
|
+
} else if ((first & 224) === 192) {
|
|
600
|
+
codePoint = first & 31;
|
|
601
|
+
continuationCount = 1;
|
|
602
|
+
} else if ((first & 240) === 224) {
|
|
603
|
+
codePoint = first & 15;
|
|
604
|
+
continuationCount = 2;
|
|
605
|
+
} else if ((first & 248) === 240) {
|
|
606
|
+
codePoint = first & 7;
|
|
607
|
+
continuationCount = 3;
|
|
608
|
+
} else throw new Error("Invalid UTF-8 channel key");
|
|
609
|
+
for (let offset = 0; offset < continuationCount; offset += 1) {
|
|
610
|
+
const continuation = bytes[index++];
|
|
611
|
+
if (continuation === void 0 || (continuation & 192) !== 128) throw new Error("Invalid UTF-8 channel key");
|
|
612
|
+
codePoint = codePoint << 6 | continuation & 63;
|
|
613
|
+
}
|
|
614
|
+
if (codePoint > 1114111 || codePoint >= 55296 && codePoint <= 57343 || continuationCount === 1 && codePoint < 128 || continuationCount === 2 && codePoint < 2048 || continuationCount === 3 && codePoint < 65536) throw new Error("Invalid UTF-8 channel key");
|
|
615
|
+
result += String.fromCodePoint(codePoint);
|
|
616
|
+
}
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
function base64UrlEncode(bytes) {
|
|
620
|
+
let result = "";
|
|
621
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
622
|
+
const first = bytes[index];
|
|
623
|
+
const second = bytes[index + 1];
|
|
624
|
+
const third = bytes[index + 2];
|
|
625
|
+
const value = first << 16 | (second ?? 0) << 8 | (third ?? 0);
|
|
626
|
+
result += BASE64URL_ALPHABET[value >> 18 & 63];
|
|
627
|
+
result += BASE64URL_ALPHABET[value >> 12 & 63];
|
|
628
|
+
if (second !== void 0) result += BASE64URL_ALPHABET[value >> 6 & 63];
|
|
629
|
+
if (third !== void 0) result += BASE64URL_ALPHABET[value & 63];
|
|
630
|
+
}
|
|
631
|
+
return result;
|
|
632
|
+
}
|
|
633
|
+
function base64UrlDecode(value) {
|
|
634
|
+
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) throw new Error("Invalid channel key");
|
|
635
|
+
const bytes = [];
|
|
636
|
+
for (let index = 0; index < value.length; index += 4) {
|
|
637
|
+
const chunk = value.slice(index, index + 4);
|
|
638
|
+
const encoded = [...chunk].map((character) => BASE64URL_ALPHABET.indexOf(character));
|
|
639
|
+
if (encoded.some((part) => part < 0)) throw new Error("Invalid channel key");
|
|
640
|
+
const bits = encoded[0] << 18 | encoded[1] << 12 | (encoded[2] ?? 0) << 6 | (encoded[3] ?? 0);
|
|
641
|
+
bytes.push(bits >> 16 & 255);
|
|
642
|
+
if (chunk.length >= 3) bytes.push(bits >> 8 & 255);
|
|
643
|
+
if (chunk.length === 4) bytes.push(bits & 255);
|
|
644
|
+
}
|
|
645
|
+
return bytes;
|
|
646
|
+
}
|
|
647
|
+
function normalizeChannelName(channel) {
|
|
648
|
+
return channel.trim().normalize("NFC");
|
|
649
|
+
}
|
|
650
|
+
function assertCanonicalChannelName(channel) {
|
|
651
|
+
const normalized = normalizeChannelName(channel);
|
|
652
|
+
if (normalized.length === 0 || normalized !== channel) throw new Error("Channel name must be non-empty, trimmed, and NFC-normalized");
|
|
653
|
+
let codePointCount = 0;
|
|
654
|
+
for (const _codePoint of normalized) {
|
|
655
|
+
codePointCount += 1;
|
|
656
|
+
if (codePointCount > 255) throw new Error("Channel name must not exceed 255 characters");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function encodeChannelKey(channel) {
|
|
660
|
+
assertCanonicalChannelName(channel);
|
|
661
|
+
return base64UrlEncode(encodeUtf8(channel));
|
|
662
|
+
}
|
|
663
|
+
function decodeChannelKey(channelKey) {
|
|
664
|
+
const channel = decodeUtf8(base64UrlDecode(channelKey));
|
|
665
|
+
assertCanonicalChannelName(channel);
|
|
666
|
+
if (encodeChannelKey(channel) !== channelKey) throw new Error("Channel key is not canonically encoded");
|
|
667
|
+
return channel;
|
|
668
|
+
}
|
|
669
|
+
function createReleaseCatalogScopeKey(input) {
|
|
670
|
+
decodeChannelKey(input.channelKey);
|
|
671
|
+
if (input.strategy === "APP_VERSION") return `v1:app-version:${input.platform}:${input.channelKey}`;
|
|
672
|
+
assertCatalogSegment(input.fingerprintHash, "Fingerprint hash");
|
|
673
|
+
return `v1:fingerprint:${input.platform}:${input.channelKey}:${input.fingerprintHash}`;
|
|
674
|
+
}
|
|
675
|
+
function parseReleaseCatalogScopeKey(scopeKey) {
|
|
676
|
+
const segments = scopeKey.split(":");
|
|
677
|
+
const [version, strategy, platform, channelKey] = segments;
|
|
678
|
+
if (version !== "v1" || platform !== "ios" && platform !== "android" || channelKey === void 0) throw new Error("Invalid release catalog scope key");
|
|
679
|
+
const input = strategy === "app-version" && segments.length === 4 ? {
|
|
680
|
+
channelKey,
|
|
681
|
+
platform,
|
|
682
|
+
strategy: "APP_VERSION"
|
|
683
|
+
} : strategy === "fingerprint" && segments.length === 5 && segments[4] !== void 0 ? {
|
|
684
|
+
channelKey,
|
|
685
|
+
fingerprintHash: segments[4],
|
|
686
|
+
platform,
|
|
687
|
+
strategy: "FINGERPRINT"
|
|
688
|
+
} : (() => {
|
|
689
|
+
throw new Error("Invalid release catalog scope key");
|
|
690
|
+
})();
|
|
691
|
+
if (createReleaseCatalogScopeKey(input) !== scopeKey) throw new Error("Release catalog scope key is not canonical");
|
|
692
|
+
return input;
|
|
693
|
+
}
|
|
694
|
+
//#endregion
|
|
695
|
+
//#region src/uuid.ts
|
|
696
|
+
const NIL_UUID = "00000000-0000-0000-0000-000000000000";
|
|
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}$/;
|
|
698
|
+
const isUUIDv7 = (value) => typeof value === "string" && UUID_V7_PATTERN.test(value);
|
|
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
|
|
754
|
+
exports.ARTIFACT_PROTOCOL_VERSION = ARTIFACT_PROTOCOL_VERSION;
|
|
755
|
+
exports.DEFAULT_ROLLOUT_COHORT_COUNT = DEFAULT_ROLLOUT_COHORT_COUNT;
|
|
756
|
+
exports.INVALID_COHORT_ERROR_MESSAGE = INVALID_COHORT_ERROR_MESSAGE;
|
|
757
|
+
exports.MAX_COHORT_LENGTH = MAX_COHORT_LENGTH;
|
|
758
|
+
exports.MAX_COMPILED_CATALOG_BYTES = MAX_COMPILED_CATALOG_BYTES;
|
|
759
|
+
exports.MAX_CRASHED_BUNDLES = MAX_CRASHED_BUNDLES;
|
|
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;
|
|
762
|
+
exports.MAX_TARGET_COHORTS_PER_RELEASE = MAX_TARGET_COHORTS_PER_RELEASE;
|
|
763
|
+
exports.NIL_UUID = NIL_UUID;
|
|
764
|
+
exports.NUMERIC_COHORT_SIZE = NUMERIC_COHORT_SIZE;
|
|
765
|
+
exports.RELEASE_CATALOG_FALLBACK_POLICY = RELEASE_CATALOG_FALLBACK_POLICY;
|
|
766
|
+
exports.RELEASE_CATALOG_SCHEMA_VERSION = RELEASE_CATALOG_SCHEMA_VERSION;
|
|
767
|
+
exports.assertCanonicalChannelName = assertCanonicalChannelName;
|
|
768
|
+
exports.assessCatalogAcceptance = assessCatalogAcceptance;
|
|
769
|
+
exports.authorizeReleaseTransition = authorizeReleaseTransition;
|
|
770
|
+
exports.canonicalizeAppVersion = canonicalizeAppVersion;
|
|
771
|
+
exports.createPluginHost = createPluginHost;
|
|
772
|
+
exports.createReleaseCatalogScopeKey = createReleaseCatalogScopeKey;
|
|
773
|
+
exports.createReleaseSelectionContextHash = createReleaseSelectionContextHash;
|
|
774
|
+
exports.decodeChannelKey = decodeChannelKey;
|
|
775
|
+
exports.defineClientPlugin = defineClientPlugin;
|
|
776
|
+
exports.encodeChannelKey = encodeChannelKey;
|
|
777
|
+
exports.getAssetBaseStorageUri = getAssetBaseStorageUri;
|
|
778
|
+
exports.getBundlePatch = getBundlePatch;
|
|
779
|
+
exports.getBundlePatches = getBundlePatches;
|
|
780
|
+
exports.getDefaultNumericCohort = getDefaultNumericCohort;
|
|
781
|
+
exports.getManifestFileHash = getManifestFileHash;
|
|
782
|
+
exports.getManifestStorageUri = getManifestStorageUri;
|
|
783
|
+
exports.getNumericCohortRolloutPosition = getNumericCohortRolloutPosition;
|
|
784
|
+
exports.getNumericCohortValue = getNumericCohortValue;
|
|
785
|
+
exports.getPatchBaseBundleId = getPatchBaseBundleId;
|
|
786
|
+
exports.getPatchBaseFileHash = getPatchBaseFileHash;
|
|
787
|
+
exports.getPatchFileHash = getPatchFileHash;
|
|
788
|
+
exports.getPatchStorageUri = getPatchStorageUri;
|
|
789
|
+
exports.getRolledOutNumericCohorts = getRolledOutNumericCohorts;
|
|
790
|
+
exports.getUtf8ByteLength = getUtf8ByteLength;
|
|
791
|
+
exports.hasExpectedReleaseCatalogScope = hasExpectedReleaseCatalogScope;
|
|
792
|
+
exports.isCohortEligibleForUpdate = isCohortEligibleForUpdate;
|
|
793
|
+
exports.isCustomCohort = isCustomCohort;
|
|
794
|
+
exports.isNumericCohort = isNumericCohort;
|
|
795
|
+
exports.isReleaseEligibleForCohort = isReleaseEligibleForCohort;
|
|
796
|
+
exports.isUUIDv7 = isUUIDv7;
|
|
797
|
+
exports.isValidCohort = isValidCohort;
|
|
798
|
+
exports.normalizeChannelName = normalizeChannelName;
|
|
799
|
+
exports.normalizeCohortValue = normalizeCohortValue;
|
|
800
|
+
exports.normalizeRolloutCohortCount = normalizeRolloutCohortCount;
|
|
801
|
+
exports.parseReleaseCatalog = parseReleaseCatalog;
|
|
802
|
+
exports.parseReleaseCatalogScopeKey = parseReleaseCatalogScopeKey;
|
|
803
|
+
exports.pluginStorageKey = pluginStorageKey;
|
|
804
|
+
exports.resolveBaseURL = resolveBaseURL;
|
|
805
|
+
exports.selectDesiredRelease = selectDesiredRelease;
|
|
806
|
+
exports.stripBundleArtifactMetadata = stripBundleArtifactMetadata;
|