@absolutejs/absolute 0.20.0-beta.85 → 0.20.0-beta.87
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/dist/angular/components/core/streamingSlotRegistrar.js +1 -1
- package/dist/angular/components/core/streamingSlotRegistry.js +2 -2
- package/dist/build.js +656 -18
- package/dist/build.js.map +5 -5
- package/dist/cli/{compile-d7g2fvqe.js → compile-5hchbcbd.js} +3 -3
- package/dist/cli/{config-naw5fer4.js → config-vy04mqce.js} +1 -1
- package/dist/cli/{dev-skb8dz33.js → dev-6shm8qsn.js} +2 -2
- package/dist/cli/{expoProject-3mmwtemn.js → expoProject-dac8fqhq.js} +1 -1
- package/dist/cli/{index-q78x1k9q.js → index-39j0dhq7.js} +2 -2
- package/dist/cli/{index-8gksmws3.js → index-jqdt7841.js} +2 -2
- package/dist/cli/{index-czw3jd8f.js → index-rd2hbttb.js} +67 -1
- package/dist/cli/{index-p8ezgd5e.js → index-tame6e2c.js} +65 -3
- package/dist/cli/index.js +5 -5
- package/dist/cli/{mobile-pdck6a35.js → mobile-c94a5y9k.js} +205 -10
- package/dist/cli/{start-jbgfw27x.js → start-7jg0csqy.js} +4 -4
- package/dist/index.js +656 -18
- package/dist/index.js.map +5 -5
- package/dist/mobile/index.js +840 -25
- package/dist/mobile/index.js.map +8 -8
- package/dist/mobile/remoteMacAgentEntry.js +270 -208
- package/dist/mobile/shellUpdate.js +138 -23
- package/dist/src/mobile/config.d.ts +14 -0
- package/dist/src/mobile/updateClient.d.ts +13 -0
- package/dist/src/mobile/updatePublisher.d.ts +78 -1
- package/dist/src/mobile/updateServer.d.ts +28 -0
- package/dist/types/build.d.ts +25 -0
- package/package.json +2 -2
package/dist/mobile/index.js
CHANGED
|
@@ -604,7 +604,7 @@ __export(exports_config, {
|
|
|
604
604
|
import { readFileSync as readFileSync2 } from "fs";
|
|
605
605
|
import { resolve as resolve6 } from "path";
|
|
606
606
|
import { createHash as createHash8, createPublicKey, X509Certificate as X509Certificate2 } from "crypto";
|
|
607
|
-
var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FINGERPRINT_PATTERN, UPDATE_NAME_PATTERN, UPDATE_PUBLIC_KEY_PATTERN, ENVIRONMENT_NAME_PATTERN, DEFAULT_UPDATE_BOOT_TIMEOUT_MS = 20000, MINIMUM_UPDATE_BOOT_TIMEOUT_MS = 5000, MAXIMUM_UPDATE_BOOT_TIMEOUT_MS = 120000, HOSTNAME_PATTERN, EXPO_RESERVED_ROUTE_PREFIXES, resolveProjectPath = (projectRoot, value, field2) => {
|
|
607
|
+
var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FINGERPRINT_PATTERN, UPDATE_NAME_PATTERN, UPDATE_PUBLIC_KEY_PATTERN, ENVIRONMENT_NAME_PATTERN, DEFAULT_UPDATE_BOOT_TIMEOUT_MS = 20000, DEFAULT_UPDATE_HEALTH_FAILURE_RATE = 0.2, DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS = 20, DEFAULT_UPDATE_ROLLOUT_FAILURE_RATE = 0.05, DEFAULT_UPDATE_ROLLOUT_OBSERVATION_MINUTES = 60, MINUTE_MS, DEFAULT_UPDATE_ROLLOUT_STAGES, MINIMUM_UPDATE_BOOT_TIMEOUT_MS = 5000, MAXIMUM_UPDATE_BOOT_TIMEOUT_MS = 120000, HOSTNAME_PATTERN, EXPO_RESERVED_ROUTE_PREFIXES, resolveProjectPath = (projectRoot, value, field2) => {
|
|
608
608
|
const root = resolve6(projectRoot);
|
|
609
609
|
const path = resolve6(root, value);
|
|
610
610
|
if (path !== root && !path.startsWith(`${root}/`)) {
|
|
@@ -798,6 +798,56 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
|
|
|
798
798
|
if (!ENVIRONMENT_NAME_PATTERN.test(expoPrivateKeyEnv))
|
|
799
799
|
throw new TypeError("mobile.updates.server.expoPrivateKeyEnv must be a valid environment variable name.");
|
|
800
800
|
const autoMount = config.updates.server?.autoMount ?? true;
|
|
801
|
+
const configuredHealth = config.updates.server?.health;
|
|
802
|
+
let health;
|
|
803
|
+
if (configuredHealth !== false) {
|
|
804
|
+
const failureRate = configuredHealth?.failureRate ?? DEFAULT_UPDATE_HEALTH_FAILURE_RATE;
|
|
805
|
+
const minimumReports = configuredHealth?.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
|
|
806
|
+
const secretEnv = requireText(configuredHealth?.secretEnv ?? "ABSOLUTE_MOBILE_UPDATE_HEALTH_SECRET", "mobile.updates.server.health.secretEnv");
|
|
807
|
+
if (!Number.isFinite(failureRate) || failureRate <= 0 || failureRate > 1)
|
|
808
|
+
throw new TypeError("mobile.updates.server.health.failureRate must be greater than 0 and at most 1.");
|
|
809
|
+
if (!Number.isSafeInteger(minimumReports) || minimumReports < 1)
|
|
810
|
+
throw new TypeError("mobile.updates.server.health.minimumReports must be a positive integer.");
|
|
811
|
+
if (!ENVIRONMENT_NAME_PATTERN.test(secretEnv))
|
|
812
|
+
throw new TypeError("mobile.updates.server.health.secretEnv must be a valid environment variable name.");
|
|
813
|
+
health = { failureRate, minimumReports, secretEnv };
|
|
814
|
+
}
|
|
815
|
+
const configuredRollout = config.updates.server?.rollout;
|
|
816
|
+
let rollout;
|
|
817
|
+
if (configuredRollout !== undefined && configuredRollout !== false) {
|
|
818
|
+
if (!health)
|
|
819
|
+
throw new TypeError("mobile.updates.server.rollout requires fleet health to be enabled.");
|
|
820
|
+
if (configuredRollout.automatic !== undefined && typeof configuredRollout.automatic !== "boolean")
|
|
821
|
+
throw new TypeError("mobile.updates.server.rollout.automatic must be boolean.");
|
|
822
|
+
const configuredStages = configuredRollout.stages ?? DEFAULT_UPDATE_ROLLOUT_STAGES;
|
|
823
|
+
const stages = configuredStages.map((stage, index) => {
|
|
824
|
+
const previousStage = configuredStages[index - 1];
|
|
825
|
+
const maximumFailureRate = stage.maximumFailureRate ?? DEFAULT_UPDATE_ROLLOUT_FAILURE_RATE;
|
|
826
|
+
const minimumReports = stage.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
|
|
827
|
+
const observationMinutes = stage.observationMinutes ?? DEFAULT_UPDATE_ROLLOUT_OBSERVATION_MINUTES;
|
|
828
|
+
const observationMs = observationMinutes * MINUTE_MS;
|
|
829
|
+
if (!Number.isFinite(stage.rollout) || stage.rollout <= 0 || stage.rollout > 1 || previousStage !== undefined && stage.rollout <= previousStage.rollout)
|
|
830
|
+
throw new TypeError("mobile.updates.server.rollout stages must be strictly increasing fractions greater than 0 and at most 1.");
|
|
831
|
+
if (!Number.isFinite(maximumFailureRate) || maximumFailureRate < 0 || maximumFailureRate >= health.failureRate)
|
|
832
|
+
throw new TypeError("mobile.updates.server.rollout maximumFailureRate must be non-negative and lower than the fleet-health pause rate.");
|
|
833
|
+
if (!Number.isSafeInteger(minimumReports) || minimumReports < health.minimumReports)
|
|
834
|
+
throw new TypeError("mobile.updates.server.rollout minimumReports must be an integer at least as large as the fleet-health minimumReports.");
|
|
835
|
+
if (!Number.isFinite(observationMinutes) || observationMinutes < 0 || !Number.isSafeInteger(observationMs))
|
|
836
|
+
throw new TypeError("mobile.updates.server.rollout observationMinutes must produce a non-negative whole number of milliseconds.");
|
|
837
|
+
return {
|
|
838
|
+
maximumFailureRate,
|
|
839
|
+
minimumReports,
|
|
840
|
+
observationMs,
|
|
841
|
+
rollout: stage.rollout
|
|
842
|
+
};
|
|
843
|
+
});
|
|
844
|
+
if (stages.length === 0 || stages.at(-1)?.rollout !== 1)
|
|
845
|
+
throw new TypeError("mobile.updates.server.rollout stages must end at rollout 1.");
|
|
846
|
+
rollout = {
|
|
847
|
+
automatic: configuredRollout.automatic ?? false,
|
|
848
|
+
stages
|
|
849
|
+
};
|
|
850
|
+
}
|
|
801
851
|
if (autoMount) {
|
|
802
852
|
const manifest = new URL(updates.manifestUrl);
|
|
803
853
|
if (manifest.origin !== productionOrigin)
|
|
@@ -830,7 +880,13 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
|
|
|
830
880
|
throw new TypeError(`mobile.updates.server.expoCodeSigningKeys.${keyId} certificate is not currently valid.`);
|
|
831
881
|
expoCodeSigningKeys[keyId] = { certificatePem, privateKeyEnv };
|
|
832
882
|
}
|
|
833
|
-
return {
|
|
883
|
+
return {
|
|
884
|
+
autoMount,
|
|
885
|
+
expoCodeSigningKeys,
|
|
886
|
+
...health ? { health } : {},
|
|
887
|
+
...rollout ? { rollout } : {},
|
|
888
|
+
registryModule
|
|
889
|
+
};
|
|
834
890
|
}, validateExpoNativeRouteSegment = (path, segment, index, count, parameters) => {
|
|
835
891
|
if (segment === "*" && (index !== count - 1 || count === 1)) {
|
|
836
892
|
throw new TypeError(`mobile.routes.native route ${path} must use * once, as the final segment after a static or parameterized prefix.`);
|
|
@@ -922,6 +978,12 @@ var init_config = __esm(() => {
|
|
|
922
978
|
UPDATE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u;
|
|
923
979
|
UPDATE_PUBLIC_KEY_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/u;
|
|
924
980
|
ENVIRONMENT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
981
|
+
MINUTE_MS = 60 * 1000;
|
|
982
|
+
DEFAULT_UPDATE_ROLLOUT_STAGES = [
|
|
983
|
+
{ minimumReports: 20, observationMinutes: 60, rollout: 0.05 },
|
|
984
|
+
{ minimumReports: 100, observationMinutes: 360, rollout: 0.25 },
|
|
985
|
+
{ minimumReports: 100, observationMinutes: 0, rollout: 1 }
|
|
986
|
+
];
|
|
925
987
|
HOSTNAME_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))*$/;
|
|
926
988
|
EXPO_RESERVED_ROUTE_PREFIXES = new Set([
|
|
927
989
|
"_expo",
|
|
@@ -19962,15 +20024,18 @@ __export(exports_mobileUpdate, {
|
|
|
19962
20024
|
});
|
|
19963
20025
|
import {
|
|
19964
20026
|
createHash as createHash15,
|
|
20027
|
+
createHmac,
|
|
19965
20028
|
createPrivateKey,
|
|
19966
20029
|
createPublicKey as createPublicKey2,
|
|
20030
|
+
randomUUID as randomUUID5,
|
|
19967
20031
|
sign as sign2,
|
|
20032
|
+
timingSafeEqual,
|
|
19968
20033
|
verify as verify2,
|
|
19969
20034
|
X509Certificate as X509Certificate3
|
|
19970
20035
|
} from "crypto";
|
|
19971
20036
|
import { readFile as readFile23, stat as stat5 } from "fs/promises";
|
|
19972
20037
|
import path from "path";
|
|
19973
|
-
var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updates", MAX_FILE_BYTES, MAX_TOTAL_BYTES, DAY_MS, DEFAULT_MIN_AGE_MS, DEFAULT_GRACE_PERIOD_MS, DEFAULT_RETAIN_RECENT = 5, HASH, RELEASE, APP_ID, NAME, EXPO_DESCRIPTOR = "_absolute/expo-update.json", EXPO_CODE_SIGNING_ALGORITHM = "rsa-v1_5-sha256", MobileUpdateRegistryError, object6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), text2 = (value, field2) => {
|
|
20038
|
+
var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updates", MAX_FILE_BYTES, MAX_TOTAL_BYTES, DAY_MS, DEFAULT_MIN_AGE_MS, DEFAULT_GRACE_PERIOD_MS, DEFAULT_RETAIN_RECENT = 5, HASH, RELEASE, APP_ID, NAME, EXPO_DESCRIPTOR = "_absolute/expo-update.json", EXPO_CODE_SIGNING_ALGORITHM = "rsa-v1_5-sha256", HEALTH_TOKEN_VERSION = 1, HEALTH_KINDS, FAILURE_HEALTH_KINDS, MobileUpdateRegistryError, object6 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), text2 = (value, field2) => {
|
|
19974
20039
|
if (typeof value !== "string" || value.length === 0)
|
|
19975
20040
|
throw new MobileUpdateRegistryError(`Mobile update ${field2} is invalid`);
|
|
19976
20041
|
return value;
|
|
@@ -20099,7 +20164,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20099
20164
|
throw new MobileUpdateRegistryError("Mobile update channel is invalid");
|
|
20100
20165
|
const appId = text2(value.appId, "channel appId");
|
|
20101
20166
|
const channel = text2(value.channel, "channel");
|
|
20102
|
-
if (!APP_ID.test(appId) || !NAME.test(channel) || !iso(value.promotedAt) || typeof value.rollout !== "number" || value.rollout < 0 || value.rollout > 1 || value.releaseId !== undefined && (typeof value.releaseId !== "string" || !RELEASE.test(value.releaseId)) || value.fallbackReleaseId !== undefined && (typeof value.fallbackReleaseId !== "string" || !RELEASE.test(value.fallbackReleaseId)) || value.activationId !== undefined && (typeof value.activationId !== "string" || !HASH.test(value.activationId)) || value.activatedAt !== undefined && !iso(value.activatedAt) || value.activationId === undefined !== (value.activatedAt === undefined))
|
|
20167
|
+
if (!APP_ID.test(appId) || !NAME.test(channel) || !iso(value.promotedAt) || typeof value.rollout !== "number" || value.rollout < 0 || value.rollout > 1 || value.releaseId !== undefined && (typeof value.releaseId !== "string" || !RELEASE.test(value.releaseId)) || value.fallbackReleaseId !== undefined && (typeof value.fallbackReleaseId !== "string" || !RELEASE.test(value.fallbackReleaseId)) || value.promotionId !== undefined && (typeof value.promotionId !== "string" || !HASH.test(value.promotionId)) || value.activationId !== undefined && (typeof value.activationId !== "string" || !HASH.test(value.activationId)) || value.activatedAt !== undefined && !iso(value.activatedAt) || value.activationId === undefined !== (value.activatedAt === undefined))
|
|
20103
20168
|
throw new MobileUpdateRegistryError("Mobile update channel is invalid");
|
|
20104
20169
|
return {
|
|
20105
20170
|
...value.activationId ? { activationId: value.activationId } : {},
|
|
@@ -20108,6 +20173,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20108
20173
|
channel,
|
|
20109
20174
|
...value.fallbackReleaseId ? { fallbackReleaseId: value.fallbackReleaseId } : {},
|
|
20110
20175
|
format: MOBILE_UPDATE_REGISTRY_FORMAT,
|
|
20176
|
+
...value.promotionId ? { promotionId: value.promotionId } : {},
|
|
20111
20177
|
promotedAt: value.promotedAt,
|
|
20112
20178
|
...value.releaseId ? { releaseId: value.releaseId } : {},
|
|
20113
20179
|
rollout: value.rollout
|
|
@@ -20130,15 +20196,50 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20130
20196
|
return true;
|
|
20131
20197
|
const value = createHash15("sha256").update(`${input.appId}\x00${input.channel}\x00${input.releaseId}\x00${input.installationId}`).digest().readUInt32BE(0);
|
|
20132
20198
|
return value / 4294967296 < input.rollout;
|
|
20199
|
+
}, base64Url = (value) => Buffer.from(value).toString("base64url"), healthPromotionId = (channel) => channel.promotionId ?? digest(new TextEncoder().encode(`${channel.appId}\x00${channel.channel}\x00${channel.releaseId ?? "embedded"}\x00${channel.promotedAt}`)), finiteMetric = (value, field2) => {
|
|
20200
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
|
|
20201
|
+
throw new MobileUpdateRegistryError(`Mobile update health ${field2} is invalid`);
|
|
20202
|
+
return value;
|
|
20203
|
+
}, parseHealthTransfer = (value) => {
|
|
20204
|
+
if (value === undefined)
|
|
20205
|
+
return;
|
|
20206
|
+
if (!object6(value))
|
|
20207
|
+
throw new MobileUpdateRegistryError("Mobile update health transfer is invalid");
|
|
20208
|
+
return {
|
|
20209
|
+
avoidedBytes: finiteMetric(value.avoidedBytes, "avoidedBytes"),
|
|
20210
|
+
downloadedBytes: finiteMetric(value.downloadedBytes, "downloadedBytes"),
|
|
20211
|
+
durationMs: finiteMetric(value.durationMs, "durationMs"),
|
|
20212
|
+
resumedBytes: finiteMetric(value.resumedBytes, "resumedBytes"),
|
|
20213
|
+
reusedBytes: finiteMetric(value.reusedBytes, "reusedBytes"),
|
|
20214
|
+
throughputBytesPerSecond: finiteMetric(value.throughputBytesPerSecond, "throughputBytesPerSecond")
|
|
20215
|
+
};
|
|
20133
20216
|
}, createMobileUpdateRegistry = (options) => {
|
|
20134
20217
|
const prefix = normalizedPrefix(options.prefix ?? DEFAULT_PREFIX);
|
|
20135
20218
|
const clock = options.clock ?? (() => new Date);
|
|
20219
|
+
const health = options.health;
|
|
20220
|
+
const rollout = options.rollout;
|
|
20221
|
+
if (health && health.secret.length < 32)
|
|
20222
|
+
throw new MobileUpdateRegistryError("Mobile update health secret must contain at least 32 characters");
|
|
20223
|
+
if (health && !options.store.list)
|
|
20224
|
+
throw new MobileUpdateRegistryError("Mobile update health requires storage lifecycle listing");
|
|
20225
|
+
if (rollout && (!health || !options.store.list))
|
|
20226
|
+
throw new MobileUpdateRegistryError("Mobile update rollout orchestration requires fleet health and storage lifecycle listing");
|
|
20227
|
+
if (rollout && (rollout.stages.length === 0 || rollout.stages.some((stage, index) => stage.rollout <= 0 || stage.rollout > 1 || !Number.isSafeInteger(stage.minimumReports) || stage.minimumReports < 1 || !Number.isSafeInteger(stage.observationMs) || stage.observationMs < 0 || stage.maximumFailureRate < 0 || stage.maximumFailureRate >= 1 || index > 0 && stage.rollout <= rollout.stages[index - 1].rollout)))
|
|
20228
|
+
throw new MobileUpdateRegistryError("Mobile update rollout stages are invalid");
|
|
20229
|
+
const minimumReports = health?.autoPause?.minimumReports ?? 20;
|
|
20230
|
+
const failureThreshold = health?.autoPause?.failureRate ?? 0.2;
|
|
20231
|
+
if (health && (!Number.isSafeInteger(minimumReports) || minimumReports < 1 || failureThreshold <= 0 || failureThreshold > 1))
|
|
20232
|
+
throw new MobileUpdateRegistryError("Mobile update health auto-pause policy is invalid");
|
|
20136
20233
|
const root = (appId) => `${prefix}/${appHash(appId)}`;
|
|
20137
20234
|
const releaseRoot = (manifest) => `${root(manifest.appId)}/releases/${manifest.releaseId}`;
|
|
20138
20235
|
const manifestKey = (manifest) => `${releaseRoot(manifest)}/update.json`;
|
|
20139
20236
|
const fileKey = (manifest, file) => `${releaseRoot(manifest)}/files/${file.path}`;
|
|
20140
20237
|
const contentBlobKey = (appId, sha2563) => `${root(appId)}/blobs/${sha2563}`;
|
|
20141
20238
|
const tombstoneKey = (appId, releaseId) => `${root(appId)}/gc/${releaseId}.json`;
|
|
20239
|
+
const healthRoot = (appId, promotionId, releaseId) => `${root(appId)}/health/${promotionId}/${releaseId}`;
|
|
20240
|
+
const pauseKey = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/paused.json`;
|
|
20241
|
+
const rolloutRoot = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/rollout`;
|
|
20242
|
+
const rolloutPlanKey = (appId, promotionId, releaseId) => `${rolloutRoot(appId, promotionId, releaseId)}/plan.json`;
|
|
20142
20243
|
const channelKey = (appId, channel) => {
|
|
20143
20244
|
if (!APP_ID.test(appId) || !NAME.test(channel))
|
|
20144
20245
|
throw new MobileUpdateRegistryError("Mobile update channel identity is invalid");
|
|
@@ -20167,18 +20268,128 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20167
20268
|
throw new MobileUpdateRegistryError("Stored mobile update channel identity changed");
|
|
20168
20269
|
return value;
|
|
20169
20270
|
};
|
|
20271
|
+
const listPrefix = async (value) => {
|
|
20272
|
+
const objects = [];
|
|
20273
|
+
const cursors = new Set;
|
|
20274
|
+
let cursor;
|
|
20275
|
+
do {
|
|
20276
|
+
const page = await options.store.list({
|
|
20277
|
+
...cursor ? { cursor } : {},
|
|
20278
|
+
prefix: value
|
|
20279
|
+
});
|
|
20280
|
+
objects.push(...page.objects);
|
|
20281
|
+
if (!page.truncated)
|
|
20282
|
+
break;
|
|
20283
|
+
if (!page.cursor || cursors.has(page.cursor))
|
|
20284
|
+
throw new MobileUpdateRegistryError("Mobile update storage returned an invalid cursor");
|
|
20285
|
+
cursors.add(page.cursor);
|
|
20286
|
+
cursor = page.cursor;
|
|
20287
|
+
} while (true);
|
|
20288
|
+
return objects;
|
|
20289
|
+
};
|
|
20290
|
+
const readVerifiedObject = async (key, label) => {
|
|
20291
|
+
const bytes = await options.store.get(key);
|
|
20292
|
+
if (!bytes)
|
|
20293
|
+
return null;
|
|
20294
|
+
const head = await options.store.head(key);
|
|
20295
|
+
if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
|
|
20296
|
+
throw new MobileUpdateRegistryError(`Stored mobile update ${label} integrity failed`);
|
|
20297
|
+
return decode2(bytes);
|
|
20298
|
+
};
|
|
20299
|
+
const parseRolloutPlan = (value, promotionId, releaseId) => {
|
|
20300
|
+
if (!object6(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== releaseId || typeof value.automatic !== "boolean" || !iso(value.createdAt) || !Array.isArray(value.stages))
|
|
20301
|
+
throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
|
|
20302
|
+
const stages = value.stages.map((stage) => {
|
|
20303
|
+
if (!object6(stage) || typeof stage.rollout !== "number" || typeof stage.maximumFailureRate !== "number" || !Number.isSafeInteger(stage.minimumReports) || !Number.isSafeInteger(stage.observationMs))
|
|
20304
|
+
throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
|
|
20305
|
+
return {
|
|
20306
|
+
maximumFailureRate: stage.maximumFailureRate,
|
|
20307
|
+
minimumReports: stage.minimumReports,
|
|
20308
|
+
observationMs: stage.observationMs,
|
|
20309
|
+
rollout: stage.rollout
|
|
20310
|
+
};
|
|
20311
|
+
});
|
|
20312
|
+
if (stages.length === 0 || stages.some((stage, index) => stage.rollout <= 0 || stage.rollout > 1 || stage.minimumReports < 1 || stage.observationMs < 0 || stage.maximumFailureRate < 0 || stage.maximumFailureRate >= 1 || index > 0 && stage.rollout <= stages[index - 1].rollout))
|
|
20313
|
+
throw new MobileUpdateRegistryError("Stored mobile update rollout plan is invalid");
|
|
20314
|
+
return {
|
|
20315
|
+
automatic: value.automatic,
|
|
20316
|
+
createdAt: value.createdAt,
|
|
20317
|
+
format: 1,
|
|
20318
|
+
promotionId,
|
|
20319
|
+
releaseId,
|
|
20320
|
+
stages
|
|
20321
|
+
};
|
|
20322
|
+
};
|
|
20323
|
+
const initializeRollout = async (channel, signal) => {
|
|
20324
|
+
if (!rollout || !channel.releaseId)
|
|
20325
|
+
return;
|
|
20326
|
+
if (!rollout.stages.some((stage) => stage.rollout === channel.rollout))
|
|
20327
|
+
throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
|
|
20328
|
+
const promotionId = healthPromotionId(channel);
|
|
20329
|
+
const plan = {
|
|
20330
|
+
automatic: rollout.automatic ?? false,
|
|
20331
|
+
createdAt: channel.promotedAt,
|
|
20332
|
+
format: 1,
|
|
20333
|
+
promotionId,
|
|
20334
|
+
releaseId: channel.releaseId,
|
|
20335
|
+
stages: rollout.stages.map((stage) => ({ ...stage }))
|
|
20336
|
+
};
|
|
20337
|
+
const bytes = json(plan);
|
|
20338
|
+
await options.store.put(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), bytes, {
|
|
20339
|
+
cacheControl: "no-store",
|
|
20340
|
+
contentType: "application/json",
|
|
20341
|
+
maxBytes: bytes.byteLength,
|
|
20342
|
+
metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
|
|
20343
|
+
signal
|
|
20344
|
+
});
|
|
20345
|
+
};
|
|
20346
|
+
const signHealthToken = (payload) => {
|
|
20347
|
+
if (!health)
|
|
20348
|
+
return null;
|
|
20349
|
+
const encoded = base64Url(JSON.stringify(payload));
|
|
20350
|
+
const signature = createHmac("sha256", health.secret).update(encoded).digest("base64url");
|
|
20351
|
+
return `${encoded}.${signature}`;
|
|
20352
|
+
};
|
|
20353
|
+
const verifyHealthToken = (token) => {
|
|
20354
|
+
if (!health)
|
|
20355
|
+
throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
|
|
20356
|
+
const [encoded, provided, extra] = token.split(".");
|
|
20357
|
+
if (!encoded || !provided || extra)
|
|
20358
|
+
throw new MobileUpdateRegistryError("Mobile update health token is invalid");
|
|
20359
|
+
const expected = createHmac("sha256", health.secret).update(encoded).digest();
|
|
20360
|
+
let actual;
|
|
20361
|
+
try {
|
|
20362
|
+
actual = Buffer.from(provided, "base64url");
|
|
20363
|
+
} catch {
|
|
20364
|
+
actual = Buffer.alloc(0);
|
|
20365
|
+
}
|
|
20366
|
+
if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected))
|
|
20367
|
+
throw new MobileUpdateRegistryError("Mobile update health token is invalid");
|
|
20368
|
+
let value;
|
|
20369
|
+
try {
|
|
20370
|
+
value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
|
|
20371
|
+
} catch {
|
|
20372
|
+
throw new MobileUpdateRegistryError("Mobile update health token is invalid");
|
|
20373
|
+
}
|
|
20374
|
+
if (!object6(value) || value.format !== HEALTH_TOKEN_VERSION || typeof value.appId !== "string" || typeof value.channel !== "string" || typeof value.installationId !== "string" || typeof value.promotionId !== "string" || typeof value.releaseId !== "string" || typeof value.runtimeFingerprint !== "string")
|
|
20375
|
+
throw new MobileUpdateRegistryError("Mobile update health token is invalid");
|
|
20376
|
+
return value;
|
|
20377
|
+
};
|
|
20170
20378
|
const assertNotMarked = async (appId, releaseId) => {
|
|
20171
20379
|
if (!APP_ID.test(appId) || !RELEASE.test(releaseId))
|
|
20172
20380
|
throw new MobileUpdateRegistryError("Mobile update release identity is invalid");
|
|
20173
20381
|
if (await options.store.head(tombstoneKey(appId, releaseId)))
|
|
20174
20382
|
throw new MobileUpdateRegistryError("Mobile update release is marked for collection. Increase retention and apply garbage collection to restore it before promotion");
|
|
20175
20383
|
};
|
|
20176
|
-
const writeChannel = async (input, signal) => {
|
|
20384
|
+
const writeChannel = async (input, signal, beforeWrite) => {
|
|
20385
|
+
const promotedAt = clock().toISOString();
|
|
20177
20386
|
const value = {
|
|
20178
20387
|
...input,
|
|
20179
20388
|
format: MOBILE_UPDATE_REGISTRY_FORMAT,
|
|
20180
|
-
promotedAt
|
|
20389
|
+
promotedAt,
|
|
20390
|
+
promotionId: digest(new TextEncoder().encode(`${input.appId}\x00${input.channel}\x00${input.releaseId ?? "embedded"}\x00${promotedAt}\x00${randomUUID5()}`))
|
|
20181
20391
|
};
|
|
20392
|
+
await beforeWrite?.(value);
|
|
20182
20393
|
const bytes = json(value);
|
|
20183
20394
|
await options.store.put(channelKey(value.appId, value.channel), bytes, {
|
|
20184
20395
|
cacheControl: "no-cache",
|
|
@@ -20193,10 +20404,92 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20193
20404
|
});
|
|
20194
20405
|
return value;
|
|
20195
20406
|
};
|
|
20407
|
+
const rolloutContext = async (channel) => {
|
|
20408
|
+
if (!options.store.list || !channel.releaseId)
|
|
20409
|
+
return null;
|
|
20410
|
+
const promotionId = healthPromotionId(channel);
|
|
20411
|
+
const storedPlan = await readVerifiedObject(rolloutPlanKey(channel.appId, promotionId, channel.releaseId), "rollout plan");
|
|
20412
|
+
if (storedPlan === null)
|
|
20413
|
+
return null;
|
|
20414
|
+
const plan = parseRolloutPlan(storedPlan, promotionId, channel.releaseId);
|
|
20415
|
+
const initialStage = plan.stages.findIndex((stage) => stage.rollout === channel.rollout);
|
|
20416
|
+
if (initialStage < 0)
|
|
20417
|
+
throw new MobileUpdateRegistryError("Stored mobile update rollout does not match its plan");
|
|
20418
|
+
let currentStage = initialStage;
|
|
20419
|
+
let enteredAt = plan.createdAt;
|
|
20420
|
+
const advances = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/advances/`);
|
|
20421
|
+
for (const item of advances) {
|
|
20422
|
+
const value = await readVerifiedObject(item.key, "rollout advancement");
|
|
20423
|
+
if (!object6(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== channel.releaseId || !Number.isSafeInteger(value.stage) || Number(value.stage) < initialStage || Number(value.stage) >= plan.stages.length || value.rollout !== plan.stages[Number(value.stage)].rollout || !iso(value.createdAt) || typeof value.failureRate !== "number" || !Number.isSafeInteger(value.terminalReports))
|
|
20424
|
+
throw new MobileUpdateRegistryError("Stored mobile update rollout advancement is invalid");
|
|
20425
|
+
if (Number(value.stage) >= currentStage) {
|
|
20426
|
+
currentStage = Number(value.stage);
|
|
20427
|
+
enteredAt = value.createdAt;
|
|
20428
|
+
}
|
|
20429
|
+
}
|
|
20430
|
+
const controls = await listPrefix(`${rolloutRoot(channel.appId, promotionId, channel.releaseId)}/controls/`);
|
|
20431
|
+
const parsedControls = [];
|
|
20432
|
+
for (const item of controls) {
|
|
20433
|
+
const value = await readVerifiedObject(item.key, "rollout control");
|
|
20434
|
+
if (!object6(value) || value.format !== 1 || value.promotionId !== promotionId || value.releaseId !== channel.releaseId || typeof value.id !== "string" || value.action !== "pause" && value.action !== "resume" && value.action !== "cancel" || !iso(value.createdAt) || value.resumedPauseIds !== undefined && (!Array.isArray(value.resumedPauseIds) || value.resumedPauseIds.some((id) => typeof id !== "string")))
|
|
20435
|
+
throw new MobileUpdateRegistryError("Stored mobile update rollout control is invalid");
|
|
20436
|
+
parsedControls.push(value);
|
|
20437
|
+
}
|
|
20438
|
+
const cancelled = parsedControls.some(({ action }) => action === "cancel");
|
|
20439
|
+
const resumedPauseIds = new Set(parsedControls.flatMap((control) => control.resumedPauseIds ?? []));
|
|
20440
|
+
const activePauseIds = parsedControls.filter(({ action, id }) => action === "pause" && !resumedPauseIds.has(id)).map(({ id }) => id);
|
|
20441
|
+
const operatorPaused = activePauseIds.length > 0;
|
|
20442
|
+
const fleetPaused = Boolean(await options.store.head(pauseKey(channel.appId, promotionId, channel.releaseId)));
|
|
20443
|
+
return {
|
|
20444
|
+
cancelled,
|
|
20445
|
+
activePauseIds,
|
|
20446
|
+
channel,
|
|
20447
|
+
currentStage,
|
|
20448
|
+
enteredAt,
|
|
20449
|
+
fleetPaused,
|
|
20450
|
+
operatorPaused,
|
|
20451
|
+
plan,
|
|
20452
|
+
promotionId,
|
|
20453
|
+
rollout: plan.stages[currentStage].rollout
|
|
20454
|
+
};
|
|
20455
|
+
};
|
|
20456
|
+
const writeRolloutControl = async (channel, action, signal) => {
|
|
20457
|
+
const context = await rolloutContext(channel);
|
|
20458
|
+
if (!context)
|
|
20459
|
+
throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
|
|
20460
|
+
if (context.cancelled)
|
|
20461
|
+
throw new MobileUpdateRegistryError("Mobile update rollout was already cancelled");
|
|
20462
|
+
if (action === "resume" && context.fleetPaused)
|
|
20463
|
+
throw new MobileUpdateRegistryError("A fleet-health pause requires an explicit re-promotion");
|
|
20464
|
+
const releaseId = channel.releaseId;
|
|
20465
|
+
if (!releaseId)
|
|
20466
|
+
throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
|
|
20467
|
+
const createdAt = clock().toISOString();
|
|
20468
|
+
const id = randomUUID5();
|
|
20469
|
+
const event = {
|
|
20470
|
+
action,
|
|
20471
|
+
createdAt,
|
|
20472
|
+
format: 1,
|
|
20473
|
+
id,
|
|
20474
|
+
promotionId: context.promotionId,
|
|
20475
|
+
releaseId,
|
|
20476
|
+
...action === "resume" ? { resumedPauseIds: context.activePauseIds } : {}
|
|
20477
|
+
};
|
|
20478
|
+
const bytes = json(event);
|
|
20479
|
+
await options.store.put(`${rolloutRoot(channel.appId, context.promotionId, releaseId)}/controls/${createdAt}-${id}-${action}.json`, bytes, {
|
|
20480
|
+
cacheControl: "no-store",
|
|
20481
|
+
contentType: "application/json",
|
|
20482
|
+
maxBytes: bytes.byteLength,
|
|
20483
|
+
metadata: { action, sha256: digest(bytes) },
|
|
20484
|
+
signal
|
|
20485
|
+
});
|
|
20486
|
+
};
|
|
20196
20487
|
const promoteUpdate = async (input) => {
|
|
20197
20488
|
input.signal?.throwIfAborted();
|
|
20198
20489
|
if (input.rollout <= 0 || input.rollout > 1)
|
|
20199
20490
|
throw new MobileUpdateRegistryError("Mobile update rollout is invalid");
|
|
20491
|
+
if (rollout && !rollout.stages.some((stage) => stage.rollout === input.rollout))
|
|
20492
|
+
throw new MobileUpdateRegistryError("Mobile update promotion rollout must match a configured rollout stage");
|
|
20200
20493
|
await assertNotMarked(input.appId, input.releaseId);
|
|
20201
20494
|
const release = await readManifest(input.appId, input.releaseId);
|
|
20202
20495
|
if (!release || release.manifest.channel !== input.channel)
|
|
@@ -20208,7 +20501,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20208
20501
|
...existing?.releaseId && existing.releaseId !== input.releaseId ? { fallbackReleaseId: existing.releaseId } : existing?.fallbackReleaseId ? { fallbackReleaseId: existing.fallbackReleaseId } : {},
|
|
20209
20502
|
releaseId: input.releaseId,
|
|
20210
20503
|
rollout: input.rollout
|
|
20211
|
-
}, input.signal);
|
|
20504
|
+
}, input.signal, (channel) => initializeRollout(channel, input.signal));
|
|
20212
20505
|
return {
|
|
20213
20506
|
appId: input.appId,
|
|
20214
20507
|
channel: input.channel,
|
|
@@ -20221,13 +20514,16 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20221
20514
|
const channel = await readChannel(input.appId, input.channel);
|
|
20222
20515
|
if (!channel?.releaseId)
|
|
20223
20516
|
return { status: "empty" };
|
|
20224
|
-
const
|
|
20517
|
+
const rolloutState = await rolloutContext(channel);
|
|
20518
|
+
let selected = rolloutMember({
|
|
20225
20519
|
appId: input.appId,
|
|
20226
20520
|
channel: input.channel,
|
|
20227
20521
|
installationId: input.installationId,
|
|
20228
20522
|
releaseId: channel.releaseId,
|
|
20229
|
-
rollout: channel.rollout
|
|
20523
|
+
rollout: rolloutState?.rollout ?? channel.rollout
|
|
20230
20524
|
}) ? channel.releaseId : channel.fallbackReleaseId;
|
|
20525
|
+
if (selected === channel.releaseId && (rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || health && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId))))
|
|
20526
|
+
selected = channel.fallbackReleaseId;
|
|
20231
20527
|
if (!selected)
|
|
20232
20528
|
return { status: "empty" };
|
|
20233
20529
|
const release = await readManifest(input.appId, selected);
|
|
@@ -20243,6 +20539,243 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20243
20539
|
status: "selected"
|
|
20244
20540
|
};
|
|
20245
20541
|
};
|
|
20542
|
+
const issueUpdateHealthToken = async (input) => {
|
|
20543
|
+
if (!health)
|
|
20544
|
+
return null;
|
|
20545
|
+
const channel = await readChannel(input.appId, input.channel);
|
|
20546
|
+
if (!channel?.releaseId || channel.releaseId !== input.releaseId)
|
|
20547
|
+
return null;
|
|
20548
|
+
const resolution = await resolveUpdateState(input);
|
|
20549
|
+
if (resolution.status !== "selected" || resolution.manifest.releaseId !== input.releaseId)
|
|
20550
|
+
return null;
|
|
20551
|
+
return signHealthToken({
|
|
20552
|
+
appId: input.appId,
|
|
20553
|
+
channel: input.channel,
|
|
20554
|
+
format: HEALTH_TOKEN_VERSION,
|
|
20555
|
+
installationId: input.installationId,
|
|
20556
|
+
promotionId: healthPromotionId(channel),
|
|
20557
|
+
releaseId: input.releaseId,
|
|
20558
|
+
runtimeFingerprint: input.runtimeFingerprint
|
|
20559
|
+
});
|
|
20560
|
+
};
|
|
20561
|
+
const inspectUpdateHealth = async (input) => {
|
|
20562
|
+
if (!health)
|
|
20563
|
+
throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
|
|
20564
|
+
const channel = await readChannel(input.appId, input.channel);
|
|
20565
|
+
const releaseId = input.releaseId ?? channel?.releaseId;
|
|
20566
|
+
if (!channel || !releaseId || channel.releaseId !== releaseId)
|
|
20567
|
+
return null;
|
|
20568
|
+
const promotionId = healthPromotionId(channel);
|
|
20569
|
+
const objects = await listPrefix(`${healthRoot(input.appId, promotionId, releaseId)}/events/`);
|
|
20570
|
+
const installations = new Set;
|
|
20571
|
+
const byKind = new Map([...HEALTH_KINDS].map((kind) => [
|
|
20572
|
+
kind,
|
|
20573
|
+
new Set
|
|
20574
|
+
]));
|
|
20575
|
+
const transfer = {
|
|
20576
|
+
avoidedBytes: 0,
|
|
20577
|
+
downloadedBytes: 0,
|
|
20578
|
+
durationMs: 0,
|
|
20579
|
+
resumedBytes: 0,
|
|
20580
|
+
reusedBytes: 0,
|
|
20581
|
+
throughputBytesPerSecond: 0
|
|
20582
|
+
};
|
|
20583
|
+
for (const item of objects) {
|
|
20584
|
+
const bytes = await options.store.get(item.key);
|
|
20585
|
+
if (!bytes)
|
|
20586
|
+
continue;
|
|
20587
|
+
const head = await options.store.head(item.key);
|
|
20588
|
+
if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
|
|
20589
|
+
throw new MobileUpdateRegistryError("Stored mobile update health evidence integrity failed");
|
|
20590
|
+
const value = decode2(bytes);
|
|
20591
|
+
if (!object6(value) || typeof value.installationHash !== "string" || !HEALTH_KINDS.has(String(value.kind)))
|
|
20592
|
+
throw new MobileUpdateRegistryError("Stored mobile update health evidence is invalid");
|
|
20593
|
+
const kind = value.kind;
|
|
20594
|
+
installations.add(value.installationHash);
|
|
20595
|
+
byKind.get(kind).add(value.installationHash);
|
|
20596
|
+
if (kind === "downloaded" && object6(value.transfer)) {
|
|
20597
|
+
const parsed = parseHealthTransfer(value.transfer);
|
|
20598
|
+
for (const key of Object.keys(transfer))
|
|
20599
|
+
transfer[key] += parsed[key];
|
|
20600
|
+
}
|
|
20601
|
+
}
|
|
20602
|
+
const failures = new Set([
|
|
20603
|
+
...byKind.get("quarantined"),
|
|
20604
|
+
...byKind.get("rolled-back")
|
|
20605
|
+
]);
|
|
20606
|
+
const terminals = new Set([...byKind.get("activated"), ...failures]);
|
|
20607
|
+
const failureRate = terminals.size === 0 ? 0 : failures.size / terminals.size;
|
|
20608
|
+
const rolloutState = await rolloutContext(channel);
|
|
20609
|
+
return {
|
|
20610
|
+
activated: byKind.get("activated").size,
|
|
20611
|
+
appId: input.appId,
|
|
20612
|
+
channel: input.channel,
|
|
20613
|
+
downloaded: byKind.get("downloaded").size,
|
|
20614
|
+
downloadFailed: byKind.get("download-failed").size,
|
|
20615
|
+
failureRate,
|
|
20616
|
+
failures: failures.size,
|
|
20617
|
+
paused: Boolean(rolloutState?.cancelled || rolloutState?.fleetPaused || rolloutState?.operatorPaused || await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
|
|
20618
|
+
promotionId,
|
|
20619
|
+
quarantined: byKind.get("quarantined").size,
|
|
20620
|
+
releaseId,
|
|
20621
|
+
reportedInstallations: installations.size,
|
|
20622
|
+
rolledBack: byKind.get("rolled-back").size,
|
|
20623
|
+
rollout: rolloutState?.rollout ?? channel.rollout,
|
|
20624
|
+
terminalReports: terminals.size,
|
|
20625
|
+
transfer
|
|
20626
|
+
};
|
|
20627
|
+
};
|
|
20628
|
+
const inspectUpdateRollout = async (input) => {
|
|
20629
|
+
const channel = await readChannel(input.appId, input.channel);
|
|
20630
|
+
if (!channel?.releaseId)
|
|
20631
|
+
return null;
|
|
20632
|
+
const context = await rolloutContext(channel);
|
|
20633
|
+
if (!context)
|
|
20634
|
+
throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
|
|
20635
|
+
const healthReport = await inspectUpdateHealth(input);
|
|
20636
|
+
if (!healthReport)
|
|
20637
|
+
return null;
|
|
20638
|
+
const paused = context.fleetPaused || context.operatorPaused;
|
|
20639
|
+
const complete = context.currentStage === context.plan.stages.length - 1;
|
|
20640
|
+
return {
|
|
20641
|
+
...healthReport,
|
|
20642
|
+
automatic: context.plan.automatic,
|
|
20643
|
+
currentStage: context.currentStage,
|
|
20644
|
+
enteredAt: context.enteredAt,
|
|
20645
|
+
...!complete ? { nextStage: context.plan.stages[context.currentStage + 1] } : {},
|
|
20646
|
+
...context.fleetPaused ? { pausedBy: "fleet-health" } : context.operatorPaused ? { pausedBy: "operator" } : {},
|
|
20647
|
+
status: context.cancelled ? "cancelled" : paused ? "paused" : complete ? "complete" : "active"
|
|
20648
|
+
};
|
|
20649
|
+
};
|
|
20650
|
+
const advanceRollout = async (input, strict) => {
|
|
20651
|
+
input.signal?.throwIfAborted();
|
|
20652
|
+
const channel = await readChannel(input.appId, input.channel);
|
|
20653
|
+
if (!channel?.releaseId)
|
|
20654
|
+
throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
|
|
20655
|
+
const context = await rolloutContext(channel);
|
|
20656
|
+
if (!context)
|
|
20657
|
+
throw new MobileUpdateRegistryError("Mobile update rollout orchestration is not configured");
|
|
20658
|
+
const report = await inspectUpdateRollout(input);
|
|
20659
|
+
if (!report)
|
|
20660
|
+
throw new MobileUpdateRegistryError("Mobile update rollout report is unavailable");
|
|
20661
|
+
const nextStage = context.plan.stages[context.currentStage + 1];
|
|
20662
|
+
if (!nextStage)
|
|
20663
|
+
return report;
|
|
20664
|
+
if (input.rollout !== undefined && input.rollout !== nextStage.rollout)
|
|
20665
|
+
throw new MobileUpdateRegistryError("Mobile update rollout can advance only to the next configured stage");
|
|
20666
|
+
if (report.status !== "active") {
|
|
20667
|
+
if (strict)
|
|
20668
|
+
throw new MobileUpdateRegistryError(`Mobile update rollout cannot advance while ${report.status}`);
|
|
20669
|
+
return report;
|
|
20670
|
+
}
|
|
20671
|
+
const gate = context.plan.stages[context.currentStage];
|
|
20672
|
+
const observedMs = clock().getTime() - Date.parse(context.enteredAt);
|
|
20673
|
+
const blocked = report.terminalReports < gate.minimumReports || report.failureRate > gate.maximumFailureRate || observedMs < gate.observationMs;
|
|
20674
|
+
if (blocked) {
|
|
20675
|
+
if (strict)
|
|
20676
|
+
throw new MobileUpdateRegistryError(`Mobile update rollout needs ${gate.minimumReports} terminal reports, at most ${(gate.maximumFailureRate * 100).toFixed(1)}% failures, and ${gate.observationMs}ms observation at the current stage`);
|
|
20677
|
+
return report;
|
|
20678
|
+
}
|
|
20679
|
+
const stage = context.currentStage + 1;
|
|
20680
|
+
const event = {
|
|
20681
|
+
createdAt: clock().toISOString(),
|
|
20682
|
+
failureRate: report.failureRate,
|
|
20683
|
+
format: 1,
|
|
20684
|
+
promotionId: context.promotionId,
|
|
20685
|
+
releaseId: channel.releaseId,
|
|
20686
|
+
rollout: nextStage.rollout,
|
|
20687
|
+
stage,
|
|
20688
|
+
terminalReports: report.terminalReports
|
|
20689
|
+
};
|
|
20690
|
+
const bytes = json(event);
|
|
20691
|
+
await options.store.put(`${rolloutRoot(input.appId, context.promotionId, channel.releaseId)}/advances/${String(stage).padStart(4, "0")}.json`, bytes, {
|
|
20692
|
+
cacheControl: "no-store",
|
|
20693
|
+
contentType: "application/json",
|
|
20694
|
+
maxBytes: bytes.byteLength,
|
|
20695
|
+
metadata: { releaseid: channel.releaseId, sha256: digest(bytes) },
|
|
20696
|
+
signal: input.signal
|
|
20697
|
+
});
|
|
20698
|
+
return await inspectUpdateRollout(input);
|
|
20699
|
+
};
|
|
20700
|
+
const advanceUpdateRollout = (input) => advanceRollout(input, true);
|
|
20701
|
+
const reconcileUpdateRollout = async (input) => {
|
|
20702
|
+
const report = await inspectUpdateRollout(input);
|
|
20703
|
+
if (!report || !report.automatic)
|
|
20704
|
+
return report;
|
|
20705
|
+
return advanceRollout(input, false);
|
|
20706
|
+
};
|
|
20707
|
+
const rolloutControl = (action) => async (input) => {
|
|
20708
|
+
input.signal?.throwIfAborted();
|
|
20709
|
+
const channel = await readChannel(input.appId, input.channel);
|
|
20710
|
+
if (!channel?.releaseId)
|
|
20711
|
+
throw new MobileUpdateRegistryError("Mobile update channel does not have an active release");
|
|
20712
|
+
await writeRolloutControl(channel, action, input.signal);
|
|
20713
|
+
return await inspectUpdateRollout(input);
|
|
20714
|
+
};
|
|
20715
|
+
const pauseUpdateRollout = rolloutControl("pause");
|
|
20716
|
+
const resumeUpdateRollout = rolloutControl("resume");
|
|
20717
|
+
const cancelUpdateRollout = rolloutControl("cancel");
|
|
20718
|
+
const recordUpdateHealth = async (input) => {
|
|
20719
|
+
const payload = verifyHealthToken(input.token);
|
|
20720
|
+
if (payload.appId !== input.appId || payload.channel !== input.channel || payload.installationId !== input.installationId || payload.releaseId !== input.releaseId || payload.runtimeFingerprint !== input.runtimeFingerprint || !HEALTH_KINDS.has(input.kind) || input.reason !== undefined && input.reason !== "boot-interrupted" && input.reason !== "boot-timeout")
|
|
20721
|
+
throw new MobileUpdateRegistryError("Mobile update health evidence does not match its token");
|
|
20722
|
+
const release = await readManifest(input.appId, input.releaseId);
|
|
20723
|
+
if (!release || release.manifest.runtimeFingerprint !== input.runtimeFingerprint)
|
|
20724
|
+
throw new MobileUpdateRegistryError("Mobile update health release is invalid");
|
|
20725
|
+
const activeChannel = await readChannel(input.appId, input.channel);
|
|
20726
|
+
if (!activeChannel || activeChannel.releaseId !== input.releaseId || healthPromotionId(activeChannel) !== payload.promotionId)
|
|
20727
|
+
throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
|
|
20728
|
+
const transfer = parseHealthTransfer(input.transfer);
|
|
20729
|
+
const installationHash = createHmac("sha256", health.secret).update(input.installationId).digest("hex");
|
|
20730
|
+
const evidence = {
|
|
20731
|
+
format: 1,
|
|
20732
|
+
installationHash,
|
|
20733
|
+
kind: input.kind,
|
|
20734
|
+
observedAt: clock().toISOString(),
|
|
20735
|
+
...input.reason ? { reason: input.reason } : {},
|
|
20736
|
+
...transfer ? { transfer } : {}
|
|
20737
|
+
};
|
|
20738
|
+
const bytes = json(evidence);
|
|
20739
|
+
await options.store.put(`${healthRoot(input.appId, payload.promotionId, input.releaseId)}/events/${installationHash}/${input.kind}.json`, bytes, {
|
|
20740
|
+
cacheControl: "no-store",
|
|
20741
|
+
contentType: "application/json",
|
|
20742
|
+
maxBytes: bytes.byteLength,
|
|
20743
|
+
metadata: { kind: input.kind, sha256: digest(bytes) }
|
|
20744
|
+
});
|
|
20745
|
+
let report = await inspectUpdateHealth({
|
|
20746
|
+
appId: input.appId,
|
|
20747
|
+
channel: input.channel,
|
|
20748
|
+
releaseId: input.releaseId
|
|
20749
|
+
});
|
|
20750
|
+
if (!report)
|
|
20751
|
+
throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
|
|
20752
|
+
if (FAILURE_HEALTH_KINDS.has(input.kind) && report.terminalReports >= minimumReports && report.failureRate >= failureThreshold && !report.paused) {
|
|
20753
|
+
const marker = json({
|
|
20754
|
+
appId: input.appId,
|
|
20755
|
+
channel: input.channel,
|
|
20756
|
+
failureRate: report.failureRate,
|
|
20757
|
+
failures: report.failures,
|
|
20758
|
+
format: 1,
|
|
20759
|
+
pausedAt: clock().toISOString(),
|
|
20760
|
+
promotionId: payload.promotionId,
|
|
20761
|
+
releaseId: input.releaseId,
|
|
20762
|
+
reports: report.terminalReports
|
|
20763
|
+
});
|
|
20764
|
+
await options.store.put(pauseKey(input.appId, payload.promotionId, input.releaseId), marker, {
|
|
20765
|
+
cacheControl: "no-store",
|
|
20766
|
+
contentType: "application/json",
|
|
20767
|
+
maxBytes: marker.byteLength,
|
|
20768
|
+
metadata: { releaseid: input.releaseId, sha256: digest(marker) }
|
|
20769
|
+
});
|
|
20770
|
+
report = { ...report, paused: true };
|
|
20771
|
+
}
|
|
20772
|
+
if (rollout && (input.kind === "activated" || FAILURE_HEALTH_KINDS.has(input.kind)))
|
|
20773
|
+
return await reconcileUpdateRollout({
|
|
20774
|
+
appId: input.appId,
|
|
20775
|
+
channel: input.channel
|
|
20776
|
+
}) ?? report;
|
|
20777
|
+
return report;
|
|
20778
|
+
};
|
|
20246
20779
|
const retentionValues = (input) => {
|
|
20247
20780
|
if (!APP_ID.test(input.appId))
|
|
20248
20781
|
throw new MobileUpdateRegistryError("Mobile update appId is invalid");
|
|
@@ -20485,6 +21018,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20485
21018
|
return result;
|
|
20486
21019
|
};
|
|
20487
21020
|
return {
|
|
21021
|
+
...health ? { inspectUpdateHealth, issueUpdateHealthToken, recordUpdateHealth } : {},
|
|
21022
|
+
...rollout ? {
|
|
21023
|
+
advanceUpdateRollout,
|
|
21024
|
+
cancelUpdateRollout,
|
|
21025
|
+
inspectUpdateRollout,
|
|
21026
|
+
pauseUpdateRollout,
|
|
21027
|
+
reconcileUpdateRollout,
|
|
21028
|
+
resumeUpdateRollout
|
|
21029
|
+
} : {},
|
|
20488
21030
|
inspectUpdateStorage: async (input) => (await inventory(input)).report,
|
|
20489
21031
|
pruneUpdates,
|
|
20490
21032
|
publishUpdate: async (input) => {
|
|
@@ -20762,7 +21304,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20762
21304
|
const origin = request.headers.get("origin");
|
|
20763
21305
|
const cors = origin && allowedOrigins.has(origin) ? {
|
|
20764
21306
|
"access-control-allow-origin": origin,
|
|
20765
|
-
"access-control-expose-headers": "content-range,etag",
|
|
21307
|
+
"access-control-expose-headers": "content-range,etag,x-absolute-mobile-health-token",
|
|
20766
21308
|
vary: "Origin"
|
|
20767
21309
|
} : {};
|
|
20768
21310
|
if (request.method === "OPTIONS") {
|
|
@@ -20771,17 +21313,58 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20771
21313
|
return new Response(null, {
|
|
20772
21314
|
headers: {
|
|
20773
21315
|
...cors,
|
|
20774
|
-
"access-control-allow-headers": "if-range,range,x-absolute-mobile-app,x-absolute-mobile-channel,x-absolute-mobile-installation,x-absolute-mobile-release,x-absolute-mobile-runtime",
|
|
20775
|
-
"access-control-allow-methods": "GET,OPTIONS",
|
|
21316
|
+
"access-control-allow-headers": "content-type,if-range,range,x-absolute-mobile-app,x-absolute-mobile-channel,x-absolute-mobile-health-token,x-absolute-mobile-installation,x-absolute-mobile-release,x-absolute-mobile-runtime",
|
|
21317
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
20776
21318
|
"access-control-max-age": "600"
|
|
20777
21319
|
},
|
|
20778
21320
|
status: 204
|
|
20779
21321
|
});
|
|
20780
21322
|
}
|
|
20781
|
-
if (request.method !== "GET")
|
|
20782
|
-
return new Response(null, { status: 405 });
|
|
20783
21323
|
const pathname = new URL(request.url).pathname.replace(/^\/+/, "");
|
|
20784
21324
|
const relative17 = pathname.startsWith(`${route}/`) ? pathname.slice(route.length + 1) : "";
|
|
21325
|
+
if (request.method === "POST" && relative17 === "health") {
|
|
21326
|
+
if (!options.registry.recordUpdateHealth)
|
|
21327
|
+
return new Response(null, { status: 404 });
|
|
21328
|
+
const appId = request.headers.get("x-absolute-mobile-app");
|
|
21329
|
+
const channel = request.headers.get("x-absolute-mobile-channel");
|
|
21330
|
+
const installationId = request.headers.get("x-absolute-mobile-installation");
|
|
21331
|
+
const runtimeFingerprint = request.headers.get("x-absolute-mobile-runtime");
|
|
21332
|
+
const token = request.headers.get("x-absolute-mobile-health-token");
|
|
21333
|
+
const declared = Number(request.headers.get("content-length"));
|
|
21334
|
+
if (appId !== options.appId || channel !== options.channel || !installationId || !runtimeFingerprint || !token || Number.isFinite(declared) && declared > 4096)
|
|
21335
|
+
return new Response(null, { status: 400 });
|
|
21336
|
+
const bodyBytes = new Uint8Array(await request.arrayBuffer());
|
|
21337
|
+
if (bodyBytes.byteLength > 4096)
|
|
21338
|
+
return new Response(null, { status: 413 });
|
|
21339
|
+
let body;
|
|
21340
|
+
try {
|
|
21341
|
+
body = JSON.parse(new TextDecoder().decode(bodyBytes));
|
|
21342
|
+
} catch {
|
|
21343
|
+
return new Response(null, { status: 400 });
|
|
21344
|
+
}
|
|
21345
|
+
if (!object6(body) || typeof body.releaseId !== "string" || typeof body.kind !== "string")
|
|
21346
|
+
return new Response(null, { status: 400 });
|
|
21347
|
+
try {
|
|
21348
|
+
const report = await options.registry.recordUpdateHealth({
|
|
21349
|
+
appId,
|
|
21350
|
+
channel,
|
|
21351
|
+
installationId,
|
|
21352
|
+
kind: body.kind,
|
|
21353
|
+
...body.reason === "boot-interrupted" || body.reason === "boot-timeout" ? { reason: body.reason } : {},
|
|
21354
|
+
releaseId: body.releaseId,
|
|
21355
|
+
runtimeFingerprint,
|
|
21356
|
+
token,
|
|
21357
|
+
...object6(body.transfer) ? { transfer: body.transfer } : {}
|
|
21358
|
+
});
|
|
21359
|
+
return Response.json({ paused: report.paused }, { headers: { ...cors, "cache-control": "no-store" }, status: 202 });
|
|
21360
|
+
} catch (error) {
|
|
21361
|
+
if (error instanceof MobileUpdateRegistryError)
|
|
21362
|
+
return new Response(null, { status: 403 });
|
|
21363
|
+
throw error;
|
|
21364
|
+
}
|
|
21365
|
+
}
|
|
21366
|
+
if (request.method !== "GET")
|
|
21367
|
+
return new Response(null, { status: 405 });
|
|
20785
21368
|
if (relative17 === "update.json") {
|
|
20786
21369
|
const expoProtocolVersion = request.headers.get("expo-protocol-version");
|
|
20787
21370
|
const expoProtocol = expoProtocolVersion !== null;
|
|
@@ -20807,6 +21390,13 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20807
21390
|
installationId,
|
|
20808
21391
|
runtimeFingerprint
|
|
20809
21392
|
});
|
|
21393
|
+
const healthToken = selected && options.registry.issueUpdateHealthToken ? await options.registry.issueUpdateHealthToken({
|
|
21394
|
+
appId,
|
|
21395
|
+
channel,
|
|
21396
|
+
installationId,
|
|
21397
|
+
releaseId: selected.manifest.releaseId,
|
|
21398
|
+
runtimeFingerprint
|
|
21399
|
+
}) : null;
|
|
20810
21400
|
if (expoProtocol) {
|
|
20811
21401
|
let requestedCodeSigning;
|
|
20812
21402
|
try {
|
|
@@ -20858,6 +21448,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20858
21448
|
extra: {
|
|
20859
21449
|
absolutejs: {
|
|
20860
21450
|
channel: selected.manifest.channel,
|
|
21451
|
+
...healthToken ? { healthToken } : {},
|
|
20861
21452
|
releaseId: selected.manifest.releaseId
|
|
20862
21453
|
},
|
|
20863
21454
|
expoClient: descriptor.expoConfig
|
|
@@ -20885,7 +21476,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
|
|
|
20885
21476
|
headers: {
|
|
20886
21477
|
...cors,
|
|
20887
21478
|
"cache-control": "no-store",
|
|
20888
|
-
etag: `"${selected.manifest.releaseId}"
|
|
21479
|
+
etag: `"${selected.manifest.releaseId}"`,
|
|
21480
|
+
...healthToken ? { "x-absolute-mobile-health-token": healthToken } : {}
|
|
20889
21481
|
}
|
|
20890
21482
|
});
|
|
20891
21483
|
}
|
|
@@ -20948,6 +21540,14 @@ var init_mobileUpdate = __esm(() => {
|
|
|
20948
21540
|
RELEASE = /^amu_[a-f0-9]{64}$/;
|
|
20949
21541
|
APP_ID = /^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/;
|
|
20950
21542
|
NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
21543
|
+
HEALTH_KINDS = new Set([
|
|
21544
|
+
"activated",
|
|
21545
|
+
"downloaded",
|
|
21546
|
+
"download-failed",
|
|
21547
|
+
"quarantined",
|
|
21548
|
+
"rolled-back"
|
|
21549
|
+
]);
|
|
21550
|
+
FAILURE_HEALTH_KINDS = new Set(["quarantined", "rolled-back"]);
|
|
20951
21551
|
MobileUpdateRegistryError = class MobileUpdateRegistryError extends Error {
|
|
20952
21552
|
};
|
|
20953
21553
|
expoProtocolHeaders = {
|
|
@@ -21035,6 +21635,16 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
|
|
|
21035
21635
|
} catch (error) {
|
|
21036
21636
|
throw new TypeError(`Durable mobile update storage verification failed for ${module.metadata.provider}. Check the bucket, endpoint, credentials, and read/write/delete permissions.`, { cause: error });
|
|
21037
21637
|
}
|
|
21638
|
+
}, verifyHealthModule = (config, module) => {
|
|
21639
|
+
if (!config.updateServer?.health)
|
|
21640
|
+
return;
|
|
21641
|
+
if (typeof module.registry.inspectUpdateHealth !== "function" || typeof module.registry.issueUpdateHealthToken !== "function" || typeof module.registry.recordUpdateHealth !== "function")
|
|
21642
|
+
throw new TypeError("Mobile update fleet health is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
|
|
21643
|
+
}, verifyRolloutModule = (config, module) => {
|
|
21644
|
+
if (!config.updateServer?.rollout)
|
|
21645
|
+
return;
|
|
21646
|
+
if (typeof module.registry.advanceUpdateRollout !== "function" || typeof module.registry.cancelUpdateRollout !== "function" || typeof module.registry.inspectUpdateRollout !== "function" || typeof module.registry.pauseUpdateRollout !== "function" || typeof module.registry.reconcileUpdateRollout !== "function" || typeof module.registry.resumeUpdateRollout !== "function")
|
|
21647
|
+
throw new TypeError("Mobile update rollout orchestration is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
|
|
21038
21648
|
}, expoSigningOptions = (config) => {
|
|
21039
21649
|
if (!config.updates?.expoCodeSigning)
|
|
21040
21650
|
return;
|
|
@@ -21066,8 +21676,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
|
|
|
21066
21676
|
if (!updates || !server?.autoMount)
|
|
21067
21677
|
return new Elysia4({ name: "absolutejs-mobile-updates-disabled" });
|
|
21068
21678
|
const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, server.registryModule);
|
|
21069
|
-
if (options.production)
|
|
21679
|
+
if (options.production) {
|
|
21070
21680
|
await verifyDurableModule(module);
|
|
21681
|
+
verifyHealthModule(config, module);
|
|
21682
|
+
verifyRolloutModule(config, module);
|
|
21683
|
+
}
|
|
21071
21684
|
const manifest = new URL(updates.manifestUrl);
|
|
21072
21685
|
if (!manifest.pathname.endsWith("/update.json"))
|
|
21073
21686
|
throw new TypeError("Auto-mounted mobile update manifests must end in /update.json.");
|
|
@@ -21086,10 +21699,12 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
|
|
|
21086
21699
|
return;
|
|
21087
21700
|
const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
|
|
21088
21701
|
await verifyDurableModule(module);
|
|
21702
|
+
verifyHealthModule(config, module);
|
|
21703
|
+
verifyRolloutModule(config, module);
|
|
21089
21704
|
if (config.engine === "expo")
|
|
21090
21705
|
expoSigningOptions(config);
|
|
21091
21706
|
return module.metadata;
|
|
21092
|
-
}, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"),
|
|
21707
|
+
}, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistryBase = (options) => {
|
|
21093
21708
|
const metadata = `export const absoluteMobileUpdateServer = {
|
|
21094
21709
|
format: 1,
|
|
21095
21710
|
provider: '${options.storage}',
|
|
@@ -21155,6 +21770,27 @@ export default createMobileUpdateRegistry({
|
|
|
21155
21770
|
store
|
|
21156
21771
|
});
|
|
21157
21772
|
`;
|
|
21773
|
+
}, renderAbsoluteMobileUpdateRegistry = (options) => {
|
|
21774
|
+
const source = renderAbsoluteMobileUpdateRegistryBase(options);
|
|
21775
|
+
let generated = "";
|
|
21776
|
+
if (options.health) {
|
|
21777
|
+
const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
|
|
21778
|
+
generated += ` health: {
|
|
21779
|
+
autoPause: { failureRate: ${options.health.failureRate}, minimumReports: ${options.health.minimumReports} },
|
|
21780
|
+
secret: ${secret}
|
|
21781
|
+
},
|
|
21782
|
+
`;
|
|
21783
|
+
}
|
|
21784
|
+
if (options.rollout)
|
|
21785
|
+
generated += ` rollout: ${JSON.stringify(options.rollout, null, "\t").replaceAll(`
|
|
21786
|
+
`, `
|
|
21787
|
+
`)},
|
|
21788
|
+
`;
|
|
21789
|
+
if (!generated)
|
|
21790
|
+
return source;
|
|
21791
|
+
return source.replace(`export default createMobileUpdateRegistry({
|
|
21792
|
+
`, `export default createMobileUpdateRegistry({
|
|
21793
|
+
${generated}`);
|
|
21158
21794
|
}, writeAbsoluteMobileUpdateRegistry = async (options) => {
|
|
21159
21795
|
const path2 = projectPath3(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
|
|
21160
21796
|
if (!options.force) {
|
|
@@ -21166,6 +21802,8 @@ export default createMobileUpdateRegistry({
|
|
|
21166
21802
|
}
|
|
21167
21803
|
await mkdir18(dirname19(path2), { recursive: true });
|
|
21168
21804
|
await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
|
|
21805
|
+
...options.health ? { health: options.health } : {},
|
|
21806
|
+
...options.rollout ? { rollout: options.rollout } : {},
|
|
21169
21807
|
publicKeys: options.publicKeys,
|
|
21170
21808
|
storage: options.storage
|
|
21171
21809
|
}));
|
|
@@ -27832,11 +28470,48 @@ export default function AbsoluteLayout() {
|
|
|
27832
28470
|
return <Stack screenOptions={{ headerShown: false }} />;
|
|
27833
28471
|
}
|
|
27834
28472
|
`;
|
|
27835
|
-
var updatesRuntimeSource = () => `${EXPO_GENERATED_HEADER}import { randomUUID } from 'expo-crypto';
|
|
28473
|
+
var updatesRuntimeSource = (config) => `${EXPO_GENERATED_HEADER}import { randomUUID } from 'expo-crypto';
|
|
27836
28474
|
import * as SecureStore from 'expo-secure-store';
|
|
27837
28475
|
import * as Updates from 'expo-updates';
|
|
27838
28476
|
|
|
27839
28477
|
const INSTALLATION_KEY = 'absolutejs.mobile.update.installation.v1';
|
|
28478
|
+
const PENDING_HEALTH_KEY = 'absolutejs.mobile.update.pending-health.v1';
|
|
28479
|
+
const APP_ID = ${JSON.stringify(config.appId)};
|
|
28480
|
+
const CHANNEL = ${JSON.stringify(config.updates?.channel)};
|
|
28481
|
+
const MANIFEST_URL = ${JSON.stringify(config.updates?.manifestUrl)};
|
|
28482
|
+
|
|
28483
|
+
const updateIdentity = (value: unknown) => {
|
|
28484
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
28485
|
+
const extra = Reflect.get(value, 'extra');
|
|
28486
|
+
if (!extra || typeof extra !== 'object') return undefined;
|
|
28487
|
+
const absolute = Reflect.get(extra, 'absolutejs');
|
|
28488
|
+
if (!absolute || typeof absolute !== 'object') return undefined;
|
|
28489
|
+
const healthToken = Reflect.get(absolute, 'healthToken');
|
|
28490
|
+
const releaseId = Reflect.get(absolute, 'releaseId');
|
|
28491
|
+
return typeof healthToken === 'string' && typeof releaseId === 'string'
|
|
28492
|
+
? { healthToken, releaseId }
|
|
28493
|
+
: undefined;
|
|
28494
|
+
};
|
|
28495
|
+
|
|
28496
|
+
const report = async (installationId: string, evidence: { healthToken: string; kind: string; releaseId: string }) => {
|
|
28497
|
+
const endpoint = new URL('./health', MANIFEST_URL);
|
|
28498
|
+
await fetch(endpoint.href, {
|
|
28499
|
+
body: JSON.stringify({ kind: evidence.kind, releaseId: evidence.releaseId }),
|
|
28500
|
+
cache: 'no-store',
|
|
28501
|
+
credentials: 'omit',
|
|
28502
|
+
headers: {
|
|
28503
|
+
'content-type': 'application/json',
|
|
28504
|
+
'x-absolute-mobile-app': APP_ID,
|
|
28505
|
+
'x-absolute-mobile-channel': CHANNEL,
|
|
28506
|
+
'x-absolute-mobile-health-token': evidence.healthToken,
|
|
28507
|
+
'x-absolute-mobile-installation': installationId,
|
|
28508
|
+
'x-absolute-mobile-release': updateIdentity(Updates.manifest)?.releaseId ?? 'embedded',
|
|
28509
|
+
'x-absolute-mobile-runtime': Updates.runtimeVersion ?? ''
|
|
28510
|
+
},
|
|
28511
|
+
method: 'POST',
|
|
28512
|
+
redirect: 'error'
|
|
28513
|
+
});
|
|
28514
|
+
};
|
|
27840
28515
|
|
|
27841
28516
|
let startPromise: Promise<void> | undefined;
|
|
27842
28517
|
export const startAbsoluteExpoUpdates = () => {
|
|
@@ -27848,9 +28523,34 @@ export const startAbsoluteExpoUpdates = () => {
|
|
|
27848
28523
|
await SecureStore.setItemAsync(INSTALLATION_KEY, installationId);
|
|
27849
28524
|
}
|
|
27850
28525
|
await Updates.setExtraParamAsync('absolute-installation', installationId);
|
|
28526
|
+
const pendingSource = await SecureStore.getItemAsync(PENDING_HEALTH_KEY);
|
|
28527
|
+
if (pendingSource) {
|
|
28528
|
+
try {
|
|
28529
|
+
const pending = JSON.parse(pendingSource);
|
|
28530
|
+
const active = updateIdentity(Updates.manifest);
|
|
28531
|
+
if (typeof pending?.healthToken === 'string' && typeof pending?.releaseId === 'string')
|
|
28532
|
+
await report(installationId, {
|
|
28533
|
+
healthToken: pending.healthToken,
|
|
28534
|
+
kind: active?.releaseId === pending.releaseId ? 'activated' : 'rolled-back',
|
|
28535
|
+
releaseId: pending.releaseId
|
|
28536
|
+
});
|
|
28537
|
+
} catch {}
|
|
28538
|
+
await SecureStore.deleteItemAsync(PENDING_HEALTH_KEY);
|
|
28539
|
+
}
|
|
27851
28540
|
const result = await Updates.checkForUpdateAsync();
|
|
27852
28541
|
if (result.isAvailable || result.isRollBackToEmbedded) {
|
|
27853
|
-
|
|
28542
|
+
const available = updateIdentity(Reflect.get(result, 'manifest'));
|
|
28543
|
+
try {
|
|
28544
|
+
const fetched = await Updates.fetchUpdateAsync();
|
|
28545
|
+
const identity = updateIdentity(Reflect.get(fetched, 'manifest')) ?? available;
|
|
28546
|
+
if (identity) {
|
|
28547
|
+
await SecureStore.setItemAsync(PENDING_HEALTH_KEY, JSON.stringify(identity));
|
|
28548
|
+
await report(installationId, { ...identity, kind: 'downloaded' });
|
|
28549
|
+
}
|
|
28550
|
+
} catch (error) {
|
|
28551
|
+
if (available) await report(installationId, { ...available, kind: 'download-failed' }).catch(() => undefined);
|
|
28552
|
+
throw error;
|
|
28553
|
+
}
|
|
27854
28554
|
if (result.isRollBackToEmbedded) {
|
|
27855
28555
|
await Updates.reloadAsync();
|
|
27856
28556
|
}
|
|
@@ -28795,7 +29495,7 @@ node_modules/
|
|
|
28795
29495
|
files.set(path, source);
|
|
28796
29496
|
}
|
|
28797
29497
|
if (config.updates) {
|
|
28798
|
-
files.set(join15(project, "src", "generated", "AbsoluteUpdates.ts"), updatesRuntimeSource());
|
|
29498
|
+
files.set(join15(project, "src", "generated", "AbsoluteUpdates.ts"), updatesRuntimeSource(config));
|
|
28799
29499
|
}
|
|
28800
29500
|
const expoCodeSigning = config.updates?.expoCodeSigning;
|
|
28801
29501
|
if (expoCodeSigning)
|
|
@@ -38780,6 +39480,30 @@ var requestHeaders = (config) => ({
|
|
|
38780
39480
|
"x-absolute-mobile-release": config.currentReleaseId,
|
|
38781
39481
|
"x-absolute-mobile-runtime": config.runtimeFingerprint
|
|
38782
39482
|
});
|
|
39483
|
+
var healthUrl = (manifestUrl) => new URL("./health", manifestUrl);
|
|
39484
|
+
var reportAbsoluteMobileUpdateHealth = async (config, input, request = globalThis.fetch) => {
|
|
39485
|
+
const manifestUrl = exactManifestUrl(config.manifestUrl);
|
|
39486
|
+
const headers = new Headers(requestHeaders(config));
|
|
39487
|
+
headers.set("content-type", "application/json");
|
|
39488
|
+
headers.set("x-absolute-mobile-health-token", input.healthToken);
|
|
39489
|
+
const response = await request(healthUrl(manifestUrl), {
|
|
39490
|
+
body: JSON.stringify({
|
|
39491
|
+
kind: input.kind,
|
|
39492
|
+
...input.reason ? { reason: input.reason } : {},
|
|
39493
|
+
releaseId: input.releaseId,
|
|
39494
|
+
...input.transfer ? { transfer: input.transfer } : {}
|
|
39495
|
+
}),
|
|
39496
|
+
cache: "no-store",
|
|
39497
|
+
credentials: "omit",
|
|
39498
|
+
headers,
|
|
39499
|
+
keepalive: true,
|
|
39500
|
+
method: "POST",
|
|
39501
|
+
redirect: "error",
|
|
39502
|
+
signal: AbortSignal.timeout(15000)
|
|
39503
|
+
});
|
|
39504
|
+
if (response.status !== 202)
|
|
39505
|
+
throw new TypeError(`Mobile update health report failed with HTTP ${response.status}.`);
|
|
39506
|
+
};
|
|
38783
39507
|
var requireCompatible = (manifest, config) => {
|
|
38784
39508
|
if (manifest.appId !== config.appId)
|
|
38785
39509
|
throw new TypeError("Mobile update belongs to another app.");
|
|
@@ -38975,15 +39699,24 @@ var createAbsoluteMobileUpdateClient = (options) => {
|
|
|
38975
39699
|
throw new TypeError("Mobile update manifest is not valid JSON.");
|
|
38976
39700
|
}
|
|
38977
39701
|
const manifest = parseAbsoluteMobileUpdateManifest(manifestValue);
|
|
39702
|
+
const healthToken = response.headers.get("x-absolute-mobile-health-token");
|
|
38978
39703
|
requireCompatible(manifest, options.config);
|
|
38979
39704
|
if (!await options.verifier.verify(manifest))
|
|
38980
39705
|
throw new TypeError("Mobile update signature verification failed.");
|
|
38981
39706
|
if (manifest.releaseId === options.config.currentReleaseId)
|
|
38982
39707
|
return { kind: "current" };
|
|
38983
39708
|
if (options.config.blockedReleaseIds?.includes(manifest.releaseId))
|
|
38984
|
-
return {
|
|
39709
|
+
return {
|
|
39710
|
+
...healthToken ? { healthToken } : {},
|
|
39711
|
+
kind: "quarantined",
|
|
39712
|
+
releaseId: manifest.releaseId
|
|
39713
|
+
};
|
|
38985
39714
|
if (!download)
|
|
38986
|
-
return {
|
|
39715
|
+
return {
|
|
39716
|
+
...healthToken ? { healthToken } : {},
|
|
39717
|
+
kind: "update-available",
|
|
39718
|
+
manifest
|
|
39719
|
+
};
|
|
38987
39720
|
await options.store.begin(manifest);
|
|
38988
39721
|
let transfer;
|
|
38989
39722
|
try {
|
|
@@ -38994,14 +39727,28 @@ var createAbsoluteMobileUpdateClient = (options) => {
|
|
|
38994
39727
|
await options.store.suspend(manifest.releaseId);
|
|
38995
39728
|
else
|
|
38996
39729
|
await options.store.abort(manifest.releaseId);
|
|
39730
|
+
if (healthToken)
|
|
39731
|
+
reportAbsoluteMobileUpdateHealth(options.config, {
|
|
39732
|
+
healthToken,
|
|
39733
|
+
kind: "download-failed",
|
|
39734
|
+
releaseId: manifest.releaseId
|
|
39735
|
+
}, request).catch(() => {
|
|
39736
|
+
return;
|
|
39737
|
+
});
|
|
38997
39738
|
throw error;
|
|
38998
39739
|
}
|
|
38999
|
-
return {
|
|
39740
|
+
return {
|
|
39741
|
+
...healthToken ? { healthToken } : {},
|
|
39742
|
+
kind: "downloaded",
|
|
39743
|
+
manifest,
|
|
39744
|
+
transfer
|
|
39745
|
+
};
|
|
39000
39746
|
};
|
|
39001
39747
|
return {
|
|
39002
39748
|
check,
|
|
39003
39749
|
activate: (releaseId) => options.store.activate(releaseId),
|
|
39004
|
-
download: () => check(true)
|
|
39750
|
+
download: () => check(true),
|
|
39751
|
+
report: (input) => reportAbsoluteMobileUpdateHealth(options.config, input, request)
|
|
39005
39752
|
};
|
|
39006
39753
|
};
|
|
39007
39754
|
// src/mobile/updatePublisher.ts
|
|
@@ -39124,6 +39871,66 @@ var lifecycleMethod = (publisher, name) => {
|
|
|
39124
39871
|
throw new TypeError(`Mobile update registry does not support ${name}. Re-run \`absolute mobile update provision --force\` after upgrading @absolutejs/deploy.`);
|
|
39125
39872
|
return method;
|
|
39126
39873
|
};
|
|
39874
|
+
var validateHealthReport = (report, options) => {
|
|
39875
|
+
if (!object5(report) || report.appId !== options.appId || report.channel !== options.channel || options.releaseId !== undefined && report.releaseId !== options.releaseId || typeof report.paused !== "boolean" || ![
|
|
39876
|
+
report.activated,
|
|
39877
|
+
report.downloaded,
|
|
39878
|
+
report.downloadFailed,
|
|
39879
|
+
report.failures,
|
|
39880
|
+
report.quarantined,
|
|
39881
|
+
report.reportedInstallations,
|
|
39882
|
+
report.rolledBack,
|
|
39883
|
+
report.terminalReports
|
|
39884
|
+
].every((value) => Number.isSafeInteger(value) && value >= 0) || !Number.isFinite(report.failureRate) || report.failureRate < 0 || report.failureRate > 1 || !Number.isFinite(report.rollout) || report.rollout < 0 || report.rollout > 1 || !object5(report.transfer) || ![
|
|
39885
|
+
report.transfer.avoidedBytes,
|
|
39886
|
+
report.transfer.downloadedBytes,
|
|
39887
|
+
report.transfer.durationMs,
|
|
39888
|
+
report.transfer.resumedBytes,
|
|
39889
|
+
report.transfer.reusedBytes,
|
|
39890
|
+
report.transfer.throughputBytesPerSecond
|
|
39891
|
+
].every((value) => Number.isFinite(value) && value >= 0))
|
|
39892
|
+
throw new TypeError("Mobile update registry returned an invalid health report.");
|
|
39893
|
+
return report;
|
|
39894
|
+
};
|
|
39895
|
+
var inspectAbsoluteMobileUpdateHealth = async (options) => {
|
|
39896
|
+
const report = await lifecycleMethod(options.publisher, "inspectUpdateHealth")({
|
|
39897
|
+
appId: options.appId,
|
|
39898
|
+
channel: options.channel,
|
|
39899
|
+
...options.releaseId ? { releaseId: options.releaseId } : {}
|
|
39900
|
+
});
|
|
39901
|
+
if (report === null)
|
|
39902
|
+
return null;
|
|
39903
|
+
return validateHealthReport(report, options);
|
|
39904
|
+
};
|
|
39905
|
+
var validateRolloutReport = (report, options) => {
|
|
39906
|
+
validateHealthReport(report, options);
|
|
39907
|
+
const validStage = (stage) => stage === undefined || object5(stage) && Number.isFinite(stage.rollout) && stage.rollout > 0 && stage.rollout <= 1 && Number.isSafeInteger(stage.minimumReports) && stage.minimumReports > 0 && Number.isSafeInteger(stage.observationMs) && stage.observationMs >= 0 && Number.isFinite(stage.maximumFailureRate) && stage.maximumFailureRate >= 0 && stage.maximumFailureRate < 1;
|
|
39908
|
+
if (typeof report.automatic !== "boolean" || !Number.isSafeInteger(report.currentStage) || report.currentStage < 0 || !Number.isFinite(Date.parse(report.enteredAt)) || !["active", "cancelled", "complete", "paused"].includes(report.status) || report.pausedBy !== undefined && report.pausedBy !== "fleet-health" && report.pausedBy !== "operator" || !validStage(report.nextStage) || report.paused !== (report.status === "paused" || report.status === "cancelled"))
|
|
39909
|
+
throw new TypeError("Mobile update registry returned an invalid rollout report.");
|
|
39910
|
+
return report;
|
|
39911
|
+
};
|
|
39912
|
+
var inspectAbsoluteMobileUpdateRollout = async (options) => {
|
|
39913
|
+
const report = await lifecycleMethod(options.publisher, "inspectUpdateRollout")({ appId: options.appId, channel: options.channel });
|
|
39914
|
+
return report === null ? null : validateRolloutReport(report, options);
|
|
39915
|
+
};
|
|
39916
|
+
var mutateAbsoluteMobileUpdateRollout = async (method, options) => validateRolloutReport(await lifecycleMethod(options.publisher, method)({
|
|
39917
|
+
appId: options.appId,
|
|
39918
|
+
channel: options.channel,
|
|
39919
|
+
...method === "advanceUpdateRollout" && options.rollout !== undefined ? { rollout: options.rollout } : {},
|
|
39920
|
+
...options.signal ? { signal: options.signal } : {}
|
|
39921
|
+
}), options);
|
|
39922
|
+
var advanceAbsoluteMobileUpdateRollout = (options) => mutateAbsoluteMobileUpdateRollout("advanceUpdateRollout", options);
|
|
39923
|
+
var cancelAbsoluteMobileUpdateRollout = (options) => mutateAbsoluteMobileUpdateRollout("cancelUpdateRollout", options);
|
|
39924
|
+
var pauseAbsoluteMobileUpdateRollout = (options) => mutateAbsoluteMobileUpdateRollout("pauseUpdateRollout", options);
|
|
39925
|
+
var reconcileAbsoluteMobileUpdateRollout = async (options) => {
|
|
39926
|
+
const report = await lifecycleMethod(options.publisher, "reconcileUpdateRollout")({
|
|
39927
|
+
appId: options.appId,
|
|
39928
|
+
channel: options.channel,
|
|
39929
|
+
...options.signal ? { signal: options.signal } : {}
|
|
39930
|
+
});
|
|
39931
|
+
return report === null ? null : validateRolloutReport(report, options);
|
|
39932
|
+
};
|
|
39933
|
+
var resumeAbsoluteMobileUpdateRollout = (options) => mutateAbsoluteMobileUpdateRollout("resumeUpdateRollout", options);
|
|
39127
39934
|
var validOptionalCounters = (values) => values.every((value) => value === undefined) || values.every((value) => Number.isSafeInteger(value) && (value ?? -1) >= 0);
|
|
39128
39935
|
var validateStorageIdentity = (result, appId) => {
|
|
39129
39936
|
if (!object5(result) || result.appId !== appId || !Array.isArray(result.releases) || ![
|
|
@@ -39324,6 +40131,7 @@ export {
|
|
|
39324
40131
|
acceptsAbsoluteRouteData,
|
|
39325
40132
|
acquireAbsoluteRemoteMacReleaseLease,
|
|
39326
40133
|
activateAbsoluteMobilePage,
|
|
40134
|
+
advanceAbsoluteMobileUpdateRollout,
|
|
39327
40135
|
applyAbsoluteNativeDeepLinks,
|
|
39328
40136
|
applyAbsoluteNativeDeviceCapabilities,
|
|
39329
40137
|
applyAbsoluteNativeObservability,
|
|
@@ -39335,6 +40143,7 @@ export {
|
|
|
39335
40143
|
buildAbsoluteMobileCompatibilityRelease,
|
|
39336
40144
|
buildAbsoluteMobileUpdate,
|
|
39337
40145
|
buildAbsoluteRemoteIosRelease,
|
|
40146
|
+
cancelAbsoluteMobileUpdateRollout,
|
|
39338
40147
|
canonicalizeAbsoluteMobileUpdate,
|
|
39339
40148
|
captureAbsoluteMobileRouteGraph,
|
|
39340
40149
|
captureAbsoluteRemoteMacCommand,
|
|
@@ -39385,6 +40194,8 @@ export {
|
|
|
39385
40194
|
hashAbsoluteMobilePropsSchema,
|
|
39386
40195
|
inspectAbsoluteAndroidInstalledApp,
|
|
39387
40196
|
inspectAbsoluteMobileRouteMetadata,
|
|
40197
|
+
inspectAbsoluteMobileUpdateHealth,
|
|
40198
|
+
inspectAbsoluteMobileUpdateRollout,
|
|
39388
40199
|
inspectAbsoluteMobileUpdateServer,
|
|
39389
40200
|
inspectAbsoluteMobileUpdateStorage,
|
|
39390
40201
|
inspectAbsoluteRemoteMac,
|
|
@@ -39436,6 +40247,7 @@ export {
|
|
|
39436
40247
|
parseIosDeviceTypes,
|
|
39437
40248
|
parseIosRuntimes,
|
|
39438
40249
|
parseIosSimulators,
|
|
40250
|
+
pauseAbsoluteMobileUpdateRollout,
|
|
39439
40251
|
planAbsoluteExpoDevSession,
|
|
39440
40252
|
prepareAbsoluteAndroidRelease,
|
|
39441
40253
|
prepareAbsoluteIosDevProject,
|
|
@@ -39451,10 +40263,12 @@ export {
|
|
|
39451
40263
|
readAbsoluteMobileLinkIntent,
|
|
39452
40264
|
readAbsoluteMobileMaterializedReleases,
|
|
39453
40265
|
readAbsoluteMobileUpdate,
|
|
40266
|
+
reconcileAbsoluteMobileUpdateRollout,
|
|
39454
40267
|
redactAbsoluteIosLog,
|
|
39455
40268
|
removeAbsoluteRemoteMacProfile,
|
|
39456
40269
|
renderAbsoluteMobileUpdateRegistry,
|
|
39457
40270
|
repairAbsoluteIosDevSession,
|
|
40271
|
+
reportAbsoluteMobileUpdateHealth,
|
|
39458
40272
|
requestAbsoluteMobileBack,
|
|
39459
40273
|
requireAbsoluteIosReleaseMetadata,
|
|
39460
40274
|
resolveAbsoluteDeviceCapabilityPlan,
|
|
@@ -39465,6 +40279,7 @@ export {
|
|
|
39465
40279
|
resolveAbsoluteMobileNavigation,
|
|
39466
40280
|
resolveAbsoluteMobileRoute,
|
|
39467
40281
|
resolveAbsoluteMobileUpdateRuntime,
|
|
40282
|
+
resumeAbsoluteMobileUpdateRollout,
|
|
39468
40283
|
retainAbsoluteMobileCompatibilityArtifacts,
|
|
39469
40284
|
rollbackAbsoluteMobileUpdate,
|
|
39470
40285
|
runAbsoluteAndroidUpgradeConformance,
|
|
@@ -39493,5 +40308,5 @@ export {
|
|
|
39493
40308
|
writeAbsoluteMobileUpdateRegistry
|
|
39494
40309
|
};
|
|
39495
40310
|
|
|
39496
|
-
//# debugId=
|
|
40311
|
+
//# debugId=DB548297ABB3C7C964756E2164756E21
|
|
39497
40312
|
//# sourceMappingURL=index.js.map
|