Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proxy python app frameworks #4978

Merged
merged 30 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3d1829f
hacky stuff for a two part proxy server setup
sharon-wang Oct 4, 2024
c04d869
misc small fixes and debug logs
sharon-wang Oct 4, 2024
1455aef
some attempts to get app frameworks working
sharon-wang Oct 4, 2024
30791f1
document the version of Gradio and deps that is working
sharon-wang Oct 7, 2024
a8aa708
dash app now working!!!
sharon-wang Oct 8, 2024
840b89e
streamlit is now working!!!
sharon-wang Oct 8, 2024
dcd9128
clean up debug code
sharon-wang Oct 8, 2024
54fde6d
undo changes to secrets baseline
sharon-wang Oct 8, 2024
15d171e
reduce the proxy setup to a single command
sharon-wang Oct 8, 2024
10fc498
remove pending positron proxy class and map
sharon-wang Oct 8, 2024
12caf4c
refactor positron proxy with multi-step proxy setup
sharon-wang Oct 8, 2024
9ea768f
move html rewriting functions to util.ts
sharon-wang Oct 8, 2024
f8581cc
clean up positron-run-app proxy changes
sharon-wang Oct 8, 2024
b365043
use the same scheme as the browser window
sharon-wang Oct 9, 2024
05170bd
simplify url matching
sharon-wang Oct 9, 2024
74b597e
remove `port` from `getTerminalOptions` since it's not used
sharon-wang Oct 9, 2024
2c3fb73
increase server URL wait time
sharon-wang Oct 9, 2024
c23d196
Merge remote-tracking branch 'origin/main' into proxy-python-app-fram…
sharon-wang Oct 9, 2024
7b2e49a
remove more port references
sharon-wang Oct 9, 2024
ab07485
update positron-run-app api with positron proxy handling
sharon-wang Oct 9, 2024
a520bcb
fix up web app commands
sharon-wang Oct 9, 2024
4cd3183
format-fix python extension code
sharon-wang Oct 9, 2024
629de03
Merge remote-tracking branch 'origin/main' into proxy-python-app-fram…
sharon-wang Oct 10, 2024
0e01539
update python extension with positron-run-app types
sharon-wang Oct 10, 2024
7cea0b1
update webAppCommands test to match webAppCommands changes
sharon-wang Oct 10, 2024
4d64ff0
catch possible errors from starting the proxy or finishing proxy setup
sharon-wang Oct 10, 2024
c1711b1
prefer async -> await over then() syntax
sharon-wang Oct 10, 2024
947bd81
remove ansi escape codes from terminal output string
sharon-wang Oct 11, 2024
c678196
increase url check timeout and fix return
sharon-wang Oct 11, 2024
40a1142
stub `positronProxy.startPendingProxyServer` to fix api integration test
sharon-wang Oct 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
175 changes: 98 additions & 77 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,6 +54,15 @@ type ContentRewriter = (
responseBuffer: Buffer
) => Promise<Buffer | string>;

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

/**
* Custom type guard for AddressInfo.
* @param addressInfo The value to type guard.
Expand Down Expand Up @@ -190,7 +200,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 +238,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 +295,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,9 +320,9 @@ 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> {
private 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
Expand All @@ -325,6 +333,22 @@ export class PositronProxy implements Disposable {
return;
}

// We don't have an existing proxy server for the target origin, so start a new one.
this.startNewProxyServer(contentRewriter).then(({ externalUri, finishProxySetup }) => {
sharon-wang marked this conversation as resolved.
Show resolved Hide resolved
finishProxySetup(targetOrigin).then(() => {
sharon-wang marked this conversation as resolved.
Show resolved Hide resolved
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 startNewProxyServer(contentRewriter: ContentRewriter): Promise<PendingProxyServer> {
return new Promise((resolve, reject) => {
// Create the app and start listening on a random port.
const app = express();
const server = app.listen(0, HOST, async () => {
Expand All @@ -341,74 +365,71 @@ export class PositronProxy implements Disposable {
// Create the server origin.
const serverOrigin = `http://${address.address}:${address.port}`;

// 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());
// Resolve the proxy info.
resolve({
externalUri: externalUri,
proxyPath: externalUri.path,
finishProxySetup: (targetOrigin: string) => {
return this.finishProxySetup(
targetOrigin,
serverOrigin,
externalUri,
server,
app,
contentRewriter
);
}
});
});
});
}

/**
* 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
69 changes: 69 additions & 0 deletions extensions/positron-proxy/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,3 +23,71 @@ export class PromiseHandles<T> {
});
}
}

/**
* 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/<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,
(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;
}
Loading
Loading