fix(gateway): proxy browser server functions to workspace apps
Quality / quality (ubuntu-latest) (push) Failing after 10m40s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
2026-08-18 23:30:42 +05:30
parent 701acd828c
commit 03d5cb6aa6
4 changed files with 63 additions and 16 deletions
+27 -8
View File
@@ -470,19 +470,37 @@ export function stripUntrustedInternalHeaders(headers: Headers): Headers {
}
/**
* The reserved inter-app RPC namespace is refused at the gateway edge, before
* any proxying — it is only ever mounted by a child app's own dev-server and
* must never be reachable from outside the workspace.
* Private inter-app RPC routes are refused at the gateway edge. The exact
* prefix is the CSRF-protected browser-to-app server-function endpoint and is
* intentionally proxied to the selected child app.
*/
export function isRpcGatewayPath(pathname: string): boolean {
return (
pathname === RPC_PATH_PREFIX ||
pathname.startsWith(`${RPC_PATH_PREFIX}/`) ||
pathname === RPC_STREAM_PATH_PREFIX ||
pathname.startsWith(`${RPC_STREAM_PATH_PREFIX}/`)
);
}
/** Build the trusted internal hop for a browser server-function request. */
export function gatewayBrowserRpcHeaders(
req: Request,
url: URL,
ip: string,
forwardedHeaders: boolean,
backendOrigin: string,
): Headers {
const headers = stripUntrustedInternalHeaders(
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
);
// The public request already passed the gateway's host and fetch-metadata
// checks. Present the internal proxy hop as same-origin to the child while
// retaining the double-submit CSRF cookie and header.
headers.set("origin", backendOrigin);
headers.delete("host");
return headers;
}
/** Boot every app as a child process, then route by Host on one gateway port. */
export async function startGateway(opts: GatewayOptions): Promise<RunningGateway> {
const port = opts.port ?? 3000;
@@ -497,7 +515,7 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
);
// Loopback-only origins, computed up front (ports are assigned by index
// before any child spawns) so every child can reach every other child
// directly — bypassing the gateway, which 404s the RPC prefix by design.
// directly — bypassing the gateway, which 404s private nested RPC routes.
const internalOriginsEnv: Readonly<Record<string, string>> = Object.freeze(
Object.fromEntries(
opts.apps.map((app, i) => [app.name, `http://127.0.0.1:${app.port ?? port + 1 + i}`]),
@@ -737,9 +755,10 @@ export async function startGateway(opts: GatewayOptions): Promise<RunningGateway
}
// HTTP → reverse-proxy to the app, preserving method/headers/body.
const headers = stripUntrustedInternalHeaders(
gatewayProxyHeaders(req, url, ip, forwardedHeaders),
);
const headers =
url.pathname === RPC_PATH_PREFIX
? gatewayBrowserRpcHeaders(req, url, ip, forwardedHeaders, target.origin)
: stripUntrustedInternalHeaders(gatewayProxyHeaders(req, url, ip, forwardedHeaders));
const body =
req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
let res: Response;