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), });