Skip to content

Commit a639cd4

Browse files
committed
feat: cli managed vite process, seamless and easier to work with
1 parent 5798c44 commit a639cd4

2 files changed

Lines changed: 298 additions & 25 deletions

File tree

lib/controllers/run-controller.ts

Lines changed: 100 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -506,7 +506,12 @@ export class RunController extends EventEmitter implements IRunController {
506506
// spawns the Vite bundler that inherits `process.env`) so the
507507
// bundler trusts the tunnel instead of racing us to spawn its
508508
// own adb during config-load. See packages/vite hardening.
509-
await this.setupAndroidViteHmrReverse(device, projectData, liveSyncInfo);
509+
await this.setupAndroidViteHmrReverse(
510+
device,
511+
projectData,
512+
liveSyncInfo,
513+
"pre-build",
514+
);
510515

511516
const prepareResultData =
512517
await this.$prepareController.prepare(prepareData);
@@ -582,6 +587,17 @@ export class RunController extends EventEmitter implements IRunController {
582587
liveSyncDeviceData: deviceDescriptor,
583588
});
584589

590+
// Re-establish the adb reverse on the CURRENT transport right
591+
// before launch — the transport can change during build/install
592+
// and drop the mapping set in `pre-build`, which would leave the
593+
// app unable to reach the Vite dev server at 127.0.0.1.
594+
await this.setupAndroidViteHmrReverse(
595+
device,
596+
projectData,
597+
liveSyncInfo,
598+
"pre-launch",
599+
);
600+
585601
await this.refreshApplication(
586602
projectData,
587603
liveSyncResultInfo,
@@ -660,6 +676,7 @@ export class RunController extends EventEmitter implements IRunController {
660676
device: Mobile.IDevice,
661677
projectData: IProjectData,
662678
liveSyncInfo: ILiveSyncInfo,
679+
phase: "pre-build" | "pre-launch",
663680
): Promise<void> {
664681
try {
665682
if (!this.$mobileHelper.isAndroidPlatform(device.deviceInfo.platform)) {
@@ -687,36 +704,94 @@ export class RunController extends EventEmitter implements IRunController {
687704

688705
const serial = device.deviceInfo.identifier;
689706
const port = this.getViteHmrPort();
690-
// Safe after the `isAndroidPlatform` guard above — only Android
691-
// devices carry the `adb` bridge.
692-
const adb = (device as Mobile.IAndroidDevice).adb;
693-
694-
// Don't `reverse` against a device whose adbd isn't accepting
695-
// yet (emulators report a `device` transport before adbd is
696-
// fully up). `wait-for-device` returns immediately once ready.
697-
await adb.executeCommand(["wait-for-device"], {
698-
deviceIdentifier: serial,
699-
});
700-
await adb.executeCommand(["reverse", `tcp:${port}`, `tcp:${port}`], {
701-
deviceIdentifier: serial,
702-
});
703707

704-
// Hand the exact adb the CLI used to the bundler so, in any
705-
// fallback path, it drives the same version-matched client and
706-
// can't trigger a server-version-mismatch daemon kill.
707-
const adbPath = await this.$staticConfig.getAdbFilePath();
708-
process.env.NS_ADB_PATH = adbPath;
709-
process.env.NS_DEVICE_SERIAL = serial;
710-
process.env.NS_ADB_REVERSE_READY = "1";
708+
if (phase === "pre-build") {
709+
// Decide the origin baked into bundle.mjs. Hand the bundler our
710+
// exact adb (so any self-managed fallback can't version-mismatch
711+
// the daemon) and, if the tunnel comes up, tell it to emit
712+
// `127.0.0.1` and skip adb entirely.
713+
process.env.NS_ADB_PATH = await this.$staticConfig.getAdbFilePath();
714+
process.env.NS_DEVICE_SERIAL = serial;
715+
716+
const ok = await this.ensureAndroidReverse(device, serial, port);
717+
if (ok) {
718+
process.env.NS_ADB_REVERSE_READY = "1";
719+
this.$logger.info(
720+
`Set up adb reverse tcp:${port} tcp:${port} for ${serial} (Vite HMR routes device-side 127.0.0.1:${port} through ADB).`,
721+
);
722+
} else {
723+
this.$logger.warn(
724+
`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.`,
725+
);
726+
}
727+
return;
728+
}
711729

712-
this.$logger.info(
713-
`Set up adb reverse tcp:${port} tcp:${port} for ${serial} (Vite HMR routes device-side 127.0.0.1:${port} through ADB).`,
714-
);
730+
// phase === "pre-launch": re-establish the mapping right before the
731+
// app boots. `adb reverse` mappings are bound to the device's adb
732+
// transport, and that transport can change during the (long) build
733+
// + install (fresh emulators reconnect as they settle), silently
734+
// dropping the early mapping. We only bother when we actually told
735+
// the bundle to use `127.0.0.1` (READY set during pre-build).
736+
if (!this.isTruthyEnvFlag(process.env.NS_ADB_REVERSE_READY)) {
737+
return;
738+
}
739+
const ok = await this.ensureAndroidReverse(device, serial, port);
740+
if (!ok) {
741+
this.$logger.warn(
742+
`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}.`,
743+
);
744+
}
715745
} catch (err) {
716746
this.$logger.trace(
717-
`Setting up adb reverse for Vite HMR failed; leaving it to the bundler fallback. Error: ${err}`,
747+
`Setting up adb reverse for Vite HMR (${phase}) failed; leaving it to the bundler fallback. Error: ${err}`,
748+
);
749+
}
750+
}
751+
752+
/**
753+
* Apply `adb reverse tcp:<port> tcp:<port>` to the device and confirm
754+
* via `adb reverse --list` that it actually landed, retrying a few
755+
* times. Every device-side call is bounded with a Node `spawn` timeout
756+
* + `SIGKILL` so a wedged/slow adbd (observed blocking 90s+ on some
757+
* fresh-boot / API-36 arm64 emulators) can never hang the run — the
758+
* hung adb child is reaped, not orphaned. Returns whether the mapping
759+
* is confirmed present.
760+
*/
761+
private async ensureAndroidReverse(
762+
device: Mobile.IDevice,
763+
serial: string,
764+
port: number,
765+
): Promise<boolean> {
766+
const adb = (device as Mobile.IAndroidDevice).adb;
767+
const ADB_WAIT_MS = 15000;
768+
const ADB_REVERSE_MS = 20000;
769+
const bounded = (timeout: number) => ({
770+
deviceIdentifier: serial,
771+
treatErrorsAsWarnings: true,
772+
childProcessOptions: { timeout, killSignal: "SIGKILL" },
773+
});
774+
775+
// `wait-for-device` only blocks until the transport is up; bounded so a
776+
// never-ready device can't stall us.
777+
await adb.executeCommand(["wait-for-device"], bounded(ADB_WAIT_MS));
778+
779+
for (let attempt = 1; attempt <= 3; attempt++) {
780+
await adb.executeCommand(
781+
["reverse", `tcp:${port}`, `tcp:${port}`],
782+
bounded(ADB_REVERSE_MS),
718783
);
784+
// Verify it landed (a SIGKILL'd-on-timeout reverse resolves rather
785+
// than throws, so success of the call isn't proof).
786+
const list =
787+
(
788+
await adb.executeCommand(["reverse", "--list"], bounded(ADB_WAIT_MS))
789+
)?.toString?.() ?? "";
790+
if (list.includes(`tcp:${port}`)) {
791+
return true;
792+
}
719793
}
794+
return false;
720795
}
721796

722797
private getViteHmrPort(): number {

lib/services/bundler/bundler-compiler-service.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as path from "path";
22
import * as child_process from "child_process";
3+
import * as net from "net";
34
import * as semver from "semver";
45
import * as _ from "lodash";
56
import { EventEmitter } from "events";
@@ -59,6 +60,10 @@ export class BundlerCompilerService
5960
implements IBundlerCompilerService
6061
{
6162
private bundlerProcesses: IDictionary<child_process.ChildProcess> = {};
63+
// Vite-only: the long-lived `vite serve` dev server the device fetches
64+
// modules and HMR updates from. Keyed by platform, managed by this CLI
65+
// so users no longer need a separate `concurrently`/`wait-on` process.
66+
private viteServeProcesses: IDictionary<child_process.ChildProcess> = {};
6267
private expectedHashes: IStringDictionary = {};
6368

6469
constructor(
@@ -98,6 +103,16 @@ export class BundlerCompilerService
98103

99104
let isFirstBundlerWatchCompilation = true;
100105
prepareData.watch = true;
106+
107+
// Bring up the Vite HMR dev server the device fetches modules /
108+
// HMR updates from. No-op unless bundler is vite + hmr + watch.
109+
// Fired in parallel with the build watcher; both child processes
110+
// inherit the adb-reverse env the run-controller set before
111+
// prepare, so neither one spawns adb on its own. Intentionally not
112+
// awaited — the device only connects to it at app launch, well
113+
// after the first build.
114+
this.startViteDevServer(platformData, projectData, prepareData);
115+
101116
try {
102117
const childProcess = await this.startBundleProcess(
103118
platformData,
@@ -558,6 +573,179 @@ export class BundlerCompilerService
558573
return childProcess;
559574
}
560575

576+
private getViteHmrPort(): number {
577+
const fromEnv = Number(process.env.NS_HMR_PORT);
578+
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5173;
579+
}
580+
581+
/**
582+
* Spawn and manage the Vite dev server (`vite serve`) for HMR.
583+
*
584+
* Why the CLI owns this. With Vite, HMR needs a long-lived dev server
585+
* (HTTP + the `/ns-hmr` websocket on port 5173) that the device fetches
586+
* modules and hot updates from — it is SEPARATE from the
587+
* `vite build --watch` process that emits the `bundle.mjs` bootstrap
588+
* baked into the app. Historically users wired this up themselves with
589+
* `concurrently`/`wait-on`, which left two uncoordinated processes both
590+
* touching adb during cold start (the source of the Android
591+
* "Searching for devices…" freeze). By spawning it here as a child of
592+
* the CLI, the dev server inherits the CLI's environment — crucially
593+
* `NS_ADB_REVERSE_READY`/`NS_DEVICE_SERIAL`/`NS_ADB_PATH` set by the
594+
* run-controller — so the CLI is the single adb owner and the dev
595+
* server never spawns adb itself.
596+
*
597+
* No-op unless bundler is vite, HMR is on, watch mode, and not release.
598+
* Best-effort: failures are logged, never thrown — a dev-server hiccup
599+
* must not fail the run.
600+
*/
601+
private async startViteDevServer(
602+
platformData: IPlatformData,
603+
projectData: IProjectData,
604+
prepareData: IPrepareData,
605+
): Promise<void> {
606+
try {
607+
if (this.getBundler() !== "vite") {
608+
return;
609+
}
610+
if (!prepareData.watch || !prepareData.hmr || prepareData.release) {
611+
return;
612+
}
613+
const key = platformData.platformNameLowerCase;
614+
if (this.viteServeProcesses[key]) {
615+
return;
616+
}
617+
618+
const port = this.getViteHmrPort();
619+
// One dev server per port. Simultaneous multi-platform HMR in a
620+
// single CLI invocation would collide on 5173 — that case still
621+
// needs a distinct NS_HMR_PORT per platform, so skip + warn rather
622+
// than fail to bind.
623+
const collidingPlatform = Object.keys(this.viteServeProcesses)[0];
624+
if (collidingPlatform) {
625+
this.$logger.warn(
626+
`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.`,
627+
);
628+
return;
629+
}
630+
631+
const envData = this.buildEnvData(
632+
platformData.platformNameLowerCase,
633+
projectData,
634+
prepareData,
635+
);
636+
const cliArgs = await this.buildEnvCommandLineParams(
637+
envData,
638+
platformData,
639+
projectData,
640+
prepareData,
641+
);
642+
643+
const additionalNodeArgs =
644+
semver.major(process.version) <= 8 ? ["--harmony"] : [];
645+
if (await this.shouldUsePreserveSymlinksOption()) {
646+
additionalNodeArgs.push("--preserve-symlinks");
647+
}
648+
649+
// `vite serve` (not `build`): runs the dev server and watches on
650+
// its own — no `--watch`. Env flags (`--env.android --env.hmr …`)
651+
// go after `--` so vite's CLI doesn't choke on unknown options.
652+
const args = [
653+
...additionalNodeArgs,
654+
this.getBundlerExecutablePath(projectData),
655+
"serve",
656+
`--config=${projectData.bundlerConfigPath}`,
657+
`--mode=development`,
658+
"--",
659+
...cliArgs,
660+
].filter(Boolean);
661+
662+
const options: { [key: string]: any } = {
663+
cwd: projectData.projectDir,
664+
// Inherit so the dev server's URLs/logs stream to the user as
665+
// before. No IPC needed here — the build watcher provides the
666+
// bundle-complete IPC; the dev server is fetched over HTTP/ws.
667+
stdio: "inherit",
668+
env: {
669+
...process.env,
670+
NATIVESCRIPT_BUNDLER_ENV: JSON.stringify(envData),
671+
},
672+
};
673+
if (this.$hostInfo.isWindows) {
674+
Object.assign(options.env, { APPDATA: process.env.appData });
675+
}
676+
677+
this.$logger.info(
678+
`Starting Vite dev server (HMR) for ${key} on port ${port}…`,
679+
);
680+
681+
const childProcess = this.$childProcess.spawn(
682+
process.execPath,
683+
args,
684+
options,
685+
);
686+
this.viteServeProcesses[key] = childProcess;
687+
await this.$cleanupService.addKillProcess(childProcess.pid.toString());
688+
689+
childProcess.once("exit", (code: number) => {
690+
delete this.viteServeProcesses[key];
691+
if (code) {
692+
this.$logger.warn(
693+
`Vite dev server for ${key} exited with code ${code}.`,
694+
);
695+
}
696+
});
697+
698+
// Bounded readiness probe so we can surface a clear log once the
699+
// device can actually reach modules.
700+
const ready = await this.waitForPort(port, 30000);
701+
if (ready) {
702+
this.$logger.info(
703+
`Vite dev server ready on port ${port} (HMR for ${key}).`,
704+
);
705+
} else {
706+
this.$logger.trace(
707+
`Vite dev server port ${port} not observed open within the readiness probe window; continuing (it may bind shortly).`,
708+
);
709+
}
710+
} catch (err) {
711+
this.$logger.warn(
712+
`Failed to start the Vite dev server: ${err}. HMR may be unavailable.`,
713+
);
714+
}
715+
}
716+
717+
/**
718+
* Resolve true once `127.0.0.1:<port>` accepts a TCP connection, or
719+
* false after `timeoutMs`. Used to detect the Vite dev server is up.
720+
*/
721+
private waitForPort(port: number, timeoutMs: number): Promise<boolean> {
722+
const deadline = Date.now() + timeoutMs;
723+
return new Promise<boolean>((resolve) => {
724+
const attempt = () => {
725+
const socket = net.connect({ port, host: "127.0.0.1" });
726+
let settled = false;
727+
const done = (ok: boolean) => {
728+
if (settled) {
729+
return;
730+
}
731+
settled = true;
732+
socket.destroy();
733+
if (ok) {
734+
resolve(true);
735+
} else if (Date.now() >= deadline) {
736+
resolve(false);
737+
} else {
738+
setTimeout(attempt, 250);
739+
}
740+
};
741+
socket.once("connect", () => done(true));
742+
socket.once("error", () => done(false));
743+
socket.setTimeout(1000, () => done(false));
744+
};
745+
attempt();
746+
});
747+
}
748+
561749
private buildEnvData(
562750
platform: string,
563751
projectData: IProjectData,
@@ -751,6 +939,16 @@ export class BundlerCompilerService
751939
bundlerProcess.kill("SIGINT");
752940
delete this.bundlerProcesses[platform];
753941
}
942+
943+
// Tear down the Vite dev server we manage alongside the build watcher.
944+
const viteServeProcess = this.viteServeProcesses[platform];
945+
if (viteServeProcess) {
946+
await this.$cleanupService.removeKillProcess(
947+
viteServeProcess.pid.toString(),
948+
);
949+
viteServeProcess.kill("SIGINT");
950+
delete this.viteServeProcesses[platform];
951+
}
754952
}
755953

756954
private handleHMRMessage(

0 commit comments

Comments
 (0)