Skip to content

Commit 5386bab

Browse files
committed
Derive the multipart content-type on routed requests
The probe Request built for cache sniffing derives a multipart content-type that carries its own boundary. Copying that header onto the routed request while re-encoding the body produced a boundary mismatch, so the browser VM rejected direct fs.upload calls with "failed to read form part". Let the routed fetch derive the header when the body derives its own content-type.
1 parent 7c6724f commit 5386bab

2 files changed

Lines changed: 56 additions & 0 deletions

File tree

src/lib/browser-routing.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,12 @@ function buildRoutedInit(
311311
const body = requestBodyForFetch(request, originalInit);
312312
if (body !== undefined) {
313313
routedInit.body = body;
314+
if (derivesOwnContentType(body) && !new Headers(originalInit?.headers).get('content-type')) {
315+
// `request` was constructed from the same body, so its content-type carries
316+
// that construction's multipart boundary. The routed fetch re-encodes the
317+
// body with a new boundary, so let it derive the header again.
318+
headers.delete('content-type');
319+
}
314320
}
315321
if (originalInit?.duplex !== undefined) {
316322
routedInit.duplex = originalInit.duplex;
@@ -333,6 +339,14 @@ function requestBodyForFetch(
333339
return request.body ?? undefined;
334340
}
335341

342+
function derivesOwnContentType(body: RequestInit['body'] | undefined): boolean {
343+
return (
344+
((globalThis as any).FormData && body instanceof (globalThis as any).FormData) ||
345+
((globalThis as any).URLSearchParams && body instanceof (globalThis as any).URLSearchParams) ||
346+
((globalThis as any).Blob && body instanceof (globalThis as any).Blob)
347+
);
348+
}
349+
336350
function requiresHalfDuplex(body: RequestInit['body'] | undefined): boolean {
337351
return (
338352
((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream) ||

tests/lib/browser-routing.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,4 +1016,46 @@ describe('browser routing', () => {
10161016
expect(calls[0]?.headers.get('authorization')).toBe('Bearer k');
10171017
});
10181018
});
1019+
1020+
test('lets the routed fetch derive the multipart content-type so the boundary matches the body', async () => {
1021+
await withBrowserRoutingEnv(undefined, async () => {
1022+
const parsed: Array<{ url: string; dest: unknown; file: string }> = [];
1023+
const kernel = new Kernel({
1024+
apiKey: 'k',
1025+
baseURL: 'https://api.example/',
1026+
fetch: async (input, init?: RequestInit) => {
1027+
const request = new Request(input as any, init);
1028+
if (request.url === formDataProbeURL) {
1029+
return new Response(null, { status: 204 });
1030+
}
1031+
// Throws or yields empty fields when the content-type boundary does not
1032+
// match the encoded body.
1033+
const form = await request.formData();
1034+
parsed.push({
1035+
url: request.url,
1036+
dest: form.get('files[0][dest_path]'),
1037+
file: await (form.get('files[0][file]') as File).text(),
1038+
});
1039+
return new Response(null, { status: 204 });
1040+
},
1041+
});
1042+
kernel.browserRouteCache.set({
1043+
sessionId: 'sess-1',
1044+
baseURL: 'http://browser-session.test/browser/kernel',
1045+
jwt: 'token-abc',
1046+
});
1047+
1048+
await kernel.browsers.fs.upload('sess-1', {
1049+
files: [{ dest_path: '/tmp/one', file: await toFile(Buffer.from('one'), 'one.txt') }],
1050+
});
1051+
1052+
expect(parsed).toEqual([
1053+
{
1054+
url: 'http://browser-session.test/browser/kernel/fs/upload?jwt=token-abc',
1055+
dest: '/tmp/one',
1056+
file: 'one',
1057+
},
1058+
]);
1059+
});
1060+
});
10191061
});

0 commit comments

Comments
 (0)