From 4470970f786ecedc06b78afdfb99cc8f38fcd235 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Mon, 13 Apr 2026 20:02:16 -0700 Subject: [PATCH 01/11] fix(bundler): vite double restart --- .../bundler/bundler-compiler-service.ts | 1 + .../bundler/bundler-compiler-service.ts | 85 ++++++++++++++++++- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 3eb880b2ba..b81e720e86 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -170,6 +170,7 @@ export class BundlerCompilerService ); } resolve(childProcess); + return; } // Transform Vite message to match webpack format diff --git a/test/services/bundler/bundler-compiler-service.ts b/test/services/bundler/bundler-compiler-service.ts index 49d69a55f4..24bedd5505 100644 --- a/test/services/bundler/bundler-compiler-service.ts +++ b/test/services/bundler/bundler-compiler-service.ts @@ -1,9 +1,13 @@ import { Yok } from "../../../lib/common/yok"; import { BundlerCompilerService } from "../../../lib/services/bundler/bundler-compiler-service"; import { assert } from "chai"; +import { EventEmitter } from "events"; import { ErrorsStub } from "../../stubs"; import { IInjector } from "../../../lib/common/definitions/yok"; -import { CONFIG_FILE_NAME_DISPLAY } from "../../../lib/constants"; +import { + BUNDLER_COMPILATION_COMPLETE, + CONFIG_FILE_NAME_DISPLAY, +} from "../../../lib/constants"; const iOSPlatformName = "ios"; const androidPlatformName = "android"; @@ -28,11 +32,18 @@ function createTestInjector(): IInjector { testInjector.register("hooksService", {}); testInjector.register("hostInfo", {}); testInjector.register("options", {}); - testInjector.register("logger", {}); + testInjector.register("logger", { + info: () => ({}), + trace: () => ({}), + warn: () => ({}), + }); testInjector.register("errors", ErrorsStub); testInjector.register("packageInstallationManager", {}); testInjector.register("mobileHelper", {}); - testInjector.register("cleanupService", {}); + testInjector.register("cleanupService", { + addKillProcess: async () => ({}), + removeKillProcess: async () => ({}), + }); testInjector.register("projectConfigService", { getValue: (key: string, defaultValue?: string) => defaultValue, }); @@ -218,6 +229,74 @@ describe("BundlerCompilerService", () => { `The bundler configuration file ${bundlerConfigPath} does not exist. Ensure the file exists, or update the path in ${CONFIG_FILE_NAME_DISPLAY}`, ); }); + + it("does not emit a live sync event for the initial Vite watch build", async () => { + const platformData = { + platformNameLowerCase: "ios", + appDestinationDirectoryPath: "/platform/app", + }; + const projectData = { + projectDir: "/project", + bundler: "vite", + bundlerConfigPath: "/project/vite.config.ts", + }; + const prepareData = { hmr: false }; + const childProcess = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + pid: number; + }; + + childProcess.stdout = new EventEmitter(); + childProcess.stderr = new EventEmitter(); + childProcess.pid = 123; + + testInjector.resolve("options").hostProjectModuleName = "app"; + (bundlerCompilerService).getBundler = () => "vite"; + (bundlerCompilerService).startBundleProcess = async () => + childProcess; + (bundlerCompilerService).copyViteBundleToNative = () => ({}); + + const emittedEvents: any[] = []; + bundlerCompilerService.on(BUNDLER_COMPILATION_COMPLETE, (data) => { + emittedEvents.push(data); + }); + + const compilePromise = bundlerCompilerService.compileWithWatch( + platformData, + projectData, + prepareData, + ); + await new Promise((resolve) => setImmediate(resolve)); + + childProcess.emit("message", { + emittedFiles: ["bundle.mjs"], + buildType: "initial", + hash: "hash-1", + isHMR: false, + }); + + await compilePromise; + assert.lengthOf(emittedEvents, 0); + + childProcess.emit("message", { + emittedFiles: ["bundle.mjs"], + buildType: "incremental", + hash: "hash-2", + isHMR: false, + }); + + assert.lengthOf(emittedEvents, 1); + assert.deepStrictEqual(emittedEvents[0], { + files: ["/platform/app/app/bundle.mjs"], + hasOnlyHotUpdateFiles: false, + hmrData: { + hash: "hash-2", + fallbackFiles: [], + }, + platform: "ios", + }); + }); }); describe("compileWithoutWatch", () => { From 6deed54820979d9fc879b1c75ea2bd9b56be4946 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 29 Apr 2026 14:45:41 -0700 Subject: [PATCH 02/11] chore: 9.0.7-dev.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 11a87e98c9..38366ab22f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativescript", "main": "./lib/nativescript-cli-lib.js", - "version": "9.0.6", + "version": "9.0.7-dev.0", "author": "NativeScript ", "description": "Command-line interface for building NativeScript projects", "bin": { From 5f7a70bc23a193f0eeecba56438ecfcd81dd2d61 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 21 May 2026 15:52:15 -0700 Subject: [PATCH 03/11] fix(vite): build/prepare handling for the bundle --- lib/constants.ts | 11 ++++--- .../bundler/bundler-compiler-service.ts | 33 ++++++++++++++++++- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/lib/constants.ts b/lib/constants.ts index 19a614a125..59a3811b6a 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -17,6 +17,11 @@ export const TNS_CORE_THEME_NAME = "nativescript-theme-core"; export const SCOPED_TNS_CORE_THEME_NAME = "@nativescript/theme"; export const WEBPACK_PLUGIN_NAME = "@nativescript/webpack"; export const RSPACK_PLUGIN_NAME = "@nativescript/rspack"; +// Project-relative directory the Vite bundler writes its build output to +// before the CLI copies it into the platforms app folder. Mirrors the +// default value computed in `@nativescript/vite`'s base configuration +// (`process.env.NS_VITE_DIST_DIR || '.ns-vite-build'`). +export const VITE_DIST_FOLDER_NAME = ".ns-vite-build"; export const TNS_CORE_MODULES_WIDGETS_NAME = "tns-core-modules-widgets"; export const UI_MOBILE_BASE_NAME = "@nativescript/ui-mobile-base"; export const TNS_ANDROID_RUNTIME_NAME = "tns-android"; @@ -172,9 +177,7 @@ export class ITMSConstants { static altoolExecutableName = "altool"; } -class ItunesConnectApplicationTypesClass - implements IiTunesConnectApplicationType -{ +class ItunesConnectApplicationTypesClass implements IiTunesConnectApplicationType { public iOS = "iOS App"; public Mac = "Mac OS X App"; } @@ -409,7 +412,7 @@ export enum IOSNativeTargetTypes { watchApp = "watch_app", watchExtension = "watch_extension", appExtension = "app_extension", - application = 'application', + application = "application", } const pathToLoggerAppendersDir = join( diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index b81e720e86..7da1e37aa6 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -9,6 +9,7 @@ import { BUNDLER_COMPILATION_COMPLETE, PackageManagers, CONFIG_FILE_NAME_DISPLAY, + VITE_DIST_FOLDER_NAME, } from "../../constants"; import { IPackageManager, @@ -128,7 +129,7 @@ export class BundlerCompilerService // Copy Vite output files directly to platform destination const distOutput = path.join( projectData.projectDir, - ".ns-vite-build", + VITE_DIST_FOLDER_NAME, ); const destDir = path.join( platformData.appDestinationDirectoryPath, @@ -373,6 +374,8 @@ export class BundlerCompilerService reject(err); }); + const isVite = this.getBundler() === "vite"; + childProcess.on("close", async (arg: any) => { await this.$cleanupService.removeKillProcess( childProcess.pid.toString(), @@ -381,6 +384,34 @@ export class BundlerCompilerService delete this.bundlerProcesses[platformData.platformNameLowerCase]; const exitCode = typeof arg === "number" ? arg : arg && arg.code; if (exitCode === 0) { + // Non-watch Vite builds spawn the child with stdio:"inherit" + // (no IPC channel), so the emittedFiles message handler in + // compileWithWatch never fires and the Vite output is never + // copied to the platforms app folder. Mirror that copy step + // here so release/CI prepare and build flows actually deploy + // the freshly built bundle. Without this, the deploy folder + // is left empty (or worse, runs stale dev/HMR artifacts from + // a previous `ns debug` run) and the runtime crashes on + // launch with `Check failed: has_pending_exception()`. + if (isVite) { + try { + const distOutput = path.join( + projectData.projectDir, + VITE_DIST_FOLDER_NAME, + ); + const destDir = path.join( + platformData.appDestinationDirectoryPath, + this.$options.hostProjectModuleName, + ); + this.copyViteBundleToNative(distOutput, destDir); + } catch (copyErr) { + this.$logger.warn( + `Failed to copy Vite output to platform destination: ${ + (copyErr as Error).message + }`, + ); + } + } resolve(); } else { const error: any = new Error( From b5e8d32440b6d713bd5087f0deb769b68653e522 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 21 May 2026 15:57:21 -0700 Subject: [PATCH 04/11] chore: 9.1.0-alpha.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 38366ab22f..5f5bd0782e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativescript", "main": "./lib/nativescript-cli-lib.js", - "version": "9.0.7-dev.0", + "version": "9.1.0-alpha.1", "author": "NativeScript ", "description": "Command-line interface for building NativeScript projects", "bin": { From bc03f5e7bdea4a971ec3f55f40cd981b569c79a0 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 26 May 2026 16:42:47 -0700 Subject: [PATCH 05/11] feat: allow custom vite dist folder name Provides ability to run multiple platforms builds in parallel. --- .../bundler/bundler-compiler-service.ts | 13 +++++-- .../bundler/bundler-compiler-service.ts | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 7da1e37aa6..b66b7bdb1b 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -78,6 +78,13 @@ export class BundlerCompilerService super(); } + private getViteDistOutputPath(projectDir: string): string { + return path.join( + projectDir, + process.env.NS_VITE_DIST_DIR || VITE_DIST_FOLDER_NAME, + ); + } + public async compileWithWatch( platformData: IPlatformData, projectData: IProjectData, @@ -127,9 +134,8 @@ export class BundlerCompilerService } // Copy Vite output files directly to platform destination - const distOutput = path.join( + const distOutput = this.getViteDistOutputPath( projectData.projectDir, - VITE_DIST_FOLDER_NAME, ); const destDir = path.join( platformData.appDestinationDirectoryPath, @@ -395,9 +401,8 @@ export class BundlerCompilerService // launch with `Check failed: has_pending_exception()`. if (isVite) { try { - const distOutput = path.join( + const distOutput = this.getViteDistOutputPath( projectData.projectDir, - VITE_DIST_FOLDER_NAME, ); const destDir = path.join( platformData.appDestinationDirectoryPath, diff --git a/test/services/bundler/bundler-compiler-service.ts b/test/services/bundler/bundler-compiler-service.ts index 24bedd5505..1f86e75e1b 100644 --- a/test/services/bundler/bundler-compiler-service.ts +++ b/test/services/bundler/bundler-compiler-service.ts @@ -2,6 +2,7 @@ import { Yok } from "../../../lib/common/yok"; import { BundlerCompilerService } from "../../../lib/services/bundler/bundler-compiler-service"; import { assert } from "chai"; import { EventEmitter } from "events"; +import * as path from "path"; import { ErrorsStub } from "../../stubs"; import { IInjector } from "../../../lib/common/definitions/yok"; import { @@ -215,6 +216,42 @@ describe("BundlerCompilerService", () => { }); }); + describe("getViteDistOutputPath", () => { + it("uses the current default directory when NS_VITE_DIST_DIR is unset", () => { + const previous = process.env.NS_VITE_DIST_DIR; + try { + delete process.env.NS_VITE_DIST_DIR; + assert.strictEqual( + (bundlerCompilerService).getViteDistOutputPath("/project"), + path.join("/project", ".ns-vite-build"), + ); + } finally { + if (previous === undefined) { + delete process.env.NS_VITE_DIST_DIR; + } else { + process.env.NS_VITE_DIST_DIR = previous; + } + } + }); + + it("uses NS_VITE_DIST_DIR for platform-isolated output", () => { + const previous = process.env.NS_VITE_DIST_DIR; + try { + process.env.NS_VITE_DIST_DIR = ".ns-vite-build/android"; + assert.strictEqual( + (bundlerCompilerService).getViteDistOutputPath("/project"), + path.join("/project", ".ns-vite-build", "android"), + ); + } finally { + if (previous === undefined) { + delete process.env.NS_VITE_DIST_DIR; + } else { + process.env.NS_VITE_DIST_DIR = previous; + } + } + }); + }); + describe("compileWithWatch", () => { it("fails when the value set for bundlerConfigPath is not existant file", async () => { const bundlerConfigPath = "some path.js"; From 45cc494f082d849f89a7a406fc75c237b8c5b3fc Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Tue, 26 May 2026 16:42:58 -0700 Subject: [PATCH 06/11] chore: 9.1.0-alpha.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5f5bd0782e..0a8cc1c21c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativescript", "main": "./lib/nativescript-cli-lib.js", - "version": "9.1.0-alpha.1", + "version": "9.1.0-alpha.2", "author": "NativeScript ", "description": "Command-line interface for building NativeScript projects", "bin": { From 364a30a0316d3cfc207ef1b34af8b9101e6006d8 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 29 May 2026 14:53:23 -0700 Subject: [PATCH 07/11] feat: allow proper adb reverse debug sessions --- lib/controllers/run-controller.ts | 109 +++++++++++++++++++++++++++++ test/controllers/run-controller.ts | 60 +++++++--------- 2 files changed, 135 insertions(+), 34 deletions(-) diff --git a/lib/controllers/run-controller.ts b/lib/controllers/run-controller.ts index 2bafa60cca..8157b7be9d 100644 --- a/lib/controllers/run-controller.ts +++ b/lib/controllers/run-controller.ts @@ -57,6 +57,7 @@ export class RunController extends EventEmitter implements IRunController { private $prepareNativePlatformService: IPrepareNativePlatformService, private $projectChangesService: IProjectChangesService, protected $projectDataService: IProjectDataService, + private $staticConfig: Config.IStaticConfig, ) { super(); } @@ -498,6 +499,15 @@ export class RunController extends EventEmitter implements IRunController { }, ); + // For Android + Vite HMR, own the `adb reverse` ourselves — + // with our SDK-resolved adb, scoped to this exact serial, and + // only after the device is up — then hand the bundler the + // result via env vars. This MUST run before `prepare` (which + // spawns the Vite bundler that inherits `process.env`) so the + // bundler trusts the tunnel instead of racing us to spawn its + // own adb during config-load. See packages/vite hardening. + await this.setupAndroidViteHmrReverse(device, projectData, liveSyncInfo); + const prepareResultData = await this.$prepareController.prepare(prepareData); @@ -626,6 +636,105 @@ export class RunController extends EventEmitter implements IRunController { ); } + /** + * Set up `adb reverse tcp: tcp:` for an Android device + * when the project bundles with Vite in HMR/watch mode, then export + * the result to the bundler subprocess via environment variables. + * + * The Vite dev-host helper prefers an ADB tunnel (device-side + * `127.0.0.1:` → host) over the emulator's flaky slirp NAT + * (`10.0.2.2`). Historically the bundler tried to wire that tunnel + * itself at config-load time, racing this CLI's device discovery + * over the single global adb daemon and intermittently freezing the + * run at "Searching for devices…". The CLI is the right owner: it + * knows the exact target serial and when the device is ready, and it + * already drives a single, version-matched adb. We do the reverse + * here and signal the bundler with `NS_ADB_REVERSE_READY=1` so it + * never spawns adb on its own. + * + * Best-effort: any failure is logged at trace level and swallowed. + * The bundler then falls back to its own (now hardened) adb path, or + * ultimately to `10.0.2.2`, so a reverse hiccup never fails the run. + */ + private async setupAndroidViteHmrReverse( + device: Mobile.IDevice, + projectData: IProjectData, + liveSyncInfo: ILiveSyncInfo, + ): Promise { + try { + if (!this.$mobileHelper.isAndroidPlatform(device.deviceInfo.platform)) { + return; + } + if (projectData.bundler !== "vite") { + return; + } + // HMR over the tunnel only matters for a live watch session. + if (liveSyncInfo.skipWatcher || !liveSyncInfo.useHotModuleReload) { + return; + } + // Respect the user's explicit opt-out — they want the + // `10.0.2.2` / LAN path, so don't create a tunnel or claim one + // exists. + if (this.isTruthyEnvFlag(process.env.NS_HMR_NO_ADB_REVERSE)) { + return; + } + // `NS_HMR_PREFER_LAN_HOST` means the dev wants LAN routing + // (physical device over Wi-Fi); the dev-host resolver suppresses + // the adb-reverse path for it, so don't bother wiring one. + if (this.isTruthyEnvFlag(process.env.NS_HMR_PREFER_LAN_HOST)) { + return; + } + + const serial = device.deviceInfo.identifier; + const port = this.getViteHmrPort(); + // Safe after the `isAndroidPlatform` guard above — only Android + // devices carry the `adb` bridge. + const adb = (device as Mobile.IAndroidDevice).adb; + + // Don't `reverse` against a device whose adbd isn't accepting + // yet (emulators report a `device` transport before adbd is + // fully up). `wait-for-device` returns immediately once ready. + await adb.executeCommand(["wait-for-device"], { + deviceIdentifier: serial, + }); + await adb.executeCommand(["reverse", `tcp:${port}`, `tcp:${port}`], { + deviceIdentifier: serial, + }); + + // Hand the exact adb the CLI used to the bundler so, in any + // fallback path, it drives the same version-matched client and + // can't trigger a server-version-mismatch daemon kill. + const adbPath = await this.$staticConfig.getAdbFilePath(); + process.env.NS_ADB_PATH = adbPath; + process.env.NS_DEVICE_SERIAL = serial; + process.env.NS_ADB_REVERSE_READY = "1"; + + this.$logger.info( + `Set up adb reverse tcp:${port} tcp:${port} for ${serial} (Vite HMR routes device-side 127.0.0.1:${port} through ADB).`, + ); + } catch (err) { + this.$logger.trace( + `Setting up adb reverse for Vite HMR failed; leaving it to the bundler fallback. Error: ${err}`, + ); + } + } + + private getViteHmrPort(): number { + // The Vite dev server defaults to 5173; the bundler reads the same + // default. If a project runs Vite on a different port, the dev sets + // `NS_HMR_PORT` so the CLI reverses the matching port. + const fromEnv = Number(process.env.NS_HMR_PORT); + return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5173; + } + + private isTruthyEnvFlag(value: string | undefined): boolean { + if (typeof value !== "string") { + return false; + } + const v = value.trim().toLowerCase(); + return !!v && v !== "0" && v !== "false" && v !== "off" && v !== "no"; + } + private async syncChangedDataOnDevices( data: IFilesChangeEventData, projectData: IProjectData, diff --git a/test/controllers/run-controller.ts b/test/controllers/run-controller.ts index 36251a12aa..b29bcebcce 100644 --- a/test/controllers/run-controller.ts +++ b/test/controllers/run-controller.ts @@ -69,13 +69,12 @@ function getFullSyncResult(): ILiveSyncResultInfo { } function mockDevicesService(injector: IInjector, devices: Mobile.IDevice[]) { - const devicesService: Mobile.IDevicesService = injector.resolve( - "devicesService" - ); + const devicesService: Mobile.IDevicesService = + injector.resolve("devicesService"); devicesService.execute = async ( action: (device: Mobile.IDevice) => Promise, canExecute?: (dev: Mobile.IDevice) => boolean, - options?: { allowNoDevices?: boolean } + options?: { allowNoDevices?: boolean }, ) => { for (const d of devices) { if (canExecute(d)) { @@ -132,12 +131,15 @@ function createTestInjector() { injector.register("debugController", {}); injector.register("liveSyncProcessDataService", LiveSyncProcessDataService); injector.register("tempService", TempServiceStub); + injector.register("staticConfig", { + getAdbFilePath: async () => "adb", + }); const devicesService = injector.resolve("devicesService"); devicesService.getDevicesForPlatform = () => [{ identifier: "myTestDeviceId1" }]; devicesService.getPlatformsFromDeviceDescriptors = ( - devices: ILiveSyncDeviceDescriptor[] + devices: ILiveSyncDeviceDescriptor[], ) => devices.map((d) => map[d.identifier].device.deviceInfo.platform); devicesService.on = () => ({}); @@ -206,20 +208,17 @@ describe("RunController", () => { describe("watch", () => { const testCases = [ { - name: - "should prepare only ios platform when only ios devices are connected", + name: "should prepare only ios platform when only ios devices are connected", connectedDevices: [iOSDeviceDescriptor], expectedPreparedPlatforms: ["ios"], }, { - name: - "should prepare only android platform when only android devices are connected", + name: "should prepare only android platform when only android devices are connected", connectedDevices: [androidDeviceDescriptor], expectedPreparedPlatforms: ["android"], }, { - name: - "should prepare both platforms when ios and android devices are connected", + name: "should prepare both platforms when ios and android devices are connected", connectedDevices: [iOSDeviceDescriptor, androidDeviceDescriptor], expectedPreparedPlatforms: ["ios", "android"], }, @@ -229,15 +228,14 @@ describe("RunController", () => { it(testCase.name, async () => { mockDevicesService( injector, - testCase.connectedDevices.map((d) => map[d.identifier].device) + testCase.connectedDevices.map((d) => map[d.identifier].device), ); const preparedPlatforms: string[] = []; - const prepareController: PrepareController = injector.resolve( - "prepareController" - ); + const prepareController: PrepareController = + injector.resolve("prepareController"); prepareController.prepare = async ( - currentPrepareData: PrepareData + currentPrepareData: PrepareData, ) => { preparedPlatforms.push(currentPrepareData.platform); return { @@ -253,7 +251,7 @@ describe("RunController", () => { assert.deepStrictEqual( preparedPlatforms, - testCase.expectedPreparedPlatforms + testCase.expectedPreparedPlatforms, ); }); }); @@ -263,41 +261,35 @@ describe("RunController", () => { describe("stopRunOnDevices", () => { const testCases = [ { - name: - "stops LiveSync operation for all devices and emits liveSyncStopped for all of them when stopLiveSync is called without deviceIdentifiers", + name: "stops LiveSync operation for all devices and emits liveSyncStopped for all of them when stopLiveSync is called without deviceIdentifiers", currentDeviceIdentifiers: ["device1", "device2", "device3"], expectedDeviceIdentifiers: ["device1", "device2", "device3"], }, { - name: - "stops LiveSync operation for all devices and emits liveSyncStopped for all of them when stopLiveSync is called without deviceIdentifiers (when a single device is attached)", + name: "stops LiveSync operation for all devices and emits liveSyncStopped for all of them when stopLiveSync is called without deviceIdentifiers (when a single device is attached)", currentDeviceIdentifiers: ["device1"], expectedDeviceIdentifiers: ["device1"], }, { - name: - "stops LiveSync operation for specified devices and emits liveSyncStopped for each of them (when a single device is attached)", + name: "stops LiveSync operation for specified devices and emits liveSyncStopped for each of them (when a single device is attached)", currentDeviceIdentifiers: ["device1"], expectedDeviceIdentifiers: ["device1"], deviceIdentifiersToBeStopped: ["device1"], }, { - name: - "stops LiveSync operation for specified devices and emits liveSyncStopped for each of them", + name: "stops LiveSync operation for specified devices and emits liveSyncStopped for each of them", currentDeviceIdentifiers: ["device1", "device2", "device3"], expectedDeviceIdentifiers: ["device1", "device3"], deviceIdentifiersToBeStopped: ["device1", "device3"], }, { - name: - "does not raise liveSyncStopped event for device, which is not currently being liveSynced", + name: "does not raise liveSyncStopped event for device, which is not currently being liveSynced", currentDeviceIdentifiers: ["device1", "device2", "device3"], expectedDeviceIdentifiers: ["device1"], deviceIdentifiersToBeStopped: ["device1", "device4"], }, { - name: - "stops LiveSync operation for all devices when stop method is called with empty array", + name: "stops LiveSync operation for all devices when stop method is called with empty array", currentDeviceIdentifiers: ["device1", "device2", "device3"], expectedDeviceIdentifiers: ["device1", "device2", "device3"], deviceIdentifiersToBeStopped: [], @@ -307,14 +299,14 @@ describe("RunController", () => { for (const testCase of testCases) { it(testCase.name, async () => { const liveSyncProcessDataService = injector.resolve( - "liveSyncProcessDataService" + "liveSyncProcessDataService", ); (liveSyncProcessDataService).persistData( projectDir, testCase.currentDeviceIdentifiers.map( - (identifier) => { identifier } + (identifier) => { identifier }, ), - ["ios"] + ["ios"], ); const emittedDeviceIdentifiersForLiveSyncStoppedEvent: string[] = []; @@ -322,7 +314,7 @@ describe("RunController", () => { runController.on(RunOnDeviceEvents.runOnDeviceStopped, (data: any) => { assert.equal(data.projectDir, projectDir); emittedDeviceIdentifiersForLiveSyncStoppedEvent.push( - data.deviceIdentifier + data.deviceIdentifier, ); }); @@ -333,7 +325,7 @@ describe("RunController", () => { assert.deepStrictEqual( emittedDeviceIdentifiersForLiveSyncStoppedEvent, - testCase.expectedDeviceIdentifiers + testCase.expectedDeviceIdentifiers, ); }); } From 1e7c79e14a0266642513b50fbff164b04d968342 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 29 May 2026 14:53:36 -0700 Subject: [PATCH 08/11] chore: 9.1.0-alpha.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c2f8276d0f..08801a32a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nativescript", - "version": "9.0.6", + "version": "9.1.0-alpha.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nativescript", - "version": "9.0.6", + "version": "9.1.0-alpha.3", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 0a8cc1c21c..d82d7d6d24 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativescript", "main": "./lib/nativescript-cli-lib.js", - "version": "9.1.0-alpha.2", + "version": "9.1.0-alpha.3", "author": "NativeScript ", "description": "Command-line interface for building NativeScript projects", "bin": { From 986e3cbaefd0a2b7a44dcf8e25587f87cf899ceb Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 29 May 2026 16:23:21 -0700 Subject: [PATCH 09/11] feat: cli managed vite process, seamless and easier to work with --- lib/controllers/run-controller.ts | 125 ++++++++--- .../bundler/bundler-compiler-service.ts | 198 ++++++++++++++++++ 2 files changed, 298 insertions(+), 25 deletions(-) diff --git a/lib/controllers/run-controller.ts b/lib/controllers/run-controller.ts index 8157b7be9d..942dea6c7a 100644 --- a/lib/controllers/run-controller.ts +++ b/lib/controllers/run-controller.ts @@ -506,7 +506,12 @@ export class RunController extends EventEmitter implements IRunController { // spawns the Vite bundler that inherits `process.env`) so the // bundler trusts the tunnel instead of racing us to spawn its // own adb during config-load. See packages/vite hardening. - await this.setupAndroidViteHmrReverse(device, projectData, liveSyncInfo); + await this.setupAndroidViteHmrReverse( + device, + projectData, + liveSyncInfo, + "pre-build", + ); const prepareResultData = await this.$prepareController.prepare(prepareData); @@ -582,6 +587,17 @@ export class RunController extends EventEmitter implements IRunController { liveSyncDeviceData: deviceDescriptor, }); + // Re-establish the adb reverse on the CURRENT transport right + // before launch — the transport can change during build/install + // and drop the mapping set in `pre-build`, which would leave the + // app unable to reach the Vite dev server at 127.0.0.1. + await this.setupAndroidViteHmrReverse( + device, + projectData, + liveSyncInfo, + "pre-launch", + ); + await this.refreshApplication( projectData, liveSyncResultInfo, @@ -660,6 +676,7 @@ export class RunController extends EventEmitter implements IRunController { device: Mobile.IDevice, projectData: IProjectData, liveSyncInfo: ILiveSyncInfo, + phase: "pre-build" | "pre-launch", ): Promise { try { if (!this.$mobileHelper.isAndroidPlatform(device.deviceInfo.platform)) { @@ -687,36 +704,94 @@ export class RunController extends EventEmitter implements IRunController { const serial = device.deviceInfo.identifier; const port = this.getViteHmrPort(); - // Safe after the `isAndroidPlatform` guard above — only Android - // devices carry the `adb` bridge. - const adb = (device as Mobile.IAndroidDevice).adb; - - // Don't `reverse` against a device whose adbd isn't accepting - // yet (emulators report a `device` transport before adbd is - // fully up). `wait-for-device` returns immediately once ready. - await adb.executeCommand(["wait-for-device"], { - deviceIdentifier: serial, - }); - await adb.executeCommand(["reverse", `tcp:${port}`, `tcp:${port}`], { - deviceIdentifier: serial, - }); - // Hand the exact adb the CLI used to the bundler so, in any - // fallback path, it drives the same version-matched client and - // can't trigger a server-version-mismatch daemon kill. - const adbPath = await this.$staticConfig.getAdbFilePath(); - process.env.NS_ADB_PATH = adbPath; - process.env.NS_DEVICE_SERIAL = serial; - process.env.NS_ADB_REVERSE_READY = "1"; + if (phase === "pre-build") { + // Decide the origin baked into bundle.mjs. Hand the bundler our + // exact adb (so any self-managed fallback can't version-mismatch + // the daemon) and, if the tunnel comes up, tell it to emit + // `127.0.0.1` and skip adb entirely. + process.env.NS_ADB_PATH = await this.$staticConfig.getAdbFilePath(); + process.env.NS_DEVICE_SERIAL = serial; + + const ok = await this.ensureAndroidReverse(device, serial, port); + if (ok) { + process.env.NS_ADB_REVERSE_READY = "1"; + this.$logger.info( + `Set up adb reverse tcp:${port} tcp:${port} for ${serial} (Vite HMR routes device-side 127.0.0.1:${port} through ADB).`, + ); + } else { + this.$logger.warn( + `Could not confirm 'adb reverse tcp:${port}' on ${serial} (device adbd slow/unresponsive). Vite HMR will fall back to 10.0.2.2. If this persists, cold-boot/wipe the emulator, or set NS_HMR_NO_ADB_REVERSE=1.`, + ); + } + return; + } - this.$logger.info( - `Set up adb reverse tcp:${port} tcp:${port} for ${serial} (Vite HMR routes device-side 127.0.0.1:${port} through ADB).`, - ); + // phase === "pre-launch": re-establish the mapping right before the + // app boots. `adb reverse` mappings are bound to the device's adb + // transport, and that transport can change during the (long) build + // + install (fresh emulators reconnect as they settle), silently + // dropping the early mapping. We only bother when we actually told + // the bundle to use `127.0.0.1` (READY set during pre-build). + if (!this.isTruthyEnvFlag(process.env.NS_ADB_REVERSE_READY)) { + return; + } + const ok = await this.ensureAndroidReverse(device, serial, port); + if (!ok) { + this.$logger.warn( + `adb reverse tcp:${port} was not active before launch on ${serial}; the app may fail to reach the Vite dev server at 127.0.0.1:${port}.`, + ); + } } catch (err) { this.$logger.trace( - `Setting up adb reverse for Vite HMR failed; leaving it to the bundler fallback. Error: ${err}`, + `Setting up adb reverse for Vite HMR (${phase}) failed; leaving it to the bundler fallback. Error: ${err}`, + ); + } + } + + /** + * Apply `adb reverse tcp: tcp:` to the device and confirm + * via `adb reverse --list` that it actually landed, retrying a few + * times. Every device-side call is bounded with a Node `spawn` timeout + * + `SIGKILL` so a wedged/slow adbd (observed blocking 90s+ on some + * fresh-boot / API-36 arm64 emulators) can never hang the run — the + * hung adb child is reaped, not orphaned. Returns whether the mapping + * is confirmed present. + */ + private async ensureAndroidReverse( + device: Mobile.IDevice, + serial: string, + port: number, + ): Promise { + const adb = (device as Mobile.IAndroidDevice).adb; + const ADB_WAIT_MS = 15000; + const ADB_REVERSE_MS = 20000; + const bounded = (timeout: number) => ({ + deviceIdentifier: serial, + treatErrorsAsWarnings: true, + childProcessOptions: { timeout, killSignal: "SIGKILL" }, + }); + + // `wait-for-device` only blocks until the transport is up; bounded so a + // never-ready device can't stall us. + await adb.executeCommand(["wait-for-device"], bounded(ADB_WAIT_MS)); + + for (let attempt = 1; attempt <= 3; attempt++) { + await adb.executeCommand( + ["reverse", `tcp:${port}`, `tcp:${port}`], + bounded(ADB_REVERSE_MS), ); + // Verify it landed (a SIGKILL'd-on-timeout reverse resolves rather + // than throws, so success of the call isn't proof). + const list = + ( + await adb.executeCommand(["reverse", "--list"], bounded(ADB_WAIT_MS)) + )?.toString?.() ?? ""; + if (list.includes(`tcp:${port}`)) { + return true; + } } + return false; } private getViteHmrPort(): number { diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index b66b7bdb1b..acc5acf171 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -1,5 +1,6 @@ import * as path from "path"; import * as child_process from "child_process"; +import * as net from "net"; import * as semver from "semver"; import * as _ from "lodash"; import { EventEmitter } from "events"; @@ -59,6 +60,10 @@ export class BundlerCompilerService implements IBundlerCompilerService { private bundlerProcesses: IDictionary = {}; + // Vite-only: the long-lived `vite serve` dev server the device fetches + // modules and HMR updates from. Keyed by platform, managed by this CLI + // so users no longer need a separate `concurrently`/`wait-on` process. + private viteServeProcesses: IDictionary = {}; private expectedHashes: IStringDictionary = {}; constructor( @@ -98,6 +103,16 @@ export class BundlerCompilerService let isFirstBundlerWatchCompilation = true; prepareData.watch = true; + + // Bring up the Vite HMR dev server the device fetches modules / + // HMR updates from. No-op unless bundler is vite + hmr + watch. + // Fired in parallel with the build watcher; both child processes + // inherit the adb-reverse env the run-controller set before + // prepare, so neither one spawns adb on its own. Intentionally not + // awaited — the device only connects to it at app launch, well + // after the first build. + this.startViteDevServer(platformData, projectData, prepareData); + try { const childProcess = await this.startBundleProcess( platformData, @@ -558,6 +573,179 @@ export class BundlerCompilerService return childProcess; } + private getViteHmrPort(): number { + const fromEnv = Number(process.env.NS_HMR_PORT); + return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5173; + } + + /** + * Spawn and manage the Vite dev server (`vite serve`) for HMR. + * + * Why the CLI owns this. With Vite, HMR needs a long-lived dev server + * (HTTP + the `/ns-hmr` websocket on port 5173) that the device fetches + * modules and hot updates from — it is SEPARATE from the + * `vite build --watch` process that emits the `bundle.mjs` bootstrap + * baked into the app. Historically users wired this up themselves with + * `concurrently`/`wait-on`, which left two uncoordinated processes both + * touching adb during cold start (the source of the Android + * "Searching for devices…" freeze). By spawning it here as a child of + * the CLI, the dev server inherits the CLI's environment — crucially + * `NS_ADB_REVERSE_READY`/`NS_DEVICE_SERIAL`/`NS_ADB_PATH` set by the + * run-controller — so the CLI is the single adb owner and the dev + * server never spawns adb itself. + * + * No-op unless bundler is vite, HMR is on, watch mode, and not release. + * Best-effort: failures are logged, never thrown — a dev-server hiccup + * must not fail the run. + */ + private async startViteDevServer( + platformData: IPlatformData, + projectData: IProjectData, + prepareData: IPrepareData, + ): Promise { + try { + if (this.getBundler() !== "vite") { + return; + } + if (!prepareData.watch || !prepareData.hmr || prepareData.release) { + return; + } + const key = platformData.platformNameLowerCase; + if (this.viteServeProcesses[key]) { + return; + } + + const port = this.getViteHmrPort(); + // One dev server per port. Simultaneous multi-platform HMR in a + // single CLI invocation would collide on 5173 — that case still + // needs a distinct NS_HMR_PORT per platform, so skip + warn rather + // than fail to bind. + const collidingPlatform = Object.keys(this.viteServeProcesses)[0]; + if (collidingPlatform) { + this.$logger.warn( + `Vite dev server already running for '${collidingPlatform}' on port ${port}; skipping a second server for '${key}'. For simultaneous multi-platform HMR, set a distinct NS_HMR_PORT per platform.`, + ); + return; + } + + const envData = this.buildEnvData( + platformData.platformNameLowerCase, + projectData, + prepareData, + ); + const cliArgs = await this.buildEnvCommandLineParams( + envData, + platformData, + projectData, + prepareData, + ); + + const additionalNodeArgs = + semver.major(process.version) <= 8 ? ["--harmony"] : []; + if (await this.shouldUsePreserveSymlinksOption()) { + additionalNodeArgs.push("--preserve-symlinks"); + } + + // `vite serve` (not `build`): runs the dev server and watches on + // its own — no `--watch`. Env flags (`--env.android --env.hmr …`) + // go after `--` so vite's CLI doesn't choke on unknown options. + const args = [ + ...additionalNodeArgs, + this.getBundlerExecutablePath(projectData), + "serve", + `--config=${projectData.bundlerConfigPath}`, + `--mode=development`, + "--", + ...cliArgs, + ].filter(Boolean); + + const options: { [key: string]: any } = { + cwd: projectData.projectDir, + // Inherit so the dev server's URLs/logs stream to the user as + // before. No IPC needed here — the build watcher provides the + // bundle-complete IPC; the dev server is fetched over HTTP/ws. + stdio: "inherit", + env: { + ...process.env, + NATIVESCRIPT_BUNDLER_ENV: JSON.stringify(envData), + }, + }; + if (this.$hostInfo.isWindows) { + Object.assign(options.env, { APPDATA: process.env.appData }); + } + + this.$logger.info( + `Starting Vite dev server (HMR) for ${key} on port ${port}…`, + ); + + const childProcess = this.$childProcess.spawn( + process.execPath, + args, + options, + ); + this.viteServeProcesses[key] = childProcess; + await this.$cleanupService.addKillProcess(childProcess.pid.toString()); + + childProcess.once("exit", (code: number) => { + delete this.viteServeProcesses[key]; + if (code) { + this.$logger.warn( + `Vite dev server for ${key} exited with code ${code}.`, + ); + } + }); + + // Bounded readiness probe so we can surface a clear log once the + // device can actually reach modules. + const ready = await this.waitForPort(port, 30000); + if (ready) { + this.$logger.info( + `Vite dev server ready on port ${port} (HMR for ${key}).`, + ); + } else { + this.$logger.trace( + `Vite dev server port ${port} not observed open within the readiness probe window; continuing (it may bind shortly).`, + ); + } + } catch (err) { + this.$logger.warn( + `Failed to start the Vite dev server: ${err}. HMR may be unavailable.`, + ); + } + } + + /** + * Resolve true once `127.0.0.1:` accepts a TCP connection, or + * false after `timeoutMs`. Used to detect the Vite dev server is up. + */ + private waitForPort(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve) => { + const attempt = () => { + const socket = net.connect({ port, host: "127.0.0.1" }); + let settled = false; + const done = (ok: boolean) => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + if (ok) { + resolve(true); + } else if (Date.now() >= deadline) { + resolve(false); + } else { + setTimeout(attempt, 250); + } + }; + socket.once("connect", () => done(true)); + socket.once("error", () => done(false)); + socket.setTimeout(1000, () => done(false)); + }; + attempt(); + }); + } + private buildEnvData( platform: string, projectData: IProjectData, @@ -751,6 +939,16 @@ export class BundlerCompilerService bundlerProcess.kill("SIGINT"); delete this.bundlerProcesses[platform]; } + + // Tear down the Vite dev server we manage alongside the build watcher. + const viteServeProcess = this.viteServeProcesses[platform]; + if (viteServeProcess) { + await this.$cleanupService.removeKillProcess( + viteServeProcess.pid.toString(), + ); + viteServeProcess.kill("SIGINT"); + delete this.viteServeProcesses[platform]; + } } private handleHMRMessage( From b22bcde0e1a9d155928161357faabf97860b1db0 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 29 May 2026 16:43:29 -0700 Subject: [PATCH 10/11] chore: 9.1.0-alpha.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d82d7d6d24..77e86fcc28 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativescript", "main": "./lib/nativescript-cli-lib.js", - "version": "9.1.0-alpha.3", + "version": "9.1.0-alpha.5", "author": "NativeScript ", "description": "Command-line interface for building NativeScript projects", "bin": { From fd5d69e814d243468f47014c00481ee7cf1b5980 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 12 Jun 2026 13:09:12 -0700 Subject: [PATCH 11/11] chore: 9.1.0-alpha.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 77e86fcc28..2214d7291a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativescript", "main": "./lib/nativescript-cli-lib.js", - "version": "9.1.0-alpha.5", + "version": "9.1.0-alpha.9", "author": "NativeScript ", "description": "Command-line interface for building NativeScript projects", "bin": {