diff --git a/extensions/positron-proxy/src/extension.ts b/extensions/positron-proxy/src/extension.ts index ef7486411f7..fc906ef37ff 100644 --- a/extensions/positron-proxy/src/extension.ts +++ b/extensions/positron-proxy/src/extension.ts @@ -12,6 +12,11 @@ import { PositronProxy } from './positronProxy'; */ export type ProxyServerStyles = { readonly [key: string]: string | number }; +/** + * Positron Proxy log output channel. + */ +export const log = vscode.window.createOutputChannel('Positron Proxy', { log: true }); + /** * Activates the extension. * @param context An ExtensionContext that contains the extention context. @@ -20,6 +25,9 @@ export function activate(context: vscode.ExtensionContext) { // Create the PositronProxy object. const positronProxy = new PositronProxy(context); + // Create the log output channel. + context.subscriptions.push(log); + // Register the positronProxy.startHelpProxyServer 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 9fece87370e..a02809209f5 100644 --- a/extensions/positron-proxy/src/positronProxy.ts +++ b/extensions/positron-proxy/src/positronProxy.ts @@ -8,7 +8,8 @@ import fs = require('fs'); import path = require('path'); import express from 'express'; import { AddressInfo, Server } from 'net'; -import { ProxyServerStyles } from './extension'; +import { log, ProxyServerStyles } from './extension'; +// eslint-disable-next-line no-duplicate-imports import { Disposable, ExtensionContext } from 'vscode'; import { createProxyMiddleware, responseInterceptor } from 'http-proxy-middleware'; import { HtmlProxyServer } from './htmlProxy'; @@ -176,7 +177,7 @@ export class PositronProxy implements Disposable { this._helpStyleOverrides !== undefined && this._helpScript !== undefined; } catch (error) { - console.log(`Failed to load the resources/scripts_help.html file.`); + log.error(`Failed to load the resources/scripts_help.html file: ${JSON.stringify(error)}`); } } @@ -202,6 +203,8 @@ export class PositronProxy implements Disposable { * @returns The server origin. */ startHelpProxyServer(targetOrigin: string): Promise { + log.debug(`Starting a help proxy server for target: ${targetOrigin}...`); + // Start the proxy server. return this.startProxyServer( targetOrigin, @@ -257,6 +260,8 @@ export class PositronProxy implements Disposable { * stopped. */ stopProxyServer(targetOrigin: string): boolean { + log.debug(`Stopping proxy server for target: ${targetOrigin}...`); + // See if we have a proxy server for the target origin. If we do, stop it. const proxyServer = this._proxyServers.get(targetOrigin); if (proxyServer) { @@ -278,6 +283,8 @@ export class PositronProxy implements Disposable { * @returns The server URL. */ async startHtmlProxyServer(targetPath: string) { + log.debug(`Starting an HTML proxy server for target: ${targetPath}...`); + if (!this._htmlProxyServer) { this._htmlProxyServer = new HtmlProxyServer(); } @@ -299,6 +306,7 @@ export class PositronProxy implements Disposable { * @returns The server origin. */ startHttpProxyServer(targetOrigin: string): Promise { + log.debug(`Starting an HTTP proxy server for target: ${targetOrigin}...`); // Start the proxy server. return this.startProxyServer(targetOrigin, htmlContentRewriter); } @@ -312,6 +320,7 @@ export class PositronProxy implements Disposable { * @returns The pending proxy server info. */ startPendingHttpProxyServer(): Promise { + log.debug('Starting a pending HTTP proxy server...'); // 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); @@ -332,7 +341,7 @@ export class PositronProxy implements Disposable { // server origin. const proxyServer = this._proxyServers.get(targetOrigin); if (proxyServer) { - console.debug(`Existing proxy server ${proxyServer.serverOrigin} found for target: ${targetOrigin}.`); + log.debug(`Existing proxy server ${proxyServer.serverOrigin} found for target: ${targetOrigin}.`); return proxyServer.serverOrigin; } @@ -341,20 +350,21 @@ export class PositronProxy implements Disposable { // 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}.`); + log.error(`Failed to start a proxy server for ${targetOrigin}: ${JSON.stringify(error)}`); throw error; } + const externalUri = pendingProxy.externalUri.toString(true); 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}.`); + log.error(`Failed to finish setting up the proxy server at ${externalUri} for target ${targetOrigin}: ${JSON.stringify(error)}`); throw error; } // Return the external URI. - return pendingProxy.externalUri.toString(); + return externalUri; } /** @@ -377,8 +387,10 @@ export class PositronProxy implements Disposable { // Ensure the address is an AddressInfo. if (!isAddressInfo(address)) { + const error = `Failed to get the address info ${JSON.stringify(address)} for the server.`; + log.error(error); server.close(); - throw new Error(`Failed to get the address info ${JSON.stringify(address)} for the server.`); + throw new Error(error); } // Create the server origin. @@ -388,6 +400,8 @@ export class PositronProxy implements Disposable { const originUri = vscode.Uri.parse(serverOrigin); const externalUri = await vscode.env.asExternalUri(originUri); + log.debug(`Started proxy server at ${serverOrigin} for external URI ${externalUri.toString(true)}.`); + // Return the pending proxy info. return { externalUri: externalUri, @@ -423,6 +437,11 @@ export class PositronProxy implements Disposable { app: express.Express, contentRewriter: ContentRewriter ) { + log.debug(`Finishing proxy server setup for target ${targetOrigin}\n` + + `\tserverOrigin: ${serverOrigin}\n` + + `\texternalUri: ${externalUri.toString(true)}` + ); + // Add the proxy server. this._proxyServers.set(targetOrigin, new ProxyServer( serverOrigin, @@ -436,16 +455,30 @@ export class PositronProxy implements Disposable { 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}`); - // }, + onProxyReq: (proxyReq, req, _res, _options) => { + log.trace(`onProxyReq - proxy request ${serverOrigin}${req.url} -> ${targetOrigin}${req.url}` + + `\n\tmethod: ${proxyReq.method}` + + `\n\tprotocol: ${proxyReq.protocol}` + + `\n\thost: ${proxyReq.host}` + + `\n\turl: ${proxyReq.path}` + + `\n\theaders: ${JSON.stringify(proxyReq.getHeaders())}` + + `\n\texternal uri: ${externalUri.toString(true)}` + ); + }, onProxyRes: responseInterceptor(async (responseBuffer, proxyRes, req, _res) => { + log.trace(`onProxyRes - proxy response ${targetOrigin}${req.url} -> ${serverOrigin}${req.url}` + + `\n\tstatus: ${proxyRes.statusCode}` + + `\n\tstatusMessage: ${proxyRes.statusMessage}` + + `\n\theaders: ${JSON.stringify(proxyRes.headers)}` + + `\n\texternal uri: ${externalUri.toString(true)}` + ); + // 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) { + log.trace(`onProxyRes - skipping response processing for ${serverOrigin}${url}`); // Don't process the response. return responseBuffer; } diff --git a/extensions/positron-run-app/package.json b/extensions/positron-run-app/package.json index d1553c7c868..171331576b1 100644 --- a/extensions/positron-run-app/package.json +++ b/extensions/positron-run-app/package.json @@ -39,7 +39,8 @@ "@types/mocha": "^10.0.8", "@types/node": "^22.5.4", "@types/sinon": "^17.0.3", - "@types/sinon-test": "^2.4.6" + "@types/sinon-test": "^2.4.6", + "typescript": "^4.5.5" }, "repository": { "type": "git", diff --git a/extensions/positron-run-app/src/api.ts b/extensions/positron-run-app/src/api.ts index 64e7f6cef65..f2733ad3286 100644 --- a/extensions/positron-run-app/src/api.ts +++ b/extensions/positron-run-app/src/api.ts @@ -14,6 +14,9 @@ import { raceTimeout, removeAnsiEscapeCodes, SequencerByKey } from './utils'; // Regex to match a URL with the format http://localhost:1234/path const localUrlRegex = /http:\/\/(localhost|127\.0\.0\.1):(\d{1,5})(\/[^\s]*)?/; +const isPositronWeb = vscode.env.uiKind === vscode.UIKind.Web; +const isRunningOnPwb = !!process.env.RS_SERVER_URL && isPositronWeb; + type PositronProxyInfo = { proxyPath: string; externalUri: vscode.Uri; @@ -88,12 +91,18 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable return; } + // Set up the proxy server for the application if applicable. + let urlPrefix = undefined; + let proxyInfo: PositronProxyInfo | undefined; + if (shouldUsePositronProxy(options.name)) { + // Start the proxy server + proxyInfo = await vscode.commands.executeCommand('positronProxy.startPendingProxyServer'); + log.debug(`Proxy started for app at proxy path ${proxyInfo.proxyPath} with uri ${proxyInfo.externalUri.toString()}`); + urlPrefix = proxyInfo.proxyPath; + } + // Get the terminal options for the application. progress.report({ message: vscode.l10n.t('Getting terminal options...') }); - // 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; @@ -221,11 +230,18 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable return; } + // Set up the proxy server for the application if applicable. + let urlPrefix = undefined; + let proxyInfo: PositronProxyInfo | undefined; + if (shouldUsePositronProxy(options.name)) { + // Start the proxy server + proxyInfo = await vscode.commands.executeCommand('positronProxy.startPendingProxyServer'); + log.debug(`Proxy started for app at proxy path ${proxyInfo.proxyPath} with uri ${proxyInfo.externalUri.toString()}`); + urlPrefix = proxyInfo.proxyPath; + } + // Get the debug config for the application. progress.report({ message: vscode.l10n.t('Getting debug configuration...') }); - // 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; @@ -328,7 +344,7 @@ export class PositronRunAppApiImpl implements PositronRunApp, vscode.Disposable } } -async function previewUrlInExecutionOutput(execution: vscode.TerminalShellExecution, proxyInfo: PositronProxyInfo, 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( @@ -353,18 +369,33 @@ async function previewUrlInExecutionOutput(execution: vscode.TerminalShellExecut return false; } - // Convert the url to an external URI. + // Example: http://localhost:8500 const localBaseUri = vscode.Uri.parse(url.toString()); + + // Example: http://localhost:8500/url/path or http://localhost:8500 const localUri = urlPath ? vscode.Uri.joinPath(localBaseUri, urlPath) : localBaseUri; - log.debug(`Viewing app at local uri: ${localUri} with external uri ${proxyInfo.externalUri.toString()}`); + // Example: http://localhost:8080/proxy/5678/url/path or http://localhost:8080/proxy/5678 + let previewUri = undefined; + if (proxyInfo) { + // On Web (specifically Positron Server Web and not PWB), we need to set up the proxy with + // the urlPath appended to avoid issues where the app does not set the base url of the app + // or the base url of referenced assets correctly. + const applyWebPatch = isPositronWeb && !isRunningOnPwb; + const targetOrigin = applyWebPatch ? localUri.toString(true) : localBaseUri.toString(); + + // Finish the Positron proxy setup so that proxy middleware is hooked up. + await proxyInfo.finishProxySetup(targetOrigin); + previewUri = !applyWebPatch && urlPath ? vscode.Uri.joinPath(proxyInfo.externalUri, urlPath) : proxyInfo.externalUri; + } else { + previewUri = await vscode.env.asExternalUri(localUri); + } - // Finish the Positron proxy setup so that proxy middleware is hooked up. - await proxyInfo.finishProxySetup(localUri.toString()); + log.debug(`Viewing app at local uri: ${localUri.toString(true)} with external uri ${previewUri.toString(true)}`); - // Preview the external URI. - positron.window.previewUrl(proxyInfo.externalUri); + // Preview the app in the Viewer. + positron.window.previewUrl(previewUri); return true; } @@ -442,3 +473,27 @@ async function showShellIntegrationNotSupportedMessage(): Promise { await runAppConfig.update('showShellIntegrationNotSupportedMessage', false, vscode.ConfigurationTarget.Global); } } + +/** + * Check if the Positron proxy should be used for the given app. + * Generally, we should avoid skipping the proxy unless there is a good reason to do so, as the + * proxy gives us the ability to intercept requests and responses to the app, which is useful for + * things like debugging, applying styling or fixing up urls. + * @param appName The name of the app; indicated in extensions/positron-python/src/client/positron/webAppCommands.ts + * @returns Whether to use the Positron proxy for the app. + */ +function shouldUsePositronProxy(appName: string) { + switch (appName.trim().toLowerCase()) { + // Streamlit apps don't work in Positron on Workbench with SSL enabled when run through the proxy. + case 'streamlit': + // FastAPI apps don't work in Positron on Workbench when run through the proxy. + case 'fastapi': + if (isRunningOnPwb) { + return false; + } + return true; + default: + // By default, proxy the app. + return true; + } +} diff --git a/extensions/positron-run-app/yarn.lock b/extensions/positron-run-app/yarn.lock index 5d3e6c80201..ed634e3663a 100644 --- a/extensions/positron-run-app/yarn.lock +++ b/extensions/positron-run-app/yarn.lock @@ -33,6 +33,11 @@ resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== +typescript@^4.5.5: + version "4.9.5" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" + integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== + undici-types@~6.19.2: version "6.19.8" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"