@rushstack/playwright-browser-tunnel 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.rush/temp/chunked-rush-logs/playwright-browser-tunnel._phase_build.chunks.jsonl +3 -3
  2. package/.rush/temp/operation/_phase_build/all.log +3 -3
  3. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +3 -3
  4. package/.rush/temp/operation/_phase_build/state.json +1 -1
  5. package/.rush/temp/rushstack+playwright-browser-tunnel-_phase_build-7739ed2cac57efca3ad3fa4de550f08e1f482854.tar.log +84 -0
  6. package/CHANGELOG.json +38 -0
  7. package/CHANGELOG.md +15 -1
  8. package/config/api-extractor.json +1 -1
  9. package/lib-commonjs/tunneledBrowserConnection/ITunneledBrowser.js +5 -0
  10. package/lib-commonjs/tunneledBrowserConnection/ITunneledBrowser.js.map +1 -0
  11. package/lib-commonjs/tunneledBrowserConnection/ITunneledBrowserConnection.js +5 -0
  12. package/lib-commonjs/tunneledBrowserConnection/ITunneledBrowserConnection.js.map +1 -0
  13. package/lib-commonjs/tunneledBrowserConnection/TunneledBrowser.js +42 -0
  14. package/lib-commonjs/tunneledBrowserConnection/TunneledBrowser.js.map +1 -0
  15. package/{lib/tunneledBrowserConnection.js → lib-commonjs/tunneledBrowserConnection/TunneledBrowserConnection.js} +8 -41
  16. package/lib-commonjs/tunneledBrowserConnection/TunneledBrowserConnection.js.map +1 -0
  17. package/lib-commonjs/tunneledBrowserConnection/constants.js +8 -0
  18. package/lib-commonjs/tunneledBrowserConnection/constants.js.map +1 -0
  19. package/lib-commonjs/tunneledBrowserConnection/index.js +10 -0
  20. package/lib-commonjs/tunneledBrowserConnection/index.js.map +1 -0
  21. package/{lib → lib-dts}/tsdoc-metadata.json +1 -1
  22. package/lib-dts/tunneledBrowserConnection/ITunneledBrowser.d.ts +17 -0
  23. package/lib-dts/tunneledBrowserConnection/ITunneledBrowser.d.ts.map +1 -0
  24. package/lib-dts/tunneledBrowserConnection/ITunneledBrowserConnection.d.ts +31 -0
  25. package/lib-dts/tunneledBrowserConnection/ITunneledBrowserConnection.d.ts.map +1 -0
  26. package/lib-dts/tunneledBrowserConnection/TunneledBrowser.d.ts +10 -0
  27. package/lib-dts/tunneledBrowserConnection/TunneledBrowser.d.ts.map +1 -0
  28. package/lib-dts/tunneledBrowserConnection/TunneledBrowserConnection.d.ts +8 -0
  29. package/lib-dts/tunneledBrowserConnection/TunneledBrowserConnection.d.ts.map +1 -0
  30. package/lib-dts/tunneledBrowserConnection/constants.d.ts +3 -0
  31. package/lib-dts/tunneledBrowserConnection/constants.d.ts.map +1 -0
  32. package/lib-dts/tunneledBrowserConnection/index.d.ts +5 -0
  33. package/lib-dts/tunneledBrowserConnection/index.d.ts.map +1 -0
  34. package/lib-esm/HttpServer.js +70 -0
  35. package/lib-esm/HttpServer.js.map +1 -0
  36. package/lib-esm/LaunchOptionsValidator.js +156 -0
  37. package/lib-esm/LaunchOptionsValidator.js.map +1 -0
  38. package/lib-esm/PlaywrightBrowserTunnel.js +467 -0
  39. package/lib-esm/PlaywrightBrowserTunnel.js.map +1 -0
  40. package/lib-esm/index.js +22 -0
  41. package/lib-esm/index.js.map +1 -0
  42. package/lib-esm/tunneledBrowserConnection/ITunneledBrowser.js +4 -0
  43. package/lib-esm/tunneledBrowserConnection/ITunneledBrowser.js.map +1 -0
  44. package/lib-esm/tunneledBrowserConnection/ITunneledBrowserConnection.js +4 -0
  45. package/lib-esm/tunneledBrowserConnection/ITunneledBrowserConnection.js.map +1 -0
  46. package/lib-esm/tunneledBrowserConnection/TunneledBrowser.js +36 -0
  47. package/lib-esm/tunneledBrowserConnection/TunneledBrowser.js.map +1 -0
  48. package/lib-esm/tunneledBrowserConnection/TunneledBrowserConnection.js +195 -0
  49. package/lib-esm/tunneledBrowserConnection/TunneledBrowserConnection.js.map +1 -0
  50. package/lib-esm/tunneledBrowserConnection/constants.js +5 -0
  51. package/lib-esm/tunneledBrowserConnection/constants.js.map +1 -0
  52. package/lib-esm/tunneledBrowserConnection/index.js +5 -0
  53. package/lib-esm/tunneledBrowserConnection/index.js.map +1 -0
  54. package/lib-esm/utilities.js +96 -0
  55. package/lib-esm/utilities.js.map +1 -0
  56. package/package.json +29 -6
  57. package/rush-logs/playwright-browser-tunnel._phase_build.cache.log +2 -2
  58. package/rush-logs/playwright-browser-tunnel._phase_build.log +3 -3
  59. package/src/tunneledBrowserConnection/ITunneledBrowser.ts +20 -0
  60. package/src/tunneledBrowserConnection/ITunneledBrowserConnection.ts +37 -0
  61. package/src/tunneledBrowserConnection/TunneledBrowser.ts +52 -0
  62. package/src/{tunneledBrowserConnection.ts → tunneledBrowserConnection/TunneledBrowserConnection.ts} +13 -96
  63. package/src/tunneledBrowserConnection/constants.ts +5 -0
  64. package/src/tunneledBrowserConnection/index.ts +8 -0
  65. package/temp/build/lint/_eslint-5eVG3S6w.json +23 -3
  66. package/temp/build/lint/lint.sarif +34 -9
  67. package/temp/build/typescript/ts_lnwgbP5O.json +1 -0
  68. package/.rush/temp/rushstack+playwright-browser-tunnel-_phase_build-4eb4ec7f886146b53970b110944f3028a28c72d5.tar.log +0 -42
  69. package/lib/tunneledBrowserConnection.d.ts +0 -48
  70. package/lib/tunneledBrowserConnection.d.ts.map +0 -1
  71. package/lib/tunneledBrowserConnection.js.map +0 -1
  72. package/temp/build/typescript/ts_l9Fw4VUO.json +0 -1
  73. /package/{lib → lib-commonjs}/HttpServer.js +0 -0
  74. /package/{lib → lib-commonjs}/HttpServer.js.map +0 -0
  75. /package/{lib → lib-commonjs}/LaunchOptionsValidator.js +0 -0
  76. /package/{lib → lib-commonjs}/LaunchOptionsValidator.js.map +0 -0
  77. /package/{lib → lib-commonjs}/PlaywrightBrowserTunnel.js +0 -0
  78. /package/{lib → lib-commonjs}/PlaywrightBrowserTunnel.js.map +0 -0
  79. /package/{lib → lib-commonjs}/index.js +0 -0
  80. /package/{lib → lib-commonjs}/index.js.map +0 -0
  81. /package/{lib → lib-commonjs}/utilities.js +0 -0
  82. /package/{lib → lib-commonjs}/utilities.js.map +0 -0
  83. /package/{lib → lib-dts}/HttpServer.d.ts +0 -0
  84. /package/{lib → lib-dts}/HttpServer.d.ts.map +0 -0
  85. /package/{lib → lib-dts}/LaunchOptionsValidator.d.ts +0 -0
  86. /package/{lib → lib-dts}/LaunchOptionsValidator.d.ts.map +0 -0
  87. /package/{lib → lib-dts}/PlaywrightBrowserTunnel.d.ts +0 -0
  88. /package/{lib → lib-dts}/PlaywrightBrowserTunnel.d.ts.map +0 -0
  89. /package/{lib → lib-dts}/index.d.ts +0 -0
  90. /package/{lib → lib-dts}/index.d.ts.map +0 -0
  91. /package/{lib → lib-dts}/utilities.d.ts +0 -0
  92. /package/{lib → lib-dts}/utilities.d.ts.map +0 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"HttpServer.js","sourceRoot":"","sources":["../src/HttpServer.ts"],"names":[],"mappings":"AAAA,4FAA4F;AAC5F,2DAA2D;AAE3D,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,GAAG,EAAE,MAAM,UAAU,CAAC;AAE/B,OAAO,EAAE,eAAe,EAAkB,MAAM,IAAI,CAAC;AAIrD,MAAM,SAAS,GAAW,WAAW,CAAC;AAEtC;;;;GAIG;AACH,SAAS,aAAa,CAAC,WAAwB;IAC7C,OAAO,WAAW,CAAC,MAAM,KAAK,MAAM;QAClC,CAAC,CAAC,IAAI,WAAW,CAAC,OAAO,KAAK,WAAW,CAAC,IAAI,EAAE;QAChD,CAAC,CAAC,GAAG,WAAW,CAAC,OAAO,IAAI,WAAW,CAAC,IAAI,EAAE,CAAC;AACnD,CAAC;AAED;;;;GAIG;AACH,MAAM,OAAO,UAAU;IAMrB,YAAmB,MAAiB;QAClC,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC;QACtB,sFAAsF;QACtF,wEAAwE;QACxE,IAAI,CAAC,OAAO,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;QACnC,IAAI,CAAC,SAAS,GAAG,IAAI,eAAe,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;QAEzD,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE;YACnD,+FAA+F;YAC/F,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAa,EAAE,EAAE;gBACpE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,EAAE,OAAO,CAAC,CAAC;YACjD,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC;IAEM,KAAK,CAAC,WAAW;QACtB,OAAO,MAAM,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;YACnC,uEAAuE;YACvE,uDAAuD;YACvD,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,EAAE,SAAS,EAAE,GAAG,EAAE;gBACrC,MAAM,WAAW,GAAgC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;gBACxE,IAAI,CAAC,WAAW,EAAE,CAAC;oBACjB,MAAM,IAAI,KAAK,CAAC,2DAA2D,CAAC,CAAC;gBAC/E,CAAC;gBACD,IAAI,OAAO,WAAW,KAAK,QAAQ,EAAE,CAAC;oBACpC,MAAM,IAAI,KAAK,CAAC,yCAAyC,WAAW,2BAA2B,CAAC,CAAC;gBACnG,CAAC;gBACD,MAAM,gBAAgB,GAAW,aAAa,CAAC,WAAW,CAAC,CAAC;gBAC5D,IAAI,CAAC,iBAAiB,GAAG,gBAAgB,CAAC;gBAC1C,oEAAoE;gBACpE,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,4CAA4C,gBAAgB,EAAE,CAAC,CAAC;gBACvF,OAAO,CAAC,IAAI,GAAG,CAAC,QAAQ,gBAAgB,EAAE,CAAC,CAAC,CAAC;YAC/C,CAAC,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC;IAED,IAAW,QAAQ;QACjB,IAAI,IAAI,CAAC,iBAAiB,KAAK,SAAS,EAAE,CAAC;YACzC,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;QAClD,CAAC;QACD,OAAO,QAAQ,IAAI,CAAC,iBAAiB,EAAE,CAAC;IAC1C,CAAC;IAED,IAAW,QAAQ;QACjB,OAAO,IAAI,CAAC,SAAS,CAAC;IACxB,CAAC;IAEM,CAAC,MAAM,CAAC,OAAO,CAAC;QACrB,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;QACvB,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;IACvB,CAAC;CACF","sourcesContent":["// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\n// See LICENSE in the project root for license information.\n\nimport http from 'node:http';\nimport type { AddressInfo } from 'node:net';\nimport { URL } from 'node:url';\n\nimport { WebSocketServer, type WebSocket } from 'ws';\n\nimport type { ITerminal } from '@rushstack/terminal';\n\nconst LOCALHOST: string = 'localhost';\n\n/**\n * Formats an address info object into a WebSocket-compatible address string.\n * IPv6 addresses are formatted with brackets: [address]:port\n * IPv4 addresses are formatted as: address:port\n */\nfunction formatAddress(addressInfo: AddressInfo): string {\n return addressInfo.family === 'IPv6'\n ? `[${addressInfo.address}]:${addressInfo.port}`\n : `${addressInfo.address}:${addressInfo.port}`;\n}\n\n/**\n * This HttpServer is used for the localProxyWs WebSocketServer.\n * The purpose is to parse the query params and path for the websocket url to get the\n * browserName and launchOptions.\n */\nexport class HttpServer {\n private readonly _server: http.Server;\n private readonly _wsServer: WebSocketServer; // local proxy websocket server accepting browser clients\n private _listeningAddress: string | undefined;\n private _logger: ITerminal;\n\n public constructor(logger: ITerminal) {\n this._logger = logger;\n // We'll create an HTTP server and attach a WebSocketServer in noServer mode so we can\n // manually parse the URL and extract query parameters before upgrading.\n this._server = http.createServer();\n this._wsServer = new WebSocketServer({ noServer: true });\n\n this._server.on('upgrade', (request, socket, head) => {\n // Accept all upgrades on the root path. We parse query string for browserName + launchOptions.\n this._wsServer.handleUpgrade(request, socket, head, (ws: WebSocket) => {\n this._wsServer.emit('connection', ws, request);\n });\n });\n }\n\n public async listenAsync(): Promise<URL> {\n return await new Promise((resolve) => {\n // Bind to 'localhost' which resolves to IPv4 (127.0.0.1) or IPv6 (::1)\n // depending on system configuration and DNS resolution\n this._server.listen(0, LOCALHOST, () => {\n const addressInfo: AddressInfo | string | null = this._server.address();\n if (!addressInfo) {\n throw new Error('Server address is null - server may not be bound properly');\n }\n if (typeof addressInfo === 'string') {\n throw new Error(`Server address is a pipe/socket path (${addressInfo}), expected an IP address`);\n }\n const formattedAddress: string = formatAddress(addressInfo);\n this._listeningAddress = formattedAddress;\n // This MUST be printed to terminal so VS Code can auto-port forward\n this._logger.writeLine(`Local proxy HttpServer listening at ws://${formattedAddress}`);\n resolve(new URL(`ws://${formattedAddress}`));\n });\n });\n }\n\n public get endpoint(): string {\n if (this._listeningAddress === undefined) {\n throw new Error('HttpServer not listening yet');\n }\n return `ws://${this._listeningAddress}`;\n }\n\n public get wsServer(): WebSocketServer {\n return this._wsServer;\n }\n\n public [Symbol.dispose](): void {\n this._wsServer.close();\n this._server.close();\n }\n}\n"]}
@@ -0,0 +1,156 @@
1
+ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2
+ // See LICENSE in the project root for license information.
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { FileSystem } from '@rushstack/node-core-library';
6
+ /**
7
+ * The filename used to store the launch options allowlist.
8
+ * Stored in the user's home directory/.playwright-browser-tunnel folder.
9
+ * @beta
10
+ */
11
+ export const LAUNCH_OPTIONS_ALLOWLIST_FILENAME = '.playwright-launch-options-allowlist.json';
12
+ /**
13
+ * Validates Playwright launch options against security allowlists.
14
+ * Provides utilities for managing client-side allowlist configuration.
15
+ * @beta
16
+ */
17
+ export class LaunchOptionsValidator {
18
+ /**
19
+ * Gets the path to the allowlist file in the user's local preferences folder.
20
+ * This follows the pattern of playwright-browser-installed.txt but stores in user's home directory.
21
+ */
22
+ static getAllowlistFilePath() {
23
+ // Store in user's home directory under .playwright-browser-tunnel
24
+ const homeDir = os.homedir();
25
+ const configDir = path.join(homeDir, '.playwright-browser-tunnel');
26
+ return path.join(configDir, LAUNCH_OPTIONS_ALLOWLIST_FILENAME);
27
+ }
28
+ /**
29
+ * Reads the allowlist from the user's local file system.
30
+ * Returns an empty allowlist if the file doesn't exist or is invalid.
31
+ */
32
+ static async readAllowlistAsync() {
33
+ const allowlistPath = this.getAllowlistFilePath();
34
+ try {
35
+ if (!FileSystem.exists(allowlistPath)) {
36
+ return {
37
+ allowedOptions: [],
38
+ version: this._allowlistVersion
39
+ };
40
+ }
41
+ const content = await FileSystem.readFileAsync(allowlistPath);
42
+ const parsed = JSON.parse(content);
43
+ if (typeof parsed === 'object' &&
44
+ parsed !== null &&
45
+ 'allowedOptions' in parsed &&
46
+ Array.isArray(parsed.allowedOptions) &&
47
+ 'version' in parsed &&
48
+ typeof parsed.version === 'number') {
49
+ return parsed;
50
+ }
51
+ // Invalid format, return empty allowlist
52
+ return {
53
+ allowedOptions: [],
54
+ version: this._allowlistVersion
55
+ };
56
+ }
57
+ catch (error) {
58
+ // If we can't read the file, return empty allowlist
59
+ return {
60
+ allowedOptions: [],
61
+ version: this._allowlistVersion
62
+ };
63
+ }
64
+ }
65
+ /**
66
+ * Writes the allowlist to the user's local file system.
67
+ */
68
+ static async writeAllowlistAsync(allowlist) {
69
+ const allowlistPath = this.getAllowlistFilePath();
70
+ const configDir = path.dirname(allowlistPath);
71
+ // Ensure the config directory exists
72
+ await FileSystem.ensureFolderAsync(configDir);
73
+ const content = JSON.stringify(allowlist, null, 2);
74
+ await FileSystem.writeFileAsync(allowlistPath, content, { ensureFolderExists: true });
75
+ }
76
+ /**
77
+ * Validates launch options against the security allowlist.
78
+ * All launch options are denied by default unless explicitly allowed by the user.
79
+ *
80
+ * @param launchOptions - The launch options to validate
81
+ * @param terminal - Optional terminal for logging warnings
82
+ * @returns Validation result with filtered options and warnings
83
+ */
84
+ static async validateLaunchOptionsAsync(launchOptions, terminal) {
85
+ const allowlist = await this.readAllowlistAsync();
86
+ const allowedOptionsSet = new Set(allowlist.allowedOptions);
87
+ const deniedOptions = [];
88
+ const warnings = [];
89
+ const filteredOptions = {};
90
+ // Check each provided launch option - deny all unless explicitly allowed
91
+ for (const key of Object.keys(launchOptions)) {
92
+ if (allowedOptionsSet.has(key)) {
93
+ // Option is in the user's allowlist - permit it
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
+ filteredOptions[key] = launchOptions[key];
96
+ if (terminal) {
97
+ terminal.writeWarningLine(`Launch option '${key}' is allowed by user allowlist. ` +
98
+ `Value: ${JSON.stringify(launchOptions[key])}`);
99
+ }
100
+ }
101
+ else {
102
+ // Option is not in allowlist - deny it
103
+ deniedOptions.push(key);
104
+ const warning = `Launch option '${key}' was denied (not in allowlist). ` +
105
+ `To allow this option, add it to your local allowlist at: ${this.getAllowlistFilePath()}`;
106
+ warnings.push(warning);
107
+ if (terminal) {
108
+ terminal.writeWarningLine(warning);
109
+ }
110
+ }
111
+ }
112
+ return {
113
+ isValid: deniedOptions.length === 0,
114
+ deniedOptions,
115
+ filteredOptions,
116
+ warnings
117
+ };
118
+ }
119
+ /**
120
+ * Adds an option to the allowlist.
121
+ */
122
+ static async addToAllowlistAsync(option) {
123
+ const allowlist = await this.readAllowlistAsync();
124
+ if (!allowlist.allowedOptions.includes(option)) {
125
+ allowlist.allowedOptions.push(option);
126
+ await this.writeAllowlistAsync(allowlist);
127
+ }
128
+ }
129
+ /**
130
+ * Removes an option from the allowlist.
131
+ */
132
+ static async removeFromAllowlistAsync(option) {
133
+ const allowlist = await this.readAllowlistAsync();
134
+ allowlist.allowedOptions = allowlist.allowedOptions.filter((opt) => opt !== option);
135
+ await this.writeAllowlistAsync(allowlist);
136
+ }
137
+ /**
138
+ * Clears the entire allowlist.
139
+ */
140
+ static async clearAllowlistAsync() {
141
+ await this.writeAllowlistAsync({
142
+ allowedOptions: [],
143
+ version: this._allowlistVersion
144
+ });
145
+ }
146
+ /**
147
+ * Gets a human-readable description of the allowlist security model.
148
+ */
149
+ static getAllowlistDescription() {
150
+ return (`All launch options are denied by default for security.\n` +
151
+ `Only options explicitly added to your allowlist will be permitted.\n\n` +
152
+ `Allowlist location: ${this.getAllowlistFilePath()}`);
153
+ }
154
+ }
155
+ LaunchOptionsValidator._allowlistVersion = 1;
156
+ //# sourceMappingURL=LaunchOptionsValidator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LaunchOptionsValidator.js","sourceRoot":"","sources":["../src/LaunchOptionsValidator.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.\n// See LICENSE in the project root for license information.\n\nimport os from 'node:os';\nimport path from 'node:path';\n\nimport type { LaunchOptions } from 'playwright-core';\n\nimport { FileSystem } from '@rushstack/node-core-library';\nimport type { ITerminal } from '@rushstack/terminal';\n\n/**\n * The filename used to store the launch options allowlist.\n * Stored in the user's home directory/.playwright-browser-tunnel folder.\n * @beta\n */\nexport const LAUNCH_OPTIONS_ALLOWLIST_FILENAME: string = '.playwright-launch-options-allowlist.json';\n\n/**\n * Interface for the allowlist configuration stored in the user's local file system.\n * @beta\n */\nexport interface ILaunchOptionsAllowlist {\n /**\n * Set of launch option keys that the user has explicitly allowed.\n * These bypass the default security restrictions.\n */\n allowedOptions: string[];\n\n /**\n * Version of the allowlist format, for future compatibility.\n */\n version: number;\n}\n\n/**\n * Result of validating launch options against the allowlist.\n * @beta\n */\nexport interface ILaunchOptionsValidationResult {\n /**\n * Whether the launch options are valid and allowed.\n */\n isValid: boolean;\n\n /**\n * Launch options that were denied due to security restrictions.\n */\n deniedOptions: Array<keyof LaunchOptions>;\n\n /**\n * Filtered launch options with denied properties removed.\n */\n filteredOptions: LaunchOptions;\n\n /**\n * Warning messages about denied options.\n */\n warnings: string[];\n}\n\n/**\n * Validates Playwright launch options against security allowlists.\n * Provides utilities for managing client-side allowlist configuration.\n * @beta\n */\nexport class LaunchOptionsValidator {\n private static readonly _allowlistVersion: number = 1;\n\n /**\n * Gets the path to the allowlist file in the user's local preferences folder.\n * This follows the pattern of playwright-browser-installed.txt but stores in user's home directory.\n */\n public static getAllowlistFilePath(): string {\n // Store in user's home directory under .playwright-browser-tunnel\n const homeDir: string = os.homedir();\n const configDir: string = path.join(homeDir, '.playwright-browser-tunnel');\n return path.join(configDir, LAUNCH_OPTIONS_ALLOWLIST_FILENAME);\n }\n\n /**\n * Reads the allowlist from the user's local file system.\n * Returns an empty allowlist if the file doesn't exist or is invalid.\n */\n public static async readAllowlistAsync(): Promise<ILaunchOptionsAllowlist> {\n const allowlistPath: string = this.getAllowlistFilePath();\n\n try {\n if (!FileSystem.exists(allowlistPath)) {\n return {\n allowedOptions: [],\n version: this._allowlistVersion\n };\n }\n\n const content: string = await FileSystem.readFileAsync(allowlistPath);\n const parsed: unknown = JSON.parse(content);\n\n if (\n typeof parsed === 'object' &&\n parsed !== null &&\n 'allowedOptions' in parsed &&\n Array.isArray(parsed.allowedOptions) &&\n 'version' in parsed &&\n typeof parsed.version === 'number'\n ) {\n return parsed as ILaunchOptionsAllowlist;\n }\n\n // Invalid format, return empty allowlist\n return {\n allowedOptions: [],\n version: this._allowlistVersion\n };\n } catch (error) {\n // If we can't read the file, return empty allowlist\n return {\n allowedOptions: [],\n version: this._allowlistVersion\n };\n }\n }\n\n /**\n * Writes the allowlist to the user's local file system.\n */\n public static async writeAllowlistAsync(allowlist: ILaunchOptionsAllowlist): Promise<void> {\n const allowlistPath: string = this.getAllowlistFilePath();\n const configDir: string = path.dirname(allowlistPath);\n\n // Ensure the config directory exists\n await FileSystem.ensureFolderAsync(configDir);\n\n const content: string = JSON.stringify(allowlist, null, 2);\n await FileSystem.writeFileAsync(allowlistPath, content, { ensureFolderExists: true });\n }\n\n /**\n * Validates launch options against the security allowlist.\n * All launch options are denied by default unless explicitly allowed by the user.\n *\n * @param launchOptions - The launch options to validate\n * @param terminal - Optional terminal for logging warnings\n * @returns Validation result with filtered options and warnings\n */\n public static async validateLaunchOptionsAsync(\n launchOptions: LaunchOptions,\n terminal?: ITerminal\n ): Promise<ILaunchOptionsValidationResult> {\n const allowlist: ILaunchOptionsAllowlist = await this.readAllowlistAsync();\n const allowedOptionsSet: Set<string> = new Set(allowlist.allowedOptions);\n\n const deniedOptions: Array<keyof LaunchOptions> = [];\n const warnings: string[] = [];\n const filteredOptions: LaunchOptions = {};\n\n // Check each provided launch option - deny all unless explicitly allowed\n for (const key of Object.keys(launchOptions) as Array<keyof LaunchOptions>) {\n if (allowedOptionsSet.has(key)) {\n // Option is in the user's allowlist - permit it\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n (filteredOptions as any)[key] = launchOptions[key];\n\n if (terminal) {\n terminal.writeWarningLine(\n `Launch option '${key}' is allowed by user allowlist. ` +\n `Value: ${JSON.stringify(launchOptions[key])}`\n );\n }\n } else {\n // Option is not in allowlist - deny it\n deniedOptions.push(key);\n\n const warning: string =\n `Launch option '${key}' was denied (not in allowlist). ` +\n `To allow this option, add it to your local allowlist at: ${this.getAllowlistFilePath()}`;\n warnings.push(warning);\n\n if (terminal) {\n terminal.writeWarningLine(warning);\n }\n }\n }\n\n return {\n isValid: deniedOptions.length === 0,\n deniedOptions,\n filteredOptions,\n warnings\n };\n }\n\n /**\n * Adds an option to the allowlist.\n */\n public static async addToAllowlistAsync(option: keyof LaunchOptions): Promise<void> {\n const allowlist: ILaunchOptionsAllowlist = await this.readAllowlistAsync();\n\n if (!allowlist.allowedOptions.includes(option)) {\n allowlist.allowedOptions.push(option);\n await this.writeAllowlistAsync(allowlist);\n }\n }\n\n /**\n * Removes an option from the allowlist.\n */\n public static async removeFromAllowlistAsync(option: keyof LaunchOptions): Promise<void> {\n const allowlist: ILaunchOptionsAllowlist = await this.readAllowlistAsync();\n allowlist.allowedOptions = allowlist.allowedOptions.filter((opt) => opt !== option);\n await this.writeAllowlistAsync(allowlist);\n }\n\n /**\n * Clears the entire allowlist.\n */\n public static async clearAllowlistAsync(): Promise<void> {\n await this.writeAllowlistAsync({\n allowedOptions: [],\n version: this._allowlistVersion\n });\n }\n\n /**\n * Gets a human-readable description of the allowlist security model.\n */\n public static getAllowlistDescription(): string {\n return (\n `All launch options are denied by default for security.\\n` +\n `Only options explicitly added to your allowlist will be permitted.\\n\\n` +\n `Allowlist location: ${this.getAllowlistFilePath()}`\n );\n }\n}\n"]}
@@ -0,0 +1,467 @@
1
+ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2
+ // See LICENSE in the project root for license information.
3
+ import { once } from 'node:events';
4
+ import { WebSocket } from 'ws';
5
+ import semver from 'semver';
6
+ import { TerminalProviderSeverity, TerminalStreamWritable } from '@rushstack/terminal';
7
+ import { Executable, FileSystem, Async } from '@rushstack/node-core-library';
8
+ import { getNormalizedErrorString, getWebSocketCloseReason, getWebSocketReadyStateString, WebSocketCloseCode } from './utilities';
9
+ import { LaunchOptionsValidator } from './LaunchOptionsValidator';
10
+ const validBrowserNames = new Set(['chromium', 'firefox', 'webkit']);
11
+ function isValidBrowserName(browserName) {
12
+ return validBrowserNames.has(browserName);
13
+ }
14
+ /**
15
+ * Hosts a Playwright browser server and forwards traffic over a WebSocket tunnel.
16
+ * @beta
17
+ */
18
+ export class PlaywrightTunnel {
19
+ constructor(options) {
20
+ this._playwrightBrowsersInstalled = new Set();
21
+ this._status = 'stopped';
22
+ this._keepRunning = false;
23
+ const { mode, terminal, onStatusChange, playwrightInstallPath, onBeforeLaunch } = options;
24
+ switch (mode) {
25
+ case 'poll-connection':
26
+ if (!options.wsEndpoint) {
27
+ throw new Error('wsEndpoint is required for poll-connection mode');
28
+ }
29
+ this._wsEndpoint = options.wsEndpoint;
30
+ this._listenPort = undefined;
31
+ break;
32
+ case 'wait-for-incoming-connection':
33
+ if (options.listenPort === undefined) {
34
+ throw new Error('listenPort is required for wait-for-incoming-connection mode');
35
+ }
36
+ this._wsEndpoint = undefined;
37
+ this._listenPort = options.listenPort;
38
+ break;
39
+ default:
40
+ throw new Error(`Invalid mode: ${mode}`);
41
+ }
42
+ this._mode = mode;
43
+ this._terminal = terminal;
44
+ this._onStatusChange = onStatusChange;
45
+ this._onBeforeLaunch = onBeforeLaunch;
46
+ this._playwrightInstallPath = playwrightInstallPath;
47
+ }
48
+ get status() {
49
+ return this._status;
50
+ }
51
+ // eslint-disable-next-line @typescript-eslint/naming-convention
52
+ set status(newStatus) {
53
+ this._status = newStatus;
54
+ this._onStatusChange(newStatus);
55
+ }
56
+ async waitForCloseAsync() {
57
+ const terminal = this._terminal;
58
+ const initWsPromise = this._initWsPromise;
59
+ if (initWsPromise) {
60
+ const ws = await initWsPromise;
61
+ await once(ws, 'close');
62
+ terminal.writeDebugLine('WebSocket connection closed. resolving init promise.');
63
+ this._initWsPromise = undefined;
64
+ }
65
+ }
66
+ async startAsync(options = {}) {
67
+ var _a;
68
+ this._keepRunning = (_a = options.keepRunning) !== null && _a !== void 0 ? _a : true;
69
+ const terminal = this._terminal;
70
+ terminal.writeLine(`keepRunning: ${this._keepRunning}`);
71
+ while (this._keepRunning) {
72
+ if (!this._initWsPromise) {
73
+ this._initWsPromise = this._initPlaywrightBrowserTunnelAsync();
74
+ }
75
+ else {
76
+ terminal.writeLine(`Tunnel is already running with status: ${this.status}`);
77
+ }
78
+ await this.waitForCloseAsync();
79
+ }
80
+ }
81
+ async stopAsync() {
82
+ var _a;
83
+ this._keepRunning = false;
84
+ if (this._pollInterval) {
85
+ clearInterval(this._pollInterval);
86
+ this._pollInterval = undefined;
87
+ }
88
+ await ((_a = this._initWsPromise) === null || _a === void 0 ? void 0 : _a.finally(() => {
89
+ var _a;
90
+ (_a = this._ws) === null || _a === void 0 ? void 0 : _a.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Tunnel stopped');
91
+ }));
92
+ }
93
+ async [Symbol.asyncDispose]() {
94
+ this._terminal.writeLine('Disposing WebSocket connection.');
95
+ await this.stopAsync();
96
+ }
97
+ async cleanTempFilesAsync() {
98
+ const tmpPath = this._playwrightInstallPath;
99
+ this._terminal.writeLine(`Cleaning up temporary files in ${tmpPath}`);
100
+ try {
101
+ await FileSystem.ensureEmptyFolderAsync(tmpPath);
102
+ this._terminal.writeLine(`Temporary files cleaned up.`);
103
+ }
104
+ catch (error) {
105
+ this._terminal.writeLine(`Failed to clean up temporary files: ${getNormalizedErrorString(error)}`);
106
+ }
107
+ }
108
+ // TODO: We should implement an uninstall command to remove installed Playwright browsers
109
+ // public async uninstallPlaywrightBrowsersAsync(): Promise<void> {}
110
+ async _runCommandAsync(command, args) {
111
+ var _a, _b;
112
+ const tmpPath = this._playwrightInstallPath;
113
+ await FileSystem.ensureFolderAsync(tmpPath);
114
+ this._terminal.writeLine(`Running command: ${command} ${args.join(' ')} in ${tmpPath}`);
115
+ const cp = Executable.spawn(command, args, {
116
+ stdio: [
117
+ 'ignore', // stdin
118
+ 'pipe', // stdout
119
+ 'pipe' // stderr
120
+ ],
121
+ currentWorkingDirectory: tmpPath
122
+ });
123
+ (_a = cp.stdout) === null || _a === void 0 ? void 0 : _a.pipe(new TerminalStreamWritable({
124
+ terminal: this._terminal,
125
+ severity: TerminalProviderSeverity.log
126
+ }));
127
+ (_b = cp.stderr) === null || _b === void 0 ? void 0 : _b.pipe(new TerminalStreamWritable({
128
+ terminal: this._terminal,
129
+ severity: TerminalProviderSeverity.error
130
+ }));
131
+ await Executable.waitForExitAsync(cp, { throwOnNonZeroExitCode: true, throwOnSignal: true });
132
+ }
133
+ async _installPlaywrightCoreAsync({ playwrightVersion }) {
134
+ this._terminal.writeLine(`Installing playwright-core version ${playwrightVersion}`);
135
+ await this._runCommandAsync('npm', [
136
+ 'install',
137
+ `playwright-core-${playwrightVersion}@npm:playwright-core@${playwrightVersion}`
138
+ ]);
139
+ }
140
+ async _installPlaywrightBrowsersAsync({ playwrightVersion, browserName }) {
141
+ await this._installPlaywrightCoreAsync({ playwrightVersion });
142
+ this._terminal.writeLine(`Executing playwright-core version ${playwrightVersion}`);
143
+ await this._runCommandAsync('node', [
144
+ `node_modules/playwright-core-${playwrightVersion}/cli.js`,
145
+ 'install',
146
+ browserName
147
+ ]);
148
+ }
149
+ async _tryConnectAsync() {
150
+ const wsEndpoint = this._wsEndpoint;
151
+ if (!wsEndpoint) {
152
+ throw new Error('WebSocket endpoint is not defined');
153
+ }
154
+ return await new Promise((resolve, reject) => {
155
+ const ws = new WebSocket(wsEndpoint);
156
+ ws.on('open', () => {
157
+ this._terminal.writeLine(`WebSocket connection opened`);
158
+ resolve(ws);
159
+ });
160
+ ws.once('error', (error) => {
161
+ reject(error);
162
+ });
163
+ });
164
+ }
165
+ // TODO: Only supporting one test at a time.
166
+ // Need to support multiple simultaneous connections for parallel tests.
167
+ async _pollConnectionAsync() {
168
+ this._terminal.writeLine(`Waiting for WebSocket connection`);
169
+ return await new Promise((resolve, reject) => {
170
+ this._pollInterval = setInterval(() => {
171
+ if (this._pendingConnectionAttempt) {
172
+ return; // Skip if a connection attempt is already in progress
173
+ }
174
+ const connectionPromise = this._tryConnectAsync();
175
+ this._pendingConnectionAttempt = connectionPromise;
176
+ connectionPromise
177
+ .then((ws) => {
178
+ clearInterval(this._pollInterval);
179
+ this._pollInterval = undefined;
180
+ ws.removeAllListeners();
181
+ this._pendingConnectionAttempt = undefined;
182
+ resolve(ws);
183
+ })
184
+ .catch(() => {
185
+ // no-op - will retry on next interval
186
+ this._pendingConnectionAttempt = undefined;
187
+ });
188
+ }, 500);
189
+ });
190
+ }
191
+ async _waitForIncomingConnectionAsync() {
192
+ this._terminal.writeLine('Waiting for incoming WebSocket connection');
193
+ return await new Promise((resolve, reject) => {
194
+ const server = new WebSocket.Server({ port: this._listenPort });
195
+ const cleanup = () => {
196
+ server.removeAllListeners();
197
+ };
198
+ server.once('connection', (ws) => {
199
+ this._terminal.writeLine('Incoming WebSocket connection established');
200
+ // Stop listening immediately so the port is released
201
+ cleanup();
202
+ server.close((closeError) => {
203
+ if (closeError) {
204
+ this._terminal.writeLine(`Failed to close WebSocket server: ${closeError instanceof Error ? closeError.message : closeError}`);
205
+ }
206
+ resolve(ws);
207
+ });
208
+ });
209
+ server.once('error', (error) => {
210
+ this._terminal.writeLine(`WebSocket server error: ${getNormalizedErrorString(error)}`);
211
+ cleanup();
212
+ // Try to close (best-effort), then reject
213
+ server.close(() => reject(error));
214
+ });
215
+ });
216
+ }
217
+ // TODO: If a user runs this for the first time, `this._playwrightBrowsersInstalled` will be empty
218
+ // and it will try to install the browsers every time. We should persist this information. Maybe a cache file with text per
219
+ // machine instance?
220
+ async _setupPlaywrightAsync({ playwrightVersion, browserName }) {
221
+ const browserKey = `${playwrightVersion}-${browserName}`;
222
+ this._terminal.writeLine(`Checking for installed playwright browsers. Installed browsers: ${browserKey}`);
223
+ if (!this._playwrightBrowsersInstalled.has(browserKey)) {
224
+ this._terminal.writeLine(`Playwright browser not found. Installing playwright-core version ${playwrightVersion}`);
225
+ await this._installPlaywrightBrowsersAsync({ playwrightVersion, browserName });
226
+ this._playwrightBrowsersInstalled.add(browserKey);
227
+ }
228
+ this._terminal.writeLine(`Using playwright-core version ${playwrightVersion} for browser server`);
229
+ return await import(`${this._playwrightInstallPath}/node_modules/playwright-core-${playwrightVersion}`);
230
+ }
231
+ async _getPlaywrightBrowserServerProxyAsync({ browserName, playwrightVersion, launchOptions }) {
232
+ const terminal = this._terminal;
233
+ // Validate launch options against security allowlist
234
+ terminal.writeLine('Validating launch options against security allowlist...');
235
+ const validationResult = await LaunchOptionsValidator.validateLaunchOptionsAsync(launchOptions, terminal);
236
+ if (!validationResult.isValid) {
237
+ terminal.writeWarningLine(`Some launch options were denied: ${validationResult.deniedOptions.join(', ')}`);
238
+ terminal.writeWarningLine(`Using filtered launch options. Denied options have been removed.`);
239
+ }
240
+ // Use filtered options and ensure headless: false for headed tests in codespaces
241
+ // This is critical for the extension's purpose - enabling headed Playwright tests remotely
242
+ const safeOptions = {
243
+ ...validationResult.filteredOptions,
244
+ headless: false
245
+ };
246
+ // Log the validated options, excluding 'headless' since it's always false for this extension
247
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
248
+ const { headless, ...logOptions } = safeOptions;
249
+ terminal.writeLine(`Launch options after validation: ${JSON.stringify(logOptions)} (headless: false enforced)`);
250
+ const playwright = await this._setupPlaywrightAsync({
251
+ playwrightVersion,
252
+ browserName
253
+ });
254
+ const { chromium, firefox, webkit } = playwright;
255
+ const browsers = { chromium, firefox, webkit };
256
+ const browserServer = await browsers[browserName].launchServer(safeOptions);
257
+ if (!browserServer) {
258
+ throw new Error(`Failed to launch browser server for ${browserName} with options: ${JSON.stringify(safeOptions)}`);
259
+ }
260
+ terminal.writeLine(`Launched ${browserName} browser server`);
261
+ const client = new WebSocket(browserServer.wsEndpoint());
262
+ return {
263
+ browserServer,
264
+ client
265
+ };
266
+ }
267
+ _validateHandshake(rawHandshake) {
268
+ if (typeof rawHandshake !== 'object' ||
269
+ rawHandshake === null ||
270
+ 'action' in rawHandshake === false ||
271
+ 'browserName' in rawHandshake === false ||
272
+ 'playwrightVersion' in rawHandshake === false ||
273
+ 'launchOptions' in rawHandshake === false ||
274
+ typeof rawHandshake.action !== 'string' ||
275
+ typeof rawHandshake.browserName !== 'string' ||
276
+ typeof rawHandshake.playwrightVersion !== 'string' ||
277
+ typeof rawHandshake.launchOptions !== 'object') {
278
+ throw new Error(`Invalid handshake: ${JSON.stringify(rawHandshake)}. Must be an object.`);
279
+ }
280
+ const { action, browserName, playwrightVersion, launchOptions } = rawHandshake;
281
+ if (action !== 'handshake') {
282
+ throw new Error(`Invalid action: ${action}. Expected 'handshake'.`);
283
+ }
284
+ const playwrightVersionSemver = semver.coerce(playwrightVersion);
285
+ if (!playwrightVersionSemver) {
286
+ throw new Error(`Invalid Playwright version: ${playwrightVersion}. Must be a valid semver version.`);
287
+ }
288
+ if (!isValidBrowserName(browserName)) {
289
+ throw new Error(`Invalid browser name: ${browserName}. Must be one of ${Array.from(validBrowserNames).join(', ')}.`);
290
+ }
291
+ return {
292
+ action,
293
+ launchOptions: launchOptions,
294
+ playwrightVersion: playwrightVersionSemver,
295
+ browserName
296
+ };
297
+ }
298
+ // ws1 is the tunnel websocket, ws2 is the browser server websocket
299
+ async _setupForwardingAsync(ws1, ws2) {
300
+ this._terminal.writeLine('Setting up message forwarding between ws1 and ws2');
301
+ this._terminal.writeLine(` ws1 (tunnel) readyState: ${getWebSocketReadyStateString(ws1.readyState)}`);
302
+ this._terminal.writeLine(` ws2 (browser) readyState: ${getWebSocketReadyStateString(ws2.readyState)}`);
303
+ const messageCount = { ws1ToWs2: 0, ws2ToWs1: 0 };
304
+ ws1.on('message', (data) => {
305
+ messageCount.ws1ToWs2++;
306
+ if (ws2.readyState === WebSocket.OPEN) {
307
+ ws2.send(data);
308
+ }
309
+ else {
310
+ this._terminal.writeLine(`ws2 not open (state: ${getWebSocketReadyStateString(ws2.readyState)}). Dropping message #${messageCount.ws1ToWs2}`);
311
+ }
312
+ });
313
+ ws2.on('message', (data) => {
314
+ messageCount.ws2ToWs1++;
315
+ if (ws1.readyState === WebSocket.OPEN) {
316
+ ws1.send(data);
317
+ }
318
+ else {
319
+ this._terminal.writeLine(`ws1 not open (state: ${getWebSocketReadyStateString(ws1.readyState)}). Dropping message #${messageCount.ws2ToWs1}`);
320
+ }
321
+ });
322
+ ws1.once('close', (code, reason) => {
323
+ const reasonStr = reason.toString() || 'no reason provided';
324
+ const codeDescription = getWebSocketCloseReason(code);
325
+ this._terminal.writeLine(`ws1 (tunnel) closed - code: ${code} (${codeDescription}), reason: ${reasonStr}`);
326
+ this._terminal.writeLine(` Messages forwarded: ws1->ws2: ${messageCount.ws1ToWs2}, ws2->ws1: ${messageCount.ws2ToWs1}`);
327
+ if (ws2.readyState === WebSocket.OPEN) {
328
+ this._terminal.writeLine(' Closing ws2 (browser) in response');
329
+ ws2.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Tunnel closed');
330
+ }
331
+ });
332
+ ws2.once('close', (code, reason) => {
333
+ const reasonStr = reason.toString() || 'no reason provided';
334
+ const codeDescription = getWebSocketCloseReason(code);
335
+ this._terminal.writeLine(`ws2 (browser) closed - code: ${code} (${codeDescription}), reason: ${reasonStr}`);
336
+ this._terminal.writeLine(` Messages forwarded: ws1->ws2: ${messageCount.ws1ToWs2}, ws2->ws1: ${messageCount.ws2ToWs1}`);
337
+ if (ws1.readyState === WebSocket.OPEN) {
338
+ this._terminal.writeLine(' Closing ws1 (tunnel) in response');
339
+ ws1.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Browser closed');
340
+ }
341
+ });
342
+ ws1.once('error', (error) => {
343
+ this._terminal.writeErrorLine(`ws1 (tunnel) WebSocket error: ${getNormalizedErrorString(error)}`);
344
+ this._terminal.writeErrorLine(` ws1 readyState: ${getWebSocketReadyStateString(ws1.readyState)}`);
345
+ });
346
+ ws2.once('error', (error) => {
347
+ this._terminal.writeErrorLine(`ws2 (browser) WebSocket error: ${getNormalizedErrorString(error)}`);
348
+ this._terminal.writeErrorLine(` ws2 readyState: ${getWebSocketReadyStateString(ws2.readyState)}`);
349
+ });
350
+ }
351
+ /**
352
+ * Initializes the Playwright browser tunnel by establishing a WebSocket connection
353
+ * and setting up the browser server.
354
+ * Returns when the handshake is complete and the browser server is running.
355
+ */
356
+ async _initPlaywrightBrowserTunnelAsync() {
357
+ let handshake = undefined;
358
+ let client = undefined;
359
+ let browserServer = undefined;
360
+ this.status = 'waiting-for-connection';
361
+ const ws = this._mode === 'poll-connection'
362
+ ? await this._pollConnectionAsync()
363
+ : await this._waitForIncomingConnectionAsync();
364
+ ws.on('open', () => {
365
+ this._terminal.writeLine(`WebSocket connection established`);
366
+ handshake = undefined;
367
+ });
368
+ ws.on('error', (error) => {
369
+ this._terminal.writeLine(`WebSocket error occurred: ${getNormalizedErrorString(error)}`);
370
+ });
371
+ ws.on('close', async (code, reason) => {
372
+ const reasonStr = reason.toString() || 'no reason provided';
373
+ const codeDescription = getWebSocketCloseReason(code);
374
+ this._initWsPromise = undefined;
375
+ this.status = 'stopped';
376
+ this._terminal.writeLine(`WebSocket connection closed - code: ${code} (${codeDescription}), reason: ${reasonStr}`);
377
+ this._terminal.writeLine(` handshake received: ${handshake !== undefined}`);
378
+ this._terminal.writeLine(` browserServer active: ${browserServer !== undefined}`);
379
+ if (browserServer) {
380
+ this._terminal.writeLine(' Closing browser server...');
381
+ await browserServer.close();
382
+ this._terminal.writeLine(' Browser server closed');
383
+ }
384
+ });
385
+ return await new Promise((resolve, reject) => {
386
+ const onMessageHandler = async (data) => {
387
+ const terminal = this._terminal;
388
+ if (!handshake) {
389
+ try {
390
+ const rawHandshakeString = data.toString();
391
+ const rawHandshake = JSON.parse(rawHandshakeString);
392
+ terminal.writeLine(`Received handshake: ${rawHandshakeString}`);
393
+ handshake = this._validateHandshake(rawHandshake);
394
+ // Call the onBeforeLaunch callback if provided
395
+ if (this._onBeforeLaunch) {
396
+ terminal.writeLine('Requesting user approval before launching browser server...');
397
+ const shouldProceed = await this._onBeforeLaunch(handshake);
398
+ if (!shouldProceed) {
399
+ terminal.writeLine('Browser server launch cancelled by user.');
400
+ ws.off('message', onMessageHandler);
401
+ ws.close(WebSocketCloseCode.NORMAL_CLOSURE, 'Launch cancelled by user');
402
+ reject(new Error('Browser server launch cancelled by user'));
403
+ return;
404
+ }
405
+ terminal.writeLine('User approved browser server launch.');
406
+ }
407
+ this.status = 'setting-up-browser-server';
408
+ const browserServerProxy = await this._getPlaywrightBrowserServerProxyAsync(handshake);
409
+ client = browserServerProxy.client;
410
+ browserServer = browserServerProxy.browserServer;
411
+ // Monitor browser server process for crashes
412
+ const browserProcess = browserServer.process();
413
+ if (browserProcess) {
414
+ browserProcess.on('exit', (code, signal) => {
415
+ terminal.writeErrorLine(`Browser server process exited - code: ${code}, signal: ${signal}`);
416
+ });
417
+ browserProcess.on('error', (err) => {
418
+ terminal.writeErrorLine(`Browser server process error: ${getNormalizedErrorString(err)}`);
419
+ });
420
+ terminal.writeDebugLine(`Browser server process started with PID: ${browserProcess.pid}`);
421
+ }
422
+ else {
423
+ terminal.writeDebugLine('Warning: Browser server process handle not available for monitoring');
424
+ }
425
+ this.status = 'browser-server-running';
426
+ // Send ack so that the counterpart also knows to start forwarding messages.
427
+ // NOTE: The 1-second delay is an intentional workaround. In the current
428
+ // protocol, the remote tunnel endpoint does not expose an explicit "ready"
429
+ // signal for when it has finished initializing its own forwarding logic
430
+ // after receiving the initial handshake. This
431
+ // delay avoids races where early messages could be dropped or mishandled
432
+ // if they arrive before the remote side is fully ready.
433
+ //
434
+ // TODO: A future improvement would be to replace this delay with a deterministic
435
+ // synchronization mechanism (e.g. an explicit "ready" message or event)
436
+ // instead of relying on a fixed timeout.
437
+ await Async.sleepAsync(2000);
438
+ ws.send(JSON.stringify({ action: 'handshakeAck' }));
439
+ await this._setupForwardingAsync(ws, client);
440
+ // Clean up message handler after successful handshake
441
+ ws.off('message', onMessageHandler);
442
+ resolve(ws);
443
+ }
444
+ catch (error) {
445
+ terminal.writeLine(`Error processing handshake: ${error}`);
446
+ this.status = 'error';
447
+ // Cleanup and close connection on error
448
+ ws.off('message', onMessageHandler);
449
+ ws.close(WebSocketCloseCode.INTERNAL_ERROR, 'Handshake error');
450
+ reject(error);
451
+ return;
452
+ }
453
+ }
454
+ else {
455
+ if (!client) {
456
+ terminal.writeLine('Browser WebSocket client is not initialized.');
457
+ ws.off('message', onMessageHandler);
458
+ ws.close(WebSocketCloseCode.INTERNAL_ERROR, 'Browser client not initialized');
459
+ return;
460
+ }
461
+ }
462
+ };
463
+ ws.on('message', onMessageHandler);
464
+ });
465
+ }
466
+ }
467
+ //# sourceMappingURL=PlaywrightBrowserTunnel.js.map