From 76005baf93dae78657b780f1aed529edcf44412a Mon Sep 17 00:00:00 2001 From: sharon Date: Fri, 11 Oct 2024 16:24:08 -0400 Subject: [PATCH] Proxy python app frameworks (#4978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description - Addresses https://github.com/posit-dev/positron/issues/4769 (as much as possible for now) ### Framework Support Summary (based on **_limited_** testing) | Framework | Positron Server Web | Positron Desktop | Positron on Workbench | Notes | |--------|--------|--------|--------|--------| | Dash | ✅ yes | ✅ yes | ✅ yes-ish | [Workbench extension issue](https://github.com/rstudio/rstudio-workbench-vscode-ext/issues/262) to skip Dash framework handling to avoid conflicting with Positron's Dash framework handling | | Fastapi | ✅ yes | ✅ yes | 🛑 no | Seems to be a conflict between the way we run a fastapi app and that Workbench is setting `UVICORN_ROOT_PATH` | | Flask | ✅ yes | ✅ yes | ✅ yes | Workbench: works when including the code snippet from [Workbench docs](https://docs.posit.co/ide/server-pro/user/vs-code/guide/proxying-web-servers.html#flask). | | Gradio | ✅ yes-ish | ✅ yes-ish | ✅ yes-ish | Working when the following dependency versions are used: `gradio==3.3.1 fastapi==0.85.2 httpx==0.24.1`. See https://github.com/gradio-app/gradio/issues/9529 for more information on why more recent versions don't work. | | Streamlit | ✅ yes | ✅ yes | 🟨 partially | Not working on Workbench when SSL is enabled | | Shiny | ✅ yes | ✅ yes | ✅ yes | | ### Implementation Notes #### Positron Proxy CC @softwarenerd & @jmcphers - Add new command `positronProxy.startPendingProxyServer` which starts a new proxy server, but doesn't set up the middleware right away. The command will return the `externalUri` of the proxy server (this is the same as the url shown in the Viewer), the `proxyPath` (which some app frameworks use as the `urlPrefix`) and `finishProxySetup()` which the command-caller will invoke, passing the `targetOrigin` (the actual app url) so that we can finish setting up the middleware to proxy requests from the `externalUri` (app proxy uri) to the `targetOrigin` (actual app uri). - Refactor the proxy code so we can set up a proxy in multiple steps, not only all-at-once - existing callers of `startProxyServer()` will continue to use that method which still does the all-at-once proxy setup, by calling `startNewProxyServer()` and `finishProxySetup()` in succession - new option to call `startNewProxyServer()` standalone, which will only start up the server and then call `finishProxySetup()` later, once the `targetOrigin` is known - Move the HTML-rewriting code to the util file #### Positron Run App CC @seeM - expand the `localUrlRegex` to include the path after the url port `:/` - execute the command `positronProxy.startPendingProxyServer` before setting up terminal options or the debug configuration, so we can start the proxy server and get the `urlPrefix` - before previewing the url in the Viewer, finish the proxy setup by passing the `localUri` to the positron proxy via `proxyInfo.finishProxySetup(localUri.toString())`, so that the proxy middleware is set up - remove `port` from types and test files as it is unused - increase timeout for waiting for the app url to show in the terminal (in particular, Gradio would take a bit longer on Workbench and we would timeout) #### Python Extension CC @seeM - remove `port` from the `webAppCommands` and related test files which is unused - update the framework-specific app arguments and environment variables to work with our proxy server situation #### Custom `resolveExternalUri` CC @melissa-barca - update the `resolvedUri` to inherit the protocol used by the main window as the uri's scheme, so we can upgrade to `https` if the main window is running in a secure context - NOTE: this will need to be upstreamed ### QA Notes This PR involves refactoring the positron-proxy, which is used by Help, Interactive Plots and the Viewer. We should not see any regression in these types of proxied content. Positron Server Web and Desktop should be working across all app frameworks in: - https://github.com/posit-dev/qa-example-content/tree/main/workspaces/python_apps - When running the Gradio app, you will need to install these versions `gradio==3.3.1 fastapi==0.85.2 httpx==0.24.1`. If you can get it working with a more recent combination of versions, please let me know! - https://github.com/posit-dev/qa-example-content/tree/main/workspaces/shiny-py-example - https://github.com/posit-dev/qa-example-content/tree/main/workspaces/streamlit-py-example Positron on Workbench: - `dash` works if the generated `.env` file is deleted before running. Once [this issue](https://github.com/rstudio/rstudio-workbench-vscode-ext/issues/262) is complete, this extra step won't be needed. - `fastapi` does not work. The current hunch is that Workbench setting `UVICORN_ROOT_PATH` at session start is interfering with how we run the `fastapi` app with `uvicorn`. The `UVICORN_ROOT_PATH` is set based on the default fastapi port `8000`, however I think we want the proxied app url as the root path, which is provided by the Positron Proxy. But, when setting `--root-path` in the fastapi app command to the proxied root path, it does not work in Server Web or Desktop. TBD if this resolves the issue on Workbench. More investigation needed. - `flask` works as long as you include the code snippet from [Workbench docs](https://docs.posit.co/ide/server-pro/user/vs-code/guide/proxying-web-servers.html#flask). - `gradio` works when these versions are installed `gradio==3.3.1 fastapi==0.85.2 httpx==0.24.1`. There's [an issue](https://github.com/gradio-app/gradio/issues/9529) in newer versions of Gradio when the app is run via a proxy. - `streamlit` does not work when SSL is set up. More investigation needed. - `shiny` no notes! --------- Co-authored-by: sharon wang <25834218+sharon-wang@users.noreply.github.com> --- extensions/positron-proxy/src/extension.ts | 9 +- .../positron-proxy/src/positronProxy.ts | 253 +++++++++++------- extensions/positron-proxy/src/util.ts | 69 +++++ .../src/client/positron-run-app.d.ts | 4 - .../src/client/positron/webAppCommands.ts | 110 +++----- .../test/positron/webAppCommands.unit.test.ts | 104 ++++--- extensions/positron-run-app/src/api.ts | 54 ++-- .../src/positron-run-app.d.ts | 4 - .../positron-run-app/src/test/api.test.ts | 13 +- extensions/positron-run-app/src/utils.ts | 12 + src/vs/code/browser/workbench/workbench.ts | 13 +- 11 files changed, 380 insertions(+), 265 deletions(-) diff --git a/extensions/positron-proxy/src/extension.ts b/extensions/positron-proxy/src/extension.ts index 3cafcc0ab38..ef7486411f7 100644 --- a/extensions/positron-proxy/src/extension.ts +++ b/extensions/positron-proxy/src/extension.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode'; import * as positron from 'positron'; import { PositronProxy } from './positronProxy'; -import path from 'path'; /** * ProxyServerStyles type. @@ -45,6 +44,14 @@ export function activate(context: vscode.ExtensionContext) { ) ); + // Register the positronProxy.startPendingProxyServer command and add its disposable. + context.subscriptions.push( + vscode.commands.registerCommand( + 'positronProxy.startPendingProxyServer', + async () => await positronProxy.startPendingHttpProxyServer() + ) + ); + // Register the positronProxy.stopProxyServer command and add its disposable. context.subscriptions.push( vscode.commands.registerCommand( diff --git a/extensions/positron-proxy/src/positronProxy.ts b/extensions/positron-proxy/src/positronProxy.ts index d403418226b..9fece87370e 100644 --- a/extensions/positron-proxy/src/positronProxy.ts +++ b/extensions/positron-proxy/src/positronProxy.ts @@ -12,6 +12,7 @@ import { ProxyServerStyles } from './extension'; import { Disposable, ExtensionContext } from 'vscode'; import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; import { HtmlProxyServer } from './htmlProxy'; +import { htmlContentRewriter, rewriteUrlsWithProxyPath } from './util'; /** * Constants. @@ -53,13 +54,27 @@ type ContentRewriter = ( responseBuffer: Buffer ) => Promise; +/** + * PendingProxyServer type. + */ +type PendingProxyServer = { + externalUri: vscode.Uri; + proxyPath: string; + finishProxySetup: (targetOrigin: string) => Promise; +}; + +/** + * MaybeAddressInfo type. + */ +type MaybeAddressInfo = AddressInfo | string | null | undefined; + /** * Custom type guard for AddressInfo. * @param addressInfo The value to type guard. * @returns true if the value is an AddressInfo; otherwise, false. */ export const isAddressInfo = ( - addressInfo: string | AddressInfo | null + addressInfo: MaybeAddressInfo ): addressInfo is AddressInfo => (addressInfo as AddressInfo).address !== undefined && (addressInfo as AddressInfo).family !== undefined && @@ -190,7 +205,7 @@ export class PositronProxy implements Disposable { // Start the proxy server. return this.startProxyServer( targetOrigin, - async (serverOrigin, proxyPath, url, contentType, responseBuffer) => { + async (_serverOrigin, proxyPath, _url, contentType, responseBuffer) => { // If this isn't 'text/html' content, just return the response buffer. if (!contentType.includes('text/html')) { return responseBuffer; @@ -228,7 +243,7 @@ export class PositronProxy implements Disposable { ); // Rewrite the URLs with the proxy path. - response = this.rewriteUrlsWithProxyPath(response, proxyPath); + response = rewriteUrlsWithProxyPath(response, proxyPath); // Return the response. return response; @@ -285,23 +300,21 @@ export class PositronProxy implements Disposable { */ startHttpProxyServer(targetOrigin: string): Promise { // Start the proxy server. - return this.startProxyServer( - targetOrigin, - async (serverOrigin, proxyPath, url, contentType, responseBuffer) => { - // If this isn't 'text/html' content, just return the response buffer. - if (!contentType.includes('text/html')) { - return responseBuffer; - } - - // Get the response. - let response = responseBuffer.toString('utf8'); - - // Rewrite the URLs with the proxy path. - response = this.rewriteUrlsWithProxyPath(response, proxyPath); + return this.startProxyServer(targetOrigin, htmlContentRewriter); + } - // Return the response. - return response; - }); + /** + * Starts an HTTP proxy server that is pending middleware setup. + * Use this instead of startHttpProxyServer if you need to set up a proxy in steps instead of + * all at once. For example, you may want to start the proxy server and pass the proxy path to + * an application framework, start the app and get the targetOrigin, and then add the middleware + * to the proxy server. + * @returns The pending proxy server info. + */ + startPendingHttpProxyServer(): Promise { + // Start the proxy server and return the pending proxy server info. The caller will need to + // call finishProxySetup to complete the proxy setup. + return this.startNewProxyServer(htmlContentRewriter); } //#endregion Public Methods @@ -312,103 +325,135 @@ export class PositronProxy implements Disposable { * Starts a proxy server. * @param targetOrigin The target origin. * @param contentRewriter The content rewriter. - * @returns The server origin. + * @returns The server origin, resolved to an external uri if applicable. */ - startProxyServer(targetOrigin: string, contentRewriter: ContentRewriter): Promise { - // Return a promise. - return new Promise((resolve, reject) => { - // See if we have an existing proxy server for target origin. If there is, return the - // server origin. - const proxyServer = this._proxyServers.get(targetOrigin); - if (proxyServer) { - resolve(proxyServer.serverOrigin); - return; - } + private async startProxyServer(targetOrigin: string, contentRewriter: ContentRewriter): Promise { + // See if we have an existing proxy server for target origin. If there is, return the + // server origin. + const proxyServer = this._proxyServers.get(targetOrigin); + if (proxyServer) { + console.debug(`Existing proxy server ${proxyServer.serverOrigin} found for target: ${targetOrigin}.`); + return proxyServer.serverOrigin; + } - // Create the app and start listening on a random port. - const app = express(); - const server = app.listen(0, HOST, async () => { - // Get the server address. - const address = server.address(); + let pendingProxy: PendingProxyServer; + try { + // We don't have an existing proxy server for the target origin, so start a new one. + pendingProxy = await this.startNewProxyServer(contentRewriter); + } catch (error) { + console.error(`Failed to start a proxy server for ${targetOrigin}.`); + throw error; + } - // Ensure that we have the address info of the server. - if (!isAddressInfo(address)) { - server.close(); - reject(); - return; - } + try { + // Finish setting up the proxy server. + await pendingProxy.finishProxySetup(targetOrigin); + } catch (error) { + console.error(`Failed to finish setting up the proxy server at ${pendingProxy.externalUri} for target: ${targetOrigin}.`); + throw error; + } - // Create the server origin. - const serverOrigin = `http://${address.address}:${address.port}`; + // Return the external URI. + return pendingProxy.externalUri.toString(); + } - // Add the proxy server. - this._proxyServers.set(targetOrigin, new ProxyServer( - serverOrigin, - targetOrigin, - server - )); - - // Convert the server origin to an external URI. - const originUri = vscode.Uri.parse(serverOrigin); - const externalUri = await vscode.env.asExternalUri(originUri); - - // Add the proxy middleware. - app.use('*', createProxyMiddleware({ - target: targetOrigin, - changeOrigin: true, - selfHandleResponse: true, - // Logging for development work. - // onProxyReq: (proxyReq, req, res, options) => { - // console.log(`Proxy request ${serverOrigin}${req.url} -> ${targetOrigin}${req.url}`); - // }, - onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, res) => { - // Get the URL and the content type. These must be present to call the - // content rewriter. Also, the scripts must be loaded. - const url = req.url; - const contentType = proxyRes.headers['content-type']; - if (!url || !contentType || !this._scriptsFileLoaded) { - // Don't process the response. - return responseBuffer; - } - - // Rewrite the content. - return contentRewriter(serverOrigin, externalUri.path, url, contentType, responseBuffer); - }) - })); - - // Resolve the server origin external URI. - resolve(externalUri.toString()); + /** + * Starts a proxy server that is pending middleware setup. + * This is used to create a server and app that will be used to add middleware later. + * @returns The server origin and the proxy path. + */ + private async startNewProxyServer(contentRewriter: ContentRewriter): Promise { + // Create the app and start listening on a random port. + const app = express(); + let address: MaybeAddressInfo; + const server = await new Promise((resolve, reject) => { + const srv = app.listen(0, HOST, () => { + // Get the server address. + address = srv.address(); + resolve(srv); }); + srv.on('error', reject); }); + + // Ensure the address is an AddressInfo. + if (!isAddressInfo(address)) { + server.close(); + throw new Error(`Failed to get the address info ${JSON.stringify(address)} for the server.`); + } + + // Create the server origin. + const serverOrigin = `http://${address.address}:${address.port}`; + + // Convert the server origin to an external URI. + const originUri = vscode.Uri.parse(serverOrigin); + const externalUri = await vscode.env.asExternalUri(originUri); + + // Return the pending proxy info. + return { + externalUri: externalUri, + proxyPath: externalUri.path, + finishProxySetup: (targetOrigin: string) => { + return this.finishProxySetup( + targetOrigin, + serverOrigin, + externalUri, + server, + app, + contentRewriter + ); + } + } satisfies PendingProxyServer; } /** - * Rewrites the URLs in the content. - * @param content The content. - * @param proxyPath The proxy path. - * @returns The content with the URLs rewritten. + * Finishes setting up the proxy server by adding the proxy middleware. + * @param targetOrigin The target origin. + * @param serverOrigin The server origin. + * @param externalUri The external URI. + * @param server The server. + * @param app The express app. + * @param contentRewriter The content rewriter. + * @returns A promise that resolves when the proxy setup is complete. */ - rewriteUrlsWithProxyPath(content: string, proxyPath: string): string { - // When running on Web, we need to prepend root-relative URLs with the proxy path, - // because the help proxy server is running at a different origin than the target origin. - // When running on Desktop, we don't need to do this, because the help proxy server is - // running at the same origin as the target origin (localhost). - if (vscode.env.uiKind === vscode.UIKind.Web) { - // Prepend root-relative URLs with the proxy path. The proxy path may look like - // /proxy/ or a different proxy path if an external uri is used. - return content.replace( - // This is icky and we should use a proper HTML parser, but it works for now. - // Possible sources of error are: whitespace differences, single vs. double - // quotes, etc., which are not covered in this regex. - // Regex translation: look for src="/ or href="/ and replace it with - // src=" or href=" respectively. - /(src|href)="\/([^"]+)"/g, - `$1="${proxyPath}/$2"` - ); - } + private async finishProxySetup( + targetOrigin: string, + serverOrigin: string, + externalUri: vscode.Uri, + server: Server, + app: express.Express, + contentRewriter: ContentRewriter + ) { + // Add the proxy server. + this._proxyServers.set(targetOrigin, new ProxyServer( + serverOrigin, + targetOrigin, + server + )); + + // Add the proxy middleware. + app.use('*', createProxyMiddleware({ + target: targetOrigin, + changeOrigin: true, + selfHandleResponse: true, + ws: true, + // Logging for development work. + // onProxyReq: (proxyReq, req, res, options) => { + // console.log(`Proxy request ${serverOrigin}${req.url} -> ${targetOrigin}${req.url}`); + // }, + onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, _res) => { + // Get the URL and the content type. These must be present to call the + // content rewriter. Also, the scripts must be loaded. + const url = req.url; + const contentType = proxyRes.headers['content-type']; + if (!url || !contentType || !this._scriptsFileLoaded) { + // Don't process the response. + return responseBuffer; + } - // Return the content as-is. - return content; + // Rewrite the content. + return contentRewriter(serverOrigin, externalUri.path, url, contentType, responseBuffer); + }) + })); } //#endregion Private Methods diff --git a/extensions/positron-proxy/src/util.ts b/extensions/positron-proxy/src/util.ts index cbf94901709..57fd374a5a4 100644 --- a/extensions/positron-proxy/src/util.ts +++ b/extensions/positron-proxy/src/util.ts @@ -3,6 +3,7 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ +import * as vscode from 'vscode'; /** * PromiseHandles is a class that represents a promise that can be resolved or @@ -22,3 +23,71 @@ export class PromiseHandles { }); } } + +/** + * A generic content rewriter for HTML content. + * @param _serverOrigin The server origin. + * @param proxyPath The proxy path. + * @param _url The URL. + * @param contentType The content type. + * @param responseBuffer The response buffer. + * @returns The rewritten response buffer. + */ +export async function htmlContentRewriter(_serverOrigin: string, proxyPath: string, _url: string, contentType: string, responseBuffer: Buffer) { + // If this isn't 'text/html' content, just return the response buffer. + if (!contentType.includes('text/html')) { + return responseBuffer; + } + + // Get the response. + let response = responseBuffer.toString('utf8'); + + // Rewrite the URLs with the proxy path. + response = rewriteUrlsWithProxyPath(response, proxyPath); + + // Return the response. + return response; +} + +/** +* Rewrites the URLs in the content. +* @param content The content. +* @param proxyPath The proxy path. +* @returns The content with the URLs rewritten. +*/ +export function rewriteUrlsWithProxyPath(content: string, proxyPath: string): string { + // When running on Web, we need to prepend root-relative URLs with the proxy path, + // because the help proxy server is running at a different origin than the target origin. + // When running on Desktop, we don't need to do this, because the help proxy server is + // running at the same origin as the target origin (localhost). + if (vscode.env.uiKind === vscode.UIKind.Web) { + // Prepend root-relative URLs with the proxy path. The proxy path may look like + // /proxy/ or a different proxy path if an external uri is used. + return content.replace( + // This is icky and we should use a proper HTML parser, but it works for now. + // Possible sources of error are: whitespace differences, single vs. double + // quotes, etc., which are not covered in this regex. + // Regex translation: look for src="/ or href="/ and replace it with + // src=" or href=" respectively. + /(src|href)="\/([^"]+)"/g, + (match, p1, p2, _offset, _string, _groups) => { + // Add a leading slash to the matched path which was removed by the regex. + const matchedPath = '/' + p2; + + // If the URL already starts with the proxy path, don't rewrite it. Some app + // frameworks may already have rewritten the URLs. + // Example: match = src="/proxy/1234/path/to/resource" + // p2 = "proxy/1234/path/to/resource" + if (matchedPath.startsWith(proxyPath)) { + return match; + } + + // Example: src="/path/to/resource" -> src="/proxy/1234/path/to/resource" + return `${p1}="${proxyPath}/${p2}"`; + } + ); + } + + // Return the content as-is. + return content; +} diff --git a/extensions/positron-python/src/client/positron-run-app.d.ts b/extensions/positron-python/src/client/positron-run-app.d.ts index ecf76e69601..1543bbfabe0 100644 --- a/extensions/positron-python/src/client/positron-run-app.d.ts +++ b/extensions/positron-python/src/client/positron-run-app.d.ts @@ -36,14 +36,12 @@ export interface RunAppOptions { * * @param runtime The language runtime metadata for the document's language. * @param document The document to run. - * @param port The port to run the application on, if known. * @param urlPrefix The URL prefix to use, if known. * @returns The terminal options for running the application. Return `undefined` to abort the run. */ getTerminalOptions: ( runtime: positron.LanguageRuntimeMetadata, document: vscode.TextDocument, - port?: string, urlPrefix?: string, ) => RunAppTerminalOptions | undefined | Promise; @@ -67,14 +65,12 @@ export interface DebugAppOptions { * * @param runtime The language runtime metadata for the document's language. * @param document The document to debug. - * @param port The port to run the application on, if known. * @param urlPrefix The URL prefix to use, if known. * @returns The debug configuration for debugging the application. Return `undefined` to abort debugging. */ getDebugConfiguration( runtime: positron.LanguageRuntimeMetadata, document: vscode.TextDocument, - port?: string, urlPrefix?: string, ): vscode.DebugConfiguration | undefined | Promise; diff --git a/extensions/positron-python/src/client/positron/webAppCommands.ts b/extensions/positron-python/src/client/positron/webAppCommands.ts index d5876b95f22..aa3b1d72076 100644 --- a/extensions/positron-python/src/client/positron/webAppCommands.ts +++ b/extensions/positron-python/src/client/positron/webAppCommands.ts @@ -16,43 +16,44 @@ import { Commands } from '../common/constants'; export function activateWebAppCommands(serviceContainer: IServiceContainer, disposables: vscode.Disposable[]): void { disposables.push( - registerExecCommand(Commands.Exec_Dash_In_Terminal, 'Dash', (_runtime, document, port, urlPrefix) => - getDashDebugConfig(document, port, urlPrefix), + registerExecCommand(Commands.Exec_Dash_In_Terminal, 'Dash', (_runtime, document, urlPrefix) => + getDashDebugConfig(document, urlPrefix), ), - registerExecCommand(Commands.Exec_FastAPI_In_Terminal, 'FastAPI', (runtime, document, port, urlPrefix) => - getFastAPIDebugConfig(serviceContainer, runtime, document, port, urlPrefix), + registerExecCommand( + Commands.Exec_FastAPI_In_Terminal, + 'FastAPI', + (runtime, document, _urlPrefix) => getFastAPIDebugConfig(serviceContainer, runtime, document), + '/docs', ), - registerExecCommand(Commands.Exec_Flask_In_Terminal, 'Flask', (_runtime, document, port, urlPrefix) => - getFlaskDebugConfig(document, port, urlPrefix), + registerExecCommand(Commands.Exec_Flask_In_Terminal, 'Flask', (_runtime, document, _urlPrefix) => + getFlaskDebugConfig(document), ), - registerExecCommand(Commands.Exec_Gradio_In_Terminal, 'Gradio', (_runtime, document, port, urlPrefix) => - getGradioDebugConfig(document, port, urlPrefix), + registerExecCommand(Commands.Exec_Gradio_In_Terminal, 'Gradio', (_runtime, document, urlPrefix) => + getGradioDebugConfig(document, urlPrefix), ), - registerExecCommand(Commands.Exec_Shiny_In_Terminal, 'Shiny', (_runtime, document, port, _urlPrefix) => - getShinyDebugConfig(document, port), + registerExecCommand(Commands.Exec_Shiny_In_Terminal, 'Shiny', (_runtime, document, _urlPrefix) => + getShinyDebugConfig(document), ), - registerExecCommand(Commands.Exec_Streamlit_In_Terminal, 'Streamlit', (_runtime, document, port, _urlPrefix) => - getStreamlitDebugConfig(document, port), + registerExecCommand(Commands.Exec_Streamlit_In_Terminal, 'Streamlit', (_runtime, document, _urlPrefix) => + getStreamlitDebugConfig(document), ), - registerDebugCommand(Commands.Debug_Dash_In_Terminal, 'Dash', (_runtime, document, port, urlPrefix) => - getDashDebugConfig(document, port, urlPrefix), + registerDebugCommand(Commands.Debug_Dash_In_Terminal, 'Dash', (_runtime, document, urlPrefix) => + getDashDebugConfig(document, urlPrefix), ), - registerDebugCommand(Commands.Debug_FastAPI_In_Terminal, 'FastAPI', (runtime, document, port, urlPrefix) => - getFastAPIDebugConfig(serviceContainer, runtime, document, port, urlPrefix), + registerDebugCommand(Commands.Debug_FastAPI_In_Terminal, 'FastAPI', (runtime, document, _urlPrefix) => + getFastAPIDebugConfig(serviceContainer, runtime, document), ), - registerDebugCommand(Commands.Debug_Flask_In_Terminal, 'Flask', (_runtime, document, port, urlPrefix) => - getFlaskDebugConfig(document, port, urlPrefix), + registerDebugCommand(Commands.Debug_Flask_In_Terminal, 'Flask', (_runtime, document, _urlPrefix) => + getFlaskDebugConfig(document), ), - registerDebugCommand(Commands.Debug_Gradio_In_Terminal, 'Gradio', (_runtime, document, port, urlPrefix) => - getGradioDebugConfig(document, port, urlPrefix), + registerDebugCommand(Commands.Debug_Gradio_In_Terminal, 'Gradio', (_runtime, document, urlPrefix) => + getGradioDebugConfig(document, urlPrefix), ), - registerDebugCommand(Commands.Debug_Shiny_In_Terminal, 'Shiny', (_runtime, document, port, _urlPrefix) => - getShinyDebugConfig(document, port), + registerDebugCommand(Commands.Debug_Shiny_In_Terminal, 'Shiny', (_runtime, document, _urlPrefix) => + getShinyDebugConfig(document), ), - registerDebugCommand( - Commands.Debug_Streamlit_In_Terminal, - 'Streamlit', - (_runtime, document, port, _urlPrefix) => getStreamlitDebugConfig(document, port), + registerDebugCommand(Commands.Debug_Streamlit_In_Terminal, 'Streamlit', (_runtime, document, _urlPrefix) => + getStreamlitDebugConfig(document), ), ); } @@ -63,7 +64,6 @@ function registerExecCommand( getDebugConfiguration: ( runtime: positron.LanguageRuntimeMetadata, document: vscode.TextDocument, - port?: string, urlPrefix?: string, ) => DebugConfiguration | undefined | Promise, urlPath?: string, @@ -72,8 +72,8 @@ function registerExecCommand( const runAppApi = await getPositronRunAppApi(); await runAppApi.runApplication({ name, - async getTerminalOptions(runtime, document, port, urlPrefix) { - const config = await getDebugConfiguration(runtime, document, port, urlPrefix); + async getTerminalOptions(runtime, document, urlPrefix) { + const config = await getDebugConfiguration(runtime, document, urlPrefix); if (!config) { return undefined; } @@ -108,7 +108,6 @@ function registerDebugCommand( getPythonDebugConfiguration: ( runtime: positron.LanguageRuntimeMetadata, document: vscode.TextDocument, - port?: string, urlPrefix?: string, ) => DebugConfiguration | undefined | Promise, ): vscode.Disposable { @@ -116,8 +115,8 @@ function registerDebugCommand( const runAppApi = await getPositronRunAppApi(); await runAppApi.debugApplication({ name, - async getDebugConfiguration(runtime, document, port, urlPrefix) { - const config = await getPythonDebugConfiguration(runtime, document, port, urlPrefix); + async getDebugConfiguration(runtime, document, urlPrefix) { + const config = await getPythonDebugConfiguration(runtime, document, urlPrefix); if (!config) { return undefined; } @@ -149,15 +148,13 @@ interface ProgramDebugConfiguration extends BaseDebugConfiguration { type DebugConfiguration = ModuleDebugConfiguration | ProgramDebugConfiguration; -function getDashDebugConfig(document: vscode.TextDocument, port?: string, urlPrefix?: string): DebugConfiguration { +function getDashDebugConfig(document: vscode.TextDocument, urlPrefix?: string): DebugConfiguration { const env: { [key: string]: string | null | undefined } = { PYTHONPATH: path.dirname(document.uri.fsPath), }; - if (port) { - env.DASH_PORT = port; - } if (urlPrefix) { - env.DASH_URL_PREFIX = urlPrefix; + // Note that this will result in the app being run at http://localhost:APP_PORT/proxy/PROXY_PORT/ + env.DASH_URL_BASE_PATHNAME = urlPrefix; } return { program: document.uri.fsPath, env }; @@ -167,8 +164,6 @@ async function getFastAPIDebugConfig( serviceContainer: IServiceContainer, runtime: positron.LanguageRuntimeMetadata, document: vscode.TextDocument, - port?: string, - urlPrefix?: string, ): Promise { let mod: string | undefined; let args: string[]; @@ -184,13 +179,6 @@ async function getFastAPIDebugConfig( args = ['--reload', `${pathToModule(document.uri.fsPath)}:${appName}`]; } - if (port) { - args.push('--port', port); - } - if (urlPrefix) { - args.push('--root-path', urlPrefix); - } - return { module: mod, args }; } @@ -204,42 +192,30 @@ async function isFastAPICLIInstalled(serviceContainer: IServiceContainer, python return installer.isInstalled(Product.fastapiCli, interpreter); } -function getFlaskDebugConfig(document: vscode.TextDocument, port?: string, urlPrefix?: string): DebugConfiguration { +function getFlaskDebugConfig(document: vscode.TextDocument): DebugConfiguration { const args = ['--app', document.uri.fsPath, 'run']; - if (port) { - args.push('--port', port); - } - const env: { [key: string]: string } = {}; - if (urlPrefix) { - env.SCRIPT_NAME = urlPrefix; - } - return { module: 'flask', args, env }; + return { module: 'flask', args }; } -function getGradioDebugConfig(document: vscode.TextDocument, port?: string, urlPrefix?: string): DebugConfiguration { +function getGradioDebugConfig(document: vscode.TextDocument, urlPrefix?: string): DebugConfiguration { const env: { [key: string]: string } = {}; - if (port) { - env.GRADIO_SERVER_PORT = port; - } if (urlPrefix) { + // Gradio doc: https://www.gradio.app/guides/environment-variables#7-gradio-root-path + // Issue with Gradio not loading assets when Gradio is run via proxy: + // https://github.com/gradio-app/gradio/issues/9529 + // Gradio works if we use these versions: gradio==3.3.1 fastapi==0.85.2 httpx==0.24.1 env.GRADIO_ROOT_PATH = urlPrefix; } return { program: document.uri.fsPath, env }; } -function getShinyDebugConfig(document: vscode.TextDocument, port?: string): DebugConfiguration { +function getShinyDebugConfig(document: vscode.TextDocument): DebugConfiguration { const args = ['run', '--reload', document.uri.fsPath]; - if (port) { - args.push('--port', port); - } return { module: 'shiny', args }; } -function getStreamlitDebugConfig(document: vscode.TextDocument, port?: string): DebugConfiguration { +function getStreamlitDebugConfig(document: vscode.TextDocument): DebugConfiguration { const args = ['run', document.uri.fsPath, '--server.headless', 'true']; - if (port) { - args.push('--port', port); - } return { module: 'streamlit', args }; } diff --git a/extensions/positron-python/src/test/positron/webAppCommands.unit.test.ts b/extensions/positron-python/src/test/positron/webAppCommands.unit.test.ts index b7df5900c8c..adf9e16cd48 100644 --- a/extensions/positron-python/src/test/positron/webAppCommands.unit.test.ts +++ b/extensions/positron-python/src/test/positron/webAppCommands.unit.test.ts @@ -23,7 +23,6 @@ suite('Web app commands', () => { const runtimePath = path.join('path', 'to', 'python'); const workspacePath = path.join('path', 'to', 'workspace'); const documentPath = path.join(workspacePath, 'file.py'); - const port = '8080'; const urlPrefix = 'http://new-url-prefix'; const disposables: IDisposableRegistry = []; @@ -124,7 +123,7 @@ suite('Web app commands', () => { async function verifyRunAppCommand( command: string, expectedTerminalOptions: RunAppTerminalOptions | undefined, - options?: { documentText?: string; port?: string; urlPrefix?: string }, + options?: { documentText?: string; urlPrefix?: string }, ) { // Call the command callback and ensure that it sets runAppOptions. const callback = commands.get(command); @@ -140,34 +139,28 @@ suite('Web app commands', () => { return options?.documentText ?? ''; }, } as vscode.TextDocument; - const terminalOptions = await runAppOptions.getTerminalOptions( - runtime, - document, - options?.port, - options?.urlPrefix, - ); + const terminalOptions = await runAppOptions.getTerminalOptions(runtime, document, options?.urlPrefix); assert.deepStrictEqual(terminalOptions, expectedTerminalOptions); } - test('Exec Dash in terminal - without port and urlPrefix', async () => { + test('Exec Dash in terminal - without urlPrefix', async () => { await verifyRunAppCommand(Commands.Exec_Dash_In_Terminal, { commandLine: `${runtimePath} ${documentPath}`, env: { PYTHONPATH: workspacePath }, }); }); - test('Exec Dash in terminal - with port and urlPrefix', async () => { + test('Exec Dash in terminal - with urlPrefix', async () => { await verifyRunAppCommand( Commands.Exec_Dash_In_Terminal, { commandLine: `${runtimePath} ${documentPath}`, env: { PYTHONPATH: workspacePath, - DASH_PORT: port, - DASH_URL_PREFIX: urlPrefix, + DASH_URL_BASE_PATHNAME: urlPrefix, }, }, - { port, urlPrefix }, + { urlPrefix }, ); }); @@ -197,84 +190,83 @@ suite('Web app commands', () => { await verifyRunAppCommand(Commands.Exec_FastAPI_In_Terminal, undefined); }); - test('Exec FastAPI in terminal - with port and urlPrefix', async () => { + test('Exec FastAPI in terminal - with urlPrefix', async () => { await verifyRunAppCommand( Commands.Exec_FastAPI_In_Terminal, - { commandLine: `${runtimePath} -m fastapi dev ${documentPath} --port ${port} --root-path ${urlPrefix}` }, - { port, urlPrefix }, + { commandLine: `${runtimePath} -m fastapi dev ${documentPath}` }, + { urlPrefix }, ); }); - test('Exec Flask in terminal - without port and urlPrefix', async () => { + test('Exec Flask in terminal - without urlPrefix', async () => { await verifyRunAppCommand(Commands.Exec_Flask_In_Terminal, { commandLine: `${runtimePath} -m flask --app ${documentPath} run`, }); }); - test('Exec Flask in terminal - with port and urlPrefix', async () => { + test('Exec Flask in terminal - with urlPrefix', async () => { await verifyRunAppCommand( Commands.Exec_Flask_In_Terminal, { - commandLine: `${runtimePath} -m flask --app ${documentPath} run --port ${port}`, - env: { SCRIPT_NAME: urlPrefix }, + commandLine: `${runtimePath} -m flask --app ${documentPath} run`, }, - { port, urlPrefix }, + { urlPrefix }, ); }); - test('Exec Gradio in terminal - without port and urlPrefix', async () => { + test('Exec Gradio in terminal - without urlPrefix', async () => { await verifyRunAppCommand(Commands.Exec_Gradio_In_Terminal, { commandLine: `${runtimePath} ${documentPath}`, }); }); - test('Exec Gradio in terminal - with port and urlPrefix', async () => { + test('Exec Gradio in terminal - with urlPrefix', async () => { await verifyRunAppCommand( Commands.Exec_Gradio_In_Terminal, { commandLine: `${runtimePath} ${documentPath}`, - env: { GRADIO_SERVER_PORT: port, GRADIO_ROOT_PATH: urlPrefix }, + env: { GRADIO_ROOT_PATH: urlPrefix }, }, - { port, urlPrefix }, + { urlPrefix }, ); }); - test('Exec Shiny in terminal - without port and urlPrefix', async () => { + test('Exec Shiny in terminal - without urlPrefix', async () => { await verifyRunAppCommand(Commands.Exec_Shiny_In_Terminal, { commandLine: `${runtimePath} -m shiny run --reload ${documentPath}`, }); }); - test('Exec Shiny in terminal - with port and urlPrefix', async () => { + test('Exec Shiny in terminal - with urlPrefix', async () => { await verifyRunAppCommand( Commands.Exec_Shiny_In_Terminal, { - commandLine: `${runtimePath} -m shiny run --reload ${documentPath} --port ${port}`, + commandLine: `${runtimePath} -m shiny run --reload ${documentPath}`, }, - { port, urlPrefix }, + { urlPrefix }, ); }); - test('Exec Streamlit in terminal - without port and urlPrefix', async () => { + test('Exec Streamlit in terminal - without urlPrefix', async () => { await verifyRunAppCommand(Commands.Exec_Streamlit_In_Terminal, { commandLine: `${runtimePath} -m streamlit run ${documentPath} --server.headless true`, }); }); - test('Exec Streamlit in terminal - with port and urlPrefix', async () => { + test('Exec Streamlit in terminal - with urlPrefix', async () => { await verifyRunAppCommand( Commands.Exec_Streamlit_In_Terminal, { - commandLine: `${runtimePath} -m streamlit run ${documentPath} --server.headless true --port ${port}`, + commandLine: `${runtimePath} -m streamlit run ${documentPath} --server.headless true`, }, - { port, urlPrefix }, + { urlPrefix }, ); }); async function verifyDebugAppCommand( command: string, expectedDebugConfig: vscode.DebugConfiguration | undefined, - options?: { documentText?: string; port?: string; urlPrefix?: string }, + options?: { documentText?: string; urlPrefix?: string }, ) { // Call the command callback and ensure that it sets runAppOptions. const callback = commands.get(command); @@ -290,16 +282,11 @@ suite('Web app commands', () => { return options?.documentText ?? ''; }, } as vscode.TextDocument; - const terminalOptions = await debugAppOptions.getDebugConfiguration( - runtime, - document, - options?.port, - options?.urlPrefix, - ); + const terminalOptions = await debugAppOptions.getDebugConfiguration(runtime, document, options?.urlPrefix); assert.deepStrictEqual(terminalOptions, expectedDebugConfig); } - test('Debug Dash in terminal - with port and urlPrefix', async () => { + test('Debug Dash in terminal - with urlPrefix', async () => { await verifyDebugAppCommand( Commands.Debug_Dash_In_Terminal, { @@ -309,13 +296,13 @@ suite('Web app commands', () => { jinja: true, stopOnEntry: false, program: documentPath, - env: { PYTHONPATH: workspacePath, DASH_PORT: port, DASH_URL_PREFIX: urlPrefix }, + env: { PYTHONPATH: workspacePath, DASH_URL_BASE_PATHNAME: urlPrefix }, }, - { port, urlPrefix }, + { urlPrefix }, ); }); - test('Debug FastAPI in terminal - with port and urlPrefix', async () => { + test('Debug FastAPI in terminal - with urlPrefix', async () => { await verifyDebugAppCommand( Commands.Debug_FastAPI_In_Terminal, { @@ -325,13 +312,13 @@ suite('Web app commands', () => { jinja: true, stopOnEntry: false, module: 'fastapi', - args: ['dev', documentPath, '--port', port, '--root-path', urlPrefix], + args: ['dev', documentPath], }, - { port, urlPrefix }, + { urlPrefix }, ); }); - test('Debug Flask in terminal - without port and urlPrefix', async () => { + test('Debug Flask in terminal - without urlPrefix', async () => { await verifyDebugAppCommand( Commands.Debug_Flask_In_Terminal, { @@ -341,14 +328,13 @@ suite('Web app commands', () => { jinja: true, stopOnEntry: false, module: 'flask', - args: ['--app', documentPath, 'run', '--port', port], - env: { SCRIPT_NAME: urlPrefix }, + args: ['--app', documentPath, 'run'], }, - { port, urlPrefix }, + { urlPrefix }, ); }); - test('Debug Gradio in terminal - without port and urlPrefix', async () => { + test('Debug Gradio in terminal - without urlPrefix', async () => { await verifyDebugAppCommand( Commands.Debug_Gradio_In_Terminal, { @@ -358,13 +344,13 @@ suite('Web app commands', () => { jinja: true, stopOnEntry: false, program: documentPath, - env: { GRADIO_SERVER_PORT: port, GRADIO_ROOT_PATH: urlPrefix }, + env: { GRADIO_ROOT_PATH: urlPrefix }, }, - { port, urlPrefix }, + { urlPrefix }, ); }); - test('Debug Shiny in terminal - with port and urlPrefix', async () => { + test('Debug Shiny in terminal - with purlPrefix', async () => { await verifyDebugAppCommand( Commands.Debug_Shiny_In_Terminal, { @@ -374,13 +360,13 @@ suite('Web app commands', () => { jinja: true, stopOnEntry: false, module: 'shiny', - args: ['run', '--reload', documentPath, '--port', port], + args: ['run', '--reload', documentPath], }, - { port, urlPrefix }, + { urlPrefix }, ); }); - test('Debug Streamlit in terminal - with port and urlPrefix', async () => { + test('Debug Streamlit in terminal - with urlPrefix', async () => { await verifyDebugAppCommand( Commands.Debug_Streamlit_In_Terminal, { @@ -390,9 +376,9 @@ suite('Web app commands', () => { jinja: true, stopOnEntry: false, module: 'streamlit', - args: ['run', documentPath, '--server.headless', 'true', '--port', port], + args: ['run', documentPath, '--server.headless', 'true'], }, - { port, urlPrefix }, + { urlPrefix }, ); }); }); diff --git a/extensions/positron-run-app/src/api.ts b/extensions/positron-run-app/src/api.ts index cb0d22c2f82..64e7f6cef65 100644 --- a/extensions/positron-run-app/src/api.ts +++ b/extensions/positron-run-app/src/api.ts @@ -9,9 +9,16 @@ import * as vscode from 'vscode'; import { DebugAdapterTrackerFactory } from './debugAdapterTrackerFactory'; import { Config, log } from './extension'; import { DebugAppOptions, PositronRunApp, RunAppOptions } from './positron-run-app'; -import { raceTimeout, SequencerByKey } from './utils'; +import { raceTimeout, removeAnsiEscapeCodes, SequencerByKey } from './utils'; -const localUrlRegex = /http:\/\/(localhost|127\.0\.0\.1):(\d{1,5})/; +// Regex to match a URL with the format http://localhost:1234/path +const localUrlRegex = /http:\/\/(localhost|127\.0\.0\.1):(\d{1,5})(\/[^\s]*)?/; + +type PositronProxyInfo = { + proxyPath: string; + externalUri: vscode.Uri; + finishProxySetup: (targetOrigin: string) => Promise; +}; export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable { private readonly _debugApplicationSequencerByName = new SequencerByKey(); @@ -82,12 +89,12 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable } // Get the terminal options for the application. - // TODO: If we're in Posit Workbench find a free port and corresponding URL prefix. - // Some application frameworks need to know the URL prefix when running behind a proxy. progress.report({ message: vscode.l10n.t('Getting terminal options...') }); - const port = undefined; - const urlPrefix = undefined; - const terminalOptions = await options.getTerminalOptions(runtime, document, port, urlPrefix); + // Start the proxy server + const proxyInfo = await vscode.commands.executeCommand('positronProxy.startPendingProxyServer'); + log.debug(`Proxy started for app at proxy path ${proxyInfo.proxyPath} with uri ${proxyInfo.externalUri.toString()}`); + const urlPrefix = proxyInfo.proxyPath; + const terminalOptions = await options.getTerminalOptions(runtime, document, urlPrefix); if (!terminalOptions) { return; } @@ -146,7 +153,7 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable await this.setShellIntegrationSupported(true); if (e.terminal === terminal) { - const didPreviewUrl = await previewUrlInExecutionOutput(e.execution, options.urlPath); + const didPreviewUrl = await previewUrlInExecutionOutput(e.execution, proxyInfo, options.urlPath); if (didPreviewUrl) { resolve(didPreviewUrl); } @@ -215,12 +222,11 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable } // Get the debug config for the application. - // TODO: If we're in Posit Workbench find a free port and corresponding URL prefix. - // Some application frameworks need to know the URL prefix when running behind a proxy. progress.report({ message: vscode.l10n.t('Getting debug configuration...') }); - const port = undefined; - const urlPrefix = undefined; - const debugConfig = await options.getDebugConfiguration(runtime, document, port, urlPrefix); + // Start the proxy server + const proxyInfo = await vscode.commands.executeCommand('positronProxy.startPendingProxyServer'); + const urlPrefix = proxyInfo.proxyPath; + const debugConfig = await options.getDebugConfiguration(runtime, document, urlPrefix); if (!debugConfig) { return; } @@ -257,7 +263,7 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable await this.setShellIntegrationSupported(true); if (await e.terminal.processId === processId) { - const didPreviewUrl = await previewUrlInExecutionOutput(e.execution, options.urlPath); + const didPreviewUrl = await previewUrlInExecutionOutput(e.execution, proxyInfo, options.urlPath); if (didPreviewUrl) { resolve(didPreviewUrl); } @@ -322,22 +328,24 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable } } -async function previewUrlInExecutionOutput(execution: vscode.TerminalShellExecution, urlPath?: string) { +async function previewUrlInExecutionOutput(execution: vscode.TerminalShellExecution, proxyInfo: PositronProxyInfo, urlPath?: string) { // Wait for the server URL to appear in the terminal output, or a timeout. const stream = execution.read(); const url = await raceTimeout( (async () => { for await (const data of stream) { log.warn('Execution:', execution.commandLine.value, data); - const match = data.match(localUrlRegex)?.[0]; + // Ansi escape codes seem to mess up the regex match on Windows, so remove them first. + const dataCleaned = removeAnsiEscapeCodes(data); + const match = dataCleaned.match(localUrlRegex)?.[0]; if (match) { - return new URL(match); + return new URL(match.trim()); } } log.warn('URL not found in terminal output'); - return false; + return undefined; })(), - 5_000, + 15_000, ); if (url === undefined) { @@ -349,10 +357,14 @@ async function previewUrlInExecutionOutput(execution: vscode.TerminalShellExecut const localBaseUri = vscode.Uri.parse(url.toString()); const localUri = urlPath ? vscode.Uri.joinPath(localBaseUri, urlPath) : localBaseUri; - const externalUri = await vscode.env.asExternalUri(localUri); + + log.debug(`Viewing app at local uri: ${localUri} with external uri ${proxyInfo.externalUri.toString()}`); + + // Finish the Positron proxy setup so that proxy middleware is hooked up. + await proxyInfo.finishProxySetup(localUri.toString()); // Preview the external URI. - positron.window.previewUrl(externalUri); + positron.window.previewUrl(proxyInfo.externalUri); return true; } diff --git a/extensions/positron-run-app/src/positron-run-app.d.ts b/extensions/positron-run-app/src/positron-run-app.d.ts index e3bd4eb2acb..989b72cf331 100644 --- a/extensions/positron-run-app/src/positron-run-app.d.ts +++ b/extensions/positron-run-app/src/positron-run-app.d.ts @@ -36,14 +36,12 @@ export interface RunAppOptions { * * @param runtime The language runtime metadata for the document's language. * @param document The document to run. - * @param port The port to run the application on, if known. * @param urlPrefix The URL prefix to use, if known. * @returns The terminal options for running the application. Return `undefined` to abort the run. */ getTerminalOptions: ( runtime: positron.LanguageRuntimeMetadata, document: vscode.TextDocument, - port?: string, urlPrefix?: string, ) => RunAppTerminalOptions | undefined | Promise; @@ -67,14 +65,12 @@ export interface DebugAppOptions { * * @param runtime The language runtime metadata for the document's language. * @param document The document to debug. - * @param port The port to run the application on, if known. * @param urlPrefix The URL prefix to use, if known. * @returns The debug configuration for debugging the application. Return `undefined` to abort debugging. */ getDebugConfiguration( runtime: positron.LanguageRuntimeMetadata, document: vscode.TextDocument, - port?: string, urlPrefix?: string, ): vscode.DebugConfiguration | undefined | Promise; diff --git a/extensions/positron-run-app/src/test/api.test.ts b/extensions/positron-run-app/src/test/api.test.ts index bee2414007b..34b7a97e061 100644 --- a/extensions/positron-run-app/src/test/api.test.ts +++ b/extensions/positron-run-app/src/test/api.test.ts @@ -21,7 +21,7 @@ suite('PositronRunApp', () => { // Options for running the test application. const runAppOptions: RunAppOptions = { name: 'Test App', - getTerminalOptions(runtime, document, _port, _urlPrefix) { + getTerminalOptions(runtime, document, _urlPrefix) { return { commandLine: [runtime.runtimePath, document.uri.fsPath].join(' '), }; @@ -31,7 +31,7 @@ suite('PositronRunApp', () => { // Options for debugging the test application. const debugAppOptions: DebugAppOptions = { name: 'Test App', - getDebugConfiguration(_runtime, document, _port, _urlPrefix) { + getDebugConfiguration(_runtime, document, _urlPrefix) { return { name: 'Launch Test App', type: 'node', @@ -64,6 +64,15 @@ suite('PositronRunApp', () => { // Stub the runtime API to return the test runtime. sinon.stub(positron.runtime, 'getPreferredRuntime').callsFake(async (_languageId) => runtime); + // Stub the positron proxy API. + sinon.stub(vscode.commands, 'executeCommand') + .withArgs('positronProxy.startPendingProxyServer') + .resolves({ + proxyPath: '/proxy/path', + externalUri: vscode.Uri.parse('http://localhost:1234'), + finishProxySetup: () => {}, + }); + // Stub the preview URL function. previewUrlStub = sinon.stub(positron.window, 'previewUrl'); diff --git a/extensions/positron-run-app/src/utils.ts b/extensions/positron-run-app/src/utils.ts index a6b75c91513..7159de87953 100644 --- a/extensions/positron-run-app/src/utils.ts +++ b/extensions/positron-run-app/src/utils.ts @@ -45,3 +45,15 @@ export class SequencerByKey { return newPromise; } } + +/* Utilities copied from ../../../src/vs/base/common/strings.ts */ + +const CSI_SEQUENCE = /(?:(?:\x1b\[|\x9B)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~])|(:?\x1b\].*?\x07)/g; + +export function removeAnsiEscapeCodes(str: string): string { + if (str) { + str = str.replace(CSI_SEQUENCE, ''); + } + + return str; +} diff --git a/src/vs/code/browser/workbench/workbench.ts b/src/vs/code/browser/workbench/workbench.ts index 5883e2d6f1a..b5c0d35055b 100644 --- a/src/vs/code/browser/workbench/workbench.ts +++ b/src/vs/code/browser/workbench/workbench.ts @@ -616,10 +616,17 @@ function readCookie(name: string): string | undefined { .replace('/p/', '/proxy/') .replace('{{port}}', localhostMatch.port.toString()); } - // Update the authority and path of the URI to point to the proxy server. This - // retains the original query and fragment, while updating the authority and - // path to the proxy server. + + // Use the same scheme as the main window, to ensure that the proxy server is + // accessed using http if the main window is accessed using http or https if the + // main window is accessed using https. Otherwise we'll get a mixed content error. + // We need to slice the protocol to remove the colon at the end. + const resolvedScheme = mainWindow.location.protocol.slice(0, -1); + + // Update the URI to point to the proxy server. This retains the original query + // and fragment, while updating the scheme, authority and path to the proxy server. resolvedUri = resolvedUri.with({ + scheme: resolvedScheme, authority: mainWindow.location.host, path: join(mainWindow.location.pathname, renderedTemplate, resolvedUri.path), });