From fa7c081b13e503c9c4ecea90c0a3e44b663fba3c Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 29 Apr 2026 07:30:30 -0700 Subject: [PATCH 1/4] feat: devtools host --- lib/bootstrap.ts | 2 + lib/controllers/run-controller.ts | 4 + lib/definitions/devtools-host-service.d.ts | 44 ++++ lib/helpers/livesync-command-helper.ts | 64 +++-- .../bundler/bundler-compiler-service.ts | 7 + lib/services/devtools-host-service.ts | 219 ++++++++++++++++++ test/controllers/run-controller.ts | 63 +++-- .../bundler/bundler-compiler-service.ts | 3 + 8 files changed, 349 insertions(+), 57 deletions(-) create mode 100644 lib/definitions/devtools-host-service.d.ts create mode 100644 lib/services/devtools-host-service.ts diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 3281d84930..276c5ea9db 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -427,6 +427,8 @@ injector.require( "./services/bundler/bundler-compiler-service", ); +injector.require("devtoolsHostService", "./services/devtools-host-service"); + injector.require( "applePortalSessionService", "./services/apple-portal/apple-portal-session-service", diff --git a/lib/controllers/run-controller.ts b/lib/controllers/run-controller.ts index b1860c5ead..8dd855fb82 100644 --- a/lib/controllers/run-controller.ts +++ b/lib/controllers/run-controller.ts @@ -12,6 +12,7 @@ import * as util from "util"; import * as _ from "lodash"; import { IProjectDataService, IProjectData } from "../definitions/project"; import { IBuildController } from "../definitions/build"; +import { IDevtoolsHostService } from "../definitions/devtools-host-service"; import { IPlatformsDataService } from "../definitions/platform"; import { IDebugController } from "../definitions/debug"; import { IPluginsService } from "../definitions/plugins"; @@ -48,6 +49,7 @@ export class RunController extends EventEmitter implements IRunController { private $prepareNativePlatformService: IPrepareNativePlatformService, private $projectChangesService: IProjectChangesService, protected $projectDataService: IProjectDataService, + private $devtoolsHostService: IDevtoolsHostService, ) { super(); } @@ -181,6 +183,8 @@ export class RunController extends EventEmitter implements IRunController { liveSyncProcessInfo.deviceDescriptors = []; + await this.$devtoolsHostService.stopAll(); + if (this.prepareReadyEventHandler) { this.$prepareController.removeListener( PREPARE_READY_EVENT_NAME, diff --git a/lib/definitions/devtools-host-service.d.ts b/lib/definitions/devtools-host-service.d.ts new file mode 100644 index 0000000000..983bd4f05e --- /dev/null +++ b/lib/definitions/devtools-host-service.d.ts @@ -0,0 +1,44 @@ +import { IProjectData } from "./project"; + +/** + * Origin of a running loopback HTTP server for a single platform, + * e.g. "http://127.0.0.1:41500". + */ +export interface IDevtoolsHostOrigin { + platform: string; + origin: string; +} + +/** + * Loopback HTTP server (per platform) that serves the webpack output + * directory with CORS so Chrome DevTools (served from + * https://chrome-devtools-frontend.appspot.com) can fetch .map / .js + * files while a debug session is active. + * + * Bound to 127.0.0.1 only; never exposed to the network. + */ +export interface IDevtoolsHostService { + /** + * Start (or return existing) HTTP server for a platform. Idempotent + * per platform — subsequent calls return the same origin. + */ + start( + projectData: IProjectData, + platform: string, + ): Promise; + + /** + * Stop the server for a single platform. Quiet no-op if nothing running. + */ + stop(platform: string): Promise; + + /** + * Stop all running servers. + */ + stopAll(): Promise; + + /** + * Current origin for a platform, or null if not running. + */ + getOrigin(platform: string): string | null; +} diff --git a/lib/helpers/livesync-command-helper.ts b/lib/helpers/livesync-command-helper.ts index ea9cc44ea4..4f02c7cee5 100644 --- a/lib/helpers/livesync-command-helper.ts +++ b/lib/helpers/livesync-command-helper.ts @@ -12,6 +12,7 @@ import { DeployController } from "../controllers/deploy-controller"; import { IAndroidBundleValidatorHelper, IOptions } from "../declarations"; import { IBuildController, IBuildDataService } from "../definitions/build"; import { ICleanupService } from "../definitions/cleanup-service"; +import { IDevtoolsHostService } from "../definitions/devtools-host-service"; import { IPlatformsDataService } from "../definitions/platform"; import { IProjectData, IValidatePlatformOutput } from "../definitions/project"; @@ -31,7 +32,8 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { private $errors: IErrors, private $iOSSimulatorLogProvider: Mobile.IiOSSimulatorLogProvider, private $cleanupService: ICleanupService, - private $runController: IRunController + private $runController: IRunController, + private $devtoolsHostService: IDevtoolsHostService, ) {} private get $platformsDataService(): IPlatformsDataService { @@ -56,7 +58,7 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { } public async getDeviceInstances( - platform?: string + platform?: string, ): Promise { await this.$devicesService.initialize({ platform, @@ -71,7 +73,7 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { .filter( (d) => !platform || - d.deviceInfo.platform.toLowerCase() === platform.toLowerCase() + d.deviceInfo.platform.toLowerCase() === platform.toLowerCase(), ); return devices; @@ -80,7 +82,7 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { public async createDeviceDescriptors( devices: Mobile.IDevice[], platform: string, - additionalOptions?: ILiveSyncCommandHelperAdditionalOptions + additionalOptions?: ILiveSyncCommandHelperAdditionalOptions, ): Promise { // Now let's take data for each device: const deviceDescriptors: ILiveSyncDeviceDescriptor[] = devices.map((d) => { @@ -105,7 +107,7 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { forceRebuildNativeApp: additionalOptions.forceRebuildNativeApp, }, _device: d, - } + }, ); this.$androidBundleValidatorHelper.validateDeviceApiLevel(d, buildData); @@ -115,8 +117,8 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { additionalOptions.buildPlatform, d.deviceInfo.platform, buildData, - this.$projectData - ) + this.$projectData, + ) : this.$buildController.build.bind(this.$buildController, buildData); const info: ILiveSyncDeviceDescriptor = { @@ -142,14 +144,14 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { const availablePlatforms = platform ? [platform] : _.values( - this.$mobileHelper.platformNames.map((p) => p.toLowerCase()) - ); + this.$mobileHelper.platformNames.map((p) => p.toLowerCase()), + ); return availablePlatforms; } public async executeCommandLiveSync( platform?: string, - additionalOptions?: ILiveSyncCommandHelperAdditionalOptions + additionalOptions?: ILiveSyncCommandHelperAdditionalOptions, ) { const devices = await this.getDeviceInstances(platform); await this.executeLiveSyncOperation(devices, platform, additionalOptions); @@ -158,13 +160,13 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { public async executeLiveSyncOperation( devices: Mobile.IDevice[], platform: string, - additionalOptions?: ILiveSyncCommandHelperAdditionalOptions + additionalOptions?: ILiveSyncCommandHelperAdditionalOptions, ): Promise { const { liveSyncInfo, deviceDescriptors } = await this.executeLiveSyncOperationCore( devices, platform, - additionalOptions + additionalOptions, ); if (this.$options.release) { @@ -189,6 +191,7 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { }); return; } else { + await this.startDevtoolsHostIfDebugging(deviceDescriptors); await this.$runController.run({ liveSyncInfo, deviceDescriptors, @@ -204,19 +207,19 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { }) => { const devices = await this.getDeviceInstances(platform); const remainingDevicesToSync = devices.map( - (d) => d.deviceInfo.identifier + (d) => d.deviceInfo.identifier, ); _.remove(remainingDevicesToSync, (d) => d === data.deviceIdentifier); if (remainingDevicesToSync.length === 0 && !data.keepProcessAlive) { process.exit(ErrorCodes.ALL_DEVICES_DISCONNECTED); } - } + }, ); } public async validatePlatform( - platform: string + platform: string, ): Promise> { const result: IDictionary = {}; @@ -224,12 +227,12 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { for (const availablePlatform of availablePlatforms) { const platformData = this.$platformsDataService.getPlatformData( availablePlatform, - this.$projectData + this.$projectData, ); const platformProjectService = platformData.platformProjectService; const validateOutput = await platformProjectService.validate( this.$projectData, - this.$options + this.$options, ); result[availablePlatform.toLowerCase()] = validateOutput; } @@ -237,10 +240,25 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { return result; } + private async startDevtoolsHostIfDebugging( + deviceDescriptors: ILiveSyncDeviceDescriptor[], + ): Promise { + const platforms = _.uniq( + deviceDescriptors + .filter((d) => d.debuggingEnabled && d.buildData?.platform) + .map((d) => d.buildData.platform.toLowerCase()), + ); + for (const platform of platforms) { + // DevtoolsHostService swallows port/bind failures and returns null; + // a missing source-map origin degrades DevTools gracefully. + await this.$devtoolsHostService.start(this.$projectData, platform); + } + } + private async executeLiveSyncOperationCore( devices: Mobile.IDevice[], platform: string, - additionalOptions?: ILiveSyncCommandHelperAdditionalOptions + additionalOptions?: ILiveSyncCommandHelperAdditionalOptions, ): Promise<{ liveSyncInfo: ILiveSyncInfo; deviceDescriptors: ILiveSyncDeviceDescriptor[]; @@ -248,11 +266,11 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { if (!devices || !devices.length) { if (platform) { this.$errors.fail( - "Unable to find applicable devices to execute operation. Ensure connected devices are trusted and try again." + "Unable to find applicable devices to execute operation. Ensure connected devices are trusted and try again.", ); } else { this.$errors.fail( - "Unable to find applicable devices to execute operation and unable to start emulator when platform is not specified." + "Unable to find applicable devices to execute operation and unable to start emulator when platform is not specified.", ); } } @@ -273,7 +291,7 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { const deviceDescriptors = await this.createDeviceDescriptors( devices, platform, - additionalOptions + additionalOptions, ); const liveSyncInfo = this.getLiveSyncData(this.$projectData.projectDir); @@ -282,7 +300,7 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { private async runInRelease( platform: string, - deviceDescriptors: ILiveSyncDeviceDescriptor[] + deviceDescriptors: ILiveSyncDeviceDescriptor[], ): Promise { await this.$devicesService.initialize({ platform, @@ -296,7 +314,7 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { for (const deviceDescriptor of deviceDescriptors) { const device = this.$devicesService.getDeviceByIdentifier( - deviceDescriptor.identifier + deviceDescriptor.identifier, ); await device.applicationManager.startApplication({ appId: diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 3eb880b2ba..2a35150cac 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -31,6 +31,7 @@ import { IHostInfo, } from "../../common/declarations"; import { ICleanupService } from "../../definitions/cleanup-service"; +import { IDevtoolsHostService } from "../../definitions/devtools-host-service"; import { injector } from "../../common/yok"; import { resolvePackagePath, @@ -73,6 +74,7 @@ export class BundlerCompilerService private $packageManager: IPackageManager, private $packageInstallationManager: IPackageInstallationManager, // private $sharedEventBus: ISharedEventBus private $projectConfigService: IProjectConfigService, + private $devtoolsHostService: IDevtoolsHostService, ) { super(); } @@ -585,6 +587,11 @@ export class BundlerCompilerService envData.uniqueBundle = prepareData.uniqueBundle; } + const devtoolsOrigin = this.$devtoolsHostService.getOrigin(platform); + if (devtoolsOrigin) { + envData.devtoolsHost = devtoolsOrigin; + } + return envData; } diff --git a/lib/services/devtools-host-service.ts b/lib/services/devtools-host-service.ts new file mode 100644 index 0000000000..93c0d8d703 --- /dev/null +++ b/lib/services/devtools-host-service.ts @@ -0,0 +1,219 @@ +import * as http from "http"; +import * as path from "path"; +import * as fs from "fs"; +import { AddressInfo } from "net"; +import { INet } from "../common/declarations"; +import { IPlatformsDataService } from "../definitions/platform"; +import { IProjectData } from "../definitions/project"; +import { + IDevtoolsHostService, + IDevtoolsHostOrigin, +} from "../definitions/devtools-host-service"; +import { injector } from "../common/yok"; + +const LOOPBACK_HOST = "127.0.0.1"; +const DEVTOOLS_ORIGIN = "https://chrome-devtools-frontend.appspot.com"; +const PORT_RANGE_START = 41500; +const PORT_RANGE_END = 41999; + +const CONTENT_TYPES: { [ext: string]: string } = { + ".map": "application/json; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", +}; + +interface IServerEntry { + server: http.Server; + port: number; + rootDir: string; +} + +export class DevtoolsHostService implements IDevtoolsHostService { + private servers = new Map(); + + constructor( + private $net: INet, + private $logger: ILogger, + private $platformsDataService: IPlatformsDataService, + ) {} + + public async start( + projectData: IProjectData, + platform: string, + ): Promise { + const key = platform.toLowerCase(); + const existing = this.servers.get(key); + if (existing) { + return { platform: key, origin: this.formatOrigin(existing.port) }; + } + + const platformData = this.$platformsDataService.getPlatformData( + platform, + projectData, + ); + const rootDir = platformData?.appDestinationDirectoryPath; + if (!rootDir) { + this.$logger.warn( + `DevTools host: unable to resolve output directory for ${platform}.`, + ); + return null; + } + + let port: number; + try { + port = await this.$net.getAvailablePortInRange( + PORT_RANGE_START, + PORT_RANGE_END, + ); + } catch (err) { + this.$logger.warn( + `DevTools host: no free port in ${PORT_RANGE_START}-${PORT_RANGE_END}. Source maps will not load in Chrome DevTools. (${err?.message || err})`, + ); + return null; + } + + const server = http.createServer((req, res) => + this.handleRequest(req, res, rootDir), + ); + + try { + await new Promise((resolve, reject) => { + const onError = (err: Error) => reject(err); + server.once("error", onError); + server.listen(port, LOOPBACK_HOST, () => { + server.off("error", onError); + resolve(); + }); + }); + } catch (err) { + this.$logger.warn( + `DevTools host: failed to bind ${LOOPBACK_HOST}:${port} (${(err as Error)?.message || err}).`, + ); + return null; + } + + // `.unref()` so a lingering server doesn't keep the CLI process alive. + server.unref(); + + const actualPort = (server.address() as AddressInfo)?.port ?? port; + const entry: IServerEntry = { server, port: actualPort, rootDir }; + this.servers.set(key, entry); + + const origin = this.formatOrigin(actualPort); + this.$logger.info( + `DevTools host (${platform}) serving ${rootDir} at ${origin}`, + ); + return { platform: key, origin }; + } + + public async stop(platform: string): Promise { + const key = platform.toLowerCase(); + const entry = this.servers.get(key); + if (!entry) { + return; + } + + this.servers.delete(key); + await new Promise((resolve) => { + entry.server.close(() => resolve()); + }); + } + + public async stopAll(): Promise { + const platforms = Array.from(this.servers.keys()); + await Promise.all(platforms.map((p) => this.stop(p))); + } + + public getOrigin(platform: string): string | null { + const entry = this.servers.get(platform.toLowerCase()); + return entry ? this.formatOrigin(entry.port) : null; + } + + private formatOrigin(port: number): string { + return `http://${LOOPBACK_HOST}:${port}`; + } + + private handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + rootDir: string, + ): void { + res.setHeader("Access-Control-Allow-Origin", DEVTOOLS_ORIGIN); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "*"); + res.setHeader("Cache-Control", "no-cache"); + + if (req.method === "OPTIONS") { + res.writeHead(204); + res.end(); + return; + } + + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405); + res.end(); + return; + } + + let urlPath: string; + try { + urlPath = decodeURIComponent( + new URL(req.url || "/", "http://x").pathname, + ); + } catch { + res.writeHead(400); + res.end(); + return; + } + + const ext = path.extname(urlPath).toLowerCase(); + if (!CONTENT_TYPES[ext]) { + res.writeHead(403); + res.end(); + return; + } + + const resolvedRoot = path.resolve(rootDir); + const filePath = path.resolve(resolvedRoot, "." + urlPath); + if ( + filePath !== resolvedRoot && + !filePath.startsWith(resolvedRoot + path.sep) + ) { + res.writeHead(403); + res.end(); + return; + } + + fs.stat(filePath, (statErr, stats) => { + if (statErr || !stats.isFile()) { + res.writeHead(404); + res.end(); + return; + } + + res.setHeader("Content-Type", CONTENT_TYPES[ext]); + res.setHeader("Content-Length", String(stats.size)); + + if (req.method === "HEAD") { + res.writeHead(200); + res.end(); + return; + } + + const stream = fs.createReadStream(filePath); + stream.on("error", () => { + if (!res.headersSent) { + res.writeHead(500); + } + res.end(); + }); + res.writeHead(200); + stream.pipe(res); + }); + } +} + +injector.register("devtoolsHostService", DevtoolsHostService); diff --git a/test/controllers/run-controller.ts b/test/controllers/run-controller.ts index 36251a12aa..ca7b68347c 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,18 @@ function createTestInjector() { injector.register("debugController", {}); injector.register("liveSyncProcessDataService", LiveSyncProcessDataService); injector.register("tempService", TempServiceStub); + injector.register("devtoolsHostService", { + start: async (): Promise => null, + stop: async (): Promise => {}, + stopAll: async (): Promise => {}, + getOrigin: (): null => null, + }); 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 +211,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 +231,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 +254,7 @@ describe("RunController", () => { assert.deepStrictEqual( preparedPlatforms, - testCase.expectedPreparedPlatforms + testCase.expectedPreparedPlatforms, ); }); }); @@ -263,41 +264,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 +302,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 +317,7 @@ describe("RunController", () => { runController.on(RunOnDeviceEvents.runOnDeviceStopped, (data: any) => { assert.equal(data.projectDir, projectDir); emittedDeviceIdentifiersForLiveSyncStoppedEvent.push( - data.deviceIdentifier + data.deviceIdentifier, ); }); @@ -333,7 +328,7 @@ describe("RunController", () => { assert.deepStrictEqual( emittedDeviceIdentifiersForLiveSyncStoppedEvent, - testCase.expectedDeviceIdentifiers + testCase.expectedDeviceIdentifiers, ); }); } diff --git a/test/services/bundler/bundler-compiler-service.ts b/test/services/bundler/bundler-compiler-service.ts index 49d69a55f4..63f453978f 100644 --- a/test/services/bundler/bundler-compiler-service.ts +++ b/test/services/bundler/bundler-compiler-service.ts @@ -39,6 +39,9 @@ function createTestInjector(): IInjector { testInjector.register("fs", { exists: (filePath: string) => true, }); + testInjector.register("devtoolsHostService", { + getOrigin: (_platform: string): null => null, + }); return testInjector; } From f79065edbd2213d7c8546c7dcb8e426108b97f38 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 29 Apr 2026 08:55:13 -0700 Subject: [PATCH 2/4] fix: app folder in served base --- lib/services/devtools-host-service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/services/devtools-host-service.ts b/lib/services/devtools-host-service.ts index 93c0d8d703..68e7925fd7 100644 --- a/lib/services/devtools-host-service.ts +++ b/lib/services/devtools-host-service.ts @@ -3,6 +3,7 @@ import * as path from "path"; import * as fs from "fs"; import { AddressInfo } from "net"; import { INet } from "../common/declarations"; +import { APP_FOLDER_NAME } from "../constants"; import { IPlatformsDataService } from "../definitions/platform"; import { IProjectData } from "../definitions/project"; import { @@ -53,7 +54,13 @@ export class DevtoolsHostService implements IDevtoolsHostService { platform, projectData, ); - const rootDir = platformData?.appDestinationDirectoryPath; + // Webpack writes to /app on both iOS + // (platforms/ios//app) and Android + // (platforms/android/app/src/main/assets/app). Match that exactly so + // requests for /bundle.mjs.map resolve to the actual emitted file. + const rootDir = platformData?.appDestinationDirectoryPath + ? path.join(platformData.appDestinationDirectoryPath, APP_FOLDER_NAME) + : null; if (!rootDir) { this.$logger.warn( `DevTools host: unable to resolve output directory for ${platform}.`, From a94c1ad04b74a6e0e1a1af21f38218bfac2890b7 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 29 Apr 2026 09:02:07 -0700 Subject: [PATCH 3/4] ci: workflow dispatch --- .github/workflows/npm_release_cli.yml | 39 ++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/.github/workflows/npm_release_cli.yml b/.github/workflows/npm_release_cli.yml index 445178b256..13526d53b3 100644 --- a/.github/workflows/npm_release_cli.yml +++ b/.github/workflows/npm_release_cli.yml @@ -9,6 +9,17 @@ on: paths-ignore: - 'packages/**' workflow_dispatch: + inputs: + release_type: + description: 'Release type. "dev" publishes a -next prerelease without bumping package.json. patch/minor/major/prerelease bump package.json, commit + tag to main, then publish as a stable release.' + type: choice + options: + - dev + - patch + - minor + - major + - prerelease + default: patch permissions: read-all @@ -19,9 +30,12 @@ jobs: build: name: Build runs-on: macos-latest + permissions: + contents: write outputs: npm_version: ${{ steps.npm_version_output.outputs.NPM_VERSION }} npm_tag: ${{ steps.npm_version_output.outputs.NPM_TAG }} + is_release: ${{ steps.npm_version_output.outputs.IS_RELEASE }} steps: - name: Harden the runner (Audit all outbound calls) @@ -32,6 +46,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: @@ -46,8 +61,18 @@ jobs: NPM_VERSION=$(node -e "console.log(require('./package.json').version);") echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV + - name: Bump, commit and tag stable release (manual dispatch) + if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type != 'dev' }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + npm version ${{ inputs.release_type }} -m "chore: release v%s" + NPM_VERSION=$(node -e "console.log(require('./package.json').version);") + echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV + git push origin HEAD:${GITHUB_REF_NAME} --follow-tags + - name: Bump version for dev release - if: ${{ !contains(github.ref, 'refs/tags/') }} + if: ${{ !contains(github.ref, 'refs/tags/') && (github.event_name != 'workflow_dispatch' || inputs.release_type == 'dev') }} run: | NPM_VERSION=$(node ./scripts/get-next-version.js) echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV @@ -57,8 +82,14 @@ jobs: id: npm_version_output run: | NPM_TAG=$(node ./scripts/get-npm-tag.js) + if [[ "${GITHUB_REF}" == refs/tags/* ]] || [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.release_type }}" != "dev" ]]; then + IS_RELEASE=true + else + IS_RELEASE=false + fi echo NPM_VERSION=$NPM_VERSION >> $GITHUB_OUTPUT echo NPM_TAG=$NPM_TAG >> $GITHUB_OUTPUT + echo IS_RELEASE=$IS_RELEASE >> $GITHUB_OUTPUT - name: Build nativescript run: npm pack @@ -123,8 +154,8 @@ jobs: github-release: runs-on: ubuntu-latest - # only runs on tagged commits - if: ${{ contains(github.ref, 'refs/tags/') }} + # runs for tag pushes and for manual dispatches that bumped a stable release + if: ${{ needs.build.outputs.is_release == 'true' }} permissions: contents: write needs: @@ -140,6 +171,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 0 + ref: v${{needs.build.outputs.npm_version}} - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: @@ -169,6 +201,7 @@ jobs: - uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: + tag: v${{needs.build.outputs.npm_version}} artifacts: "dist/nativescript-*.tgz,dist/nativescript-*.intoto.jsonl" bodyFile: "body.md" prerelease: ${{needs.build.outputs.npm_tag != 'latest'}} From 89b81024ceaa1d2556bbc6ce0b0707ced6b0727f Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Wed, 29 Apr 2026 11:18:28 -0700 Subject: [PATCH 4/4] chore: clear devtoolshost won't work with cors issues --- lib/bootstrap.ts | 2 - lib/controllers/run-controller.ts | 4 - lib/definitions/devtools-host-service.d.ts | 44 ---- lib/helpers/livesync-command-helper.ts | 18 -- .../bundler/bundler-compiler-service.ts | 7 - lib/services/devtools-host-service.ts | 226 ------------------ test/controllers/run-controller.ts | 6 - .../bundler/bundler-compiler-service.ts | 3 - 8 files changed, 310 deletions(-) delete mode 100644 lib/definitions/devtools-host-service.d.ts delete mode 100644 lib/services/devtools-host-service.ts diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 276c5ea9db..3281d84930 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -427,8 +427,6 @@ injector.require( "./services/bundler/bundler-compiler-service", ); -injector.require("devtoolsHostService", "./services/devtools-host-service"); - injector.require( "applePortalSessionService", "./services/apple-portal/apple-portal-session-service", diff --git a/lib/controllers/run-controller.ts b/lib/controllers/run-controller.ts index 8dd855fb82..b1860c5ead 100644 --- a/lib/controllers/run-controller.ts +++ b/lib/controllers/run-controller.ts @@ -12,7 +12,6 @@ import * as util from "util"; import * as _ from "lodash"; import { IProjectDataService, IProjectData } from "../definitions/project"; import { IBuildController } from "../definitions/build"; -import { IDevtoolsHostService } from "../definitions/devtools-host-service"; import { IPlatformsDataService } from "../definitions/platform"; import { IDebugController } from "../definitions/debug"; import { IPluginsService } from "../definitions/plugins"; @@ -49,7 +48,6 @@ export class RunController extends EventEmitter implements IRunController { private $prepareNativePlatformService: IPrepareNativePlatformService, private $projectChangesService: IProjectChangesService, protected $projectDataService: IProjectDataService, - private $devtoolsHostService: IDevtoolsHostService, ) { super(); } @@ -183,8 +181,6 @@ export class RunController extends EventEmitter implements IRunController { liveSyncProcessInfo.deviceDescriptors = []; - await this.$devtoolsHostService.stopAll(); - if (this.prepareReadyEventHandler) { this.$prepareController.removeListener( PREPARE_READY_EVENT_NAME, diff --git a/lib/definitions/devtools-host-service.d.ts b/lib/definitions/devtools-host-service.d.ts deleted file mode 100644 index 983bd4f05e..0000000000 --- a/lib/definitions/devtools-host-service.d.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { IProjectData } from "./project"; - -/** - * Origin of a running loopback HTTP server for a single platform, - * e.g. "http://127.0.0.1:41500". - */ -export interface IDevtoolsHostOrigin { - platform: string; - origin: string; -} - -/** - * Loopback HTTP server (per platform) that serves the webpack output - * directory with CORS so Chrome DevTools (served from - * https://chrome-devtools-frontend.appspot.com) can fetch .map / .js - * files while a debug session is active. - * - * Bound to 127.0.0.1 only; never exposed to the network. - */ -export interface IDevtoolsHostService { - /** - * Start (or return existing) HTTP server for a platform. Idempotent - * per platform — subsequent calls return the same origin. - */ - start( - projectData: IProjectData, - platform: string, - ): Promise; - - /** - * Stop the server for a single platform. Quiet no-op if nothing running. - */ - stop(platform: string): Promise; - - /** - * Stop all running servers. - */ - stopAll(): Promise; - - /** - * Current origin for a platform, or null if not running. - */ - getOrigin(platform: string): string | null; -} diff --git a/lib/helpers/livesync-command-helper.ts b/lib/helpers/livesync-command-helper.ts index 4f02c7cee5..0683d48ddc 100644 --- a/lib/helpers/livesync-command-helper.ts +++ b/lib/helpers/livesync-command-helper.ts @@ -12,7 +12,6 @@ import { DeployController } from "../controllers/deploy-controller"; import { IAndroidBundleValidatorHelper, IOptions } from "../declarations"; import { IBuildController, IBuildDataService } from "../definitions/build"; import { ICleanupService } from "../definitions/cleanup-service"; -import { IDevtoolsHostService } from "../definitions/devtools-host-service"; import { IPlatformsDataService } from "../definitions/platform"; import { IProjectData, IValidatePlatformOutput } from "../definitions/project"; @@ -33,7 +32,6 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { private $iOSSimulatorLogProvider: Mobile.IiOSSimulatorLogProvider, private $cleanupService: ICleanupService, private $runController: IRunController, - private $devtoolsHostService: IDevtoolsHostService, ) {} private get $platformsDataService(): IPlatformsDataService { @@ -191,7 +189,6 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { }); return; } else { - await this.startDevtoolsHostIfDebugging(deviceDescriptors); await this.$runController.run({ liveSyncInfo, deviceDescriptors, @@ -240,21 +237,6 @@ export class LiveSyncCommandHelper implements ILiveSyncCommandHelper { return result; } - private async startDevtoolsHostIfDebugging( - deviceDescriptors: ILiveSyncDeviceDescriptor[], - ): Promise { - const platforms = _.uniq( - deviceDescriptors - .filter((d) => d.debuggingEnabled && d.buildData?.platform) - .map((d) => d.buildData.platform.toLowerCase()), - ); - for (const platform of platforms) { - // DevtoolsHostService swallows port/bind failures and returns null; - // a missing source-map origin degrades DevTools gracefully. - await this.$devtoolsHostService.start(this.$projectData, platform); - } - } - private async executeLiveSyncOperationCore( devices: Mobile.IDevice[], platform: string, diff --git a/lib/services/bundler/bundler-compiler-service.ts b/lib/services/bundler/bundler-compiler-service.ts index 2a35150cac..3eb880b2ba 100644 --- a/lib/services/bundler/bundler-compiler-service.ts +++ b/lib/services/bundler/bundler-compiler-service.ts @@ -31,7 +31,6 @@ import { IHostInfo, } from "../../common/declarations"; import { ICleanupService } from "../../definitions/cleanup-service"; -import { IDevtoolsHostService } from "../../definitions/devtools-host-service"; import { injector } from "../../common/yok"; import { resolvePackagePath, @@ -74,7 +73,6 @@ export class BundlerCompilerService private $packageManager: IPackageManager, private $packageInstallationManager: IPackageInstallationManager, // private $sharedEventBus: ISharedEventBus private $projectConfigService: IProjectConfigService, - private $devtoolsHostService: IDevtoolsHostService, ) { super(); } @@ -587,11 +585,6 @@ export class BundlerCompilerService envData.uniqueBundle = prepareData.uniqueBundle; } - const devtoolsOrigin = this.$devtoolsHostService.getOrigin(platform); - if (devtoolsOrigin) { - envData.devtoolsHost = devtoolsOrigin; - } - return envData; } diff --git a/lib/services/devtools-host-service.ts b/lib/services/devtools-host-service.ts deleted file mode 100644 index 68e7925fd7..0000000000 --- a/lib/services/devtools-host-service.ts +++ /dev/null @@ -1,226 +0,0 @@ -import * as http from "http"; -import * as path from "path"; -import * as fs from "fs"; -import { AddressInfo } from "net"; -import { INet } from "../common/declarations"; -import { APP_FOLDER_NAME } from "../constants"; -import { IPlatformsDataService } from "../definitions/platform"; -import { IProjectData } from "../definitions/project"; -import { - IDevtoolsHostService, - IDevtoolsHostOrigin, -} from "../definitions/devtools-host-service"; -import { injector } from "../common/yok"; - -const LOOPBACK_HOST = "127.0.0.1"; -const DEVTOOLS_ORIGIN = "https://chrome-devtools-frontend.appspot.com"; -const PORT_RANGE_START = 41500; -const PORT_RANGE_END = 41999; - -const CONTENT_TYPES: { [ext: string]: string } = { - ".map": "application/json; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".js": "application/javascript; charset=utf-8", - ".mjs": "application/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", -}; - -interface IServerEntry { - server: http.Server; - port: number; - rootDir: string; -} - -export class DevtoolsHostService implements IDevtoolsHostService { - private servers = new Map(); - - constructor( - private $net: INet, - private $logger: ILogger, - private $platformsDataService: IPlatformsDataService, - ) {} - - public async start( - projectData: IProjectData, - platform: string, - ): Promise { - const key = platform.toLowerCase(); - const existing = this.servers.get(key); - if (existing) { - return { platform: key, origin: this.formatOrigin(existing.port) }; - } - - const platformData = this.$platformsDataService.getPlatformData( - platform, - projectData, - ); - // Webpack writes to /app on both iOS - // (platforms/ios//app) and Android - // (platforms/android/app/src/main/assets/app). Match that exactly so - // requests for /bundle.mjs.map resolve to the actual emitted file. - const rootDir = platformData?.appDestinationDirectoryPath - ? path.join(platformData.appDestinationDirectoryPath, APP_FOLDER_NAME) - : null; - if (!rootDir) { - this.$logger.warn( - `DevTools host: unable to resolve output directory for ${platform}.`, - ); - return null; - } - - let port: number; - try { - port = await this.$net.getAvailablePortInRange( - PORT_RANGE_START, - PORT_RANGE_END, - ); - } catch (err) { - this.$logger.warn( - `DevTools host: no free port in ${PORT_RANGE_START}-${PORT_RANGE_END}. Source maps will not load in Chrome DevTools. (${err?.message || err})`, - ); - return null; - } - - const server = http.createServer((req, res) => - this.handleRequest(req, res, rootDir), - ); - - try { - await new Promise((resolve, reject) => { - const onError = (err: Error) => reject(err); - server.once("error", onError); - server.listen(port, LOOPBACK_HOST, () => { - server.off("error", onError); - resolve(); - }); - }); - } catch (err) { - this.$logger.warn( - `DevTools host: failed to bind ${LOOPBACK_HOST}:${port} (${(err as Error)?.message || err}).`, - ); - return null; - } - - // `.unref()` so a lingering server doesn't keep the CLI process alive. - server.unref(); - - const actualPort = (server.address() as AddressInfo)?.port ?? port; - const entry: IServerEntry = { server, port: actualPort, rootDir }; - this.servers.set(key, entry); - - const origin = this.formatOrigin(actualPort); - this.$logger.info( - `DevTools host (${platform}) serving ${rootDir} at ${origin}`, - ); - return { platform: key, origin }; - } - - public async stop(platform: string): Promise { - const key = platform.toLowerCase(); - const entry = this.servers.get(key); - if (!entry) { - return; - } - - this.servers.delete(key); - await new Promise((resolve) => { - entry.server.close(() => resolve()); - }); - } - - public async stopAll(): Promise { - const platforms = Array.from(this.servers.keys()); - await Promise.all(platforms.map((p) => this.stop(p))); - } - - public getOrigin(platform: string): string | null { - const entry = this.servers.get(platform.toLowerCase()); - return entry ? this.formatOrigin(entry.port) : null; - } - - private formatOrigin(port: number): string { - return `http://${LOOPBACK_HOST}:${port}`; - } - - private handleRequest( - req: http.IncomingMessage, - res: http.ServerResponse, - rootDir: string, - ): void { - res.setHeader("Access-Control-Allow-Origin", DEVTOOLS_ORIGIN); - res.setHeader("Vary", "Origin"); - res.setHeader("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "*"); - res.setHeader("Cache-Control", "no-cache"); - - if (req.method === "OPTIONS") { - res.writeHead(204); - res.end(); - return; - } - - if (req.method !== "GET" && req.method !== "HEAD") { - res.writeHead(405); - res.end(); - return; - } - - let urlPath: string; - try { - urlPath = decodeURIComponent( - new URL(req.url || "/", "http://x").pathname, - ); - } catch { - res.writeHead(400); - res.end(); - return; - } - - const ext = path.extname(urlPath).toLowerCase(); - if (!CONTENT_TYPES[ext]) { - res.writeHead(403); - res.end(); - return; - } - - const resolvedRoot = path.resolve(rootDir); - const filePath = path.resolve(resolvedRoot, "." + urlPath); - if ( - filePath !== resolvedRoot && - !filePath.startsWith(resolvedRoot + path.sep) - ) { - res.writeHead(403); - res.end(); - return; - } - - fs.stat(filePath, (statErr, stats) => { - if (statErr || !stats.isFile()) { - res.writeHead(404); - res.end(); - return; - } - - res.setHeader("Content-Type", CONTENT_TYPES[ext]); - res.setHeader("Content-Length", String(stats.size)); - - if (req.method === "HEAD") { - res.writeHead(200); - res.end(); - return; - } - - const stream = fs.createReadStream(filePath); - stream.on("error", () => { - if (!res.headersSent) { - res.writeHead(500); - } - res.end(); - }); - res.writeHead(200); - stream.pipe(res); - }); - } -} - -injector.register("devtoolsHostService", DevtoolsHostService); diff --git a/test/controllers/run-controller.ts b/test/controllers/run-controller.ts index ca7b68347c..4143c9a0b9 100644 --- a/test/controllers/run-controller.ts +++ b/test/controllers/run-controller.ts @@ -131,12 +131,6 @@ function createTestInjector() { injector.register("debugController", {}); injector.register("liveSyncProcessDataService", LiveSyncProcessDataService); injector.register("tempService", TempServiceStub); - injector.register("devtoolsHostService", { - start: async (): Promise => null, - stop: async (): Promise => {}, - stopAll: async (): Promise => {}, - getOrigin: (): null => null, - }); const devicesService = injector.resolve("devicesService"); devicesService.getDevicesForPlatform = () => diff --git a/test/services/bundler/bundler-compiler-service.ts b/test/services/bundler/bundler-compiler-service.ts index 63f453978f..49d69a55f4 100644 --- a/test/services/bundler/bundler-compiler-service.ts +++ b/test/services/bundler/bundler-compiler-service.ts @@ -39,9 +39,6 @@ function createTestInjector(): IInjector { testInjector.register("fs", { exists: (filePath: string) => true, }); - testInjector.register("devtoolsHostService", { - getOrigin: (_platform: string): null => null, - }); return testInjector; }