Skip to content

Commit bbbde99

Browse files
feat(node): add clone() support to ImpitResponse (#419)
## Summary Implements `Response.clone()` on `ImpitResponse` using `ReadableStream.tee()`, making impit compatible with libraries like [ky](https://github.com/sindresorhus/ky) that call `response.clone()` internally. ## Approach When `.clone()` is called: 1. `this.body.tee()` splits the underlying `ReadableStream` into two independent streams (synchronous, no eager buffering) 2. The original response's `.body` getter and body methods (`text`, `json`, `arrayBuffer`, `bytes`) are re-patched to read from one stream 3. A standard `Response` is returned as the clone, backed by the other stream Multiple clones are supported (matching the Fetch spec) — each call tees the current branch, so the original and all clones can be read independently. Charset-aware `text()` decoding is preserved on the original via `decodeBuffer`. The clone uses standard `Response.text()` (UTF-8). Throws `TypeError` on clone after body consumption, matching Fetch API semantics. ## Changes - **`index.wrapper.js`** — add `clone()` in `#wrapResponse`, re-patch `.body` getter and body methods with `configurable: true` so subsequent clones work - **`dts-header.d.ts`** — add `clone(): Response` to `ImpitResponse` via declaration merging - **`test/basics.test.ts`** — tests covering: return type, url/header preservation, independent body reads, text() on both, multiple clones, body streaming after clone, clone-after-consume error, arrayBuffer on both, read ordering, non-200 status --------- Co-authored-by: Jindřich Bär <jindrichbar@gmail.com>
1 parent 6406d55 commit bbbde99

4 files changed

Lines changed: 234 additions & 4 deletions

File tree

impit-node/index.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,18 @@ export declare class ImpitResponse {
224224
* ```
225225
*/
226226
get body(): ReadableStream<Uint8Array>
227+
/**
228+
* Creates a copy of the response.
229+
*
230+
* The original response's body methods are re-bound to one half of the
231+
* tee'd stream; the returned clone is a standard `Response` backed by the
232+
* other half.
233+
*
234+
* Calling `clone()` after the body has been consumed throws a `TypeError`.
235+
*
236+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response/clone | Fetch API `Response.clone()`}
237+
*/
238+
clone(): Response
227239
/**
228240
* Aborts the response.
229241
*

impit-node/index.wrapper.js

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -272,34 +272,120 @@ class Impit extends native.Impit {
272272
}
273273

274274
Object.defineProperty(originalResponse, 'text', {
275-
value: ResponsePatches.text.bind(originalResponse)
275+
value: ResponsePatches.text.bind(originalResponse),
276+
configurable: true,
276277
});
277278

279+
let bodyConsumed = false;
280+
278281
const nativeBytes = originalResponse.bytes.bind(originalResponse);
279282
Object.defineProperty(originalResponse, 'bytes', {
280283
value: async function() {
284+
bodyConsumed = true;
281285
try { return await nativeBytes(); } finally { cleanup(); }
282-
}
286+
},
287+
configurable: true,
283288
});
284289

285290
const nativeArrayBuffer = originalResponse.arrayBuffer.bind(originalResponse);
286291
Object.defineProperty(originalResponse, 'arrayBuffer', {
287292
value: async function() {
293+
bodyConsumed = true;
288294
try { return await nativeArrayBuffer(); } finally { cleanup(); }
289-
}
295+
},
296+
configurable: true,
290297
});
291298

292299
const nativeJson = originalResponse.json.bind(originalResponse);
293300
Object.defineProperty(originalResponse, 'json', {
294301
value: async function() {
302+
bodyConsumed = true;
295303
try { return await nativeJson(); } finally { cleanup(); }
296-
}
304+
},
305+
configurable: true,
297306
});
298307

299308
Object.defineProperty(originalResponse, 'headers', {
300309
value: new Headers(originalResponse.headers)
301310
});
302311

312+
Object.defineProperty(originalResponse, 'clone', {
313+
value: function () {
314+
if (bodyConsumed) {
315+
throw new TypeError('Response body has already been consumed');
316+
}
317+
318+
const [stream1, stream2] = this.body.tee();
319+
320+
// Create a delegate Response from stream1 for the original's body methods
321+
const delegate = new Response(stream1, {
322+
status: this.status,
323+
statusText: this.statusText,
324+
headers: this.headers,
325+
});
326+
327+
// Re-patch original's body getter to return the delegate's stream
328+
// (the original stream is now locked after tee)
329+
Object.defineProperty(this, 'body', {
330+
get: () => delegate.body,
331+
configurable: true,
332+
});
333+
334+
// Re-patch original's body methods to read from the delegate
335+
const decodeBuffer = this.decodeBuffer.bind(this);
336+
Object.defineProperty(this, 'arrayBuffer', {
337+
value: async function () {
338+
bodyConsumed = true;
339+
try { return await delegate.arrayBuffer(); } finally { cleanup(); }
340+
},
341+
configurable: true,
342+
});
343+
Object.defineProperty(this, 'bytes', {
344+
value: async function () {
345+
bodyConsumed = true;
346+
try { return await delegate.bytes(); } finally { cleanup(); }
347+
},
348+
configurable: true,
349+
});
350+
Object.defineProperty(this, 'json', {
351+
value: async function () {
352+
bodyConsumed = true;
353+
try { return await delegate.json(); } finally { cleanup(); }
354+
},
355+
configurable: true,
356+
});
357+
Object.defineProperty(this, 'text', {
358+
value: async function () {
359+
bodyConsumed = true;
360+
try {
361+
const buffer = await delegate.arrayBuffer();
362+
return decodeBuffer(Buffer.from(buffer));
363+
} finally { cleanup(); }
364+
},
365+
configurable: true,
366+
});
367+
368+
// Create the clone from stream2
369+
const clone = new Response(stream2, {
370+
status: this.status,
371+
statusText: this.statusText,
372+
headers: this.headers,
373+
});
374+
Object.defineProperty(clone, 'url', {
375+
value: this.url,
376+
enumerable: true,
377+
});
378+
Object.defineProperty(clone, 'text', {
379+
value: async function () {
380+
const buffer = await clone.arrayBuffer();
381+
return decodeBuffer(Buffer.from(buffer));
382+
},
383+
});
384+
385+
return clone;
386+
},
387+
});
388+
303389
return originalResponse;
304390
}
305391
}

impit-node/src/response.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,23 @@ impl<'env> ImpitResponse {
311311
response.get_named_property("body")
312312
}
313313

314+
/// Creates a copy of the response.
315+
///
316+
/// The original response's body methods are re-bound to one half of the
317+
/// tee'd stream; the returned clone is a standard `Response` backed by the
318+
/// other half.
319+
///
320+
/// Calling `clone()` after the body has been consumed throws a `TypeError`.
321+
///
322+
/// @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response/clone | Fetch API `Response.clone()`}
323+
#[napi(js_name = "clone", ts_return_type = "Response")]
324+
pub fn clone_response(&self) -> Result<()> {
325+
Err(napi::Error::new(
326+
napi::Status::GenericFailure,
327+
"clone() is implemented in the JavaScript wrapper".to_string(),
328+
))
329+
}
330+
314331
/// Aborts the response.
315332
///
316333
/// This API is called internally and can change without notice.

impit-node/test/basics.test.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,121 @@ describe.each([
623623
});
624624
});
625625

626+
describe('Response.clone()', () => {
627+
test('clone returns a standard Response', async () => {
628+
const response = await impit.fetch(getHttpBinUrl('/get'));
629+
const clone = response.clone();
630+
631+
expect(clone).toBeInstanceOf(Response);
632+
expect(clone.status).toBe(200);
633+
expect(clone.ok).toBe(true);
634+
});
635+
636+
test('clone preserves url', async () => {
637+
const response = await impit.fetch(getHttpBinUrl('/get'));
638+
const clone = response.clone();
639+
640+
expect(clone.url).toBe(response.url);
641+
});
642+
643+
test('clone preserves headers', async () => {
644+
const response = await impit.fetch(getHttpBinUrl('/get'));
645+
const clone = response.clone();
646+
647+
expect(clone.headers.get('content-type')).toBe(
648+
response.headers.get('content-type'),
649+
);
650+
});
651+
652+
test('both original and clone bodies are independently readable', async () => {
653+
const response = await impit.fetch(getHttpBinUrl('/get'));
654+
const clone = response.clone();
655+
656+
const cloneData = await clone.json();
657+
const originalData = await response.json();
658+
659+
expect(cloneData).toEqual(originalData);
660+
});
661+
662+
test('text() works on both original and clone', async () => {
663+
const response = await impit.fetch(getHttpBinUrl('/get'));
664+
const clone = response.clone();
665+
666+
const cloneText = await clone.text();
667+
const originalText = await response.text();
668+
669+
expect(cloneText.length).toBeGreaterThan(0);
670+
expect(cloneText).toBe(originalText);
671+
});
672+
673+
test('multiple clones produce independent readable bodies', async () => {
674+
const response = await impit.fetch(getHttpBinUrl('/get'));
675+
const clone1 = response.clone();
676+
const clone2 = response.clone();
677+
678+
const [original, first, second] = await Promise.all([
679+
response.json(),
680+
clone1.json(),
681+
clone2.json(),
682+
]);
683+
684+
expect(original).toEqual(first);
685+
expect(original).toEqual(second);
686+
});
687+
688+
test('clone() after body consumed throws TypeError', async () => {
689+
const response = await impit.fetch(getHttpBinUrl('/get'));
690+
await response.text();
691+
692+
expect(() => response.clone()).toThrow(TypeError);
693+
expect(() => response.clone()).toThrow(/body has already been consumed/);
694+
});
695+
696+
test('response.body is streamable after clone', async () => {
697+
const response = await impit.fetch(getHttpBinUrl('/get'));
698+
response.clone();
699+
700+
const reader = response.body.getReader();
701+
const chunks: Uint8Array[] = [];
702+
while (true) {
703+
const { done, value } = await reader.read();
704+
if (done) break;
705+
chunks.push(value);
706+
}
707+
708+
expect(chunks.length).toBeGreaterThan(0);
709+
});
710+
711+
test('arrayBuffer() works on both original and clone', async () => {
712+
const response = await impit.fetch(getHttpBinUrl('/get'));
713+
const clone = response.clone();
714+
715+
const cloneBuf = await clone.arrayBuffer();
716+
const originalBuf = await response.arrayBuffer();
717+
718+
expect(cloneBuf.byteLength).toBeGreaterThan(0);
719+
expect(cloneBuf.byteLength).toBe(originalBuf.byteLength);
720+
});
721+
722+
test('reading original first, then clone', async () => {
723+
const response = await impit.fetch(getHttpBinUrl('/get'));
724+
const clone = response.clone();
725+
726+
const originalData = await response.json();
727+
const cloneData = await clone.json();
728+
729+
expect(originalData).toEqual(cloneData);
730+
});
731+
732+
test('clone preserves non-200 status', async () => {
733+
const response = await impit.fetch(getHttpBinUrl('/status/404'));
734+
const clone = response.clone();
735+
736+
expect(clone.status).toBe(404);
737+
expect(clone.ok).toBe(false);
738+
});
739+
});
740+
626741
describe('Redirects', () => {
627742
test('follows redirects by default', async () => {
628743
const response = await impit.fetch('http://localhost:3001/redirect/1');

0 commit comments

Comments
 (0)