Skip to content

Commit

Permalink
Proxy python app frameworks (#4978)
Browse files Browse the repository at this point in the history
## Description

- Addresses #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](rstudio/rstudio-workbench-vscode-ext#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 gradio-app/gradio#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
`<HOST>:<PORT>/<PATH>`
- 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](rstudio/rstudio-workbench-vscode-ext#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](gradio-app/gradio#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 <[email protected]>
  • Loading branch information
sharon-wang and sharon-wang authored Oct 11, 2024
1 parent 677614b commit 76005ba
Show file tree
Hide file tree
Showing 11 changed files with 380 additions and 265 deletions.
9 changes: 8 additions & 1 deletion extensions/positron-proxy/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import * as vscode from 'vscode';
import * as positron from 'positron';
import { PositronProxy } from './positronProxy';
import path from 'path';

/**
* ProxyServerStyles type.
Expand Down Expand Up @@ -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(
Expand Down
253 changes: 149 additions & 104 deletions extensions/positron-proxy/src/positronProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -53,13 +54,27 @@ type ContentRewriter = (
responseBuffer: Buffer
) => Promise<Buffer | string>;

/**
* PendingProxyServer type.
*/
type PendingProxyServer = {
externalUri: vscode.Uri;
proxyPath: string;
finishProxySetup: (targetOrigin: string) => Promise<void>;
};

/**
* 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 &&
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -285,23 +300,21 @@ export class PositronProxy implements Disposable {
*/
startHttpProxyServer(targetOrigin: string): Promise<string> {
// 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<PendingProxyServer> {
// 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
Expand All @@ -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<string> {
// 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<string> {
// 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<PendingProxyServer> {
// Create the app and start listening on a random port.
const app = express();
let address: MaybeAddressInfo;
const server = await new Promise<Server>((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/<PORT> 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="<PROXY_PATH> or href="<PROXY_PATH> 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
Expand Down
Loading

0 comments on commit 76005ba

Please sign in to comment.