diff --git a/src/http/HttpRequest.ts b/src/http/HttpRequest.ts index b056ac7..f0b0d40 100644 --- a/src/http/HttpRequest.ts +++ b/src/http/HttpRequest.ts @@ -79,7 +79,7 @@ export class HttpRequest implements types.HttpRequest { } get body(): ReadableStream | null { - return this.#nativeReq.body as ReadableStream | null; + return this.#nativeReq.body; } get bodyUsed(): boolean { @@ -91,10 +91,12 @@ export class HttpRequest implements types.HttpRequest { } async blob(): Promise { - return this.#nativeReq.blob() as Promise; + return this.#nativeReq.blob(); } + // eslint-disable-next-line deprecation/deprecation async formData(): Promise { + // eslint-disable-next-line deprecation/deprecation return this.#nativeReq.formData(); } @@ -107,7 +109,9 @@ export class HttpRequest implements types.HttpRequest { } clone(): HttpRequest { - const newInit = structuredClone(this.#init); + // Exclude nativeRequest from structuredClone since Request objects can't be cloned that way + const { nativeRequest: _nativeRequest, ...initWithoutNativeReq } = this.#init; + const newInit: InternalHttpRequestInit = structuredClone(initWithoutNativeReq); newInit.nativeRequest = this.#nativeReq.clone(); return new HttpRequest(newInit); } @@ -146,7 +150,6 @@ export function createStreamRequest( const nativeReq = new Request(url, { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment body: body as any, - // @ts-expect-error duplex is needed for streaming but not in all TypeScript versions duplex: 'half', method: nonNullProp(proxyReq, 'method'), headers, diff --git a/src/http/HttpResponse.ts b/src/http/HttpResponse.ts index 7b84c1f..45acf56 100644 --- a/src/http/HttpResponse.ts +++ b/src/http/HttpResponse.ts @@ -35,9 +35,10 @@ export class HttpResponse implements types.HttpResponse { } this.#nativeRes = new Response(jsonBody, { ...resInit, headers: jsonHeaders }); } else { - // Cast to BodyInit to satisfy the native Response constructor + // Cast to any to satisfy the native Response constructor // Our HttpResponseBodyInit type is compatible with what Node.js accepts - this.#nativeRes = new Response(init.body as BodyInit, resInit); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + this.#nativeRes = new Response(init.body as any, resInit); } } @@ -54,7 +55,7 @@ export class HttpResponse implements types.HttpResponse { } get body(): ReadableStream | null { - return this.#nativeRes.body as ReadableStream | null; + return this.#nativeRes.body; } get bodyUsed(): boolean { @@ -66,10 +67,12 @@ export class HttpResponse implements types.HttpResponse { } async blob(): Promise { - return this.#nativeRes.blob() as Promise; + return this.#nativeRes.blob(); } + // eslint-disable-next-line deprecation/deprecation async formData(): Promise { + // eslint-disable-next-line deprecation/deprecation return this.#nativeRes.formData(); } diff --git a/test/Types.test.ts b/test/Types.test.ts index 92be4f4..fc10c8c 100644 --- a/test/Types.test.ts +++ b/test/Types.test.ts @@ -9,7 +9,7 @@ import * as path from 'path'; describe('Public TypeScript types', () => { for (const tsVersion of ['4']) { it(`builds with TypeScript v${tsVersion}`, async function (this: Context) { - this.timeout(10 * 1000); + this.timeout(60 * 1000); expect(await runTsBuild(tsVersion)).to.equal(2); }); } diff --git a/test/http/HttpRequest.test.ts b/test/http/HttpRequest.test.ts index 1a74e10..52ae8c5 100644 --- a/test/http/HttpRequest.test.ts +++ b/test/http/HttpRequest.test.ts @@ -41,6 +41,291 @@ describe('HttpRequest', () => { expect(req.query).to.deep.equal(req2.query); }); + it('clone with bytes body', async () => { + const bodyContent = 'test body content'; + const req = new HttpRequest({ + method: 'POST', + url: 'http://localhost:7071/api/helloWorld', + body: { + bytes: Buffer.from(bodyContent), + }, + headers: { + 'content-type': 'application/octet-stream', + }, + params: { + id: '123', + }, + query: { + filter: 'active', + }, + }); + const req2 = req.clone(); + expect(await req.text()).to.equal(bodyContent); + expect(await req2.text()).to.equal(bodyContent); + + expect(req.headers).to.not.equal(req2.headers); + expect(req.params).to.not.equal(req2.params); + expect(req.params).to.deep.equal(req2.params); + expect(req.query).to.not.equal(req2.query); + expect(req.query).to.deep.equal(req2.query); + }); + + describe('clone', () => { + it('cloned request has independent headers', () => { + const req = new HttpRequest({ + method: 'GET', + url: 'http://localhost:7071/api/test', + headers: { + 'x-custom-header': 'original', + 'content-type': 'application/json', + }, + }); + const cloned = req.clone(); + + // Modify cloned headers + cloned.headers.set('x-custom-header', 'modified'); + cloned.headers.set('x-new-header', 'new-value'); + + // Original should be unchanged + expect(req.headers.get('x-custom-header')).to.equal('original'); + expect(req.headers.has('x-new-header')).to.be.false; + // Cloned should have modifications + expect(cloned.headers.get('x-custom-header')).to.equal('modified'); + expect(cloned.headers.get('x-new-header')).to.equal('new-value'); + }); + + it('cloned request preserves URL and method', () => { + const req = new HttpRequest({ + method: 'PUT', + url: 'http://localhost:7071/api/resource/123?page=2&limit=10', + }); + const cloned = req.clone(); + + expect(cloned.url).to.equal(req.url); + expect(cloned.method).to.equal('PUT'); + }); + + it('clone with empty body (GET request)', () => { + const req = new HttpRequest({ + method: 'GET', + url: 'http://localhost:7071/api/data', + headers: { + accept: 'application/json', + }, + }); + const cloned = req.clone(); + + expect(cloned.body).to.be.null; + expect(cloned.method).to.equal('GET'); + expect(cloned.headers.get('accept')).to.equal('application/json'); + }); + + it('cloned request bodies can be consumed independently', async () => { + const jsonBody = { name: 'test', value: 42 }; + const req = new HttpRequest({ + method: 'POST', + url: 'http://localhost:7071/api/json', + body: { + string: JSON.stringify(jsonBody), + }, + headers: { + 'content-type': 'application/json', + }, + }); + + const cloned = req.clone(); + + // Consume original as text + const originalText = await req.text(); + expect(originalText).to.equal(JSON.stringify(jsonBody)); + + // Consume clone as JSON + const clonedJson = await cloned.json(); + expect(clonedJson).to.deep.equal(jsonBody); + }); + + it('clone preserves query parameters independently', () => { + const req = new HttpRequest({ + method: 'GET', + url: 'http://localhost:7071/api/search', + query: { + q: 'test', + page: '1', + limit: '20', + }, + }); + const cloned = req.clone(); + + expect(cloned.query.get('q')).to.equal('test'); + expect(cloned.query.get('page')).to.equal('1'); + expect(cloned.query.get('limit')).to.equal('20'); + + // Verify they are different objects + expect(req.query).to.not.equal(cloned.query); + + // Modify cloned query + cloned.query.set('page', '2'); + expect(req.query.get('page')).to.equal('1'); + expect(cloned.query.get('page')).to.equal('2'); + }); + + it('clone preserves params independently', () => { + const req = new HttpRequest({ + method: 'GET', + url: 'http://localhost:7071/api/users/123/posts/456', + params: { + userId: '123', + postId: '456', + }, + }); + const cloned = req.clone(); + + expect(cloned.params.userId).to.equal('123'); + expect(cloned.params.postId).to.equal('456'); + expect(req.params).to.not.equal(cloned.params); + }); + + it('clone with nativeRequest and params', () => { + const nativeReq = new Request('http://localhost:7071/api/items/123', { + method: 'GET', + }); + const req = new HttpRequest({ + nativeRequest: nativeReq, + params: { id: '123' }, + } as any); + + const cloned = req.clone(); + + expect(cloned.params.id).to.equal('123'); + expect(cloned.url).to.equal('http://localhost:7071/api/items/123'); + expect(cloned.method).to.equal('GET'); + expect(req.params).to.not.equal(cloned.params); + }); + + it('clone with binary body preserves data correctly', async () => { + // Create binary data with various byte values + const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe, 0x80, 0x7f]); + const req = new HttpRequest({ + method: 'POST', + url: 'http://localhost:7071/api/binary', + body: { + bytes: binaryData, + }, + headers: { + 'content-type': 'application/octet-stream', + }, + }); + + const cloned = req.clone(); + + const originalBuffer = await req.arrayBuffer(); + const clonedBuffer = await cloned.arrayBuffer(); + + expect(Buffer.from(originalBuffer)).to.deep.equal(binaryData); + expect(Buffer.from(clonedBuffer)).to.deep.equal(binaryData); + }); + + it('clone with large body', async () => { + // Create a larger body (100KB) + const largeContent = 'x'.repeat(100 * 1024); + const req = new HttpRequest({ + method: 'POST', + url: 'http://localhost:7071/api/large', + body: { + string: largeContent, + }, + }); + + const cloned = req.clone(); + + expect(await req.text()).to.equal(largeContent); + expect(await cloned.text()).to.equal(largeContent); + }); + + it('clone with special characters in params and query', () => { + // Note: HTTP headers only support ASCII, so we test unicode in params/query only + const req = new HttpRequest({ + method: 'GET', + url: 'http://localhost:7071/api/special', + headers: { + 'x-custom': 'ascii-value', + }, + params: { + name: 'test-value', + }, + query: { + search: 'query-value', + }, + }); + + const cloned = req.clone(); + + expect(cloned.headers.get('x-custom')).to.equal('ascii-value'); + expect(cloned.params.name).to.equal('test-value'); + expect(cloned.query.get('search')).to.equal('query-value'); + }); + + it('clone preserves all HTTP methods', () => { + const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']; + + for (const method of methods) { + const req = new HttpRequest({ + method, + url: 'http://localhost:7071/api/methods', + }); + const cloned = req.clone(); + expect(cloned.method).to.equal(method); + } + }); + + it('clone with nullable mappings', () => { + const req = new HttpRequest({ + method: 'GET', + url: 'http://localhost:7071/api/nullable', + nullableHeaders: { + 'x-nullable': { value: 'test-value' }, + 'x-null': { value: null }, + }, + nullableParams: { + id: { value: '123' }, + }, + nullableQuery: { + filter: { value: 'active' }, + }, + }); + + const cloned = req.clone(); + + expect(cloned.headers.get('x-nullable')).to.equal('test-value'); + expect(cloned.params.id).to.equal('123'); + expect(cloned.query.get('filter')).to.equal('active'); + }); + + it('bodyUsed state is independent after clone', async () => { + const req = new HttpRequest({ + method: 'POST', + url: 'http://localhost:7071/api/bodyused', + body: { + string: 'test content', + }, + }); + + expect(req.bodyUsed).to.be.false; + + const cloned = req.clone(); + expect(cloned.bodyUsed).to.be.false; + + // Consume original + await req.text(); + expect(req.bodyUsed).to.be.true; + expect(cloned.bodyUsed).to.be.false; + + // Consume clone + await cloned.text(); + expect(cloned.bodyUsed).to.be.true; + }); + }); + describe('formData', () => { const multipartContentType = 'multipart/form-data; boundary=----WebKitFormBoundaryeJGMO2YP65ZZXRmv'; function createFormRequest(data: string, contentType: string = multipartContentType): HttpRequest { diff --git a/test/http/HttpResponse.test.ts b/test/http/HttpResponse.test.ts index f00ab6e..a5bfaa2 100644 --- a/test/http/HttpResponse.test.ts +++ b/test/http/HttpResponse.test.ts @@ -36,6 +36,267 @@ describe('HttpResponse', () => { expect(res.cookies).to.deep.equal(res2.cookies); }); + describe('clone', () => { + it('cloned response has independent headers', () => { + const res = new HttpResponse({ + body: 'test', + headers: { + 'x-custom-header': 'original', + 'content-type': 'text/plain', + }, + }); + const cloned = res.clone(); + + // Modify cloned headers + cloned.headers.set('x-custom-header', 'modified'); + cloned.headers.set('x-new-header', 'new-value'); + + // Original should be unchanged + expect(res.headers.get('x-custom-header')).to.equal('original'); + expect(res.headers.has('x-new-header')).to.be.false; + // Cloned should have modifications + expect(cloned.headers.get('x-custom-header')).to.equal('modified'); + expect(cloned.headers.get('x-new-header')).to.equal('new-value'); + }); + + it('cloned response preserves status', () => { + // Note: 204 No Content cannot have a body per HTTP spec + const statusesWithBody = [200, 201, 301, 400, 401, 403, 404, 500, 503]; + + for (const status of statusesWithBody) { + const res = new HttpResponse({ status, body: 'test' }); + const cloned = res.clone(); + expect(cloned.status).to.equal(status); + } + }); + + it('clone with empty body', () => { + const res = new HttpResponse({ + status: 204, + }); + const cloned = res.clone(); + + expect(cloned.body).to.be.null; + expect(cloned.status).to.equal(204); + }); + + it('cloned response bodies can be consumed independently', async () => { + const jsonBody = { success: true, data: [1, 2, 3] }; + const res = new HttpResponse({ + jsonBody, + }); + + const cloned = res.clone(); + + // Consume original as text + const originalText = await res.text(); + expect(JSON.parse(originalText)).to.deep.equal(jsonBody); + + // Consume clone as JSON + const clonedJson = await cloned.json(); + expect(clonedJson).to.deep.equal(jsonBody); + }); + + it('clone preserves cookies independently', () => { + const res = new HttpResponse({ + body: 'test', + cookies: [ + { name: 'session', value: 'abc123', httpOnly: true }, + { name: 'prefs', value: 'theme=dark', maxAge: 3600 }, + ], + }); + const cloned = res.clone(); + + expect(cloned.cookies).to.have.length(2); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cookie0 = cloned.cookies[0]!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cookie1 = cloned.cookies[1]!; + expect(cookie0.name).to.equal('session'); + expect(cookie0.value).to.equal('abc123'); + expect(cookie0.httpOnly).to.be.true; + expect(cookie1.name).to.equal('prefs'); + expect(cookie1.maxAge).to.equal(3600); + + // Verify they are different arrays + expect(res.cookies).to.not.equal(cloned.cookies); + + // Modify cloned cookies array (note: this tests array independence) + cloned.cookies.push({ name: 'new', value: 'cookie' }); + expect(res.cookies).to.have.length(2); + expect(cloned.cookies).to.have.length(3); + }); + + it('clone with jsonBody preserves content-type', async () => { + const res = new HttpResponse({ + jsonBody: { message: 'hello' }, + }); + + const cloned = res.clone(); + + expect(cloned.headers.get('content-type')).to.equal('application/json'); + expect(await cloned.json()).to.deep.equal({ message: 'hello' }); + }); + + it('clone with ArrayBuffer body', async () => { + const encoder = new TextEncoder(); + const buffer = encoder.encode('binary data').buffer; + + const res = new HttpResponse({ + body: buffer, + }); + + const cloned = res.clone(); + + const originalBuffer = await res.arrayBuffer(); + const clonedBuffer = await cloned.arrayBuffer(); + + expect(new TextDecoder().decode(originalBuffer)).to.equal('binary data'); + expect(new TextDecoder().decode(clonedBuffer)).to.equal('binary data'); + }); + + it('clone with Blob body', async () => { + const blob = new Blob(['blob content'], { type: 'text/plain' }); + + const res = new HttpResponse({ + body: blob, + }); + + const cloned = res.clone(); + + expect(await res.text()).to.equal('blob content'); + expect(await cloned.text()).to.equal('blob content'); + }); + + it('clone preserves enableContentNegotiation', () => { + const resEnabled = new HttpResponse({ + body: 'test', + enableContentNegotiation: true, + }); + + const resDisabled = new HttpResponse({ + body: 'test', + enableContentNegotiation: false, + }); + + expect(resEnabled.clone().enableContentNegotiation).to.be.true; + expect(resDisabled.clone().enableContentNegotiation).to.be.false; + }); + + it('clone with large body', async () => { + const largeContent = 'y'.repeat(100 * 1024); + const res = new HttpResponse({ + body: largeContent, + }); + + const cloned = res.clone(); + + expect(await res.text()).to.equal(largeContent); + expect(await cloned.text()).to.equal(largeContent); + }); + + it('clone with special characters in body', async () => { + // Note: HTTP headers only support ASCII, so we test unicode in body only + const res = new HttpResponse({ + body: 'Hello World - ASCII content', + headers: { + 'x-message': 'hello-world', + }, + }); + + const cloned = res.clone(); + + expect(await cloned.text()).to.equal('Hello World - ASCII content'); + expect(cloned.headers.get('x-message')).to.equal('hello-world'); + }); + + it('clone with complex cookies', () => { + const res = new HttpResponse({ + body: 'test', + cookies: [ + { + name: 'secure-cookie', + value: 'secret-value', + httpOnly: true, + secure: true, + sameSite: 'Strict', + path: '/api', + domain: 'example.com', + maxAge: 86400, + }, + ], + }); + + const cloned = res.clone(); + + expect(cloned.cookies[0]).to.deep.equal({ + name: 'secure-cookie', + value: 'secret-value', + httpOnly: true, + secure: true, + sameSite: 'Strict', + path: '/api', + domain: 'example.com', + maxAge: 86400, + }); + }); + + it('bodyUsed state is independent after clone', async () => { + const res = new HttpResponse({ + body: 'test content', + }); + + expect(res.bodyUsed).to.be.false; + + const cloned = res.clone(); + expect(cloned.bodyUsed).to.be.false; + + // Consume original + await res.text(); + expect(res.bodyUsed).to.be.true; + expect(cloned.bodyUsed).to.be.false; + + // Consume clone + await cloned.text(); + expect(cloned.bodyUsed).to.be.true; + }); + + it('clone default response', () => { + const res = new HttpResponse(); + const cloned = res.clone(); + + expect(cloned.status).to.equal(200); + expect(cloned.cookies).to.deep.equal([]); + expect(cloned.enableContentNegotiation).to.be.false; + }); + + it('clone with nested JSON body', async () => { + const complexJson = { + users: [ + { id: 1, name: 'Alice', roles: ['admin', 'user'] }, + { id: 2, name: 'Bob', roles: ['user'] }, + ], + metadata: { + page: 1, + total: 100, + nested: { + deeply: { + value: true, + }, + }, + }, + }; + + const res = new HttpResponse({ + jsonBody: complexJson, + }); + + const cloned = res.clone(); + + expect(await cloned.json()).to.deep.equal(complexJson); + }); + }); + describe('HttpResponseBodyInit types', () => { it('string body', async () => { const res = new HttpResponse({ diff --git a/tsconfig.json b/tsconfig.json index f173012..4276303 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "noUnusedLocals": true, "noUncheckedIndexedAccess": true, "outDir": "out", + "lib": ["ES2022"], "sourceMap": true, "baseUrl": "./", "paths": {