|
1 | 1 | import * as path from "path"; |
2 | 2 | import * as child_process from "child_process"; |
| 3 | +import * as net from "net"; |
3 | 4 | import * as semver from "semver"; |
4 | 5 | import * as _ from "lodash"; |
5 | 6 | import { EventEmitter } from "events"; |
@@ -59,6 +60,10 @@ export class BundlerCompilerService |
59 | 60 | implements IBundlerCompilerService |
60 | 61 | { |
61 | 62 | 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> = {}; |
62 | 67 | private expectedHashes: IStringDictionary = {}; |
63 | 68 |
|
64 | 69 | constructor( |
@@ -98,6 +103,16 @@ export class BundlerCompilerService |
98 | 103 |
|
99 | 104 | let isFirstBundlerWatchCompilation = true; |
100 | 105 | 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 | + |
101 | 116 | try { |
102 | 117 | const childProcess = await this.startBundleProcess( |
103 | 118 | platformData, |
@@ -558,6 +573,179 @@ export class BundlerCompilerService |
558 | 573 | return childProcess; |
559 | 574 | } |
560 | 575 |
|
| 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 | + |
561 | 749 | private buildEnvData( |
562 | 750 | platform: string, |
563 | 751 | projectData: IProjectData, |
@@ -751,6 +939,16 @@ export class BundlerCompilerService |
751 | 939 | bundlerProcess.kill("SIGINT"); |
752 | 940 | delete this.bundlerProcesses[platform]; |
753 | 941 | } |
| 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 | + } |
754 | 952 | } |
755 | 953 |
|
756 | 954 | private handleHMRMessage( |
|
0 commit comments