@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.
@@ -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) => {
@@ -20762,7 +21003,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20762
21003
  const origin = request.headers.get("origin");
20763
21004
  const cors = origin && allowedOrigins.has(origin) ? {
20764
21005
  "access-control-allow-origin": origin,
20765
- "access-control-expose-headers": "content-range,etag",
21006
+ "access-control-expose-headers": "content-range,etag,x-absolute-mobile-health-token",
20766
21007
  vary: "Origin"
20767
21008
  } : {};
20768
21009
  if (request.method === "OPTIONS") {
@@ -20771,17 +21012,58 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20771
21012
  return new Response(null, {
20772
21013
  headers: {
20773
21014
  ...cors,
20774
- "access-control-allow-headers": "if-range,range,x-absolute-mobile-app,x-absolute-mobile-channel,x-absolute-mobile-installation,x-absolute-mobile-release,x-absolute-mobile-runtime",
20775
- "access-control-allow-methods": "GET,OPTIONS",
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",
20776
21017
  "access-control-max-age": "600"
20777
21018
  },
20778
21019
  status: 204
20779
21020
  });
20780
21021
  }
20781
- if (request.method !== "GET")
20782
- return new Response(null, { status: 405 });
20783
21022
  const pathname = new URL(request.url).pathname.replace(/^\/+/, "");
20784
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 });
20785
21067
  if (relative17 === "update.json") {
20786
21068
  const expoProtocolVersion = request.headers.get("expo-protocol-version");
20787
21069
  const expoProtocol = expoProtocolVersion !== null;
@@ -20807,6 +21089,13 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20807
21089
  installationId,
20808
21090
  runtimeFingerprint
20809
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;
20810
21099
  if (expoProtocol) {
20811
21100
  let requestedCodeSigning;
20812
21101
  try {
@@ -20858,6 +21147,7 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20858
21147
  extra: {
20859
21148
  absolutejs: {
20860
21149
  channel: selected.manifest.channel,
21150
+ ...healthToken ? { healthToken } : {},
20861
21151
  releaseId: selected.manifest.releaseId
20862
21152
  },
20863
21153
  expoClient: descriptor.expoConfig
@@ -20885,7 +21175,8 @@ var MOBILE_UPDATE_REGISTRY_FORMAT = 1, DEFAULT_PREFIX = "absolutejs/mobile-updat
20885
21175
  headers: {
20886
21176
  ...cors,
20887
21177
  "cache-control": "no-store",
20888
- etag: `"${selected.manifest.releaseId}"`
21178
+ etag: `"${selected.manifest.releaseId}"`,
21179
+ ...healthToken ? { "x-absolute-mobile-health-token": healthToken } : {}
20889
21180
  }
20890
21181
  });
20891
21182
  }
@@ -20948,6 +21239,14 @@ var init_mobileUpdate = __esm(() => {
20948
21239
  RELEASE = /^amu_[a-f0-9]{64}$/;
20949
21240
  APP_ID = /^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/;
20950
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"]);
20951
21250
  MobileUpdateRegistryError = class MobileUpdateRegistryError extends Error {
20952
21251
  };
20953
21252
  expoProtocolHeaders = {
@@ -21035,6 +21334,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
21035
21334
  } catch (error) {
21036
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 });
21037
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`.");
21038
21342
  }, expoSigningOptions = (config) => {
21039
21343
  if (!config.updates?.expoCodeSigning)
21040
21344
  return;
@@ -21066,8 +21370,10 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
21066
21370
  if (!updates || !server?.autoMount)
21067
21371
  return new Elysia4({ name: "absolutejs-mobile-updates-disabled" });
21068
21372
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, server.registryModule);
21069
- if (options.production)
21373
+ if (options.production) {
21070
21374
  await verifyDurableModule(module);
21375
+ verifyHealthModule(config, module);
21376
+ }
21071
21377
  const manifest = new URL(updates.manifestUrl);
21072
21378
  if (!manifest.pathname.endsWith("/update.json"))
21073
21379
  throw new TypeError("Auto-mounted mobile update manifests must end in /update.json.");
@@ -21086,10 +21392,11 @@ var ABSOLUTE_MOBILE_UPDATE_SERVER_FORMAT = 1, DEFAULT_MOBILE_UPDATE_REGISTRY_MOD
21086
21392
  return;
21087
21393
  const module = await loadAbsoluteMobileUpdateServerModule(projectRoot, config.updateServer?.registryModule);
21088
21394
  await verifyDurableModule(module);
21395
+ verifyHealthModule(config, module);
21089
21396
  if (config.engine === "expo")
21090
21397
  expoSigningOptions(config);
21091
21398
  return module.metadata;
21092
- }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistry = (options) => {
21399
+ }, publicKeysSource = (publicKeys) => JSON.stringify(publicKeys, null, "\t"), renderAbsoluteMobileUpdateRegistryBase = (options) => {
21093
21400
  const metadata = `export const absoluteMobileUpdateServer = {
21094
21401
  format: 1,
21095
21402
  provider: '${options.storage}',
@@ -21155,6 +21462,19 @@ export default createMobileUpdateRegistry({
21155
21462
  store
21156
21463
  });
21157
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}`);
21158
21478
  }, writeAbsoluteMobileUpdateRegistry = async (options) => {
21159
21479
  const path2 = projectPath3(options.projectRoot, options.modulePath ?? DEFAULT_MOBILE_UPDATE_REGISTRY_MODULE);
21160
21480
  if (!options.force) {
@@ -21166,6 +21486,7 @@ export default createMobileUpdateRegistry({
21166
21486
  }
21167
21487
  await mkdir18(dirname19(path2), { recursive: true });
21168
21488
  await Bun.write(path2, renderAbsoluteMobileUpdateRegistry({
21489
+ ...options.health ? { health: options.health } : {},
21169
21490
  publicKeys: options.publicKeys,
21170
21491
  storage: options.storage
21171
21492
  }));
@@ -27832,11 +28153,48 @@ export default function AbsoluteLayout() {
27832
28153
  return <Stack screenOptions={{ headerShown: false }} />;
27833
28154
  }
27834
28155
  `;
27835
- var updatesRuntimeSource = () => `${EXPO_GENERATED_HEADER}import { randomUUID } from 'expo-crypto';
28156
+ var updatesRuntimeSource = (config) => `${EXPO_GENERATED_HEADER}import { randomUUID } from 'expo-crypto';
27836
28157
  import * as SecureStore from 'expo-secure-store';
27837
28158
  import * as Updates from 'expo-updates';
27838
28159
 
27839
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
+ };
27840
28198
 
27841
28199
  let startPromise: Promise<void> | undefined;
27842
28200
  export const startAbsoluteExpoUpdates = () => {
@@ -27848,9 +28206,34 @@ export const startAbsoluteExpoUpdates = () => {
27848
28206
  await SecureStore.setItemAsync(INSTALLATION_KEY, installationId);
27849
28207
  }
27850
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
+ }
27851
28223
  const result = await Updates.checkForUpdateAsync();
27852
28224
  if (result.isAvailable || result.isRollBackToEmbedded) {
27853
- 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
+ }
27854
28237
  if (result.isRollBackToEmbedded) {
27855
28238
  await Updates.reloadAsync();
27856
28239
  }
@@ -28795,7 +29178,7 @@ node_modules/
28795
29178
  files.set(path, source);
28796
29179
  }
28797
29180
  if (config.updates) {
28798
- files.set(join15(project, "src", "generated", "AbsoluteUpdates.ts"), updatesRuntimeSource());
29181
+ files.set(join15(project, "src", "generated", "AbsoluteUpdates.ts"), updatesRuntimeSource(config));
28799
29182
  }
28800
29183
  const expoCodeSigning = config.updates?.expoCodeSigning;
28801
29184
  if (expoCodeSigning)
@@ -38780,6 +39163,30 @@ var requestHeaders = (config) => ({
38780
39163
  "x-absolute-mobile-release": config.currentReleaseId,
38781
39164
  "x-absolute-mobile-runtime": config.runtimeFingerprint
38782
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
+ };
38783
39190
  var requireCompatible = (manifest, config) => {
38784
39191
  if (manifest.appId !== config.appId)
38785
39192
  throw new TypeError("Mobile update belongs to another app.");
@@ -38975,15 +39382,24 @@ var createAbsoluteMobileUpdateClient = (options) => {
38975
39382
  throw new TypeError("Mobile update manifest is not valid JSON.");
38976
39383
  }
38977
39384
  const manifest = parseAbsoluteMobileUpdateManifest(manifestValue);
39385
+ const healthToken = response.headers.get("x-absolute-mobile-health-token");
38978
39386
  requireCompatible(manifest, options.config);
38979
39387
  if (!await options.verifier.verify(manifest))
38980
39388
  throw new TypeError("Mobile update signature verification failed.");
38981
39389
  if (manifest.releaseId === options.config.currentReleaseId)
38982
39390
  return { kind: "current" };
38983
39391
  if (options.config.blockedReleaseIds?.includes(manifest.releaseId))
38984
- return { kind: "quarantined", releaseId: manifest.releaseId };
39392
+ return {
39393
+ ...healthToken ? { healthToken } : {},
39394
+ kind: "quarantined",
39395
+ releaseId: manifest.releaseId
39396
+ };
38985
39397
  if (!download)
38986
- return { kind: "update-available", manifest };
39398
+ return {
39399
+ ...healthToken ? { healthToken } : {},
39400
+ kind: "update-available",
39401
+ manifest
39402
+ };
38987
39403
  await options.store.begin(manifest);
38988
39404
  let transfer;
38989
39405
  try {
@@ -38994,14 +39410,28 @@ var createAbsoluteMobileUpdateClient = (options) => {
38994
39410
  await options.store.suspend(manifest.releaseId);
38995
39411
  else
38996
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
+ });
38997
39421
  throw error;
38998
39422
  }
38999
- return { kind: "downloaded", manifest, transfer };
39423
+ return {
39424
+ ...healthToken ? { healthToken } : {},
39425
+ kind: "downloaded",
39426
+ manifest,
39427
+ transfer
39428
+ };
39000
39429
  };
39001
39430
  return {
39002
39431
  check,
39003
39432
  activate: (releaseId) => options.store.activate(releaseId),
39004
- download: () => check(true)
39433
+ download: () => check(true),
39434
+ report: (input) => reportAbsoluteMobileUpdateHealth(options.config, input, request)
39005
39435
  };
39006
39436
  };
39007
39437
  // src/mobile/updatePublisher.ts
@@ -39124,6 +39554,34 @@ var lifecycleMethod = (publisher, name) => {
39124
39554
  throw new TypeError(`Mobile update registry does not support ${name}. Re-run \`absolute mobile update provision --force\` after upgrading @absolutejs/deploy.`);
39125
39555
  return method;
39126
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
+ };
39127
39585
  var validOptionalCounters = (values) => values.every((value) => value === undefined) || values.every((value) => Number.isSafeInteger(value) && (value ?? -1) >= 0);
39128
39586
  var validateStorageIdentity = (result, appId) => {
39129
39587
  if (!object5(result) || result.appId !== appId || !Array.isArray(result.releases) || ![
@@ -39385,6 +39843,7 @@ export {
39385
39843
  hashAbsoluteMobilePropsSchema,
39386
39844
  inspectAbsoluteAndroidInstalledApp,
39387
39845
  inspectAbsoluteMobileRouteMetadata,
39846
+ inspectAbsoluteMobileUpdateHealth,
39388
39847
  inspectAbsoluteMobileUpdateServer,
39389
39848
  inspectAbsoluteMobileUpdateStorage,
39390
39849
  inspectAbsoluteRemoteMac,
@@ -39455,6 +39914,7 @@ export {
39455
39914
  removeAbsoluteRemoteMacProfile,
39456
39915
  renderAbsoluteMobileUpdateRegistry,
39457
39916
  repairAbsoluteIosDevSession,
39917
+ reportAbsoluteMobileUpdateHealth,
39458
39918
  requestAbsoluteMobileBack,
39459
39919
  requireAbsoluteIosReleaseMetadata,
39460
39920
  resolveAbsoluteDeviceCapabilityPlan,
@@ -39493,5 +39953,5 @@ export {
39493
39953
  writeAbsoluteMobileUpdateRegistry
39494
39954
  };
39495
39955
 
39496
- //# debugId=B713A6D1CA49D10364756E2164756E21
39956
+ //# debugId=01F0CE93B9ED6BCC64756E2164756E21
39497
39957
  //# sourceMappingURL=index.js.map