From 7fb47d8e6a988658089315970d5662f5bf6bcb1f Mon Sep 17 00:00:00 2001 From: Arda TANRIKULU Date: Wed, 11 Dec 2024 00:17:34 +0300 Subject: [PATCH] Several fixes (#1872) --- .changeset/chilly-beans-destroy.md | 7 +++++ .changeset/good-pans-remember.md | 11 ++++++++ .changeset/shy-hotels-roll.md | 33 ++++++++++++++++++++++ .changeset/sweet-mayflies-decide.md | 7 +++++ packages/node-fetch/src/URL.ts | 13 +++++++++ packages/node-fetch/src/declarations.d.ts | 2 +- packages/node-fetch/src/fetchNodeHttp.ts | 3 +- packages/node-fetch/tests/URL.spec.ts | 12 ++++++++ packages/server/src/createServerAdapter.ts | 2 ++ packages/server/test/node.spec.ts | 14 ++++++++- 10 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 .changeset/chilly-beans-destroy.md create mode 100644 .changeset/good-pans-remember.md create mode 100644 .changeset/shy-hotels-roll.md create mode 100644 .changeset/sweet-mayflies-decide.md diff --git a/.changeset/chilly-beans-destroy.md b/.changeset/chilly-beans-destroy.md new file mode 100644 index 00000000000..fc27d316196 --- /dev/null +++ b/.changeset/chilly-beans-destroy.md @@ -0,0 +1,7 @@ +--- +'@whatwg-node/node-fetch': patch +--- + +Fix the error thrown \`ENOTFOUND\` when a parsed URL with IPV6 hostname is given + +Instead of using the parsed URL passed to the `fetch` function, let `node:http` parse it again. This way, the IPV6 hostname is correctly resolved. diff --git a/.changeset/good-pans-remember.md b/.changeset/good-pans-remember.md new file mode 100644 index 00000000000..622a301a956 --- /dev/null +++ b/.changeset/good-pans-remember.md @@ -0,0 +1,11 @@ +--- +'@whatwg-node/node-fetch': patch +--- + +`url.searchParams` parameter should reflect the changes in `toString()` + +```ts +const url = new URL('http://example.com/?a=b'); +url.searchParams.set('a', 'c'); +console.log(url.toString()); // http://example.com/?a=c +``` diff --git a/.changeset/shy-hotels-roll.md b/.changeset/shy-hotels-roll.md new file mode 100644 index 00000000000..b0ece225311 --- /dev/null +++ b/.changeset/shy-hotels-roll.md @@ -0,0 +1,33 @@ +--- +'@whatwg-node/server': patch +--- + +Wait for remaining promises during `asyncDispose` correctly + +The `asyncDispose` function should wait for all remaining promises to resolve before returning. This ensures that the server is fully disposed of before the function returns. + +```ts +import { createServerAdapter } from '@whatwg-node/server'; + +const deferred = Promise.withResolvers(); + +const adapter = createServerAdapter((req, ctx) => { + ctx.waitUntil(deferred.promise); + return new Response('Hello, world!'); +}); + +const res = await adapter.fetch('http://example.com'); +console.assert(res.status === 200); +console.assert(await res.text() === 'Hello, world!'); + +let disposed = false; +adapter[Symbol.asyncDispose]().then(() => { + disposed = true; +}); + +console.assert(!disposed); + +deferred.resolve(); + +console.assert(disposed); +``` \ No newline at end of file diff --git a/.changeset/sweet-mayflies-decide.md b/.changeset/sweet-mayflies-decide.md new file mode 100644 index 00000000000..4c6d0a811bc --- /dev/null +++ b/.changeset/sweet-mayflies-decide.md @@ -0,0 +1,7 @@ +--- +'@whatwg-node/node-fetch': patch +--- + +Fix IPV6 parsing in \`URL\`; + +`new URL('http://[::1]')` should parse the host as \`[::1]\` not \`::1\`. diff --git a/packages/node-fetch/src/URL.ts b/packages/node-fetch/src/URL.ts index 34788121960..8eca2f41f07 100644 --- a/packages/node-fetch/src/URL.ts +++ b/packages/node-fetch/src/URL.ts @@ -7,6 +7,9 @@ import { PonyfillURLSearchParams } from './URLSearchParams.js'; FastUrl.queryString = FastQuerystring; +const IPV6_REGEX = + /^(?:(?:(?:[0-9A-Fa-f]{1,4}:){6}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]))|::(?:[0-9A-Fa-f]{1,4}:){5}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]))|(?:[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){4}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,1}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){3}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:){2}(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}:(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})?::(?:[0-9A-Fa-f]{1,4}:[0-9A-Fa-f]{1,4}|(?:(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}(?:0?0?[0-9]|0?[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5]))|(?:(?:[0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})?::[0-9A-Fa-f]{1,4}|(?:(?:[0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})?::)(?:\/(?:0?0?[0-9]|0?[1-9][0-9]|1[01][0-9]|12[0-8]))?)$/; + export class PonyfillURL extends FastUrl implements URL { constructor(url: string, base?: string | URL) { super(); @@ -16,6 +19,13 @@ export class PonyfillURL extends FastUrl implements URL { return; } this.parse(url, false); + // `fast-url-parser` cannot handle ipv6 hosts correctly + if ( + (url.startsWith('http://[') || url.startsWith('https://[')) && + IPV6_REGEX.test(this.hostname) + ) { + this.hostname = `[${this.hostname}]`; + } if (base) { const baseParsed = typeof base === 'string' ? new PonyfillURL(base) : base; this.protocol ||= baseParsed.protocol; @@ -55,6 +65,9 @@ export class PonyfillURL extends FastUrl implements URL { } toString(): string { + if (this._searchParams) { + this.search = this._searchParams.toString(); + } return this.format(); } diff --git a/packages/node-fetch/src/declarations.d.ts b/packages/node-fetch/src/declarations.d.ts index 650f31dcc8e..a5b76376b68 100644 --- a/packages/node-fetch/src/declarations.d.ts +++ b/packages/node-fetch/src/declarations.d.ts @@ -23,7 +23,7 @@ declare module '@kamilkisiela/fast-url-parser' { path: string; pathname: string; protocol: string; - readonly search: string; + search: string; slashes: boolean; port: string; query: string | any; diff --git a/packages/node-fetch/src/fetchNodeHttp.ts b/packages/node-fetch/src/fetchNodeHttp.ts index c170fe10f44..7993eb9f92f 100644 --- a/packages/node-fetch/src/fetchNodeHttp.ts +++ b/packages/node-fetch/src/fetchNodeHttp.ts @@ -41,7 +41,8 @@ export function fetchNodeHttp( let nodeRequest: ReturnType; - if (fetchRequest.parsedUrl) { + // Skip using parsed URL if it's an IPv6 address (starting with brackets) + if (fetchRequest.parsedUrl && !fetchRequest.parsedUrl.hostname?.startsWith('[')) { nodeRequest = requestFn({ auth: fetchRequest.parsedUrl.username ? `${fetchRequest.parsedUrl.username}:${fetchRequest.parsedUrl.password}` diff --git a/packages/node-fetch/tests/URL.spec.ts b/packages/node-fetch/tests/URL.spec.ts index 0aa4dd49ed6..a0de193d665 100644 --- a/packages/node-fetch/tests/URL.spec.ts +++ b/packages/node-fetch/tests/URL.spec.ts @@ -10,4 +10,16 @@ describe('URL', () => { expect(url.searchParams.get('baz')).toBeNull(); expect(url.searchParams.getAll('baz')).toEqual([]); }); + it('should update search params', () => { + const url = new PonyfillURL('https://example.com/?foo=bar&foo=baz&bar=qux'); + url.searchParams.set('foo', 'qux'); + url.searchParams.delete('baz'); + expect(url.toString()).toBe('https://example.com/?foo=qux&bar=qux'); + }); + it('parses ipv6 hosts', () => { + const url = new PonyfillURL('http://[::1]:8000'); + expect(url.host).toBe('[::1]:8000'); + expect(url.hostname).toBe('[::1]'); + expect(url.port).toBe('8000'); + }); }); diff --git a/packages/server/src/createServerAdapter.ts b/packages/server/src/createServerAdapter.ts index e34e4f6a9e0..4a99c6d1b62 100644 --- a/packages/server/src/createServerAdapter.ts +++ b/packages/server/src/createServerAdapter.ts @@ -122,6 +122,8 @@ function createServerAdapter< } function waitUntil(promiseLike: PromiseLike) { + // Ensure that the disposable stack is created + ensureDisposableStack(); waitUntilPromises.add(promiseLike); promiseLike.then( () => { diff --git a/packages/server/test/node.spec.ts b/packages/server/test/node.spec.ts index fd3f40f85f8..0ce870d6fe3 100644 --- a/packages/server/test/node.spec.ts +++ b/packages/server/test/node.spec.ts @@ -211,7 +211,7 @@ describe('Node Specific Cases', () => { // TODO: Flakey on native fetch if (!process.env.LEAK_TEST || fetchImplName.toLowerCase() !== 'native') { - it.only('handles Request.signal inside adapter correctly', async () => { + it('handles Request.signal inside adapter correctly', async () => { const abortListener = jest.fn(); const abortDeferred = createDeferredPromise(); const adapterResponseDeferred = createDeferredPromise(); @@ -374,6 +374,18 @@ describe('Node Specific Cases', () => { await setTimeout(100); expect(disposedThen).toHaveBeenCalled(); }); + + it('handles ipv6 addresses correctly', async () => { + await using serverAdapter = createServerAdapter(() => { + return new Response('Hello world!', { status: 200 }); + }); + await testServer.addOnceHandler(serverAdapter); + const port = new URL(testServer.url).port; + const ipv6Url = new URL(`http://[::1]:${port}/`); + const response = await fetch(ipv6Url); + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe('Hello world!'); + }); }); }, );