mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
feat(adapter-utils): bound the sandbox side of the http2 bridge clients
Each sandbox HTTP/2 gateway client now waits for the host's first remoteSettings event, with a time bound, before it opens its first stream. Each client also counts response bytes and fails closed past the configured body size limit. This defends the sandbox process; it does not change any host-side bound or default. Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
be1a212c30
commit
ea8bf4d95a
2 files changed
+481
-62
No files matched your search
@@ -3,7 +3,9 @@ import { createServer } from "node:http";
|
||||
import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Duplex, duplexPair } from "node:stream";
|
||||
import { promisify } from "node:util";
|
||||
import http2 from "node:http2";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getActiveStepContext, measureStartupStep } from "./acpx-engine/startup-timing.js";
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
createFileSystemSandboxCallbackBridgeQueueClient,
|
||||
createSandboxCallbackBridgeAsset,
|
||||
createSandboxCallbackBridgeToken,
|
||||
createSandboxHttp2BridgeGateway,
|
||||
getSandboxCallbackBridgeServerSource,
|
||||
sandboxCallbackBridgeDirectories,
|
||||
syncRemoteTextFileWithHashSkip,
|
||||
@@ -3100,8 +3103,17 @@ describe("sandbox callback bridge", () => {
|
||||
async function startHttp2GatewayForTest(options: {
|
||||
bridgeToken: string;
|
||||
maxBodyBytes?: number;
|
||||
responseTimeoutMs?: number;
|
||||
forwardRequest: (request: Http2BridgeForwardRequest) => Promise<Http2BridgeForwardResult>;
|
||||
}): Promise<{ baseUrl: string; stop: () => Promise<void> }> {
|
||||
onSession?: (session: http2.ServerHttp2Session) => void;
|
||||
/**
|
||||
* When true, hold every byte the host writes toward the sandbox process
|
||||
* until a test calls the returned `releaseHostBytes`. This lets a test
|
||||
* prove the sandbox gateway waits for the host's settings frame before
|
||||
* it opens its first stream.
|
||||
*/
|
||||
gateHostBytes?: boolean;
|
||||
}): Promise<{ baseUrl: string; stop: () => Promise<void>; releaseHostBytes: () => void }> {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bridge-http2-test-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const entrypoint = path.join(rootDir, "paperclip-bridge-server.mjs");
|
||||
@@ -3130,6 +3142,9 @@ describe("sandbox callback bridge", () => {
|
||||
...(options.maxBodyBytes != null
|
||||
? { PAPERCLIP_BRIDGE_MAX_BODY_BYTES: String(options.maxBodyBytes) }
|
||||
: {}),
|
||||
...(options.responseTimeoutMs != null
|
||||
? { PAPERCLIP_BRIDGE_RESPONSE_TIMEOUT_MS: String(options.responseTimeoutMs) }
|
||||
: {}),
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
@@ -3182,9 +3197,24 @@ describe("sandbox callback bridge", () => {
|
||||
}
|
||||
});
|
||||
|
||||
let hostBytesOpen = !options.gateHostBytes;
|
||||
const bufferedHostBytes: Buffer[] = [];
|
||||
const releaseHostBytes = () => {
|
||||
if (hostBytesOpen) return;
|
||||
hostBytesOpen = true;
|
||||
for (const chunk of bufferedHostBytes.splice(0)) {
|
||||
child.stdin.write(chunk);
|
||||
}
|
||||
};
|
||||
|
||||
const channel: CommandManagedDuplexChannel = {
|
||||
write: (data) => {
|
||||
child.stdin.write(Buffer.from(data));
|
||||
const chunk = Buffer.from(data);
|
||||
if (hostBytesOpen) {
|
||||
child.stdin.write(chunk);
|
||||
} else {
|
||||
bufferedHostBytes.push(chunk);
|
||||
}
|
||||
},
|
||||
onData: (listener) => {
|
||||
dataListeners.push(listener);
|
||||
@@ -3205,6 +3235,7 @@ describe("sandbox callback bridge", () => {
|
||||
const handle = createHttp2BridgeServer({
|
||||
bridgeToken: options.bridgeToken,
|
||||
forwardRequest: options.forwardRequest,
|
||||
onSession: options.onSession,
|
||||
});
|
||||
const boundDuplex = handle.bindChannel(channel);
|
||||
dispatchStarted = true;
|
||||
@@ -3224,7 +3255,7 @@ describe("sandbox callback bridge", () => {
|
||||
};
|
||||
cleanupFns.push(stop);
|
||||
|
||||
return { baseUrl: `http://127.0.0.1:${assignedPort}`, stop };
|
||||
return { baseUrl: `http://127.0.0.1:${assignedPort}`, stop, releaseHostBytes };
|
||||
}
|
||||
|
||||
it("forwards the exact request body bytes to the HTTP/2 host handler, including a non-ASCII character", async () => {
|
||||
@@ -3434,4 +3465,288 @@ describe("sandbox callback bridge", () => {
|
||||
expect(typeof seenRequests[0]?.body).toBe("string");
|
||||
expect(seenRequests[0]?.body).toBe(requestBodyText);
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrap one side of a paired in-memory `Duplex` as a minimal
|
||||
* `CommandManagedDuplexChannel`, so the real host `createHttp2BridgeServer`
|
||||
* can bind to it directly, with no spawned process in between.
|
||||
*/
|
||||
function channelFromDuplex(duplex: Duplex): CommandManagedDuplexChannel {
|
||||
const dataListeners: Array<(chunk: Uint8Array) => void> = [];
|
||||
duplex.on("data", (chunk: Buffer) => {
|
||||
for (const listener of dataListeners) listener(chunk);
|
||||
});
|
||||
return {
|
||||
write: (data) => {
|
||||
duplex.write(Buffer.from(data));
|
||||
},
|
||||
onData: (listener) => {
|
||||
dataListeners.push(listener);
|
||||
},
|
||||
onExit: (listener) => {
|
||||
duplex.once("end", () => listener({ exitCode: null, transportClosed: true }));
|
||||
},
|
||||
stop: () => {
|
||||
duplex.destroy();
|
||||
},
|
||||
close: async () => {
|
||||
duplex.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold back every chunk `source` emits until a test calls `release()`. A
|
||||
* test uses this to delay the host's settings frame from reaching the
|
||||
* exported sandbox HTTP/2 client gateway, so it can prove the gateway
|
||||
* waits for that frame before it opens its first stream.
|
||||
*/
|
||||
function createGatedDuplex(source: Duplex): { gated: Duplex; release: () => void } {
|
||||
let open = false;
|
||||
const buffered: Buffer[] = [];
|
||||
const gated = new Duplex({
|
||||
read() {},
|
||||
write(chunk, _encoding, callback) {
|
||||
source.write(chunk);
|
||||
callback();
|
||||
},
|
||||
final(callback) {
|
||||
source.end();
|
||||
callback();
|
||||
},
|
||||
});
|
||||
source.on("data", (chunk: Buffer) => {
|
||||
if (open) {
|
||||
gated.push(chunk);
|
||||
} else {
|
||||
buffered.push(chunk);
|
||||
}
|
||||
});
|
||||
source.on("end", () => {
|
||||
if (open) gated.push(null);
|
||||
});
|
||||
return {
|
||||
gated,
|
||||
release: () => {
|
||||
open = true;
|
||||
for (const chunk of buffered.splice(0)) gated.push(chunk);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("the gateway client opens its first stream only after the host settings frame arrives", async () => {
|
||||
const bridgeToken = createSandboxCallbackBridgeToken();
|
||||
const [serverSide, clientSide] = duplexPair();
|
||||
let sawStream = false;
|
||||
const handle = createHttp2BridgeServer({
|
||||
bridgeToken,
|
||||
forwardRequest: async () => ({ status: 200, headers: {}, body: "" }),
|
||||
onSession: (session) => {
|
||||
session.on("stream", () => {
|
||||
sawStream = true;
|
||||
});
|
||||
},
|
||||
});
|
||||
handle.bindChannel(channelFromDuplex(serverSide));
|
||||
|
||||
const gate = createGatedDuplex(clientSide);
|
||||
const gateway = createSandboxHttp2BridgeGateway({
|
||||
bridgeToken,
|
||||
createConnection: () => gate.gated,
|
||||
});
|
||||
cleanupFns.push(async () => {
|
||||
await gateway.close();
|
||||
serverSide.destroy();
|
||||
clientSide.destroy();
|
||||
});
|
||||
|
||||
const forwardPromise = gateway.forwardRequest({
|
||||
method: "GET",
|
||||
path: "/api/agents/me",
|
||||
query: "",
|
||||
headers: {},
|
||||
body: Buffer.alloc(0),
|
||||
receivedToken: bridgeToken,
|
||||
});
|
||||
|
||||
// Wait before the settings frame is let through, so an early dispatch
|
||||
// has time to open a stream if the gateway does not wait for it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(sawStream).toBe(false);
|
||||
|
||||
gate.release();
|
||||
const response = await forwardPromise;
|
||||
expect(response.status).toBe(200);
|
||||
expect(sawStream).toBe(true);
|
||||
}, 15_000);
|
||||
|
||||
it("a host that sends no settings frame fails the first request at the bound", async () => {
|
||||
const bridgeToken = createSandboxCallbackBridgeToken();
|
||||
const blackHole = new Duplex({
|
||||
read() {},
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const gateway = createSandboxHttp2BridgeGateway({
|
||||
bridgeToken,
|
||||
createConnection: () => blackHole,
|
||||
responseTimeoutMs: 50,
|
||||
});
|
||||
// `gateway.close()` sends a graceful GOAWAY and waits for the transport
|
||||
// to end, which a black-hole duplex never does. Destroy the transport
|
||||
// directly instead, so teardown does not hang.
|
||||
cleanupFns.push(async () => {
|
||||
blackHole.destroy();
|
||||
});
|
||||
|
||||
await expect(
|
||||
gateway.forwardRequest({
|
||||
method: "GET",
|
||||
path: "/api/agents/me",
|
||||
query: "",
|
||||
headers: {},
|
||||
body: Buffer.alloc(0),
|
||||
receivedToken: bridgeToken,
|
||||
}),
|
||||
).rejects.toThrow("Timed out waiting for the HTTP/2 bridge host settings handshake.");
|
||||
});
|
||||
|
||||
it("a failed settings handshake raises no unhandled rejection when no request waits", async () => {
|
||||
const bridgeToken = createSandboxCallbackBridgeToken();
|
||||
const blackHole = new Duplex({
|
||||
read() {},
|
||||
write(_chunk, _encoding, callback) {
|
||||
callback();
|
||||
},
|
||||
});
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandledRejection = (reason: unknown) => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
process.on("unhandledRejection", onUnhandledRejection);
|
||||
try {
|
||||
const gateway = createSandboxHttp2BridgeGateway({
|
||||
bridgeToken,
|
||||
createConnection: () => blackHole,
|
||||
responseTimeoutMs: 20,
|
||||
});
|
||||
// `gateway.close()` sends a graceful GOAWAY and waits for the
|
||||
// transport to end, which a black-hole duplex never does. Destroy the
|
||||
// transport directly instead, so teardown does not hang.
|
||||
cleanupFns.push(async () => {
|
||||
blackHole.destroy();
|
||||
});
|
||||
|
||||
// No request is in flight while the handshake bound passes, so the
|
||||
// stored handshake promise settles with nobody awaiting it yet.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(unhandled).toHaveLength(0);
|
||||
|
||||
// A request made after the bound already passed must still fail
|
||||
// closed with the fixed handshake message. This proves the rejection
|
||||
// was stored, not swallowed.
|
||||
await expect(
|
||||
gateway.forwardRequest({
|
||||
method: "GET",
|
||||
path: "/api/agents/me",
|
||||
query: "",
|
||||
headers: {},
|
||||
body: Buffer.alloc(0),
|
||||
receivedToken: bridgeToken,
|
||||
}),
|
||||
).rejects.toThrow("Timed out waiting for the HTTP/2 bridge host settings handshake.");
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it("the gateway client fails closed on a response body past the configured size limit", async () => {
|
||||
const bridgeToken = createSandboxCallbackBridgeToken();
|
||||
const maxBodyBytes = 16;
|
||||
const [serverSide, clientSide] = duplexPair();
|
||||
const handle = createHttp2BridgeServer({
|
||||
bridgeToken,
|
||||
forwardRequest: async () => ({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: "x".repeat(maxBodyBytes + 1),
|
||||
}),
|
||||
});
|
||||
handle.bindChannel(channelFromDuplex(serverSide));
|
||||
|
||||
const gateway = createSandboxHttp2BridgeGateway({
|
||||
bridgeToken,
|
||||
createConnection: () => clientSide,
|
||||
maxBodyBytes,
|
||||
});
|
||||
cleanupFns.push(async () => {
|
||||
await gateway.close();
|
||||
serverSide.destroy();
|
||||
clientSide.destroy();
|
||||
});
|
||||
|
||||
await expect(
|
||||
gateway.forwardRequest({
|
||||
method: "GET",
|
||||
path: "/api/agents/me",
|
||||
query: "",
|
||||
headers: {},
|
||||
body: Buffer.alloc(0),
|
||||
receivedToken: bridgeToken,
|
||||
}),
|
||||
).rejects.toThrow("Bridge response body exceeded the configured size limit.");
|
||||
});
|
||||
|
||||
it("the generated gateway asset opens its first stream only after the host settings frame arrives", async () => {
|
||||
const bridgeToken = createSandboxCallbackBridgeToken();
|
||||
let sawStream = false;
|
||||
const gateway = await startHttp2GatewayForTest({
|
||||
bridgeToken,
|
||||
gateHostBytes: true,
|
||||
onSession: (session) => {
|
||||
session.on("stream", () => {
|
||||
sawStream = true;
|
||||
});
|
||||
},
|
||||
forwardRequest: async () => ({ status: 200, headers: {}, body: "" }),
|
||||
});
|
||||
|
||||
const responsePromise = fetch(`${gateway.baseUrl}/api/agents/me`, {
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${bridgeToken}` },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
expect(sawStream).toBe(false);
|
||||
|
||||
gateway.releaseHostBytes();
|
||||
const response = await responsePromise;
|
||||
expect(response.status).toBe(200);
|
||||
expect(sawStream).toBe(true);
|
||||
}, 15_000);
|
||||
|
||||
it("the generated gateway asset fails closed on a response body past the configured size limit", async () => {
|
||||
const bridgeToken = createSandboxCallbackBridgeToken();
|
||||
const maxBodyBytes = 32;
|
||||
const oversizedResponseBody = "x".repeat(maxBodyBytes + 1);
|
||||
const gateway = await startHttp2GatewayForTest({
|
||||
bridgeToken,
|
||||
maxBodyBytes,
|
||||
forwardRequest: async () => ({
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain" },
|
||||
body: oversizedResponseBody,
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await fetch(`${gateway.baseUrl}/api/agents/me`, {
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${bridgeToken}` },
|
||||
});
|
||||
expect(response.status).toBe(502);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: "Bridge response body exceeded the configured size limit.",
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
@@ -1890,6 +1890,19 @@ export interface CreateSandboxHttp2BridgeGatewayOptions {
|
||||
* `http2-bridge-server.ts` to know which requests need a retry elsewhere.
|
||||
*/
|
||||
onGoaway?: (record: { lastStreamId: number; errorCode: number }) => void;
|
||||
/**
|
||||
* The time bound, in milliseconds, on the wait for the host's first
|
||||
* `remoteSettings` event. The gateway opens no stream before that event
|
||||
* arrives or before this bound passes, whichever comes first. The default
|
||||
* is {@link DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS}.
|
||||
*/
|
||||
responseTimeoutMs?: number;
|
||||
/**
|
||||
* The response body byte limit. The gateway counts the bytes it collects
|
||||
* from each response and fails closed, destroying the stream, past this
|
||||
* limit. The default is {@link DEFAULT_BRIDGE_MAX_BODY_BYTES}.
|
||||
*/
|
||||
maxBodyBytes?: number;
|
||||
}
|
||||
|
||||
function forwardOneHttp2Request(
|
||||
@@ -1902,6 +1915,7 @@ function forwardOneHttp2Request(
|
||||
headers: Record<string, string>;
|
||||
body: Buffer;
|
||||
},
|
||||
maxBodyBytes: number,
|
||||
): Promise<SandboxHttp2BridgeGatewayResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = request.query.trim();
|
||||
@@ -1920,7 +1934,8 @@ function forwardOneHttp2Request(
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
const chunks: Buffer[] = [];
|
||||
let chunks: Buffer[] = [];
|
||||
let receivedBytes = 0;
|
||||
let responseHeaders: Record<string, string> = {};
|
||||
let status = 502;
|
||||
let settled = false;
|
||||
@@ -1938,7 +1953,17 @@ function forwardOneHttp2Request(
|
||||
responseHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
}
|
||||
});
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
if (settled) return;
|
||||
receivedBytes += chunk.length;
|
||||
if (receivedBytes > maxBodyBytes) {
|
||||
chunks = [];
|
||||
stream.destroy();
|
||||
settle(() => reject(new Error("Bridge response body exceeded the configured size limit.")));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.once("end", () => settle(() => resolve({ status, headers: responseHeaders, body: Buffer.concat(chunks) })));
|
||||
stream.once("error", (error) =>
|
||||
settle(() => reject(error instanceof Error ? error : new Error(String(error)))),
|
||||
@@ -1952,6 +1977,35 @@ function forwardOneHttp2Request(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the settings-handshake wait for one HTTP/2 client session. The
|
||||
* client must wait for the host's first `remoteSettings` event before it
|
||||
* opens its first stream (see the module-level notes on `forwardRequest`).
|
||||
* A timer bounds the wait so a silent host never hangs a request forever.
|
||||
* The timer is unref'd, so it never holds the process open, and the
|
||||
* returned promise carries a no-operation rejection handler, so a rejection
|
||||
* with no request yet in flight never raises an unhandled rejection.
|
||||
*/
|
||||
function createRemoteSettingsReadyPromise(session: http2.ClientHttp2Session, timeoutMs: number): Promise<void> {
|
||||
let settled = false;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error("Timed out waiting for the HTTP/2 bridge host settings handshake."));
|
||||
}, timeoutMs);
|
||||
timer.unref();
|
||||
session.once("remoteSettings", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
ready.catch(() => undefined);
|
||||
return ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the sandbox HTTP/2 client gateway. It opens one HTTP/2 client
|
||||
* session on the transport `createConnection` returns, and forwards each
|
||||
@@ -1965,6 +2019,8 @@ export function createSandboxHttp2BridgeGateway(
|
||||
): SandboxHttp2BridgeGateway {
|
||||
const authority = options.authority?.trim() || SANDBOX_HTTP2_GATEWAY_DEFAULT_AUTHORITY;
|
||||
const headerAllowlist = options.headerAllowlist ?? DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST;
|
||||
const responseTimeoutMs = options.responseTimeoutMs ?? DEFAULT_BRIDGE_RESPONSE_TIMEOUT_MS;
|
||||
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_BRIDGE_MAX_BODY_BYTES;
|
||||
const session = http2.connect(`http://${authority}`, {
|
||||
createConnection: options.createConnection,
|
||||
});
|
||||
@@ -1975,6 +2031,10 @@ export function createSandboxHttp2BridgeGateway(
|
||||
session.on("goaway", (errorCode: number, lastStreamId: number) => {
|
||||
options.onGoaway?.({ lastStreamId, errorCode });
|
||||
});
|
||||
// One handshake promise for the whole session, created here before any
|
||||
// request. Every `forwardRequest` call awaits this same stored promise, so
|
||||
// exactly one timer is armed per session, not one per request.
|
||||
const remoteSettingsReady = createRemoteSettingsReadyPromise(session, responseTimeoutMs);
|
||||
|
||||
return {
|
||||
forwardRequest(request: SandboxHttp2BridgeGatewayRequest): Promise<SandboxHttp2BridgeGatewayResponse> {
|
||||
@@ -1984,14 +2044,20 @@ export function createSandboxHttp2BridgeGateway(
|
||||
if (!compareBridgeTokensConstantTime(options.bridgeToken, request.receivedToken)) {
|
||||
return Promise.reject(new Error("Invalid bridge token."));
|
||||
}
|
||||
return forwardOneHttp2Request(session, {
|
||||
bridgeToken: options.bridgeToken,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
query: request.query,
|
||||
headers: sanitizeSandboxCallbackBridgeHeaders(request.headers, headerAllowlist),
|
||||
body: request.body,
|
||||
});
|
||||
return remoteSettingsReady.then(() =>
|
||||
forwardOneHttp2Request(
|
||||
session,
|
||||
{
|
||||
bridgeToken: options.bridgeToken,
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
query: request.query,
|
||||
headers: sanitizeSandboxCallbackBridgeHeaders(request.headers, headerAllowlist),
|
||||
body: request.body,
|
||||
},
|
||||
maxBodyBytes,
|
||||
),
|
||||
);
|
||||
},
|
||||
close(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -2387,6 +2453,13 @@ function runHttp2Gateway() {
|
||||
const authority = "bridge.internal";
|
||||
let session = null;
|
||||
let unavailable = false;
|
||||
// One handshake promise for the whole session, created where the session
|
||||
// is created, before any request. Every forwarded request awaits this
|
||||
// same stored promise, so exactly one timer is armed per session, not one
|
||||
// per request. The catch below stops an unobserved rejection (a failed
|
||||
// handshake with no request yet in flight) from raising an unhandled
|
||||
// rejection warning.
|
||||
let remoteSettingsReady = null;
|
||||
|
||||
function openSession() {
|
||||
if (session) return session;
|
||||
@@ -2403,61 +2476,92 @@ function runHttp2Gateway() {
|
||||
session.on("goaway", (errorCode, lastStreamId) => {
|
||||
diag("host sent GOAWAY (errorCode=" + errorCode + ", lastStreamId=" + lastStreamId + ")");
|
||||
});
|
||||
const activeSession = session;
|
||||
let handshakeSettled = false;
|
||||
remoteSettingsReady = new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(function () {
|
||||
if (handshakeSettled) return;
|
||||
handshakeSettled = true;
|
||||
reject(new Error("Timed out waiting for the HTTP/2 bridge host settings handshake."));
|
||||
}, responseTimeoutMs);
|
||||
timer.unref();
|
||||
activeSession.once("remoteSettings", function () {
|
||||
if (handshakeSettled) return;
|
||||
handshakeSettled = true;
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
remoteSettingsReady.catch(function () {});
|
||||
return session;
|
||||
}
|
||||
|
||||
function forwardOverHttp2(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const activeSession = openSession();
|
||||
const query = request.query || "";
|
||||
const pathWithQuery =
|
||||
query.length === 0 ? request.path : request.path + (query.charAt(0) === "?" ? query : "?" + query);
|
||||
const outboundHeaders = Object.assign(
|
||||
{
|
||||
":method": request.method,
|
||||
":path": pathWithQuery,
|
||||
authorization: "Bearer " + bridgeToken,
|
||||
},
|
||||
request.headers,
|
||||
);
|
||||
let stream;
|
||||
try {
|
||||
stream = activeSession.request(outboundHeaders, { endStream: request.body.length === 0 });
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
let status = 502;
|
||||
let responseHeaders = {};
|
||||
let settled = false;
|
||||
const settle = (run) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
run();
|
||||
};
|
||||
stream.on("response", (headers) => {
|
||||
const raw = headers[":status"];
|
||||
status = typeof raw === "number" ? raw : Number(raw) || 502;
|
||||
responseHeaders = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.charAt(0) === ":" || value == null) continue;
|
||||
responseHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
const activeSession = openSession();
|
||||
const handshakeReady = remoteSettingsReady;
|
||||
return handshakeReady.then(function () {
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = request.query || "";
|
||||
const pathWithQuery =
|
||||
query.length === 0 ? request.path : request.path + (query.charAt(0) === "?" ? query : "?" + query);
|
||||
const outboundHeaders = Object.assign(
|
||||
{
|
||||
":method": request.method,
|
||||
":path": pathWithQuery,
|
||||
authorization: "Bearer " + bridgeToken,
|
||||
},
|
||||
request.headers,
|
||||
);
|
||||
let stream;
|
||||
try {
|
||||
stream = activeSession.request(outboundHeaders, { endStream: request.body.length === 0 });
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
let chunks = [];
|
||||
let receivedBytes = 0;
|
||||
let status = 502;
|
||||
let responseHeaders = {};
|
||||
let settled = false;
|
||||
const settle = (run) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
run();
|
||||
};
|
||||
stream.on("response", (headers) => {
|
||||
const raw = headers[":status"];
|
||||
status = typeof raw === "number" ? raw : Number(raw) || 502;
|
||||
responseHeaders = {};
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (key.charAt(0) === ":" || value == null) continue;
|
||||
responseHeaders[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
||||
}
|
||||
});
|
||||
stream.on("data", (chunk) => {
|
||||
if (settled) return;
|
||||
receivedBytes += chunk.length;
|
||||
if (receivedBytes > maxBodyBytes) {
|
||||
chunks = [];
|
||||
stream.destroy();
|
||||
settle(() => reject(new Error("Bridge response body exceeded the configured size limit.")));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.once("end", () =>
|
||||
settle(() => resolve({ status: status, headers: responseHeaders, body: Buffer.concat(chunks) })),
|
||||
);
|
||||
stream.once("error", (error) =>
|
||||
settle(() => reject(error instanceof Error ? error : new Error(String(error)))),
|
||||
);
|
||||
stream.once("aborted", () => settle(() => reject(new Error("Bridge HTTP/2 stream aborted."))));
|
||||
if (request.body.length > 0) {
|
||||
stream.end(request.body);
|
||||
} else if (!stream.writableEnded) {
|
||||
stream.end();
|
||||
}
|
||||
});
|
||||
stream.on("data", (chunk) => chunks.push(chunk));
|
||||
stream.once("end", () =>
|
||||
settle(() => resolve({ status: status, headers: responseHeaders, body: Buffer.concat(chunks) })),
|
||||
);
|
||||
stream.once("error", (error) =>
|
||||
settle(() => reject(error instanceof Error ? error : new Error(String(error)))),
|
||||
);
|
||||
stream.once("aborted", () => settle(() => reject(new Error("Bridge HTTP/2 stream aborted."))));
|
||||
if (request.body.length > 0) {
|
||||
stream.end(request.body);
|
||||
} else if (!stream.writableEnded) {
|
||||
stream.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user