Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .github/workflows/build-vsix.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Build VSIX

# Packages the extension and, for a v* tag, attaches the VSIX to that release
# so it can be installed on a machine without the build toolchain:
#
# gh release download v1.14.2 --repo teoboetti/vscode-react-native
# codium --install-extension vscode-react-native-1.14.2.vsix

on:
push:
tags:
- "v*"
workflow_dispatch:

permissions:
contents: write

jobs:
package:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm

- name: Install dependencies
run: npm ci

- name: Package
run: npx vsce package

- name: Attach the VSIX to the release
if: startsWith(github.ref, 'refs/tags/')
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
gh release upload "$GITHUB_REF_NAME" ./*.vsix --clobber
else
gh release create "$GITHUB_REF_NAME" ./*.vsix --generate-notes
fi

- uses: actions/upload-artifact@v4
with:
name: vsix
path: "*.vsix"
if-no-files-found: error
524 changes: 262 additions & 262 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "vscode-react-native",
"displayName": "React Native Tools",
"version": "1.14.1",
"version": "1.14.2",
"private": true,
"publisher": "msjsdiag",
"icon": "resources/images/icon.png",
Expand Down Expand Up @@ -1394,6 +1394,7 @@
},
"scripts": {
"build": "gulp build",
"install-local": "vsce package && codium --install-extension vscode-react-native-$npm_package_version.vsix",
"lint": "gulp lint",
"format": "gulp format",
"vscode:prepublish": "gulp",
Expand Down
1 change: 1 addition & 0 deletions src/cdp-proxy/CDPMessageHandlers/CDPAPINames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export const CDP_API_NAMES = {
CLOSE: "close",
DEBUGGER_SCRIPT_PARSED: "Debugger.scriptParsed",
DEBUGGER_SET_BREAKPOINT_BY_URL: "Debugger.setBreakpointByUrl",
PAGE_RELOAD: "Page.reload",
};
66 changes: 66 additions & 0 deletions src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,34 @@ export class HermesCDPMessageHandler extends BaseCDPMessageHandler {
*/
private readonly HERMES_NATIVE_FUNCTION_SCRIPT_ID: string = "4294967295";

/**
* @description Quiet period after the last breakpoint request before the application
* is reloaded, so that a burst of breakpoint requests causes a single reload
* @type {number}
*/
private readonly RELOAD_DEBOUNCE_MS: number = 300;

/**
* @description Id of the out-of-band Page.reload request. The debugger never sent it,
* so it discards the reply as an unknown response id
* @type {number}
*/
private readonly RELOAD_REQUEST_ID: number = 2147483646;

private isFirstRun: boolean = true;
private reloadTimer: NodeJS.Timeout | null = null;

public processDebuggerCDPMessage(event: any): ProcessedCDPMessage {
let sendBack = false;
if (event.method === CDP_API_NAMES.CLOSE) {
this.cancelFirstRunReload();
} else if (
event.method === CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT ||
event.method === CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL
) {
this.scheduleFirstRunReload();
}

if (event.method === CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT) {
event = this.handleBreakpointSetting(event);
} else if (event.method === CDP_API_NAMES.RUNTIME_CALL_FUNCTION_ON) {
Expand Down Expand Up @@ -100,6 +126,46 @@ export class HermesCDPMessageHandler extends BaseCDPMessageHandler {
return event;
}

/**
* The application finishes executing its bundle before the debugger manages to attach,
* so breakpoints registered for the first run resolve but are never reached. Reload the
* application once the debugger has finished sending its initial breakpoints, so that the
* bundle is evaluated again while they are in place. Reloading is driven by the device
* (the "nativePageReloads" capability), so the debugger stays attached across it.
*/
private scheduleFirstRunReload(): void {
if (!this.isFirstRun) {
return;
}

if (this.reloadTimer) {
clearTimeout(this.reloadTimer);
}

this.reloadTimer = setTimeout(() => {
this.reloadTimer = null;

if (!this.isFirstRun) {
return;
}
this.isFirstRun = false;

this.applicationTarget?.send({
id: this.RELOAD_REQUEST_ID,
method: CDP_API_NAMES.PAGE_RELOAD,
params: {},
});
}, this.RELOAD_DEBOUNCE_MS);
}

private cancelFirstRunReload(): void {
if (this.reloadTimer) {
clearTimeout(this.reloadTimer);
this.reloadTimer = null;
}
this.isFirstRun = true;
}

private handleBreakpointSetting(event: any): any {
if (event.params) {
delete event.params.location.columnNumber;
Expand Down
39 changes: 36 additions & 3 deletions src/cdp-proxy/reactNativeCDPProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for details.

import { IncomingMessage } from "http";
import { URL } from "url";
import {
Connection,
Server,
Expand All @@ -11,6 +12,7 @@ import {
IProtocolSuccess,
} from "vscode-cdp-proxy";
import { CancellationToken, EventEmitter } from "vscode";
import WebSocket = require("ws");
import { OutputChannelLogger } from "../extension/log/OutputChannelLogger";
import { LogLevel } from "../extension/log/LogHelper";
import { DebuggerEndpointHelper } from "./debuggerEndpointHelper";
Expand Down Expand Up @@ -123,9 +125,7 @@ export class ReactNativeCDPProxy {
}
}

this.applicationTarget = new Connection(
await WebSocketTransport.create(this.browserInspectUri),
);
this.applicationTarget = new Connection(await this.createApplicationTransport());

this.applicationTarget.onError(this.onApplicationTargetError.bind(this));
this.debuggerTarget.onError(this.onDebuggerTargetError.bind(this));
Expand Down Expand Up @@ -214,6 +214,39 @@ export class ReactNativeCDPProxy {
this.logger.error("Error on application transport", err);
}

private createApplicationTransport(): Promise<WebSocketTransport> {
// Metro's inspector proxy rejects debugger WebSockets whose Origin is not the
// dev server itself (or a loopback host), so send the expected Origin header.
const webSocket = new WebSocket(this.browserInspectUri, [], {
perMessageDeflate: false,
maxPayload: 256 * 1024 * 1024,
origin: ReactNativeCDPProxy.getWebSocketOrigin(this.browserInspectUri),
} as WebSocket.IClientOptions & { maxPayload: number; perMessageDeflate: boolean });

return new Promise((resolve, reject) => {
webSocket.once("open", () => {
// vscode-cdp-proxy resolves its own copy of @types/ws, so the two
// structurally identical WebSocket types are not assignable
resolve(new WebSocketTransport(webSocket as any));
});
webSocket.once("error", reject);
});
}

private static getWebSocketOrigin(webSocketUrl: string): string | undefined {
const url = new URL(webSocketUrl);

if (url.protocol === "ws:") {
return `http://${url.host}`;
}

if (url.protocol === "wss:") {
return `https://${url.host}`;
}

return undefined;
}

private async onApplicationTargetClosed() {
this.applicationTarget = null;
this.applicationTargetEventEmitter.fire({});
Expand Down
17 changes: 12 additions & 5 deletions src/extension/reactNativeSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class ReactNativeSessionManager
): vscode.ProviderResult<vscode.DebugAdapterDescriptor> {
const rnSession = new RNSession(session);

let debugServer;
let debugServer: Net.Server;
if (session.configuration.platform != "expoweb") {
debugServer = Net.createServer(socket => {
const rnDebugSession =
Expand All @@ -45,11 +45,18 @@ export class ReactNativeSessionManager
});
}

debugServer.listen(0, "127.0.0.1");
this.servers.set(session.id, debugServer);
return new Promise<vscode.DebugAdapterServer>((resolve, reject) => {
debugServer.once("error", reject);
debugServer.listen(0, "127.0.0.1", () => {
debugServer.removeListener("error", reject);
this.servers.set(session.id, debugServer);

// make VS Code connect to debug server
return new vscode.DebugAdapterServer((<Net.AddressInfo>debugServer.address()).port);
// make VS Code connect to debug server
resolve(
new vscode.DebugAdapterServer((<Net.AddressInfo>debugServer.address()).port),
);
});
});
}

public terminate(terminateEvent: TerminateEventArgs): void {
Expand Down
101 changes: 101 additions & 0 deletions test/cdp-proxy/hermesCDPMessageHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for details.

import { Connection } from "vscode-cdp-proxy";
import { HermesCDPMessageHandler } from "../../src/cdp-proxy/CDPMessageHandlers/hermesCDPMessageHandler";
import { CDP_API_NAMES } from "../../src/cdp-proxy/CDPMessageHandlers/CDPAPINames";
import { PromiseUtil } from "../../src/common/node/promise";
import assert = require("assert");

suite("hermesCDPMessageHandler", function () {
// Must outlast RELOAD_DEBOUNCE_MS (300) in the handler
const afterDebounce = 500;

function createHandler(): {
handler: HermesCDPMessageHandler;
sent: Array<Record<string, any>>;
} {
const handler = new HermesCDPMessageHandler();
const sent: Array<Record<string, any>> = [];
handler.setApplicationTarget({
send: (message: Record<string, any>) => sent.push(message),
} as unknown as Connection);
return { handler, sent };
}

suite("first run reload", function () {
test("reloads the application once the debugger has sent its breakpoints", async () => {
const { handler, sent } = createHandler();

handler.processDebuggerCDPMessage({
method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL,
params: {},
});
await PromiseUtil.delay(afterDebounce);

assert.strictEqual(sent.length, 1);
assert.strictEqual(sent[0].method, CDP_API_NAMES.PAGE_RELOAD);
});

test("coalesces a burst of breakpoints into a single reload", async () => {
const { handler, sent } = createHandler();

for (let i = 0; i < 5; i++) {
handler.processDebuggerCDPMessage({
method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL,
params: {},
});
}
await PromiseUtil.delay(afterDebounce);

assert.strictEqual(sent.length, 1);
});

test("does not reload again for later breakpoints in the same session", async () => {
const { handler, sent } = createHandler();

handler.processDebuggerCDPMessage({
method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL,
params: {},
});
await PromiseUtil.delay(afterDebounce);

handler.processDebuggerCDPMessage({
method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL,
params: {},
});
await PromiseUtil.delay(afterDebounce);

assert.strictEqual(sent.length, 1);
});

test("does not reload after the debugger disconnects", async () => {
const { handler, sent } = createHandler();

handler.processDebuggerCDPMessage({
method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT_BY_URL,
params: {},
});
handler.processDebuggerCDPMessage({ method: CDP_API_NAMES.CLOSE });
await PromiseUtil.delay(afterDebounce);

assert.strictEqual(sent.length, 0);
});

test("leaves breakpoint messages on their way to the application", function () {
const { handler } = createHandler();

const processed = handler.processDebuggerCDPMessage({
method: CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT,
params: { location: { lineNumber: 1, columnNumber: 2 } },
});

const event = processed.event as Record<string, any>;

assert.strictEqual(processed.sendBack, false);
assert.strictEqual(event.method, CDP_API_NAMES.DEBUGGER_SET_BREAKPOINT);
// Hermes rejects breakpoints carrying a column number
assert.strictEqual(event.params.location.columnNumber, undefined);
});
});
});
9 changes: 5 additions & 4 deletions test/cdp-proxy/reactNativeCDPProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,9 +290,10 @@ suite("reactNativeCDPProxy", function () {
generateRandomPortNumber(),
);
const debuggerTarget = createConnectionStub();
const createWebSocketTransportStub = Sinon.stub(WebSocketTransport, "create").returns(
Promise.resolve(createTransportStub()),
);
const createApplicationTransportStub = Sinon.stub(
localProxy as any,
"createApplicationTransport",
).returns(Promise.resolve(createTransportStub()));

Object.assign(localProxy, {
browserInspectUri: "ws://localhost:1234/debugger",
Expand All @@ -310,7 +311,7 @@ suite("reactNativeCDPProxy", function () {
},
]);
} finally {
createWebSocketTransportStub.restore();
createApplicationTransportStub.restore();
}

assert.strictEqual(debuggerTarget.close.called, false);
Expand Down
Loading