@absolutejs/absolute 0.20.0-beta.85 → 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.
package/dist/index.js CHANGED
@@ -10636,7 +10636,7 @@ __export(exports_config, {
10636
10636
  import { readFileSync as readFileSync11 } from "fs";
10637
10637
  import { resolve as resolve12 } from "path";
10638
10638
  import { createHash as createHash4, createPublicKey, X509Certificate } from "crypto";
10639
- 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, field) => {
10639
+ 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, field) => {
10640
10640
  const root = resolve12(projectRoot);
10641
10641
  const path = resolve12(root, value);
10642
10642
  if (path !== root && !path.startsWith(`${root}/`)) {
@@ -10830,6 +10830,20 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
10830
10830
  if (!ENVIRONMENT_NAME_PATTERN.test(expoPrivateKeyEnv))
10831
10831
  throw new TypeError("mobile.updates.server.expoPrivateKeyEnv must be a valid environment variable name.");
10832
10832
  const autoMount = config.updates.server?.autoMount ?? true;
10833
+ const configuredHealth = config.updates.server?.health;
10834
+ let health;
10835
+ if (configuredHealth !== false) {
10836
+ const failureRate = configuredHealth?.failureRate ?? DEFAULT_UPDATE_HEALTH_FAILURE_RATE;
10837
+ const minimumReports = configuredHealth?.minimumReports ?? DEFAULT_UPDATE_HEALTH_MINIMUM_REPORTS;
10838
+ const secretEnv = requireText(configuredHealth?.secretEnv ?? "ABSOLUTE_MOBILE_UPDATE_HEALTH_SECRET", "mobile.updates.server.health.secretEnv");
10839
+ if (!Number.isFinite(failureRate) || failureRate <= 0 || failureRate > 1)
10840
+ throw new TypeError("mobile.updates.server.health.failureRate must be greater than 0 and at most 1.");
10841
+ if (!Number.isSafeInteger(minimumReports) || minimumReports < 1)
10842
+ throw new TypeError("mobile.updates.server.health.minimumReports must be a positive integer.");
10843
+ if (!ENVIRONMENT_NAME_PATTERN.test(secretEnv))
10844
+ throw new TypeError("mobile.updates.server.health.secretEnv must be a valid environment variable name.");
10845
+ health = { failureRate, minimumReports, secretEnv };
10846
+ }
10833
10847
  if (autoMount) {
10834
10848
  const manifest = new URL(updates.manifestUrl);
10835
10849
  if (manifest.origin !== productionOrigin)
@@ -10862,7 +10876,12 @@ var APP_ID_PATTERN, SCHEME_PATTERN, APPLE_APP_ID_PREFIX_PATTERN, CERTIFICATE_FIN
10862
10876
  throw new TypeError(`mobile.updates.server.expoCodeSigningKeys.${keyId} certificate is not currently valid.`);
10863
10877
  expoCodeSigningKeys[keyId] = { certificatePem, privateKeyEnv };
10864
10878
  }
10865
- return { autoMount, expoCodeSigningKeys, registryModule };
10879
+ return {
10880
+ autoMount,
10881
+ expoCodeSigningKeys,
10882
+ ...health ? { health } : {},
10883
+ registryModule
10884
+ };
10866
10885
  }, validateExpoNativeRouteSegment = (path, segment, index, count, parameters) => {
10867
10886
  if (segment === "*" && (index !== count - 1 || count === 1)) {
10868
10887
  throw new TypeError(`mobile.routes.native route ${path} must use * once, as the final segment after a static or parameterized prefix.`);
@@ -12280,15 +12299,17 @@ __export(exports_mobileUpdate, {
12280
12299
  });
12281
12300
  import {
12282
12301
  createHash as createHash5,
12302
+ createHmac,
12283
12303
  createPrivateKey,
12284
12304
  createPublicKey as createPublicKey2,
12285
12305
  sign,
12306
+ timingSafeEqual,
12286
12307
  verify,
12287
12308
  X509Certificate as X509Certificate2
12288
12309
  } from "crypto";
12289
12310
  import { readFile as readFile4, stat as stat2 } from "fs/promises";
12290
12311
  import path from "path";
12291
- 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, object3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), text2 = (value, field) => {
12312
+ 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, object3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value), text2 = (value, field) => {
12292
12313
  if (typeof value !== "string" || value.length === 0)
12293
12314
  throw new MobileUpdateRegistryError(`Mobile update ${field} is invalid`);
12294
12315
  return value;
@@ -12448,15 +12469,43 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12448
12469
  return true;
12449
12470
  const value = createHash5("sha256").update(`${input.appId}\x00${input.channel}\x00${input.releaseId}\x00${input.installationId}`).digest().readUInt32BE(0);
12450
12471
  return value / 4294967296 < input.rollout;
12472
+ }, 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, field) => {
12473
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
12474
+ throw new MobileUpdateRegistryError(`Mobile update health ${field} is invalid`);
12475
+ return value;
12476
+ }, parseHealthTransfer = (value) => {
12477
+ if (value === undefined)
12478
+ return;
12479
+ if (!object3(value))
12480
+ throw new MobileUpdateRegistryError("Mobile update health transfer is invalid");
12481
+ return {
12482
+ avoidedBytes: finiteMetric(value.avoidedBytes, "avoidedBytes"),
12483
+ downloadedBytes: finiteMetric(value.downloadedBytes, "downloadedBytes"),
12484
+ durationMs: finiteMetric(value.durationMs, "durationMs"),
12485
+ resumedBytes: finiteMetric(value.resumedBytes, "resumedBytes"),
12486
+ reusedBytes: finiteMetric(value.reusedBytes, "reusedBytes"),
12487
+ throughputBytesPerSecond: finiteMetric(value.throughputBytesPerSecond, "throughputBytesPerSecond")
12488
+ };
12451
12489
  }, createMobileUpdateRegistry = (options) => {
12452
12490
  const prefix = normalizedPrefix(options.prefix ?? DEFAULT_PREFIX);
12453
12491
  const clock = options.clock ?? (() => new Date);
12492
+ const health = options.health;
12493
+ if (health && health.secret.length < 32)
12494
+ throw new MobileUpdateRegistryError("Mobile update health secret must contain at least 32 characters");
12495
+ if (health && !options.store.list)
12496
+ throw new MobileUpdateRegistryError("Mobile update health requires storage lifecycle listing");
12497
+ const minimumReports = health?.autoPause?.minimumReports ?? 20;
12498
+ const failureThreshold = health?.autoPause?.failureRate ?? 0.2;
12499
+ if (health && (!Number.isSafeInteger(minimumReports) || minimumReports < 1 || failureThreshold <= 0 || failureThreshold > 1))
12500
+ throw new MobileUpdateRegistryError("Mobile update health auto-pause policy is invalid");
12454
12501
  const root = (appId) => `${prefix}/${appHash(appId)}`;
12455
12502
  const releaseRoot = (manifest) => `${root(manifest.appId)}/releases/${manifest.releaseId}`;
12456
12503
  const manifestKey = (manifest) => `${releaseRoot(manifest)}/update.json`;
12457
12504
  const fileKey = (manifest, file2) => `${releaseRoot(manifest)}/files/${file2.path}`;
12458
12505
  const contentBlobKey = (appId, sha256) => `${root(appId)}/blobs/${sha256}`;
12459
12506
  const tombstoneKey = (appId, releaseId) => `${root(appId)}/gc/${releaseId}.json`;
12507
+ const healthRoot = (appId, promotionId, releaseId) => `${root(appId)}/health/${promotionId}/${releaseId}`;
12508
+ const pauseKey = (appId, promotionId, releaseId) => `${healthRoot(appId, promotionId, releaseId)}/paused.json`;
12460
12509
  const channelKey = (appId, channel) => {
12461
12510
  if (!APP_ID.test(appId) || !NAME.test(channel))
12462
12511
  throw new MobileUpdateRegistryError("Mobile update channel identity is invalid");
@@ -12485,6 +12534,38 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12485
12534
  throw new MobileUpdateRegistryError("Stored mobile update channel identity changed");
12486
12535
  return value;
12487
12536
  };
12537
+ const signHealthToken = (payload) => {
12538
+ if (!health)
12539
+ return null;
12540
+ const encoded = base64Url(JSON.stringify(payload));
12541
+ const signature = createHmac("sha256", health.secret).update(encoded).digest("base64url");
12542
+ return `${encoded}.${signature}`;
12543
+ };
12544
+ const verifyHealthToken = (token) => {
12545
+ if (!health)
12546
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
12547
+ const [encoded, provided, extra] = token.split(".");
12548
+ if (!encoded || !provided || extra)
12549
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
12550
+ const expected = createHmac("sha256", health.secret).update(encoded).digest();
12551
+ let actual;
12552
+ try {
12553
+ actual = Buffer.from(provided, "base64url");
12554
+ } catch {
12555
+ actual = Buffer.alloc(0);
12556
+ }
12557
+ if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected))
12558
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
12559
+ let value;
12560
+ try {
12561
+ value = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
12562
+ } catch {
12563
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
12564
+ }
12565
+ if (!object3(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")
12566
+ throw new MobileUpdateRegistryError("Mobile update health token is invalid");
12567
+ return value;
12568
+ };
12488
12569
  const assertNotMarked = async (appId, releaseId) => {
12489
12570
  if (!APP_ID.test(appId) || !RELEASE.test(releaseId))
12490
12571
  throw new MobileUpdateRegistryError("Mobile update release identity is invalid");
@@ -12539,13 +12620,15 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12539
12620
  const channel = await readChannel(input.appId, input.channel);
12540
12621
  if (!channel?.releaseId)
12541
12622
  return { status: "empty" };
12542
- const selected = rolloutMember({
12623
+ let selected = rolloutMember({
12543
12624
  appId: input.appId,
12544
12625
  channel: input.channel,
12545
12626
  installationId: input.installationId,
12546
12627
  releaseId: channel.releaseId,
12547
12628
  rollout: channel.rollout
12548
12629
  }) ? channel.releaseId : channel.fallbackReleaseId;
12630
+ if (health && selected === channel.releaseId && await options.store.head(pauseKey(input.appId, healthPromotionId(channel), channel.releaseId)))
12631
+ selected = channel.fallbackReleaseId;
12549
12632
  if (!selected)
12550
12633
  return { status: "empty" };
12551
12634
  const release = await readManifest(input.appId, selected);
@@ -12561,6 +12644,163 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12561
12644
  status: "selected"
12562
12645
  };
12563
12646
  };
12647
+ const issueUpdateHealthToken = async (input) => {
12648
+ if (!health)
12649
+ return null;
12650
+ const channel = await readChannel(input.appId, input.channel);
12651
+ if (!channel?.releaseId || channel.releaseId !== input.releaseId)
12652
+ return null;
12653
+ const resolution = await resolveUpdateState(input);
12654
+ if (resolution.status !== "selected" || resolution.manifest.releaseId !== input.releaseId)
12655
+ return null;
12656
+ return signHealthToken({
12657
+ appId: input.appId,
12658
+ channel: input.channel,
12659
+ format: HEALTH_TOKEN_VERSION,
12660
+ installationId: input.installationId,
12661
+ promotionId: healthPromotionId(channel),
12662
+ releaseId: input.releaseId,
12663
+ runtimeFingerprint: input.runtimeFingerprint
12664
+ });
12665
+ };
12666
+ const inspectUpdateHealth = async (input) => {
12667
+ if (!health)
12668
+ throw new MobileUpdateRegistryError("Mobile update health reporting is not configured");
12669
+ const channel = await readChannel(input.appId, input.channel);
12670
+ const releaseId = input.releaseId ?? channel?.releaseId;
12671
+ if (!channel || !releaseId || channel.releaseId !== releaseId)
12672
+ return null;
12673
+ const promotionId = healthPromotionId(channel);
12674
+ const prefix2 = `${healthRoot(input.appId, promotionId, releaseId)}/events/`;
12675
+ const objects = [];
12676
+ const cursors = new Set;
12677
+ let cursor;
12678
+ do {
12679
+ const page = await options.store.list({
12680
+ ...cursor ? { cursor } : {},
12681
+ prefix: prefix2
12682
+ });
12683
+ objects.push(...page.objects);
12684
+ if (!page.truncated)
12685
+ break;
12686
+ if (!page.cursor || cursors.has(page.cursor))
12687
+ throw new MobileUpdateRegistryError("Mobile update health storage returned an invalid cursor");
12688
+ cursors.add(page.cursor);
12689
+ cursor = page.cursor;
12690
+ } while (true);
12691
+ const installations = new Set;
12692
+ const byKind = new Map([...HEALTH_KINDS].map((kind) => [
12693
+ kind,
12694
+ new Set
12695
+ ]));
12696
+ const transfer = {
12697
+ avoidedBytes: 0,
12698
+ downloadedBytes: 0,
12699
+ durationMs: 0,
12700
+ resumedBytes: 0,
12701
+ reusedBytes: 0,
12702
+ throughputBytesPerSecond: 0
12703
+ };
12704
+ for (const item of objects) {
12705
+ const bytes = await options.store.get(item.key);
12706
+ if (!bytes)
12707
+ continue;
12708
+ const head = await options.store.head(item.key);
12709
+ if (!head || head.size !== bytes.byteLength || head.metadata?.sha256 !== digest(bytes))
12710
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence integrity failed");
12711
+ const value = decode(bytes);
12712
+ if (!object3(value) || typeof value.installationHash !== "string" || !HEALTH_KINDS.has(String(value.kind)))
12713
+ throw new MobileUpdateRegistryError("Stored mobile update health evidence is invalid");
12714
+ const kind = value.kind;
12715
+ installations.add(value.installationHash);
12716
+ byKind.get(kind).add(value.installationHash);
12717
+ if (kind === "downloaded" && object3(value.transfer)) {
12718
+ const parsed = parseHealthTransfer(value.transfer);
12719
+ for (const key of Object.keys(transfer))
12720
+ transfer[key] += parsed[key];
12721
+ }
12722
+ }
12723
+ const failures = new Set([
12724
+ ...byKind.get("quarantined"),
12725
+ ...byKind.get("rolled-back")
12726
+ ]);
12727
+ const terminals = new Set([...byKind.get("activated"), ...failures]);
12728
+ const failureRate = terminals.size === 0 ? 0 : failures.size / terminals.size;
12729
+ return {
12730
+ activated: byKind.get("activated").size,
12731
+ appId: input.appId,
12732
+ channel: input.channel,
12733
+ downloaded: byKind.get("downloaded").size,
12734
+ downloadFailed: byKind.get("download-failed").size,
12735
+ failureRate,
12736
+ failures: failures.size,
12737
+ paused: Boolean(await options.store.head(pauseKey(input.appId, promotionId, releaseId))),
12738
+ promotionId,
12739
+ quarantined: byKind.get("quarantined").size,
12740
+ releaseId,
12741
+ reportedInstallations: installations.size,
12742
+ rolledBack: byKind.get("rolled-back").size,
12743
+ rollout: channel.rollout,
12744
+ terminalReports: terminals.size,
12745
+ transfer
12746
+ };
12747
+ };
12748
+ const recordUpdateHealth = async (input) => {
12749
+ const payload = verifyHealthToken(input.token);
12750
+ 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")
12751
+ throw new MobileUpdateRegistryError("Mobile update health evidence does not match its token");
12752
+ const release = await readManifest(input.appId, input.releaseId);
12753
+ if (!release || release.manifest.runtimeFingerprint !== input.runtimeFingerprint)
12754
+ throw new MobileUpdateRegistryError("Mobile update health release is invalid");
12755
+ const activeChannel = await readChannel(input.appId, input.channel);
12756
+ if (!activeChannel || activeChannel.releaseId !== input.releaseId || healthPromotionId(activeChannel) !== payload.promotionId)
12757
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
12758
+ const transfer = parseHealthTransfer(input.transfer);
12759
+ const installationHash = createHmac("sha256", health.secret).update(input.installationId).digest("hex");
12760
+ const evidence = {
12761
+ format: 1,
12762
+ installationHash,
12763
+ kind: input.kind,
12764
+ observedAt: clock().toISOString(),
12765
+ ...input.reason ? { reason: input.reason } : {},
12766
+ ...transfer ? { transfer } : {}
12767
+ };
12768
+ const bytes = json(evidence);
12769
+ await options.store.put(`${healthRoot(input.appId, payload.promotionId, input.releaseId)}/events/${installationHash}/${input.kind}.json`, bytes, {
12770
+ cacheControl: "no-store",
12771
+ contentType: "application/json",
12772
+ maxBytes: bytes.byteLength,
12773
+ metadata: { kind: input.kind, sha256: digest(bytes) }
12774
+ });
12775
+ let report = await inspectUpdateHealth({
12776
+ appId: input.appId,
12777
+ channel: input.channel,
12778
+ releaseId: input.releaseId
12779
+ });
12780
+ if (!report)
12781
+ throw new MobileUpdateRegistryError("Mobile update health promotion is no longer active");
12782
+ if (FAILURE_HEALTH_KINDS.has(input.kind) && report.terminalReports >= minimumReports && report.failureRate >= failureThreshold && !report.paused) {
12783
+ const marker = json({
12784
+ appId: input.appId,
12785
+ channel: input.channel,
12786
+ failureRate: report.failureRate,
12787
+ failures: report.failures,
12788
+ format: 1,
12789
+ pausedAt: clock().toISOString(),
12790
+ promotionId: payload.promotionId,
12791
+ releaseId: input.releaseId,
12792
+ reports: report.terminalReports
12793
+ });
12794
+ await options.store.put(pauseKey(input.appId, payload.promotionId, input.releaseId), marker, {
12795
+ cacheControl: "no-store",
12796
+ contentType: "application/json",
12797
+ maxBytes: marker.byteLength,
12798
+ metadata: { releaseid: input.releaseId, sha256: digest(marker) }
12799
+ });
12800
+ report = { ...report, paused: true };
12801
+ }
12802
+ return report;
12803
+ };
12564
12804
  const retentionValues = (input) => {
12565
12805
  if (!APP_ID.test(input.appId))
12566
12806
  throw new MobileUpdateRegistryError("Mobile update appId is invalid");
@@ -12803,6 +13043,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
12803
13043
  return result;
12804
13044
  };
12805
13045
  return {
13046
+ ...health ? { inspectUpdateHealth, issueUpdateHealthToken, recordUpdateHealth } : {},
12806
13047
  inspectUpdateStorage: async (input) => (await inventory(input)).report,
12807
13048
  pruneUpdates,
12808
13049
  publishUpdate: async (input) => {
@@ -13080,7 +13321,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13080
13321
  const origin = request.headers.get("origin");
13081
13322
  const cors = origin && allowedOrigins.has(origin) ? {
13082
13323
  "access-control-allow-origin": origin,
13083
- "access-control-expose-headers": "content-range,etag",
13324
+ "access-control-expose-headers": "content-range,etag,x-absolute-mobile-health-token",
13084
13325
  vary: "Origin"
13085
13326
  } : {};
13086
13327
  if (request.method === "OPTIONS") {
@@ -13089,17 +13330,58 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13089
13330
  return new Response(null, {
13090
13331
  headers: {
13091
13332
  ...cors,
13092
- "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",
13093
- "access-control-allow-methods": "GET,OPTIONS",
13333
+ "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",
13334
+ "access-control-allow-methods": "GET,POST,OPTIONS",
13094
13335
  "access-control-max-age": "600"
13095
13336
  },
13096
13337
  status: 204
13097
13338
  });
13098
13339
  }
13099
- if (request.method !== "GET")
13100
- return new Response(null, { status: 405 });
13101
13340
  const pathname = new URL(request.url).pathname.replace(/^\/+/, "");
13102
13341
  const relative7 = pathname.startsWith(`${route}/`) ? pathname.slice(route.length + 1) : "";
13342
+ if (request.method === "POST" && relative7 === "health") {
13343
+ if (!options.registry.recordUpdateHealth)
13344
+ return new Response(null, { status: 404 });
13345
+ const appId = request.headers.get("x-absolute-mobile-app");
13346
+ const channel = request.headers.get("x-absolute-mobile-channel");
13347
+ const installationId = request.headers.get("x-absolute-mobile-installation");
13348
+ const runtimeFingerprint = request.headers.get("x-absolute-mobile-runtime");
13349
+ const token = request.headers.get("x-absolute-mobile-health-token");
13350
+ const declared = Number(request.headers.get("content-length"));
13351
+ if (appId !== options.appId || channel !== options.channel || !installationId || !runtimeFingerprint || !token || Number.isFinite(declared) && declared > 4096)
13352
+ return new Response(null, { status: 400 });
13353
+ const bodyBytes = new Uint8Array(await request.arrayBuffer());
13354
+ if (bodyBytes.byteLength > 4096)
13355
+ return new Response(null, { status: 413 });
13356
+ let body;
13357
+ try {
13358
+ body = JSON.parse(new TextDecoder().decode(bodyBytes));
13359
+ } catch {
13360
+ return new Response(null, { status: 400 });
13361
+ }
13362
+ if (!object3(body) || typeof body.releaseId !== "string" || typeof body.kind !== "string")
13363
+ return new Response(null, { status: 400 });
13364
+ try {
13365
+ const report = await options.registry.recordUpdateHealth({
13366
+ appId,
13367
+ channel,
13368
+ installationId,
13369
+ kind: body.kind,
13370
+ ...body.reason === "boot-interrupted" || body.reason === "boot-timeout" ? { reason: body.reason } : {},
13371
+ releaseId: body.releaseId,
13372
+ runtimeFingerprint,
13373
+ token,
13374
+ ...object3(body.transfer) ? { transfer: body.transfer } : {}
13375
+ });
13376
+ return Response.json({ paused: report.paused }, { headers: { ...cors, "cache-control": "no-store" }, status: 202 });
13377
+ } catch (error) {
13378
+ if (error instanceof MobileUpdateRegistryError)
13379
+ return new Response(null, { status: 403 });
13380
+ throw error;
13381
+ }
13382
+ }
13383
+ if (request.method !== "GET")
13384
+ return new Response(null, { status: 405 });
13103
13385
  if (relative7 === "update.json") {
13104
13386
  const expoProtocolVersion = request.headers.get("expo-protocol-version");
13105
13387
  const expoProtocol = expoProtocolVersion !== null;
@@ -13125,6 +13407,13 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13125
13407
  installationId,
13126
13408
  runtimeFingerprint
13127
13409
  });
13410
+ const healthToken = selected && options.registry.issueUpdateHealthToken ? await options.registry.issueUpdateHealthToken({
13411
+ appId,
13412
+ channel,
13413
+ installationId,
13414
+ releaseId: selected.manifest.releaseId,
13415
+ runtimeFingerprint
13416
+ }) : null;
13128
13417
  if (expoProtocol) {
13129
13418
  let requestedCodeSigning;
13130
13419
  try {
@@ -13176,6 +13465,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13176
13465
  extra: {
13177
13466
  absolutejs: {
13178
13467
  channel: selected.manifest.channel,
13468
+ ...healthToken ? { healthToken } : {},
13179
13469
  releaseId: selected.manifest.releaseId
13180
13470
  },
13181
13471
  expoClient: descriptor.expoConfig
@@ -13203,7 +13493,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13203
13493
  headers: {
13204
13494
  ...cors,
13205
13495
  "cache-control": "no-store",
13206
- etag: `"${selected.manifest.releaseId}"`
13496
+ etag: `"${selected.manifest.releaseId}"`,
13497
+ ...healthToken ? { "x-absolute-mobile-health-token": healthToken } : {}
13207
13498
  }
13208
13499
  });
13209
13500
  }
@@ -13266,6 +13557,14 @@ var init_mobileUpdate = __esm(() => {
13266
13557
  RELEASE = /^amu_[a-f0-9]{64}$/;
13267
13558
  APP_ID = /^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/;
13268
13559
  NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
13560
+ HEALTH_KINDS = new Set([
13561
+ "activated",
13562
+ "downloaded",
13563
+ "download-failed",
13564
+ "quarantined",
13565
+ "rolled-back"
13566
+ ]);
13567
+ FAILURE_HEALTH_KINDS = new Set(["quarantined", "rolled-back"]);
13269
13568
  MobileUpdateRegistryError = class MobileUpdateRegistryError extends Error {
13270
13569
  };
13271
13570
  expoProtocolHeaders = {
@@ -13353,6 +13652,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13353
13652
  } catch (error) {
13354
13653
  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 });
13355
13654
  }
13655
+ }, verifyHealthModule = (config, module) => {
13656
+ if (!config.updateServer?.health)
13657
+ return;
13658
+ if (typeof module.registry.inspectUpdateHealth !== "function" || typeof module.registry.issueUpdateHealthToken !== "function" || typeof module.registry.recordUpdateHealth !== "function")
13659
+ throw new TypeError("Mobile update fleet health is enabled but the registry is not provisioned for it. Run `absolute mobile update provision --force`.");
13356
13660
  }, expoSigningOptions = (config) => {
13357
13661
  if (!config.updates?.expoCodeSigning)
13358
13662
  return;
@@ -13384,8 +13688,10 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13384
13688
  if (!updates || !server?.autoMount)
13385
13689
  return new Elysia4({ name: "absolutejs-mobile-updates-disabled" });
13386
13690
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, server.registryModule);
13387
- if (options.production)
13691
+ if (options.production) {
13388
13692
  await verifyDurableModule(module);
13693
+ verifyHealthModule(config, module);
13694
+ }
13389
13695
  const manifest = new URL(updates.manifestUrl);
13390
13696
  if (!manifest.pathname.endsWith("/update.json"))
13391
13697
  throw new TypeError("Auto-mounted mobile update manifests must end in /update.json.");
@@ -13404,10 +13710,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13404
13710
  return;
13405
13711
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
13406
13712
  await verifyDurableModule(module);
13713
+ verifyHealthModule(config, module);
13407
13714
  if (config.engine === "expo")
13408
13715
  expoSigningOptions(config);
13409
13716
  return module.metadata;
13410
- }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistry = (options) => {
13717
+ }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistryBase = (options) => {
13411
13718
  const metadata2 = `export const absoluteMobileUpdateServer = {
13412
13719
  format: 1,
13413
13720
  provider: '${options.storage}',
@@ -13473,6 +13780,19 @@ export default createMobileUpdateRegistry({
13473
13780
  store
13474
13781
  });
13475
13782
  `;
13783
+ }, renderAbsoluteMobileUpdateRegistry = (options) => {
13784
+ const source = renderAbsoluteMobileUpdateRegistryBase(options);
13785
+ if (!options.health)
13786
+ return source;
13787
+ const secret = options.storage === "local" ? `process.env.${options.health.secretEnv} ?? 'absolutejs-local-health-secret-not-for-production'` : `required('${options.health.secretEnv}')`;
13788
+ const health = ` health: {
13789
+ autoPause: { failureRate: ${options.health.failureRate}, minimumReports: ${options.health.minimumReports} },
13790
+ secret: ${secret}
13791
+ },
13792
+ `;
13793
+ return source.replace(`export default createMobileUpdateRegistry({
13794
+ `, `export default createMobileUpdateRegistry({
13795
+ ${health}`);
13476
13796
  }, writeAbsoluteMobileUpdateRegistry = async (options) => {
13477
13797
  const path2 = projectPath(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
13478
13798
  if (!options.force) {
@@ -13484,6 +13804,7 @@ export default createMobileUpdateRegistry({
13484
13804
  }
13485
13805
  await mkdir6(dirname12(path2), { recursive: true });
13486
13806
  await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
13807
+ ...options.health ? { health: options.health } : {},
13487
13808
  publicKeys: options.publicKeys,
13488
13809
  storage: options.storage
13489
13810
  }));
@@ -44896,5 +45217,5 @@ export {
44896
45217
  wrapPageHandlerWithStreamingSlots
44897
45218
  };
44898
45219
 
44899
- //# debugId=99E9CBD68908A78B64756E2164756E21
45220
+ //# debugId=CC09B0266A0CC45B64756E2164756E21
44900
45221
  //# sourceMappingURL=index.js.map