Skip to content

Commit 654c2dd

Browse files
committed
Merge pull request #300 from allouis/request-clone-unusable-error
2 parents d5b05e2 + 6ca0895 commit 654c2dd

2 files changed

Lines changed: 50 additions & 24 deletions

File tree

CHANGES.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ To be released.
5252
- Added `LookupWebFingerOptions.maxRedirection` option.
5353
[[#248], [#281] by Lee ByeongJun]
5454

55+
- Optimized `doubleKnock()` function to avoid multiple request body clones
56+
during redirects. The request body is now read once and reused throughout
57+
the entire operation, preventing potential `TypeError: unusable` errors
58+
and improving performance. [[#300] by Fabien O'Carroll]
59+
60+
- Added `SignRequestOptions.body` option.
61+
- Added `DoubleKnockOptions.body` option.
62+
- Updated internal signing functions to accept pre-read body buffers.
63+
5564
- Added `fedify webfinger` command. This command allows users to look up
5665
WebFinger information for a given resource.
5766
[[#260], [#278] by ChanHaeng Lee]
@@ -75,6 +84,7 @@ To be released.
7584
[#281]: https://github.com/fedify-dev/fedify/pull/281
7685
[#282]: https://github.com/fedify-dev/fedify/pull/282
7786
[#285]: https://github.com/fedify-dev/fedify/pull/285
87+
[#300]: https://github.com/fedify-dev/fedify/pull/300
7888

7989

8090
Version 1.7.5

fedify/sig/http.ts

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ export interface SignRequestOptions {
5858
*/
5959
currentTime?: Temporal.Instant;
6060

61+
/**
62+
* The request body as ArrayBuffer. If provided, avoids cloning the request body.
63+
* @since 1.8.0
64+
*/
65+
body?: ArrayBuffer | null;
66+
6167
/**
6268
* The OpenTelemetry tracer provider. If omitted, the global tracer provider
6369
* is used.
@@ -102,6 +108,7 @@ export async function signRequest(
102108
keyId,
103109
span,
104110
options.currentTime,
111+
options.body,
105112
);
106113
} else {
107114
// Default to draft-cavage
@@ -111,6 +118,7 @@ export async function signRequest(
111118
keyId,
112119
span,
113120
options.currentTime,
121+
options.body,
114122
);
115123
}
116124

@@ -142,15 +150,17 @@ async function signRequestDraft(
142150
keyId: URL,
143151
span: Span,
144152
currentTime?: Temporal.Instant,
153+
bodyBuffer?: ArrayBuffer | null,
145154
): Promise<Request> {
146155
if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") {
147156
throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name);
148157
}
149158
const url = new URL(request.url);
150-
const body: ArrayBuffer | null =
151-
request.method !== "GET" && request.method !== "HEAD"
152-
? await request.clone().arrayBuffer()
153-
: null;
159+
const body: ArrayBuffer | null = bodyBuffer !== undefined
160+
? bodyBuffer
161+
: request.method !== "GET" && request.method !== "HEAD"
162+
? await request.clone().arrayBuffer()
163+
: null;
154164
const headers = new Headers(request.headers);
155165
if (!headers.has("Host")) {
156166
headers.set("Host", url.host);
@@ -382,16 +392,18 @@ async function signRequestRfc9421(
382392
keyId: URL,
383393
span: Span,
384394
currentTime?: Temporal.Instant,
395+
bodyBuffer?: ArrayBuffer | null,
385396
): Promise<Request> {
386397
if (privateKey.algorithm.name !== "RSASSA-PKCS1-v1_5") {
387398
throw new TypeError("Unsupported algorithm: " + privateKey.algorithm.name);
388399
}
389400

390401
const url = new URL(request.url);
391-
const body: ArrayBuffer | null =
392-
request.method !== "GET" && request.method !== "HEAD"
393-
? await request.clone().arrayBuffer()
394-
: null;
402+
const body: ArrayBuffer | null = bodyBuffer !== undefined
403+
? bodyBuffer
404+
: request.method !== "GET" && request.method !== "HEAD"
405+
? await request.clone().arrayBuffer()
406+
: null;
395407

396408
const headers = new Headers(request.headers);
397409
if (!headers.has("Host")) {
@@ -1214,6 +1226,12 @@ export interface DoubleKnockOptions {
12141226
*/
12151227
log?: (request: Request) => void;
12161228

1229+
/**
1230+
* The request body as ArrayBuffer. If provided, avoids cloning the request body.
1231+
* @since 1.8.0
1232+
*/
1233+
body?: ArrayBuffer | null;
1234+
12171235
/**
12181236
* The OpenTelemetry tracer provider. If omitted, the global tracer provider
12191237
* is used.
@@ -1225,13 +1243,13 @@ export interface DoubleKnockOptions {
12251243
* Helper function to create a new Request for redirect handling.
12261244
* @param request The original request.
12271245
* @param location The redirect location.
1228-
* @param body The request body as ArrayBuffer or undefined.
1246+
* @param body The request body as ArrayBuffer or null.
12291247
* @returns A new Request object for the redirect.
12301248
*/
12311249
function createRedirectRequest(
12321250
request: Request,
12331251
location: string,
1234-
body: ArrayBuffer | undefined,
1252+
body: ArrayBuffer | null,
12351253
): Request {
12361254
return new Request(location, {
12371255
method: request.method,
@@ -1269,11 +1287,19 @@ export async function doubleKnock(
12691287
const firstTrySpec: HttpMessageSignaturesSpec = specDeterminer == null
12701288
? "rfc9421"
12711289
: await specDeterminer.determineSpec(origin);
1290+
1291+
// Get the request body once at the top level to avoid multiple clones
1292+
const body = options.body !== undefined
1293+
? options.body
1294+
: request.method !== "GET" && request.method !== "HEAD"
1295+
? await request.clone().arrayBuffer()
1296+
: null;
1297+
12721298
let signedRequest = await signRequest(
12731299
request,
12741300
identity.privateKey,
12751301
identity.keyId,
1276-
{ spec: firstTrySpec, tracerProvider },
1302+
{ spec: firstTrySpec, tracerProvider, body },
12771303
);
12781304
log?.(signedRequest);
12791305
let response = await fetch(signedRequest, {
@@ -1288,13 +1314,10 @@ export async function doubleKnock(
12881314
response.headers.has("Location")
12891315
) {
12901316
const location = response.headers.get("Location")!;
1291-
const body = request.method !== "GET" && request.method !== "HEAD"
1292-
? await request.clone().arrayBuffer()
1293-
: undefined;
12941317
return doubleKnock(
12951318
createRedirectRequest(request, location, body),
12961319
identity,
1297-
options,
1320+
{ ...options, body },
12981321
);
12991322
} else if (
13001323
// FIXME: Temporary hotfix for Mastodon RFC 9421 implementation bug (as of 2025-06-19).
@@ -1323,7 +1346,7 @@ export async function doubleKnock(
13231346
request,
13241347
identity.privateKey,
13251348
identity.keyId,
1326-
{ spec, tracerProvider },
1349+
{ spec, tracerProvider, body },
13271350
);
13281351
log?.(signedRequest);
13291352
response = await fetch(signedRequest, {
@@ -1338,17 +1361,10 @@ export async function doubleKnock(
13381361
response.headers.has("Location")
13391362
) {
13401363
const location = response.headers.get("Location")!;
1341-
// IMPORTANT: Use arrayBuffer() instead of .body to prevent "TypeError: unusable"
1342-
// When using .body (ReadableStream), subsequent clone() calls in signRequest functions
1343-
// will fail because the stream has already been consumed. Using arrayBuffer() ensures
1344-
// the body can be safely cloned for HTTP signature generation.
1345-
const body = request.method !== "GET" && request.method !== "HEAD"
1346-
? await request.clone().arrayBuffer()
1347-
: undefined;
13481364
return doubleKnock(
13491365
createRedirectRequest(request, location, body),
13501366
identity,
1351-
options,
1367+
{ ...options, body },
13521368
);
13531369
} else if (response.status !== 400 && response.status !== 401) {
13541370
await specDeterminer?.rememberSpec(origin, spec);

0 commit comments

Comments
 (0)