@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.
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) => {
@@ -13078,24 +13319,69 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13078
13319
  const expoCodeSigning = resolveExpoCodeSigning(options.expoCodeSigning);
13079
13320
  return async (request) => {
13080
13321
  const origin = request.headers.get("origin");
13081
- const cors = origin && allowedOrigins.has(origin) ? { "access-control-allow-origin": origin, vary: "Origin" } : {};
13322
+ const cors = origin && allowedOrigins.has(origin) ? {
13323
+ "access-control-allow-origin": origin,
13324
+ "access-control-expose-headers": "content-range,etag,x-absolute-mobile-health-token",
13325
+ vary: "Origin"
13326
+ } : {};
13082
13327
  if (request.method === "OPTIONS") {
13083
13328
  if (!origin || !allowedOrigins.has(origin))
13084
13329
  return new Response(null, { status: 403 });
13085
13330
  return new Response(null, {
13086
13331
  headers: {
13087
13332
  ...cors,
13088
- "access-control-allow-headers": "x-absolute-mobile-app,x-absolute-mobile-channel,x-absolute-mobile-installation,x-absolute-mobile-release,x-absolute-mobile-runtime",
13089
- "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",
13090
13335
  "access-control-max-age": "600"
13091
13336
  },
13092
13337
  status: 204
13093
13338
  });
13094
13339
  }
13095
- if (request.method !== "GET")
13096
- return new Response(null, { status: 405 });
13097
13340
  const pathname = new URL(request.url).pathname.replace(/^\/+/, "");
13098
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 });
13099
13385
  if (relative7 === "update.json") {
13100
13386
  const expoProtocolVersion = request.headers.get("expo-protocol-version");
13101
13387
  const expoProtocol = expoProtocolVersion !== null;
@@ -13121,6 +13407,13 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13121
13407
  installationId,
13122
13408
  runtimeFingerprint
13123
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;
13124
13417
  if (expoProtocol) {
13125
13418
  let requestedCodeSigning;
13126
13419
  try {
@@ -13172,6 +13465,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13172
13465
  extra: {
13173
13466
  absolutejs: {
13174
13467
  channel: selected.manifest.channel,
13468
+ ...healthToken ? { healthToken } : {},
13175
13469
  releaseId: selected.manifest.releaseId
13176
13470
  },
13177
13471
  expoClient: descriptor.expoConfig
@@ -13199,7 +13493,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13199
13493
  headers: {
13200
13494
  ...cors,
13201
13495
  "cache-control": "no-store",
13202
- etag: `"${selected.manifest.releaseId}"`
13496
+ etag: `"${selected.manifest.releaseId}"`,
13497
+ ...healthToken ? { "x-absolute-mobile-health-token": healthToken } : {}
13203
13498
  }
13204
13499
  });
13205
13500
  }
@@ -13213,14 +13508,42 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
13213
13508
  });
13214
13509
  if (!file2)
13215
13510
  return new Response(null, { status: 404 });
13216
- return new Response(new Blob([new Uint8Array(file2.bytes).buffer]), {
13511
+ const etag = `"${file2.file.sha256}"`;
13512
+ const range = request.headers.get("range");
13513
+ const useRange = range !== null && (!request.headers.has("if-range") || request.headers.get("if-range") === etag);
13514
+ let contents = file2.bytes;
13515
+ let status = 200;
13516
+ let contentRange;
13517
+ if (useRange) {
13518
+ const parsed = /^bytes=(\d+)-(\d*)$/.exec(range);
13519
+ const start = parsed?.[1] === undefined ? NaN : Number(parsed[1]);
13520
+ const requestedEnd = parsed?.[2] === undefined || parsed[2] === "" ? file2.bytes.byteLength - 1 : Number(parsed[2]);
13521
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(requestedEnd) || start < 0 || start >= file2.bytes.byteLength || requestedEnd < start)
13522
+ return new Response(null, {
13523
+ headers: {
13524
+ ...cors,
13525
+ "accept-ranges": "bytes",
13526
+ "content-range": `bytes */${file2.bytes.byteLength}`,
13527
+ etag
13528
+ },
13529
+ status: 416
13530
+ });
13531
+ const end = Math.min(requestedEnd, file2.bytes.byteLength - 1);
13532
+ contents = file2.bytes.slice(start, end + 1);
13533
+ status = 206;
13534
+ contentRange = `bytes ${start}-${end}/${file2.bytes.byteLength}`;
13535
+ }
13536
+ return new Response(new Blob([new Uint8Array(contents).buffer]), {
13217
13537
  headers: {
13218
13538
  ...cors,
13539
+ "accept-ranges": "bytes",
13219
13540
  "cache-control": "public, max-age=31536000, immutable",
13220
- "content-length": String(file2.file.bytes),
13541
+ "content-length": String(contents.byteLength),
13542
+ ...contentRange ? { "content-range": contentRange } : {},
13221
13543
  "content-type": expoContentType(file2.file.path.includes(".") ? file2.file.path.slice(file2.file.path.lastIndexOf(".") + 1) : undefined, false),
13222
- etag: `"${file2.file.sha256}"`
13223
- }
13544
+ etag
13545
+ },
13546
+ status
13224
13547
  });
13225
13548
  };
13226
13549
  };
@@ -13234,6 +13557,14 @@ var init_mobileUpdate = __esm(() => {
13234
13557
  RELEASE = /^amu_[a-f0-9]{64}$/;
13235
13558
  APP_ID = /^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/;
13236
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"]);
13237
13568
  MobileUpdateRegistryError = class MobileUpdateRegistryError extends Error {
13238
13569
  };
13239
13570
  expoProtocolHeaders = {
@@ -13321,6 +13652,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13321
13652
  } catch (error) {
13322
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 });
13323
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`.");
13324
13660
  }, expoSigningOptions = (config) => {
13325
13661
  if (!config.updates?.expoCodeSigning)
13326
13662
  return;
@@ -13352,8 +13688,10 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13352
13688
  if (!updates || !server?.autoMount)
13353
13689
  return new Elysia4({ name: "absolutejs-mobile-updates-disabled" });
13354
13690
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, server.registryModule);
13355
- if (options.production)
13691
+ if (options.production) {
13356
13692
  await verifyDurableModule(module);
13693
+ verifyHealthModule(config, module);
13694
+ }
13357
13695
  const manifest = new URL(updates.manifestUrl);
13358
13696
  if (!manifest.pathname.endsWith("/update.json"))
13359
13697
  throw new TypeError("Auto-mounted mobile update manifests must end in /update.json.");
@@ -13372,10 +13710,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
13372
13710
  return;
13373
13711
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
13374
13712
  await verifyDurableModule(module);
13713
+ verifyHealthModule(config, module);
13375
13714
  if (config.engine === "expo")
13376
13715
  expoSigningOptions(config);
13377
13716
  return module.metadata;
13378
- }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistry = (options) => {
13717
+ }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistryBase = (options) => {
13379
13718
  const metadata2 = `export const absoluteMobileUpdateServer = {
13380
13719
  format: 1,
13381
13720
  provider: '${options.storage}',
@@ -13441,6 +13780,19 @@ export default createMobileUpdateRegistry({
13441
13780
  store
13442
13781
  });
13443
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}`);
13444
13796
  }, writeAbsoluteMobileUpdateRegistry = async (options) => {
13445
13797
  const path2 = projectPath(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
13446
13798
  if (!options.force) {
@@ -13452,6 +13804,7 @@ export default createMobileUpdateRegistry({
13452
13804
  }
13453
13805
  await mkdir6(dirname12(path2), { recursive: true });
13454
13806
  await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
13807
+ ...options.health ? { health: options.health } : {},
13455
13808
  publicKeys: options.publicKeys,
13456
13809
  storage: options.storage
13457
13810
  }));
@@ -44864,5 +45217,5 @@ export {
44864
45217
  wrapPageHandlerWithStreamingSlots
44865
45218
  };
44866
45219
 
44867
- //# debugId=CE82990F0B81B09764756E2164756E21
45220
+ //# debugId=CC09B0266A0CC45B64756E2164756E21
44868
45221
  //# sourceMappingURL=index.js.map