Skip to content
Merged
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
12 changes: 12 additions & 0 deletions impit-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ export declare class ImpitResponse {
* ```
*/
get body(): ReadableStream<Uint8Array>
/**
* Creates a copy of the response.
*
* The original response's body methods are re-bound to one half of the
* tee'd stream; the returned clone is a standard `Response` backed by the
* other half.
*
* Calling `clone()` after the body has been consumed throws a `TypeError`.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response/clone | Fetch API `Response.clone()`}
*/
clone(): Response
/**
* Aborts the response.
*
Expand Down
94 changes: 90 additions & 4 deletions impit-node/index.wrapper.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,34 +272,120 @@ class Impit extends native.Impit {
}

Object.defineProperty(originalResponse, 'text', {
value: ResponsePatches.text.bind(originalResponse)
value: ResponsePatches.text.bind(originalResponse),
configurable: true,
});

let bodyConsumed = false;

const nativeBytes = originalResponse.bytes.bind(originalResponse);
Object.defineProperty(originalResponse, 'bytes', {
value: async function() {
bodyConsumed = true;
try { return await nativeBytes(); } finally { cleanup(); }
}
},
configurable: true,
});

const nativeArrayBuffer = originalResponse.arrayBuffer.bind(originalResponse);
Object.defineProperty(originalResponse, 'arrayBuffer', {
value: async function() {
bodyConsumed = true;
try { return await nativeArrayBuffer(); } finally { cleanup(); }
}
},
configurable: true,
});

const nativeJson = originalResponse.json.bind(originalResponse);
Object.defineProperty(originalResponse, 'json', {
value: async function() {
bodyConsumed = true;
try { return await nativeJson(); } finally { cleanup(); }
}
},
configurable: true,
});

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

Object.defineProperty(originalResponse, 'clone', {
value: function () {
if (bodyConsumed) {
throw new TypeError('Response body has already been consumed');
}

const [stream1, stream2] = this.body.tee();

// Create a delegate Response from stream1 for the original's body methods
const delegate = new Response(stream1, {
status: this.status,
statusText: this.statusText,
headers: this.headers,
});

// Re-patch original's body getter to return the delegate's stream
// (the original stream is now locked after tee)
Object.defineProperty(this, 'body', {
get: () => delegate.body,
configurable: true,
});

// Re-patch original's body methods to read from the delegate
const decodeBuffer = this.decodeBuffer.bind(this);
Object.defineProperty(this, 'arrayBuffer', {
value: async function () {
bodyConsumed = true;
try { return await delegate.arrayBuffer(); } finally { cleanup(); }
},
configurable: true,
});
Object.defineProperty(this, 'bytes', {
value: async function () {
bodyConsumed = true;
try { return await delegate.bytes(); } finally { cleanup(); }
},
configurable: true,
});
Object.defineProperty(this, 'json', {
value: async function () {
bodyConsumed = true;
try { return await delegate.json(); } finally { cleanup(); }
},
configurable: true,
});
Object.defineProperty(this, 'text', {
value: async function () {
bodyConsumed = true;
try {
const buffer = await delegate.arrayBuffer();
return decodeBuffer(Buffer.from(buffer));
} finally { cleanup(); }
},
configurable: true,
});

// Create the clone from stream2
const clone = new Response(stream2, {
status: this.status,
statusText: this.statusText,
headers: this.headers,
});
Object.defineProperty(clone, 'url', {
value: this.url,
enumerable: true,
});
Object.defineProperty(clone, 'text', {
value: async function () {
const buffer = await clone.arrayBuffer();
return decodeBuffer(Buffer.from(buffer));
},
});

return clone;
},
});

return originalResponse;
}
}
Expand Down
17 changes: 17 additions & 0 deletions impit-node/src/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,23 @@ impl<'env> ImpitResponse {
response.get_named_property("body")
}

/// Creates a copy of the response.
///
/// The original response's body methods are re-bound to one half of the
/// tee'd stream; the returned clone is a standard `Response` backed by the
/// other half.
///
/// Calling `clone()` after the body has been consumed throws a `TypeError`.
///
/// @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response/clone | Fetch API `Response.clone()`}
#[napi(js_name = "clone", ts_return_type = "Response")]
pub fn clone_response(&self) -> Result<()> {
Err(napi::Error::new(
napi::Status::GenericFailure,
"clone() is implemented in the JavaScript wrapper".to_string(),
))
}

/// Aborts the response.
///
/// This API is called internally and can change without notice.
Expand Down
115 changes: 115 additions & 0 deletions impit-node/test/basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,121 @@ describe.each([
});
});

describe('Response.clone()', () => {
test('clone returns a standard Response', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
const clone = response.clone();

expect(clone).toBeInstanceOf(Response);
expect(clone.status).toBe(200);
expect(clone.ok).toBe(true);
});

test('clone preserves url', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
const clone = response.clone();

expect(clone.url).toBe(response.url);
});

test('clone preserves headers', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
const clone = response.clone();

expect(clone.headers.get('content-type')).toBe(
response.headers.get('content-type'),
);
});

test('both original and clone bodies are independently readable', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
const clone = response.clone();

const cloneData = await clone.json();
const originalData = await response.json();

expect(cloneData).toEqual(originalData);
});

test('text() works on both original and clone', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
const clone = response.clone();

const cloneText = await clone.text();
const originalText = await response.text();

expect(cloneText.length).toBeGreaterThan(0);
expect(cloneText).toBe(originalText);
});

test('multiple clones produce independent readable bodies', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
const clone1 = response.clone();
const clone2 = response.clone();

const [original, first, second] = await Promise.all([
response.json(),
clone1.json(),
clone2.json(),
]);

expect(original).toEqual(first);
expect(original).toEqual(second);
});

test('clone() after body consumed throws TypeError', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
await response.text();

expect(() => response.clone()).toThrow(TypeError);
expect(() => response.clone()).toThrow(/body has already been consumed/);
});

test('response.body is streamable after clone', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
response.clone();

const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}

expect(chunks.length).toBeGreaterThan(0);
});

test('arrayBuffer() works on both original and clone', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
const clone = response.clone();

const cloneBuf = await clone.arrayBuffer();
const originalBuf = await response.arrayBuffer();

expect(cloneBuf.byteLength).toBeGreaterThan(0);
expect(cloneBuf.byteLength).toBe(originalBuf.byteLength);
});

test('reading original first, then clone', async () => {
const response = await impit.fetch(getHttpBinUrl('/get'));
const clone = response.clone();

const originalData = await response.json();
const cloneData = await clone.json();

expect(originalData).toEqual(cloneData);
});

test('clone preserves non-200 status', async () => {
const response = await impit.fetch(getHttpBinUrl('/status/404'));
const clone = response.clone();

expect(clone.status).toBe(404);
expect(clone.ok).toBe(false);
});
});

describe('Redirects', () => {
test('follows redirects by default', async () => {
const response = await impit.fetch('http://localhost:3001/redirect/1');
Expand Down
Loading