Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 37 additions & 1 deletion apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe("ElectronProtocol", () => {
assert.equal((yield* request("/%invalid")).status, 400);
assert.equal((yield* request("/", { method: "POST" })).status, 405);
assert.equal(netFetchMock.mock.calls.length, 0);
}).pipe(Effect.provide(Layer.merge(protocolLayer, NodeServices.layer)), Effect.scoped),
}).pipe(Effect.scoped, Effect.provide(Layer.merge(protocolLayer, NodeServices.layer))),
);

it.effect("proxies the stable renderer origin to the current app server", () =>
Expand Down Expand Up @@ -183,6 +183,42 @@ describe("ElectronProtocol", () => {
}).pipe(Effect.provide(protocolLayer)),
);

it.effect("buffers large GET responses to prevent stream truncation", () =>
Effect.gen(function* () {
let handler: ((request: Request) => Promise<Response>) | undefined;
handleMock.mockImplementation((_scheme, nextHandler) => {
handler = nextHandler;
});

// Simulate a large JS bundle (1 MiB) that would be truncated if the
// ReadableStream from net.fetch were forwarded without buffering.
const largePayload = "x".repeat(1024 * 1024);
netFetchMock.mockResolvedValue(
new Response(largePayload, {
headers: { "content-type": "application/javascript" },
}),
);

const response = yield* Effect.scoped(
Effect.gen(function* () {
const protocol = yield* ElectronProtocol.ElectronProtocol;
yield* protocol.registerDesktopProtocol({
scheme: "t3code-dev",
targetOrigin: new URL("http://127.0.0.1:3773/"),
clerkFrontendApiHostname: undefined,
});
return yield* Effect.promise(() =>
handler!(new Request("t3code-dev://app/assets/bundle.js")),
);
}),
);

const text = yield* Effect.promise(() => response.text());
assert.equal(text.length, largePayload.length);
assert.equal(text, largePayload);
}).pipe(Effect.provide(protocolLayer)),
);

it.effect("preserves protocol registration failures", () =>
Effect.gen(function* () {
const cause = new Error("protocol registration failed");
Expand Down
27 changes: 26 additions & 1 deletion apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,32 @@ async function proxyRequest(
request.method === "GET" || request.method === "HEAD"
? await fetchWithTransientRetry(targetUrl.toString(), init)
: await Electron.net.fetch(targetUrl.toString(), init);
return withContentSecurityPolicy(response, contentSecurityPolicy);

// Buffer the full response body before re-wrapping. Electron's net.fetch
// returns a ReadableStream that can be truncated when forwarded directly
// into a new Response inside a protocol.handle callback, which causes
// large JS bundles to arrive incomplete and fail with SyntaxError.
// Only buffer responses whose declared size fits within this cap; anything
// larger (or without a content-length) streams through instead of being
// held in the main process.
const MAX_BUFFERED_RESPONSE_BYTES = 64 * 1024 * 1024;
const contentLength = Number(response.headers.get("content-length"));
const shouldBufferBody =
response.body !== null &&
(request.method === "GET" || request.method === "HEAD") &&
Number.isFinite(contentLength) &&
contentLength >= 0 &&
contentLength <= MAX_BUFFERED_RESPONSE_BYTES;
const body = shouldBufferBody ? await response.arrayBuffer() : response.body;

return withContentSecurityPolicy(
new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
}),
contentSecurityPolicy,
);
}

const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const;
Expand Down
Loading