@absolutejs/absolute 0.20.0-beta.97 → 0.20.0-beta.99

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/CHANGELOG.md CHANGED
@@ -6,6 +6,20 @@ This file is generated by `absolute-changelog` from the entries in
6
6
  `changelog/`. Edit an entry, not this file — and add new ones under
7
7
  `changelog/unreleased/`.
8
8
 
9
+ ## 0.20.0-beta.99 — 2026-09-15
10
+
11
+ ### Fixed
12
+
13
+ - **Expo development recovers from Metro startup port races** (`AbsoluteExpoDevPhaseTiming`, `AbsoluteExpoDevSession`, `StartAbsoluteExpoDevOptions`, `startAbsoluteExpoDevSession`)
14
+ AbsoluteJS retries a newly allocated loopback port without prompting, propagates the final port through local, WSL, and Remote Mac native targets, preserves it across rebuilds, and emits only a categorical reason plus bounded retry count.
15
+
16
+ ## 0.20.0-beta.98 — 2026-09-15
17
+
18
+ ### Added
19
+
20
+ - **Expo Android development now has a performance and accessibility quality gate** (`DEFAULT_ABSOLUTE_EXPO_ANDROID_QUALITY_BUDGETS`, `parseAbsoluteAndroidAccessibilityHierarchy`, `parseAbsoluteExpoAndroidLaunchTiming`, `parseAbsoluteExpoAndroidMemory`)
21
+ An installed API 36 gate measures cold and warm launch, bridge and all-framework HMR p95, memory growth, native rebuild/reconnect, and native plus embedded-web accessibility while emitting only sanitized timing artifacts.
22
+
9
23
  ## 0.20.0-beta.97 — 2026-09-15
10
24
 
11
25
  ### Fixed
package/changelog.json CHANGED
@@ -2,6 +2,40 @@
2
2
  "contract": 1,
3
3
  "name": "@absolutejs/absolute",
4
4
  "releases": [
5
+ {
6
+ "changes": [
7
+ {
8
+ "detail": "AbsoluteJS retries a newly allocated loopback port without prompting, propagates the final port through local, WSL, and Remote Mac native targets, preserves it across rebuilds, and emits only a categorical reason plus bounded retry count.",
9
+ "kind": "fixed",
10
+ "summary": "Expo development recovers from Metro startup port races",
11
+ "symbols": [
12
+ "AbsoluteExpoDevPhaseTiming",
13
+ "AbsoluteExpoDevSession",
14
+ "StartAbsoluteExpoDevOptions",
15
+ "startAbsoluteExpoDevSession"
16
+ ]
17
+ }
18
+ ],
19
+ "date": "2026-09-15",
20
+ "version": "0.20.0-beta.99"
21
+ },
22
+ {
23
+ "changes": [
24
+ {
25
+ "detail": "An installed API 36 gate measures cold and warm launch, bridge and all-framework HMR p95, memory growth, native rebuild/reconnect, and native plus embedded-web accessibility while emitting only sanitized timing artifacts.",
26
+ "kind": "added",
27
+ "summary": "Expo Android development now has a performance and accessibility quality gate",
28
+ "symbols": [
29
+ "DEFAULT_ABSOLUTE_EXPO_ANDROID_QUALITY_BUDGETS",
30
+ "parseAbsoluteAndroidAccessibilityHierarchy",
31
+ "parseAbsoluteExpoAndroidLaunchTiming",
32
+ "parseAbsoluteExpoAndroidMemory"
33
+ ]
34
+ }
35
+ ],
36
+ "date": "2026-09-15",
37
+ "version": "0.20.0-beta.98"
38
+ },
5
39
  {
6
40
  "changes": [
7
41
  {
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  var __require = import.meta.require;
3
3
 
4
- // .angular-partial-tmp-VPql4f/src/core/streamingSlotRegistrar.ts
4
+ // .angular-partial-tmp-lbMe6j/src/core/streamingSlotRegistrar.ts
5
5
  var STREAMING_SLOT_REGISTRAR_KEY = Symbol.for("absolutejs.streamingSlotRegistrar");
6
6
  var STREAMING_SLOT_WARNING_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotWarningController");
7
7
  var STREAMING_SLOT_COLLECTION_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotCollectionController");
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  var __require = import.meta.require;
3
3
 
4
- // .angular-partial-tmp-VPql4f/src/core/streamingSlotRegistrar.ts
4
+ // .angular-partial-tmp-lbMe6j/src/core/streamingSlotRegistrar.ts
5
5
  var STREAMING_SLOT_REGISTRAR_KEY = Symbol.for("absolutejs.streamingSlotRegistrar");
6
6
  var STREAMING_SLOT_WARNING_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotWarningController");
7
7
  var STREAMING_SLOT_COLLECTION_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotCollectionController");
@@ -48,7 +48,7 @@ var warnMissingStreamingSlotCollector = (primitiveName) => {
48
48
  getWarningController()?.maybeWarn(primitiveName);
49
49
  };
50
50
 
51
- // .angular-partial-tmp-VPql4f/src/core/streamingSlotRegistry.ts
51
+ // .angular-partial-tmp-lbMe6j/src/core/streamingSlotRegistry.ts
52
52
  var STREAMING_SLOT_STORAGE_KEY = Symbol.for("absolutejs.streamingSlotAsyncLocalStorage");
53
53
  var isObjectRecord2 = (value) => Boolean(value) && typeof value === "object";
54
54
  var isAsyncLocalStorage = (value) => isObjectRecord2(value) && ("getStore" in value) && typeof value.getStore === "function" && ("run" in value) && typeof value.run === "function";
@@ -1287,7 +1287,7 @@ var loadAbsoluteMobileDevModules = async () => {
1287
1287
  ] = await Promise.all([
1288
1288
  import("./config-vy04mqce.js"),
1289
1289
  import("./expoProject-ceey4cfg.js"),
1290
- import("./expoDevController-a0jk8d43.js"),
1290
+ import("./expoDevController-8wyrcy6a.js"),
1291
1291
  import("./nativeAuth-zx4tsb1f.js"),
1292
1292
  import("./androidEmulatorController-nybhzc6c.js"),
1293
1293
  import("./emulatorDoctor-y2nqmaha.js"),
@@ -2174,13 +2174,15 @@ var dev = async (serverEntry, configPath, options = {}) => {
2174
2174
  platforms: [],
2175
2175
  signal: expoDevAbort.signal,
2176
2176
  log: (message) => printNativeOutput(cliTag("\x1B[35m", `Expo ${message}`)),
2177
- onPhaseTiming: ({ durationMs, phase }) => {
2177
+ onPhaseTiming: ({ durationMs, phase, reason, retryCount }) => {
2178
2178
  sendTelemetryEvent("mobile:expo-dev-phase", {
2179
2179
  durationMs: Math.round(durationMs),
2180
2180
  host: mobile.detectAbsoluteMobileHost(),
2181
2181
  phase,
2182
2182
  platform: expoTelemetryPlatform(phase),
2183
- provider: "expo"
2183
+ provider: "expo",
2184
+ reason,
2185
+ retryCount
2184
2186
  });
2185
2187
  },
2186
2188
  onStateChange: (state) => {
@@ -2193,7 +2195,7 @@ var dev = async (serverEntry, configPath, options = {}) => {
2193
2195
  metroSession = session;
2194
2196
  return session;
2195
2197
  });
2196
- const localNativeStart = metroStart.then(async () => {
2198
+ const localNativeStart = metroStart.then(async (metro) => {
2197
2199
  if (project.platforms.length === 0)
2198
2200
  return;
2199
2201
  const session = await mobile.startAbsoluteExpoDevSession({
@@ -2205,17 +2207,19 @@ var dev = async (serverEntry, configPath, options = {}) => {
2205
2207
  iosDevice: options.iosDevice,
2206
2208
  iosOrigin,
2207
2209
  metro: "external",
2208
- metroPort: expoMetroPort,
2210
+ metroPort: metro.metroPort,
2209
2211
  platforms: project.platforms,
2210
2212
  signal: expoDevAbort.signal,
2211
2213
  log: (message) => printNativeOutput(cliTag("\x1B[35m", `Expo ${message}`)),
2212
- onPhaseTiming: ({ durationMs, phase }) => {
2214
+ onPhaseTiming: ({ durationMs, phase, reason, retryCount }) => {
2213
2215
  sendTelemetryEvent("mobile:expo-dev-phase", {
2214
2216
  durationMs: Math.round(durationMs),
2215
2217
  host: mobile.detectAbsoluteMobileHost(),
2216
2218
  phase,
2217
2219
  platform: expoTelemetryPlatform(phase),
2218
- provider: "expo"
2220
+ provider: "expo",
2221
+ reason,
2222
+ retryCount
2219
2223
  });
2220
2224
  },
2221
2225
  onStateChange: (state) => {
@@ -2229,24 +2233,31 @@ var dev = async (serverEntry, configPath, options = {}) => {
2229
2233
  return session;
2230
2234
  });
2231
2235
  const remoteIosProject = project.remoteIos;
2232
- const remoteStart = remoteIosProject ? metroStart.then(async () => {
2236
+ const remoteStart = remoteIosProject ? metroStart.then(async (metro) => {
2233
2237
  const session = await mobile.startAbsoluteRemoteExpoIosDevSession({
2234
2238
  certificateAuthorityPath: devCertificateAuthorityPath ?? undefined,
2235
2239
  deviceIdentifier: options.iosDevice,
2236
2240
  https: httpsEnabled,
2237
- metroPort: expoMetroPort,
2241
+ metroPort: metro.metroPort,
2238
2242
  port,
2239
2243
  project: remoteIosProject,
2240
2244
  serverHost: options.iosDevice ? iosPhysicalServerHost : undefined,
2241
2245
  signal: expoDevAbort.signal,
2242
2246
  log: (message) => printNativeOutput(cliTag("\x1B[35m", `Expo [remote-ios] ${message}`)),
2243
- onPhaseTiming: ({ durationMs, phase }) => {
2247
+ onPhaseTiming: ({
2248
+ durationMs,
2249
+ phase,
2250
+ reason,
2251
+ retryCount
2252
+ }) => {
2244
2253
  sendTelemetryEvent("mobile:expo-dev-phase", {
2245
2254
  durationMs: Math.round(durationMs),
2246
2255
  host: "remote-macos",
2247
2256
  phase,
2248
2257
  platform: expoTelemetryPlatform(phase),
2249
- provider: "expo"
2258
+ provider: "expo",
2259
+ reason,
2260
+ retryCount
2250
2261
  });
2251
2262
  },
2252
2263
  onStateChange: (state) => {
@@ -15,9 +15,39 @@ import"./index-mywsk201.js";
15
15
  // src/mobile/expoDevController.ts
16
16
  import { spawn } from "child_process";
17
17
  import { access } from "fs/promises";
18
+ import { createServer } from "net";
18
19
  import { join, resolve as resolvePath } from "path";
19
20
  var METRO_READY_TIMEOUT_MS = 120000;
21
+ var METRO_START_MAX_ATTEMPTS = 4;
22
+ var MAX_TCP_PORT = 65535;
20
23
  var PROCESS_CLOSE_TIMEOUT_MS = 2000;
24
+ var allocateAvailableMetroPort = () => new Promise((resolve, reject) => {
25
+ const server = createServer();
26
+ server.unref();
27
+ server.once("error", reject);
28
+ server.listen(0, "127.0.0.1", () => {
29
+ const address = server.address();
30
+ if (!address || typeof address === "string") {
31
+ server.close();
32
+ reject(new Error("Failed to allocate a replacement Metro port."));
33
+ return;
34
+ }
35
+ server.close((error) => {
36
+ if (error)
37
+ reject(error);
38
+ else
39
+ resolve(address.port);
40
+ });
41
+ });
42
+ });
43
+ var metroPortConflict = (line) => /(?:EADDRINUSE|address already in use|port \d+ is being used by another process)/iu.test(line);
44
+
45
+ class MetroStartupError extends Error {
46
+ constructor(exitCode, conflict) {
47
+ super(`Expo Metro exited with status ${exitCode}.`);
48
+ this.conflict = conflict;
49
+ }
50
+ }
21
51
  var preserveWindowsSubstRealpaths = `
22
52
  const fs = require('node:fs');
23
53
  const path = require('node:path');
@@ -512,7 +542,7 @@ var parseBootedAbsoluteExpoIosSimulators = (source) => {
512
542
  });
513
543
  };
514
544
  var startAbsoluteExpoDevSession = async (options) => {
515
- const plan = planAbsoluteExpoDevSession(options.config, options);
545
+ let plan = planAbsoluteExpoDevSession(options.config, options);
516
546
  const executable = options.executable ?? await absoluteExpoExecutable(plan.project);
517
547
  const run = options.spawnProcess ?? spawn;
518
548
  const host = options.host ?? detectAbsoluteMobileHost();
@@ -527,9 +557,9 @@ var startAbsoluteExpoDevSession = async (options) => {
527
557
  caEnrollmentServer = null;
528
558
  await server?.close();
529
559
  };
530
- const publishTiming = (phase, durationMs) => {
560
+ const publishTiming = (phase, durationMs, details = {}) => {
531
561
  timings[phase] = (timings[phase] ?? 0) + durationMs;
532
- options.onPhaseTiming?.({ durationMs, phase });
562
+ options.onPhaseTiming?.({ durationMs, phase, ...details });
533
563
  };
534
564
  const setState = (state) => {
535
565
  options.onStateChange?.(state);
@@ -537,8 +567,8 @@ var startAbsoluteExpoDevSession = async (options) => {
537
567
  if (options.signal?.aborted)
538
568
  throw abortError();
539
569
  const prepareCommand = plan.commands.find((command) => command.role === "native-prepare");
540
- const metroCommand = plan.commands.find((command) => command.role === "metro");
541
- const nativeCommands = plan.commands.filter((command) => command.role === "native-build");
570
+ let metroCommand = plan.commands.find((command) => command.role === "metro");
571
+ let nativeCommands = plan.commands.filter((command) => command.role === "native-build");
542
572
  const runPrepareCommand = async (command) => {
543
573
  setState("preparing-native");
544
574
  const prepareStarted = performance.now();
@@ -569,41 +599,67 @@ var startAbsoluteExpoDevSession = async (options) => {
569
599
  if (managedMetro)
570
600
  setState("starting-metro");
571
601
  const metroStarted = performance.now();
572
- const metro = metroCommand ? run(executable, metroCommand.args, {
573
- cwd: plan.project,
574
- env: { ...process.env, ...metroCommand.env },
575
- stdio: ["ignore", "pipe", "pipe"]
576
- }) : undefined;
577
- let metroReady = false;
578
- let resolveMetro;
579
- const metroPromise = new Promise((resolve, reject) => {
580
- if (!metro) {
581
- resolve();
582
- return;
583
- }
602
+ let metro;
603
+ let metroRetryCount = 0;
604
+ const attemptedMetroPorts = [plan.metroPort];
605
+ const waitForMetroReady = (child) => new Promise((resolve, reject) => {
606
+ let ready = false;
607
+ let conflict = false;
584
608
  const timeout = setTimeout(() => {
585
609
  reject(new Error("Expo Metro did not become ready within 120 seconds."));
586
610
  }, METRO_READY_TIMEOUT_MS);
587
- resolveMetro = () => {
588
- clearTimeout(timeout);
589
- resolve();
590
- };
591
- metro.once("exit", (code) => {
592
- if (!metroReady) {
611
+ forwardLines(child, (line) => {
612
+ if (line)
613
+ log(`[metro] ${line}`);
614
+ if (metroPortConflict(line))
615
+ conflict = true;
616
+ if (!ready && /(?:Waiting on|Metro waiting on|Dev server ready)/iu.test(line)) {
617
+ ready = true;
593
618
  clearTimeout(timeout);
594
- reject(new Error(`Expo Metro exited with status ${code ?? 1}.`));
619
+ resolve();
595
620
  }
596
621
  });
622
+ child.once("exit", (code) => {
623
+ if (ready)
624
+ return;
625
+ clearTimeout(timeout);
626
+ reject(new MetroStartupError(code ?? 1, conflict));
627
+ });
597
628
  });
598
- if (metro)
599
- forwardLines(metro, (line) => {
600
- if (line)
601
- log(`[metro] ${line}`);
602
- if (!metroReady && /(?:Waiting on|Metro waiting on|Dev server ready)/iu.test(line)) {
603
- metroReady = true;
604
- resolveMetro?.();
605
- }
629
+ const startManagedMetro = async () => {
630
+ if (!metroCommand)
631
+ return;
632
+ if (options.signal?.aborted)
633
+ throw abortError();
634
+ metro = run(executable, metroCommand.args, {
635
+ cwd: plan.project,
636
+ env: { ...process.env, ...metroCommand.env },
637
+ stdio: ["ignore", "pipe", "pipe"]
606
638
  });
639
+ try {
640
+ await waitForMetroReady(metro);
641
+ } catch (error) {
642
+ if (!(error instanceof MetroStartupError) || !error.conflict || attemptedMetroPorts.length >= METRO_START_MAX_ATTEMPTS) {
643
+ throw error;
644
+ }
645
+ await stopProcess(metro);
646
+ const allocate = options.allocateMetroPort ?? allocateAvailableMetroPort;
647
+ const replacementPort = await allocate(attemptedMetroPorts);
648
+ if (!Number.isSafeInteger(replacementPort) || replacementPort < 1 || replacementPort > MAX_TCP_PORT || attemptedMetroPorts.includes(replacementPort)) {
649
+ throw new Error("Could not allocate a distinct replacement Metro port.", { cause: error });
650
+ }
651
+ attemptedMetroPorts.push(replacementPort);
652
+ metroRetryCount += 1;
653
+ log("Metro port was claimed during startup; retrying automatically on a new port.");
654
+ plan = planAbsoluteExpoDevSession(options.config, {
655
+ ...options,
656
+ metroPort: replacementPort
657
+ });
658
+ metroCommand = plan.commands.find((command) => command.role === "metro");
659
+ nativeCommands = plan.commands.filter((command) => command.role === "native-build");
660
+ await startManagedMetro();
661
+ }
662
+ };
607
663
  const abort = () => {
608
664
  if (metro)
609
665
  stopProcess(metro);
@@ -618,7 +674,7 @@ var startAbsoluteExpoDevSession = async (options) => {
618
674
  const state = platform === "android" ? "building-android" : "building-ios";
619
675
  setState(state);
620
676
  const started = performance.now();
621
- const invocation = platform === "android" && host === "wsl" ? encodedWindowsExpoAndroidCommand(plan.project, options.androidRoot ?? absoluteManagedAndroidSdkRoot(host), options.config.appId, command.args, capture, options.metroPort, options.androidDevice) : [executable, ...command.args];
677
+ const invocation = platform === "android" && host === "wsl" ? encodedWindowsExpoAndroidCommand(plan.project, options.androidRoot ?? absoluteManagedAndroidSdkRoot(host), options.config.appId, command.args, capture, plan.metroPort, options.androidDevice) : [executable, ...command.args];
622
678
  if (platform === "android" && host === "wsl") {
623
679
  log("[android] Mirroring native inputs to the Windows host for accelerated Expo build and launch.");
624
680
  }
@@ -646,7 +702,7 @@ var startAbsoluteExpoDevSession = async (options) => {
646
702
  if (platform === "android" && host !== "wsl") {
647
703
  const androidRoot = options.androidRoot ?? process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT ?? absoluteManagedAndroidSdkRoot(host);
648
704
  const adb = options.androidAdb ?? join(androidRoot, "platform-tools", host === "windows" ? "adb.exe" : "adb");
649
- connectLocalExpoAndroid(adb, options.config.appId, options.metroPort, capture, options.androidDevice);
705
+ connectLocalExpoAndroid(adb, options.config.appId, plan.metroPort, capture, options.androidDevice);
650
706
  log("Connected the Expo Android development client to Metro.");
651
707
  }
652
708
  if (platform === "ios" && options.certificateAuthorityPath && options.iosOrigin && new URL(options.iosOrigin).protocol === "https:" && !options.iosDevice) {
@@ -684,7 +740,7 @@ var startAbsoluteExpoDevSession = async (options) => {
684
740
  "simctl",
685
741
  "openurl",
686
742
  "booted",
687
- expoDevelopmentClientUrl(options.config.appId, options.metroHost ?? "localhost", options.metroPort)
743
+ expoDevelopmentClientUrl(options.config.appId, options.metroHost ?? "localhost", plan.metroPort)
688
744
  ], utilityOptions);
689
745
  if (connectExit !== 0)
690
746
  throw new Error(`Expo iOS Simulator Metro connection exited with status ${connectExit}.`);
@@ -717,9 +773,14 @@ var startAbsoluteExpoDevSession = async (options) => {
717
773
  };
718
774
  try {
719
775
  await startPhysicalIosEnrollment();
720
- await metroPromise;
776
+ await startManagedMetro();
721
777
  if (managedMetro)
722
- publishTiming("starting-metro", performance.now() - metroStarted);
778
+ publishTiming("starting-metro", performance.now() - metroStarted, {
779
+ ...metroRetryCount > 0 ? {
780
+ reason: "metro-port-conflict",
781
+ retryCount: metroRetryCount
782
+ } : {}
783
+ });
723
784
  await runNativeCommands(nativeCommands);
724
785
  setState("ready");
725
786
  let closed = false;
package/dist/cli/index.js CHANGED
@@ -58,7 +58,7 @@ if (command === "dev") {
58
58
  }
59
59
  const positionalArgs = stripNamedArgs("--config", "--android-device", "--ios-device").filter((arg) => arg !== "--no-mobile" && arg !== "--eager");
60
60
  const serverEntry = positionalArgs[0] ?? DEFAULT_SERVER_ENTRY;
61
- const { dev } = await import("./dev-r8dztrp8.js");
61
+ const { dev } = await import("./dev-r4vwdz6b.js");
62
62
  await dev(serverEntry, configPath, {
63
63
  androidDevice,
64
64
  eager: args.includes("--eager"),