@absolutejs/absolute 0.20.0-beta.84 → 0.20.0-beta.86

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.
@@ -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, 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,20 @@ 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
+ }
801
815
  if (autoMount) {
802
816
  const manifest = new URL(updates.manifestUrl);
803
817
  if (manifest.origin !== productionOrigin)
@@ -830,7 +844,12 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
830
844
  throw new TypeError(`mobile.updates.server.expoCodeSigningKeys.${keyId} certificate is not currently valid.`);
831
845
  expoCodeSigningKeys[keyId] = { certificatePem, privateKeyEnv };
832
846
  }
833
- return { autoMount, expoCodeSigningKeys, registryModule };
847
+ return {
848
+ autoMount,
849
+ expoCodeSigningKeys,
850
+ ...health ? { health } : {},
851
+ registryModule
852
+ };
834
853
  }, validateExpoNativeRouteSegment = (path, segment, index, count, parameters) => {
835
854
  if (segment === "*" && (index !== count - 1 || count === 1)) {
836
855
  throw new TypeError(`mobile.routes.native route ${path} must use * once, as the final segment after a static or parameterized prefix.`);
@@ -19962,15 +19981,17 @@ __export(exports_mobileUpdate, {
19962
19981
  });
19963
19982
  import {
19964
19983
  createHash as createHash15,
19984
+ createHmac,
19965
19985
  createPrivateKey,
19966
19986
  createPublicKey as createPublicKey2,
19967
19987
  sign as sign2,
19988
+ timingSafeEqual,
19968
19989
  verify as verify2,
19969
19990
  X509Certificate as X509Certificate3
19970
19991
  } from "crypto";
19971
19992
  import { readFile as readFile23, stat as stat5 } from "fs/promises";
19972
19993
  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) => {
19994
+ 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
19995
  if (typeof value !== "string" || value.length === 0)
19975
19996
  throw new MobileUpdateRegistryError(`Mobile update ${field2} is invalid`);
19976
19997
  return value;
@@ -20130,15 +20151,43 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20130
20151
  return true;
20131
20152
  const value = createHash15("sha256").update(`${input.appId}\x00${input.channel}\x00${input.releaseId}\x00${input.installationId}`).digest().readUInt32BE(0);
20132
20153
  return value / 4294967296 < input.rollout;
20154
+ }, base64Url = (value) => Buffer.from(value).toString("base64url"), healthPromotionId = (channel) => digest(new TextEncoder().encode(`${channel.appId}\x00${channel.channel}\x00${channel.releaseId ?? "embedded"}\x00${channel.promotedAt}`)), finiteMetric = (value, field2) => {
20155
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
20156
+ throw new MobileUpdateRegistryError(`Mobile update health ${field2} is invalid`);
20157
+ return value;
20158
+ }, parseHealthTransfer = (value) => {
20159
+ if (value === undefined)
20160
+ return;
20161
+ if (!object6(value))
20162
+ throw new MobileUpdateRegistryError("Mobile update health transfer is invalid");
20163
+ return {
20164
+ avoidedBytes: finiteMetric(value.avoidedBytes, "avoidedBytes"),
20165
+ downloadedBytes: finiteMetric(value.downloadedBytes, "downloadedBytes"),
20166
+ durationMs: finiteMetric(value.durationMs, "durationMs"),
20167
+ resumedBytes: finiteMetric(value.resumedBytes, "resumedBytes"),
20168
+ reusedBytes: finiteMetric(value.reusedBytes, "reusedBytes"),
20169
+ throughputBytesPerSecond: finiteMetric(value.throughputBytesPerSecond, "throughputBytesPerSecond")
20170
+ };
20133
20171
  }, createMobileUpdateRegistry = (options) => {
20134
20172
  const prefix = normalizedPrefix(options.prefix ?? DEFAULT_PREFIX);
20135
20173
  const clock = options.clock ?? (() => new Date);
20174
+ const health = options.health;
20175
+ if (health && health.secret.length < 32)
20176
+ throw new MobileUpdateRegistryError("Mobile update health secret must contain at least 32 characters");
20177
+ if (health && !options.store.list)
20178
+ throw new MobileUpdateRegistryError("Mobile update health requires storage lifecycle listing");
20179
+ const minimumReports = health?.autoPause?.minimumReports ?? 20;
20180
+ const failureThreshold = health?.autoPause?.failureRate ?? 0.2;
20181
+ if (health && (!Number.isSafeInteger(minimumReports) || minimumReports < 1 || failureThreshold <= 0 || failureThreshold > 1))
20182
+ throw new MobileUpdateRegistryError("Mobile update health auto-pause policy is invalid");
20136
20183
  const root = (appId) => `${prefix}/${appHash(appId)}`;
20137
20184
  const releaseRoot = (manifest) => `${root(manifest.appId)}/releases/${manifest.releaseId}`;
20138
20185
  const manifestKey = (manifest) => `${releaseRoot(manifest)}/update.json`;
20139
20186
  const fileKey = (manifest, file) => `${releaseRoot(manifest)}/files/${file.path}`;
20140
20187
  const contentBlobKey = (appId, sha2563) => `${root(appId)}/blobs/${sha2563}`;
20141
20188
  const tombstoneKey = (appId, releaseId) => `${root(appId)}/gc/${releaseId}.json`;
20189
+ const healthRoot = (appId, promotionId, releaseId) => `${root(appId)}/health/${promotionId}/${releaseId}`;
20190
+ const pauseKey = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/paused.json`;
20142
20191
  const channelKey = (appId, channel) => {
20143
20192
  if (!APP_ID.test(appId) || !NAME.test(channel))
20144
20193
  throw new MobileUpdateRegistryError("Mobile update channel identity is invalid");
@@ -20167,6 +20216,38 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20167
20216
  throw new MobileUpdateRegistryError("Stored mobile update channel identity changed");
20168
20217
  return value;
20169
20218
  };
20219
+ const signHealthToken = (payload) => {
20220
+ if (!health)
20221
+ return null;
20222
+ const encoded = base64Url(JSON.stringify(payload));
20223
+ const signature = createHmac("sha256", health.secret).update(encoded).digest("base64url");
20224
+ return `${encoded}.${signature}`;
20225
+ };
20226
+ const verifyHealthToken = (token) => {
20227
+ if (!health)
20228
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
20229
+ const [encoded, provided, extra] = token.split(".");
20230
+ if (!encoded || !provided || extra)
20231
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
20232
+ const expected = createHmac("sha256", health.secret).update(encoded).digest();
20233
+ let actual;
20234
+ try {
20235
+ actual = Buffer.from(provided, "base64url");
20236
+ } catch {
20237
+ actual = Buffer.alloc(0);
20238
+ }
20239
+ if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected))
20240
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
20241
+ let value;
20242
+ try {
20243
+ value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
20244
+ } catch {
20245
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
20246
+ }
20247
+ 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")
20248
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
20249
+ return value;
20250
+ };
20170
20251
  const assertNotMarked = async (appId, releaseId) => {
20171
20252
  if (!APP_ID.test(appId) || !RELEASE.test(releaseId))
20172
20253
  throw new MobileUpdateRegistryError("Mobile update release identity is invalid");
@@ -20221,13 +20302,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20221
20302
  const channel = await readChannel(input.appId, input.channel);
20222
20303
  if (!channel?.releaseId)
20223
20304
  return { status: "empty" };
20224
- const selected = rolloutMember({
20305
+ let selected = rolloutMember({
20225
20306
  appId: input.appId,
20226
20307
  channel: input.channel,
20227
20308
  installationId: input.installationId,
20228
20309
  releaseId: channel.releaseId,
20229
20310
  rollout: channel.rollout
20230
20311
  }) ? channel.releaseId : channel.fallbackReleaseId;
20312
+ if (health && selected === channel.releaseId && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId)))
20313
+ selected = channel.fallbackReleaseId;
20231
20314
  if (!selected)
20232
20315
  return { status: "empty" };
20233
20316
  const release = await readManifest(input.appId, selected);
@@ -20243,6 +20326,163 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20243
20326
  status: "selected"
20244
20327
  };
20245
20328
  };
20329
+ const issueUpdateHealthToken = async (input) => {
20330
+ if (!health)
20331
+ return null;
20332
+ const channel = await readChannel(input.appId, input.channel);
20333
+ if (!channel?.releaseId || channel.releaseId !== input.releaseId)
20334
+ return null;
20335
+ const resolution = await resolveUpdateState(input);
20336
+ if (resolution.status !== "selected" || resolution.manifest.releaseId !== input.releaseId)
20337
+ return null;
20338
+ return signHealthToken({
20339
+ appId: input.appId,
20340
+ channel: input.channel,
20341
+ format: HEALTH_TOKEN_VERSION,
20342
+ installationId: input.installationId,
20343
+ promotionId: healthPromotionId(channel),
20344
+ releaseId: input.releaseId,
20345
+ runtimeFingerprint: input.runtimeFingerprint
20346
+ });
20347
+ };
20348
+ const inspectUpdateHealth = async (input) => {
20349
+ if (!health)
20350
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
20351
+ const channel = await readChannel(input.appId, input.channel);
20352
+ const releaseId = input.releaseId ?? channel?.releaseId;
20353
+ if (!channel || !releaseId || channel.releaseId !== releaseId)
20354
+ return null;
20355
+ const promotionId = healthPromotionId(channel);
20356
+ const prefix2 = `${healthRoot(input.appId, promotionId, releaseId)}/events/`;
20357
+ const objects = [];
20358
+ const cursors = new Set;
20359
+ let cursor;
20360
+ do {
20361
+ const page = await options.store.list({
20362
+ ...cursor ? { cursor } : {},
20363
+ prefix: prefix2
20364
+ });
20365
+ objects.push(...page.objects);
20366
+ if (!page.truncated)
20367
+ break;
20368
+ if (!page.cursor || cursors.has(page.cursor))
20369
+ throw new MobileUpdateRegistryError("Mobile update health storage returned an invalid cursor");
20370
+ cursors.add(page.cursor);
20371
+ cursor = page.cursor;
20372
+ } while (true);
20373
+ const installations = new Set;
20374
+ const byKind = new Map([...HEALTH_KINDS].map((kind) => [
20375
+ kind,
20376
+ new Set
20377
+ ]));
20378
+ const transfer = {
20379
+ avoidedBytes: 0,
20380
+ downloadedBytes: 0,
20381
+ durationMs: 0,
20382
+ resumedBytes: 0,
20383
+ reusedBytes: 0,
20384
+ throughputBytesPerSecond: 0
20385
+ };
20386
+ for (const item of objects) {
20387
+ const bytes = await options.store.get(item.key);
20388
+ if (!bytes)
20389
+ continue;
20390
+ const head = await options.store.head(item.key);
20391
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
20392
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence integrity failed");
20393
+ const value = decode2(bytes);
20394
+ if (!object6(value) || typeof value.installationHash !== "string" || !HEALTH_KINDS.has(String(value.kind)))
20395
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence is invalid");
20396
+ const kind = value.kind;
20397
+ installations.add(value.installationHash);
20398
+ byKind.get(kind).add(value.installationHash);
20399
+ if (kind === "downloaded" && object6(value.transfer)) {
20400
+ const parsed = parseHealthTransfer(value.transfer);
20401
+ for (const key of Object.keys(transfer))
20402
+ transfer[key] += parsed[key];
20403
+ }
20404
+ }
20405
+ const failures = new Set([
20406
+ ...byKind.get("quarantined"),
20407
+ ...byKind.get("rolled-back")
20408
+ ]);
20409
+ const terminals = new Set([...byKind.get("activated"), ...failures]);
20410
+ const failureRate = terminals.size === 0 ? 0 : failures.size / terminals.size;
20411
+ return {
20412
+ activated: byKind.get("activated").size,
20413
+ appId: input.appId,
20414
+ channel: input.channel,
20415
+ downloaded: byKind.get("downloaded").size,
20416
+ downloadFailed: byKind.get("download-failed").size,
20417
+ failureRate,
20418
+ failures: failures.size,
20419
+ paused: Boolean(await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
20420
+ promotionId,
20421
+ quarantined: byKind.get("quarantined").size,
20422
+ releaseId,
20423
+ reportedInstallations: installations.size,
20424
+ rolledBack: byKind.get("rolled-back").size,
20425
+ rollout: channel.rollout,
20426
+ terminalReports: terminals.size,
20427
+ transfer
20428
+ };
20429
+ };
20430
+ const recordUpdateHealth = async (input) => {
20431
+ const payload = verifyHealthToken(input.token);
20432
+ 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")
20433
+ throw new MobileUpdateRegistryError("Mobile update health evidence does not match its token");
20434
+ const release = await readManifest(input.appId, input.releaseId);
20435
+ if (!release || release.manifest.runtimeFingerprint !== input.runtimeFingerprint)
20436
+ throw new MobileUpdateRegistryError("Mobile update health release is invalid");
20437
+ const activeChannel = await readChannel(input.appId, input.channel);
20438
+ if (!activeChannel || activeChannel.releaseId !== input.releaseId || healthPromotionId(activeChannel) !== payload.promotionId)
20439
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
20440
+ const transfer = parseHealthTransfer(input.transfer);
20441
+ const installationHash = createHmac("sha256", health.secret).update(input.installationId).digest("hex");
20442
+ const evidence = {
20443
+ format: 1,
20444
+ installationHash,
20445
+ kind: input.kind,
20446
+ observedAt: clock().toISOString(),
20447
+ ...input.reason ? { reason: input.reason } : {},
20448
+ ...transfer ? { transfer } : {}
20449
+ };
20450
+ const bytes = json(evidence);
20451
+ await options.store.put(`${healthRoot(input.appId, payload.promotionId, input.releaseId)}/events/${installationHash}/${input.kind}.json`, bytes, {
20452
+ cacheControl: "no-store",
20453
+ contentType: "application/json",
20454
+ maxBytes: bytes.byteLength,
20455
+ metadata: { kind: input.kind, sha256: digest(bytes) }
20456
+ });
20457
+ let report = await inspectUpdateHealth({
20458
+ appId: input.appId,
20459
+ channel: input.channel,
20460
+ releaseId: input.releaseId
20461
+ });
20462
+ if (!report)
20463
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
20464
+ if (FAILURE_HEALTH_KINDS.has(input.kind) && report.terminalReports >= minimumReports && report.failureRate >= failureThreshold && !report.paused) {
20465
+ const marker = json({
20466
+ appId: input.appId,
20467
+ channel: input.channel,
20468
+ failureRate: report.failureRate,
20469
+ failures: report.failures,
20470
+ format: 1,
20471
+ pausedAt: clock().toISOString(),
20472
+ promotionId: payload.promotionId,
20473
+ releaseId: input.releaseId,
20474
+ reports: report.terminalReports
20475
+ });
20476
+ await options.store.put(pauseKey(input.appId, payload.promotionId, input.releaseId), marker, {
20477
+ cacheControl: "no-store",
20478
+ contentType: "application/json",
20479
+ maxBytes: marker.byteLength,
20480
+ metadata: { releaseid: input.releaseId, sha256: digest(marker) }
20481
+ });
20482
+ report = { ...report, paused: true };
20483
+ }
20484
+ return report;
20485
+ };
20246
20486
  const retentionValues = (input) => {
20247
20487
  if (!APP_ID.test(input.appId))
20248
20488
  throw new MobileUpdateRegistryError("Mobile update appId is invalid");
@@ -20485,6 +20725,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20485
20725
  return result;
20486
20726
  };
20487
20727
  return {
20728
+ ...health ? { inspectUpdateHealth, issueUpdateHealthToken, recordUpdateHealth } : {},
20488
20729
  inspectUpdateStorage: async (input) => (await inventory(input)).report,
20489
20730
  pruneUpdates,
20490
20731
  publishUpdate: async (input) => {
@@ -20760,24 +21001,69 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20760
21001
  const expoCodeSigning = resolveExpoCodeSigning(options.expoCodeSigning);
20761
21002
  return async (request) => {
20762
21003
  const origin = request.headers.get("origin");
20763
- const cors = origin && allowedOrigins.has(origin) ? { "access-control-allow-origin": origin, vary: "Origin" } : {};
21004
+ const cors = origin && allowedOrigins.has(origin) ? {
21005
+ "access-control-allow-origin": origin,
21006
+ "access-control-expose-headers": "content-range,etag,x-absolute-mobile-health-token",
21007
+ vary: "Origin"
21008
+ } : {};
20764
21009
  if (request.method === "OPTIONS") {
20765
21010
  if (!origin || !allowedOrigins.has(origin))
20766
21011
  return new Response(null, { status: 403 });
20767
21012
  return new Response(null, {
20768
21013
  headers: {
20769
21014
  ...cors,
20770
- "access-control-allow-headers": "x-absolute-mobile-app,x-absolute-mobile-channel,x-absolute-mobile-installation,x-absolute-mobile-release,x-absolute-mobile-runtime",
20771
- "access-control-allow-methods": "GET,OPTIONS",
21015
+ "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",
21016
+ "access-control-allow-methods": "GET,POST,OPTIONS",
20772
21017
  "access-control-max-age": "600"
20773
21018
  },
20774
21019
  status: 204
20775
21020
  });
20776
21021
  }
20777
- if (request.method !== "GET")
20778
- return new Response(null, { status: 405 });
20779
21022
  const pathname = new URL(request.url).pathname.replace(/^\/+/, "");
20780
21023
  const relative17 = pathname.startsWith(`${route}/`) ? pathname.slice(route.length + 1) : "";
21024
+ if (request.method === "POST" && relative17 === "health") {
21025
+ if (!options.registry.recordUpdateHealth)
21026
+ return new Response(null, { status: 404 });
21027
+ const appId = request.headers.get("x-absolute-mobile-app");
21028
+ const channel = request.headers.get("x-absolute-mobile-channel");
21029
+ const installationId = request.headers.get("x-absolute-mobile-installation");
21030
+ const runtimeFingerprint = request.headers.get("x-absolute-mobile-runtime");
21031
+ const token = request.headers.get("x-absolute-mobile-health-token");
21032
+ const declared = Number(request.headers.get("content-length"));
21033
+ if (appId !== options.appId || channel !== options.channel || !installationId || !runtimeFingerprint || !token || Number.isFinite(declared) && declared > 4096)
21034
+ return new Response(null, { status: 400 });
21035
+ const bodyBytes = new Uint8Array(await request.arrayBuffer());
21036
+ if (bodyBytes.byteLength > 4096)
21037
+ return new Response(null, { status: 413 });
21038
+ let body;
21039
+ try {
21040
+ body = JSON.parse(new TextDecoder().decode(bodyBytes));
21041
+ } catch {
21042
+ return new Response(null, { status: 400 });
21043
+ }
21044
+ if (!object6(body) || typeof body.releaseId !== "string" || typeof body.kind !== "string")
21045
+ return new Response(null, { status: 400 });
21046
+ try {
21047
+ const report = await options.registry.recordUpdateHealth({
21048
+ appId,
21049
+ channel,
21050
+ installationId,
21051
+ kind: body.kind,
21052
+ ...body.reason === "boot-interrupted" || body.reason === "boot-timeout" ? { reason: body.reason } : {},
21053
+ releaseId: body.releaseId,
21054
+ runtimeFingerprint,
21055
+ token,
21056
+ ...object6(body.transfer) ? { transfer: body.transfer } : {}
21057
+ });
21058
+ return Response.json({ paused: report.paused }, { headers: { ...cors, "cache-control": "no-store" }, status: 202 });
21059
+ } catch (error) {
21060
+ if (error instanceof MobileUpdateRegistryError)
21061
+ return new Response(null, { status: 403 });
21062
+ throw error;
21063
+ }
21064
+ }
21065
+ if (request.method !== "GET")
21066
+ return new Response(null, { status: 405 });
20781
21067
  if (relative17 === "update.json") {
20782
21068
  const expoProtocolVersion = request.headers.get("expo-protocol-version");
20783
21069
  const expoProtocol = expoProtocolVersion !== null;
@@ -20803,6 +21089,13 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20803
21089
  installationId,
20804
21090
  runtimeFingerprint
20805
21091
  });
21092
+ const healthToken = selected && options.registry.issueUpdateHealthToken ? await options.registry.issueUpdateHealthToken({
21093
+ appId,
21094
+ channel,
21095
+ installationId,
21096
+ releaseId: selected.manifest.releaseId,
21097
+ runtimeFingerprint
21098
+ }) : null;
20806
21099
  if (expoProtocol) {
20807
21100
  let requestedCodeSigning;
20808
21101
  try {
@@ -20854,6 +21147,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20854
21147
  extra: {
20855
21148
  absolutejs: {
20856
21149
  channel: selected.manifest.channel,
21150
+ ...healthToken ? { healthToken } : {},
20857
21151
  releaseId: selected.manifest.releaseId
20858
21152
  },
20859
21153
  expoClient: descriptor.expoConfig
@@ -20881,7 +21175,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20881
21175
  headers: {
20882
21176
  ...cors,
20883
21177
  "cache-control": "no-store",
20884
- etag: `"${selected.manifest.releaseId}"`
21178
+ etag: `"${selected.manifest.releaseId}"`,
21179
+ ...healthToken ? { "x-absolute-mobile-health-token": healthToken } : {}
20885
21180
  }
20886
21181
  });
20887
21182
  }
@@ -20895,14 +21190,42 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20895
21190
  });
20896
21191
  if (!file)
20897
21192
  return new Response(null, { status: 404 });
20898
- return new Response(new Blob([new Uint8Array(file.bytes).buffer]), {
21193
+ const etag = `"${file.file.sha256}"`;
21194
+ const range = request.headers.get("range");
21195
+ const useRange = range !== null && (!request.headers.has("if-range") || request.headers.get("if-range") === etag);
21196
+ let contents = file.bytes;
21197
+ let status = 200;
21198
+ let contentRange;
21199
+ if (useRange) {
21200
+ const parsed = /^bytes=(\d+)-(\d*)$/.exec(range);
21201
+ const start = parsed?.[1] === undefined ? NaN : Number(parsed[1]);
21202
+ const requestedEnd = parsed?.[2] === undefined || parsed[2] === "" ? file.bytes.byteLength - 1 : Number(parsed[2]);
21203
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start < 0 || start >= file.bytes.byteLength || requestedEnd < start)
21204
+ return new Response(null, {
21205
+ headers: {
21206
+ ...cors,
21207
+ "accept-ranges": "bytes",
21208
+ "content-range": `bytes */${file.bytes.byteLength}`,
21209
+ etag
21210
+ },
21211
+ status: 416
21212
+ });
21213
+ const end = Math.min(requestedEnd, file.bytes.byteLength - 1);
21214
+ contents = file.bytes.slice(start, end + 1);
21215
+ status = 206;
21216
+ contentRange = `bytes ${start}-${end}/${file.bytes.byteLength}`;
21217
+ }
21218
+ return new Response(new Blob([new Uint8Array(contents).buffer]), {
20899
21219
  headers: {
20900
21220
  ...cors,
21221
+ "accept-ranges": "bytes",
20901
21222
  "cache-control": "public, max-age=31536000, immutable",
20902
- "content-length": String(file.file.bytes),
21223
+ "content-length": String(contents.byteLength),
21224
+ ...contentRange ? { "content-range": contentRange } : {},
20903
21225
  "content-type": expoContentType(file.file.path.includes(".") ? file.file.path.slice(file.file.path.lastIndexOf(".") + 1) : undefined, false),
20904
- etag: `"${file.file.sha256}"`
20905
- }
21226
+ etag
21227
+ },
21228
+ status
20906
21229
  });
20907
21230
  };
20908
21231
  };
@@ -20916,6 +21239,14 @@ var init_mobileUpdate = __esm(() => {
20916
21239
  RELEASE = /^amu_[a-f0-9]{64}$/;
20917
21240
  APP_ID = /^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/;
20918
21241
  NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
21242
+ HEALTH_KINDS = new Set([
21243
+ "activated",
21244
+ "downloaded",
21245
+ "download-failed",
21246
+ "quarantined",
21247
+ "rolled-back"
21248
+ ]);
21249
+ FAILURE_HEALTH_KINDS = new Set(["quarantined", "rolled-back"]);
20919
21250
  MobileUpdateRegistryError = class MobileUpdateRegistryError extends Error {
20920
21251
  };
20921
21252
  expoProtocolHeaders = {
@@ -21003,6 +21334,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
21003
21334
  } catch (error) {
21004
21335
  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 });
21005
21336
  }
21337
+ }, verifyHealthModule = (config, module) => {
21338
+ if (!config.updateServer?.health)
21339
+ return;
21340
+ if (typeof module.registry.inspectUpdateHealth !== "function" || typeof module.registry.issueUpdateHealthToken !== "function" || typeof module.registry.recordUpdateHealth !== "function")
21341
+ throw new TypeError("Mobile update fleet health is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
21006
21342
  }, expoSigningOptions = (config) => {
21007
21343
  if (!config.updates?.expoCodeSigning)
21008
21344
  return;
@@ -21034,8 +21370,10 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
21034
21370
  if (!updates || !server?.autoMount)
21035
21371
  return new Elysia4({ name: "absolutejs-mobile-updates-disabled" });
21036
21372
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, server.registryModule);
21037
- if (options.production)
21373
+ if (options.production) {
21038
21374
  await verifyDurableModule(module);
21375
+ verifyHealthModule(config, module);
21376
+ }
21039
21377
  const manifest = new URL(updates.manifestUrl);
21040
21378
  if (!manifest.pathname.endsWith("/update.json"))
21041
21379
  throw new TypeError("Auto-mounted mobile update manifests must end in /update.json.");
@@ -21054,10 +21392,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
21054
21392
  return;
21055
21393
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
21056
21394
  await verifyDurableModule(module);
21395
+ verifyHealthModule(config, module);
21057
21396
  if (config.engine === "expo")
21058
21397
  expoSigningOptions(config);
21059
21398
  return module.metadata;
21060
- }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistry = (options) => {
21399
+ }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistryBase = (options) => {
21061
21400
  const metadata = `export const absoluteMobileUpdateServer = {
21062
21401
  format: 1,
21063
21402
  provider: '${options.storage}',
@@ -21123,6 +21462,19 @@ export default createMobileUpdateRegistry({
21123
21462
  store
21124
21463
  });
21125
21464
  `;
21465
+ }, renderAbsoluteMobileUpdateRegistry = (options) => {
21466
+ const source = renderAbsoluteMobileUpdateRegistryBase(options);
21467
+ if (!options.health)
21468
+ return source;
21469
+ const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
21470
+ const health = ` health: {
21471
+ autoPause: { failureRate: ${options.health.failureRate}, minimumReports: ${options.health.minimumReports} },
21472
+ secret: ${secret}
21473
+ },
21474
+ `;
21475
+ return source.replace(`export default createMobileUpdateRegistry({
21476
+ `, `export default createMobileUpdateRegistry({
21477
+ ${health}`);
21126
21478
  }, writeAbsoluteMobileUpdateRegistry = async (options) => {
21127
21479
  const path2 = projectPath3(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
21128
21480
  if (!options.force) {
@@ -21134,6 +21486,7 @@ export default createMobileUpdateRegistry({
21134
21486
  }
21135
21487
  await mkdir18(dirname19(path2), { recursive: true });
21136
21488
  await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
21489
+ ...options.health ? { health: options.health } : {},
21137
21490
  publicKeys: options.publicKeys,
21138
21491
  storage: options.storage
21139
21492
  }));
@@ -27800,11 +28153,48 @@ export default function AbsoluteLayout() {
27800
28153
  return <Stack screenOptions={{ headerShown: false }} />;
27801
28154
  }
27802
28155
  `;
27803
- var updatesRuntimeSource = () => `${EXPO_GENERATED_HEADER}import { randomUUID } from 'expo-crypto';
28156
+ var updatesRuntimeSource = (config) => `${EXPO_GENERATED_HEADER}import { randomUUID } from 'expo-crypto';
27804
28157
  import * as SecureStore from 'expo-secure-store';
27805
28158
  import * as Updates from 'expo-updates';
27806
28159
 
27807
28160
  const INSTALLATION_KEY = 'absolutejs.mobile.update.installation.v1';
28161
+ const PENDING_HEALTH_KEY = 'absolutejs.mobile.update.pending-health.v1';
28162
+ const APP_ID = ${JSON.stringify(config.appId)};
28163
+ const CHANNEL = ${JSON.stringify(config.updates?.channel)};
28164
+ const MANIFEST_URL = ${JSON.stringify(config.updates?.manifestUrl)};
28165
+
28166
+ const updateIdentity = (value: unknown) => {
28167
+ if (!value || typeof value !== 'object') return undefined;
28168
+ const extra = Reflect.get(value, 'extra');
28169
+ if (!extra || typeof extra !== 'object') return undefined;
28170
+ const absolute = Reflect.get(extra, 'absolutejs');
28171
+ if (!absolute || typeof absolute !== 'object') return undefined;
28172
+ const healthToken = Reflect.get(absolute, 'healthToken');
28173
+ const releaseId = Reflect.get(absolute, 'releaseId');
28174
+ return typeof healthToken === 'string' && typeof releaseId === 'string'
28175
+ ? { healthToken, releaseId }
28176
+ : undefined;
28177
+ };
28178
+
28179
+ const report = async (installationId: string, evidence: { healthToken: string; kind: string; releaseId: string }) => {
28180
+ const endpoint = new URL('./health', MANIFEST_URL);
28181
+ await fetch(endpoint.href, {
28182
+ body: JSON.stringify({ kind: evidence.kind, releaseId: evidence.releaseId }),
28183
+ cache: 'no-store',
28184
+ credentials: 'omit',
28185
+ headers: {
28186
+ 'content-type': 'application/json',
28187
+ 'x-absolute-mobile-app': APP_ID,
28188
+ 'x-absolute-mobile-channel': CHANNEL,
28189
+ 'x-absolute-mobile-health-token': evidence.healthToken,
28190
+ 'x-absolute-mobile-installation': installationId,
28191
+ 'x-absolute-mobile-release': updateIdentity(Updates.manifest)?.releaseId ?? 'embedded',
28192
+ 'x-absolute-mobile-runtime': Updates.runtimeVersion ?? ''
28193
+ },
28194
+ method: 'POST',
28195
+ redirect: 'error'
28196
+ });
28197
+ };
27808
28198
 
27809
28199
  let startPromise: Promise<void> | undefined;
27810
28200
  export const startAbsoluteExpoUpdates = () => {
@@ -27816,9 +28206,34 @@ export const startAbsoluteExpoUpdates = () => {
27816
28206
  await SecureStore.setItemAsync(INSTALLATION_KEY, installationId);
27817
28207
  }
27818
28208
  await Updates.setExtraParamAsync('absolute-installation', installationId);
28209
+ const pendingSource = await SecureStore.getItemAsync(PENDING_HEALTH_KEY);
28210
+ if (pendingSource) {
28211
+ try {
28212
+ const pending = JSON.parse(pendingSource);
28213
+ const active = updateIdentity(Updates.manifest);
28214
+ if (typeof pending?.healthToken === 'string' && typeof pending?.releaseId === 'string')
28215
+ await report(installationId, {
28216
+ healthToken: pending.healthToken,
28217
+ kind: active?.releaseId === pending.releaseId ? 'activated' : 'rolled-back',
28218
+ releaseId: pending.releaseId
28219
+ });
28220
+ } catch {}
28221
+ await SecureStore.deleteItemAsync(PENDING_HEALTH_KEY);
28222
+ }
27819
28223
  const result = await Updates.checkForUpdateAsync();
27820
28224
  if (result.isAvailable || result.isRollBackToEmbedded) {
27821
- await Updates.fetchUpdateAsync();
28225
+ const available = updateIdentity(Reflect.get(result, 'manifest'));
28226
+ try {
28227
+ const fetched = await Updates.fetchUpdateAsync();
28228
+ const identity = updateIdentity(Reflect.get(fetched, 'manifest')) ?? available;
28229
+ if (identity) {
28230
+ await SecureStore.setItemAsync(PENDING_HEALTH_KEY, JSON.stringify(identity));
28231
+ await report(installationId, { ...identity, kind: 'downloaded' });
28232
+ }
28233
+ } catch (error) {
28234
+ if (available) await report(installationId, { ...available, kind: 'download-failed' }).catch(() => undefined);
28235
+ throw error;
28236
+ }
27822
28237
  if (result.isRollBackToEmbedded) {
27823
28238
  await Updates.reloadAsync();
27824
28239
  }
@@ -28763,7 +29178,7 @@ node_modules/
28763
29178
  files.set(path, source);
28764
29179
  }
28765
29180
  if (config.updates) {
28766
- files.set(join15(project, "src", "generated", "AbsoluteUpdates.ts"), updatesRuntimeSource());
29181
+ files.set(join15(project, "src", "generated", "AbsoluteUpdates.ts"), updatesRuntimeSource(config));
28767
29182
  }
28768
29183
  const expoCodeSigning = config.updates?.expoCodeSigning;
28769
29184
  if (expoCodeSigning)
@@ -38705,17 +39120,18 @@ var fileUrl = (manifestUrl, releaseId, path) => {
38705
39120
  throw new TypeError("Mobile update asset escaped its signed release origin.");
38706
39121
  return result;
38707
39122
  };
38708
- var readChunks = async (reader, maximum2, chunks = [], received = 0) => {
39123
+ var readChunks = async (reader, maximum2, onChunk, chunks = [], received = 0) => {
38709
39124
  const result = await reader.read();
38710
39125
  if (result.done)
38711
39126
  return { chunks, received };
38712
39127
  const total = received + result.value.byteLength;
38713
39128
  if (total > maximum2)
38714
39129
  throw new TypeError("Mobile update response exceeds its signed size.");
39130
+ await onChunk?.(result.value, received);
38715
39131
  chunks.push(result.value);
38716
- return readChunks(reader, maximum2, chunks, total);
39132
+ return readChunks(reader, maximum2, onChunk, chunks, total);
38717
39133
  };
38718
- var readBounded = async (response, maximum2) => {
39134
+ var readBounded = async (response, maximum2, onChunk) => {
38719
39135
  const declared = Number(response.headers.get("content-length"));
38720
39136
  if (Number.isFinite(declared) && declared > maximum2)
38721
39137
  throw new TypeError("Mobile update response exceeds its signed size.");
@@ -38723,8 +39139,9 @@ var readBounded = async (response, maximum2) => {
38723
39139
  return new Uint8Array;
38724
39140
  const reader = response.body.getReader();
38725
39141
  let result;
39142
+ const chunks = [];
38726
39143
  try {
38727
- result = await readChunks(reader, maximum2);
39144
+ result = await readChunks(reader, maximum2, onChunk, chunks);
38728
39145
  } catch (error) {
38729
39146
  await reader.cancel().catch(() => {
38730
39147
  return;
@@ -38733,7 +39150,7 @@ var readBounded = async (response, maximum2) => {
38733
39150
  }
38734
39151
  const contents = new Uint8Array(result.received);
38735
39152
  let offset = 0;
38736
- for (const chunk of result.chunks) {
39153
+ for (const chunk of chunks) {
38737
39154
  contents.set(chunk, offset);
38738
39155
  offset += chunk.byteLength;
38739
39156
  }
@@ -38746,6 +39163,30 @@ var requestHeaders = (config) => ({
38746
39163
  "x-absolute-mobile-release": config.currentReleaseId,
38747
39164
  "x-absolute-mobile-runtime": config.runtimeFingerprint
38748
39165
  });
39166
+ var healthUrl = (manifestUrl) => new URL("./health", manifestUrl);
39167
+ var reportAbsoluteMobileUpdateHealth = async (config, input, request = globalThis.fetch) => {
39168
+ const manifestUrl = exactManifestUrl(config.manifestUrl);
39169
+ const headers = new Headers(requestHeaders(config));
39170
+ headers.set("content-type", "application/json");
39171
+ headers.set("x-absolute-mobile-health-token", input.healthToken);
39172
+ const response = await request(healthUrl(manifestUrl), {
39173
+ body: JSON.stringify({
39174
+ kind: input.kind,
39175
+ ...input.reason ? { reason: input.reason } : {},
39176
+ releaseId: input.releaseId,
39177
+ ...input.transfer ? { transfer: input.transfer } : {}
39178
+ }),
39179
+ cache: "no-store",
39180
+ credentials: "omit",
39181
+ headers,
39182
+ keepalive: true,
39183
+ method: "POST",
39184
+ redirect: "error",
39185
+ signal: AbortSignal.timeout(15000)
39186
+ });
39187
+ if (response.status !== 202)
39188
+ throw new TypeError(`Mobile update health report failed with HTTP ${response.status}.`);
39189
+ };
38749
39190
  var requireCompatible = (manifest, config) => {
38750
39191
  if (manifest.appId !== config.appId)
38751
39192
  throw new TypeError("Mobile update belongs to another app.");
@@ -38754,49 +39195,172 @@ var requireCompatible = (manifest, config) => {
38754
39195
  if (manifest.runtimeFingerprint !== config.runtimeFingerprint)
38755
39196
  throw new TypeError("Mobile update requires a different native runtime.");
38756
39197
  };
39198
+ var networkConcurrency = (requested) => {
39199
+ const bounded = Math.max(1, Math.min(6, Math.floor(requested ?? 3)));
39200
+ const navigatorValue = Reflect.get(globalThis, "navigator");
39201
+ const connection = typeof navigatorValue === "object" && navigatorValue !== null ? Reflect.get(navigatorValue, "connection") : undefined;
39202
+ if (typeof connection !== "object" || connection === null)
39203
+ return bounded;
39204
+ if (Reflect.get(connection, "saveData") === true)
39205
+ return 1;
39206
+ const effectiveType = Reflect.get(connection, "effectiveType");
39207
+ if (effectiveType === "slow-2g" || effectiveType === "2g")
39208
+ return 1;
39209
+ if (effectiveType === "3g")
39210
+ return Math.min(2, bounded);
39211
+ return bounded;
39212
+ };
39213
+ var combine = (prefix, suffix) => {
39214
+ const result = new Uint8Array(prefix.byteLength + suffix.byteLength);
39215
+ result.set(prefix);
39216
+ result.set(suffix, prefix.byteLength);
39217
+ return result;
39218
+ };
39219
+ var validContentRange = (value, start, total) => value === `bytes ${start}-${total - 1}/${total}`;
39220
+ var combineSignals = (signals) => {
39221
+ const nativeAny = Reflect.get(AbortSignal, "any");
39222
+ if (typeof nativeAny === "function")
39223
+ return Reflect.apply(nativeAny, AbortSignal, [signals]);
39224
+ const controller = new AbortController;
39225
+ const abort = () => controller.abort();
39226
+ if (signals.some((signal) => signal.aborted))
39227
+ abort();
39228
+ else
39229
+ signals.forEach((signal) => signal.addEventListener("abort", abort, { once: true }));
39230
+ return controller.signal;
39231
+ };
38757
39232
  var createAbsoluteMobileUpdateClient = (options) => {
38758
39233
  const manifestUrl = exactManifestUrl(options.config.manifestUrl);
38759
39234
  const request = options.fetch ?? globalThis.fetch;
38760
- const downloadFiles = async (manifest, index = 0, transfer = {
38761
- downloadedBytes: 0,
38762
- downloadedFiles: 0,
38763
- reusedBytes: 0,
38764
- reusedFiles: 0,
38765
- totalBytes: manifest.files.reduce((total, file) => total + file.bytes, 0),
38766
- totalFiles: manifest.files.length
38767
- }) => {
38768
- const file = manifest.files[index];
38769
- if (!file)
38770
- return transfer;
38771
- const reusable = await options.store.readReusable?.(file);
38772
- if (reusable && reusable.byteLength === file.bytes && await options.verifier.digest(reusable) === file.sha256) {
38773
- await options.store.write(file, reusable);
38774
- return downloadFiles(manifest, index + 1, {
38775
- ...transfer,
38776
- reusedBytes: transfer.reusedBytes + reusable.byteLength,
38777
- reusedFiles: transfer.reusedFiles + 1
39235
+ const downloadFiles = async (manifest) => {
39236
+ const startedAt = performance.now();
39237
+ const transfer = {
39238
+ avoidedBytes: 0,
39239
+ completedFiles: 0,
39240
+ downloadedBytes: 0,
39241
+ downloadedFiles: 0,
39242
+ durationMs: 0,
39243
+ resumedBytes: 0,
39244
+ resumedFiles: 0,
39245
+ reusedBytes: 0,
39246
+ reusedFiles: 0,
39247
+ throughputBytesPerSecond: 0,
39248
+ totalBytes: manifest.files.reduce((total, file) => total + file.bytes, 0),
39249
+ totalFiles: manifest.files.length
39250
+ };
39251
+ const updateTiming = () => {
39252
+ transfer.durationMs = Math.max(0, performance.now() - startedAt);
39253
+ transfer.avoidedBytes = transfer.reusedBytes + transfer.resumedBytes;
39254
+ transfer.throughputBytesPerSecond = transfer.durationMs > 0 ? Math.round(transfer.downloadedBytes * 1000 / transfer.durationMs) : transfer.downloadedBytes;
39255
+ };
39256
+ const progress = () => {
39257
+ updateTiming();
39258
+ try {
39259
+ options.onProgress?.({
39260
+ ...transfer,
39261
+ kind: "download-progress"
39262
+ });
39263
+ } catch {}
39264
+ };
39265
+ const controller = new AbortController;
39266
+ let next = 0;
39267
+ let firstError;
39268
+ const downloadFile = async (file) => {
39269
+ const staged = await options.store.readStaged?.(file);
39270
+ if (staged?.byteLength === file.bytes && await options.verifier.digest(staged) === file.sha256) {
39271
+ transfer.resumedBytes += staged.byteLength;
39272
+ transfer.resumedFiles += 1;
39273
+ transfer.completedFiles += 1;
39274
+ progress();
39275
+ return;
39276
+ }
39277
+ const reusable = await options.store.readReusable?.(file);
39278
+ if (reusable?.byteLength === file.bytes && await options.verifier.digest(reusable) === file.sha256) {
39279
+ await options.store.write(file, reusable);
39280
+ transfer.reusedBytes += reusable.byteLength;
39281
+ transfer.reusedFiles += 1;
39282
+ transfer.completedFiles += 1;
39283
+ progress();
39284
+ return;
39285
+ }
39286
+ const candidate = await options.store.readPartial?.(file);
39287
+ if (candidate?.byteLength === file.bytes && await options.verifier.digest(candidate) === file.sha256) {
39288
+ await options.store.write(file, candidate);
39289
+ transfer.resumedBytes += candidate.byteLength;
39290
+ transfer.resumedFiles += 1;
39291
+ transfer.completedFiles += 1;
39292
+ progress();
39293
+ return;
39294
+ }
39295
+ const partial = candidate && candidate.byteLength > 0 && candidate.byteLength < file.bytes ? candidate : new Uint8Array;
39296
+ const headers = new Headers;
39297
+ if (partial.byteLength > 0) {
39298
+ headers.set("if-range", `"${file.sha256}"`);
39299
+ headers.set("range", `bytes=${partial.byteLength}-`);
39300
+ }
39301
+ const asset2 = await request(fileUrl(manifestUrl, manifest.releaseId, file.path), {
39302
+ cache: "no-store",
39303
+ credentials: "omit",
39304
+ headers,
39305
+ redirect: "error",
39306
+ signal: combineSignals([
39307
+ controller.signal,
39308
+ AbortSignal.timeout(30000)
39309
+ ])
38778
39310
  });
38779
- }
38780
- const asset2 = await request(fileUrl(manifestUrl, manifest.releaseId, file.path), {
38781
- cache: "no-store",
38782
- credentials: "omit",
38783
- redirect: "error",
38784
- signal: AbortSignal.timeout(30000)
38785
- });
38786
- if (!asset2.ok)
38787
- throw new TypeError(`Mobile update asset ${file.path} failed with HTTP ${asset2.status}.`);
38788
- const contents = await readBounded(asset2, file.bytes);
38789
- const downloadedBytes = transfer.downloadedBytes + contents.byteLength;
38790
- if (contents.byteLength !== file.bytes || downloadedBytes > ABSOLUTE_MOBILE_UPDATE_MAX_TOTAL_BYTES)
38791
- throw new TypeError(`Mobile update asset ${file.path} has an invalid size.`);
38792
- if (await options.verifier.digest(contents) !== file.sha256)
38793
- throw new TypeError(`Mobile update asset ${file.path} failed integrity verification.`);
38794
- await options.store.write(file, contents);
38795
- return downloadFiles(manifest, index + 1, {
38796
- ...transfer,
38797
- downloadedBytes,
38798
- downloadedFiles: transfer.downloadedFiles + 1
38799
- });
39311
+ if (!asset2.ok)
39312
+ throw new TypeError(`Mobile update asset ${file.path} failed with HTTP ${asset2.status}.`);
39313
+ const ranged = asset2.status === 206;
39314
+ if (ranged && (partial.byteLength === 0 || !validContentRange(asset2.headers.get("content-range"), partial.byteLength, file.bytes)))
39315
+ throw new TypeError(`Mobile update asset ${file.path} returned an invalid byte range.`);
39316
+ const prefix = ranged ? partial : new Uint8Array;
39317
+ if (ranged) {
39318
+ transfer.resumedBytes += partial.byteLength;
39319
+ transfer.resumedFiles += 1;
39320
+ }
39321
+ const downloaded = await readBounded(asset2, file.bytes - prefix.byteLength, options.store.appendPartial ? async (chunk, offset) => {
39322
+ await options.store.appendPartial?.(file, chunk, prefix.byteLength + offset);
39323
+ transfer.downloadedBytes += chunk.byteLength;
39324
+ progress();
39325
+ } : undefined);
39326
+ if (!options.store.appendPartial) {
39327
+ transfer.downloadedBytes += downloaded.byteLength;
39328
+ progress();
39329
+ }
39330
+ if (transfer.downloadedBytes > ABSOLUTE_MOBILE_UPDATE_MAX_TOTAL_BYTES)
39331
+ throw new TypeError("Mobile update exceeds the maximum transfer size.");
39332
+ const contents = combine(prefix, downloaded);
39333
+ if (contents.byteLength !== file.bytes)
39334
+ throw new TypeError(`Mobile update asset ${file.path} has an invalid size.`);
39335
+ if (await options.verifier.digest(contents) !== file.sha256)
39336
+ throw new TypeError(`Mobile update asset ${file.path} failed integrity verification.`);
39337
+ await options.store.write(file, contents);
39338
+ transfer.downloadedFiles += 1;
39339
+ transfer.completedFiles += 1;
39340
+ progress();
39341
+ };
39342
+ const worker = async () => {
39343
+ if (firstError)
39344
+ return;
39345
+ const index = next++;
39346
+ const file = manifest.files[index];
39347
+ if (!file)
39348
+ return;
39349
+ try {
39350
+ await downloadFile(file);
39351
+ } catch (error) {
39352
+ firstError ??= error;
39353
+ controller.abort();
39354
+ }
39355
+ await worker();
39356
+ };
39357
+ await Promise.all(Array.from({
39358
+ length: Math.min(networkConcurrency(options.concurrency), manifest.files.length)
39359
+ }, () => worker()));
39360
+ if (firstError)
39361
+ throw firstError;
39362
+ updateTiming();
39363
+ return transfer;
38800
39364
  };
38801
39365
  const check = async (download = false) => {
38802
39366
  const response = await request(manifestUrl, {
@@ -38818,30 +39382,56 @@ var createAbsoluteMobileUpdateClient = (options) => {
38818
39382
  throw new TypeError("Mobile update manifest is not valid JSON.");
38819
39383
  }
38820
39384
  const manifest = parseAbsoluteMobileUpdateManifest(manifestValue);
39385
+ const healthToken = response.headers.get("x-absolute-mobile-health-token");
38821
39386
  requireCompatible(manifest, options.config);
38822
39387
  if (!await options.verifier.verify(manifest))
38823
39388
  throw new TypeError("Mobile update signature verification failed.");
38824
39389
  if (manifest.releaseId === options.config.currentReleaseId)
38825
39390
  return { kind: "current" };
38826
39391
  if (options.config.blockedReleaseIds?.includes(manifest.releaseId))
38827
- return { kind: "quarantined", releaseId: manifest.releaseId };
39392
+ return {
39393
+ ...healthToken ? { healthToken } : {},
39394
+ kind: "quarantined",
39395
+ releaseId: manifest.releaseId
39396
+ };
38828
39397
  if (!download)
38829
- return { kind: "update-available", manifest };
39398
+ return {
39399
+ ...healthToken ? { healthToken } : {},
39400
+ kind: "update-available",
39401
+ manifest
39402
+ };
38830
39403
  await options.store.begin(manifest);
38831
39404
  let transfer;
38832
39405
  try {
38833
39406
  transfer = await downloadFiles(manifest);
38834
39407
  await options.store.commit(manifest);
38835
39408
  } catch (error) {
38836
- await options.store.abort(manifest.releaseId);
39409
+ if (options.store.suspend)
39410
+ await options.store.suspend(manifest.releaseId);
39411
+ else
39412
+ await options.store.abort(manifest.releaseId);
39413
+ if (healthToken)
39414
+ reportAbsoluteMobileUpdateHealth(options.config, {
39415
+ healthToken,
39416
+ kind: "download-failed",
39417
+ releaseId: manifest.releaseId
39418
+ }, request).catch(() => {
39419
+ return;
39420
+ });
38837
39421
  throw error;
38838
39422
  }
38839
- return { kind: "downloaded", manifest, transfer };
39423
+ return {
39424
+ ...healthToken ? { healthToken } : {},
39425
+ kind: "downloaded",
39426
+ manifest,
39427
+ transfer
39428
+ };
38840
39429
  };
38841
39430
  return {
38842
39431
  check,
38843
39432
  activate: (releaseId) => options.store.activate(releaseId),
38844
- download: () => check(true)
39433
+ download: () => check(true),
39434
+ report: (input) => reportAbsoluteMobileUpdateHealth(options.config, input, request)
38845
39435
  };
38846
39436
  };
38847
39437
  // src/mobile/updatePublisher.ts
@@ -38964,6 +39554,34 @@ var lifecycleMethod = (publisher, name) => {
38964
39554
  throw new TypeError(`Mobile update registry does not support ${name}. Re-run \`absolute mobile update provision --force\` after upgrading @absolutejs/deploy.`);
38965
39555
  return method;
38966
39556
  };
39557
+ var inspectAbsoluteMobileUpdateHealth = async (options) => {
39558
+ const report = await lifecycleMethod(options.publisher, "inspectUpdateHealth")({
39559
+ appId: options.appId,
39560
+ channel: options.channel,
39561
+ ...options.releaseId ? { releaseId: options.releaseId } : {}
39562
+ });
39563
+ if (report === null)
39564
+ return null;
39565
+ if (!object5(report) || report.appId !== options.appId || report.channel !== options.channel || options.releaseId !== undefined && report.releaseId !== options.releaseId || typeof report.paused !== "boolean" || ![
39566
+ report.activated,
39567
+ report.downloaded,
39568
+ report.downloadFailed,
39569
+ report.failures,
39570
+ report.quarantined,
39571
+ report.reportedInstallations,
39572
+ report.rolledBack,
39573
+ report.terminalReports
39574
+ ].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) || ![
39575
+ report.transfer.avoidedBytes,
39576
+ report.transfer.downloadedBytes,
39577
+ report.transfer.durationMs,
39578
+ report.transfer.resumedBytes,
39579
+ report.transfer.reusedBytes,
39580
+ report.transfer.throughputBytesPerSecond
39581
+ ].every((value) => Number.isFinite(value) && value >= 0))
39582
+ throw new TypeError("Mobile update registry returned an invalid health report.");
39583
+ return report;
39584
+ };
38967
39585
  var validOptionalCounters = (values) => values.every((value) => value === undefined) || values.every((value) => Number.isSafeInteger(value) && (value ?? -1) >= 0);
38968
39586
  var validateStorageIdentity = (result, appId) => {
38969
39587
  if (!object5(result) || result.appId !== appId || !Array.isArray(result.releases) || ![
@@ -39225,6 +39843,7 @@ export {
39225
39843
  hashAbsoluteMobilePropsSchema,
39226
39844
  inspectAbsoluteAndroidInstalledApp,
39227
39845
  inspectAbsoluteMobileRouteMetadata,
39846
+ inspectAbsoluteMobileUpdateHealth,
39228
39847
  inspectAbsoluteMobileUpdateServer,
39229
39848
  inspectAbsoluteMobileUpdateStorage,
39230
39849
  inspectAbsoluteRemoteMac,
@@ -39295,6 +39914,7 @@ export {
39295
39914
  removeAbsoluteRemoteMacProfile,
39296
39915
  renderAbsoluteMobileUpdateRegistry,
39297
39916
  repairAbsoluteIosDevSession,
39917
+ reportAbsoluteMobileUpdateHealth,
39298
39918
  requestAbsoluteMobileBack,
39299
39919
  requireAbsoluteIosReleaseMetadata,
39300
39920
  resolveAbsoluteDeviceCapabilityPlan,
@@ -39333,5 +39953,5 @@ export {
39333
39953
  writeAbsoluteMobileUpdateRegistry
39334
39954
  };
39335
39955
 
39336
- //# debugId=A051836D5390197D64756E2164756E21
39956
+ //# debugId=01F0CE93B9ED6BCC64756E2164756E21
39337
39957
  //# sourceMappingURL=index.js.map