From 83cfccc04afd04de73bfdc92cf604d97e57244e4 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 29 Jun 2026 12:44:19 +0000 Subject: [PATCH 1/3] Support HTTP QUERY method (RFC 10008) Add QUERY to normalized method records, safe HTTP methods, idempotent default, retryable methods, and fetch safe methods. Closes #5454 --- lib/core/request.js | 2 +- lib/core/util.js | 6 ++++-- lib/handler/redirect-handler.js | 1 + lib/handler/retry-handler.js | 2 +- lib/web/fetch/constants.js | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/core/request.js b/lib/core/request.js index 2d2675a5065..8e2072a46c7 100644 --- a/lib/core/request.js +++ b/lib/core/request.js @@ -225,7 +225,7 @@ class Request { this.protocol = getProtocolFromUrlString(origin) this.idempotent = idempotent == null - ? method === 'HEAD' || method === 'GET' + ? method === 'HEAD' || method === 'GET' || method === 'QUERY' : idempotent this.blocking = blocking ?? this.method !== 'HEAD' diff --git a/lib/core/util.js b/lib/core/util.js index 1f6611d6404..f95122f5c1f 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -960,7 +960,9 @@ const normalizedMethodRecordsBase = { post: 'POST', POST: 'POST', put: 'PUT', - PUT: 'PUT' + PUT: 'PUT', + query: 'QUERY', + QUERY: 'QUERY' } const normalizedMethodRecords = { @@ -1014,7 +1016,7 @@ module.exports = { normalizedMethodRecords, isValidPort, isHttpOrHttpsPrefixed, - safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']), + safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY']), wrapRequestBody, setupConnectTimeout, getProtocolFromUrlString diff --git a/lib/handler/redirect-handler.js b/lib/handler/redirect-handler.js index 8ead972b742..fd2158302e5 100644 --- a/lib/handler/redirect-handler.js +++ b/lib/handler/redirect-handler.js @@ -55,6 +55,7 @@ class RedirectHandler { // https://tools.ietf.org/html/rfc7231#section-6.4.2 // https://fetch.spec.whatwg.org/#http-redirect-fetch // In case of HTTP 301 or 302 with POST, change the method to GET + // QUERY is safe (RFC 10008) and should not change method like GET. if ((statusCode === 301 || statusCode === 302) && this.opts.method === 'POST') { this.opts.method = 'GET' if (util.isStream(this.opts.body)) { diff --git a/lib/handler/retry-handler.js b/lib/handler/retry-handler.js index 7fbc1a6ad77..7928d447ee6 100644 --- a/lib/handler/retry-handler.js +++ b/lib/handler/retry-handler.js @@ -67,7 +67,7 @@ class RetryHandler { timeoutFactor: timeoutFactor ?? 2, maxRetries: maxRetries ?? 5, // What errors we should retry - methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'], + methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'QUERY'], // Indicates which errors to retry statusCodes: statusCodes ?? [500, 502, 503, 504, 429], // List of errors to retry diff --git a/lib/web/fetch/constants.js b/lib/web/fetch/constants.js index ef63b0c8e10..b39a0f594e5 100644 --- a/lib/web/fetch/constants.js +++ b/lib/web/fetch/constants.js @@ -46,7 +46,7 @@ const referrerPolicyTokensSet = new Set(referrerPolicyTokens) const requestRedirect = /** @type {const} */ (['follow', 'manual', 'error']) -const safeMethods = /** @type {const} */ (['GET', 'HEAD', 'OPTIONS', 'TRACE']) +const safeMethods = /** @type {const} */ (['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY']) const safeMethodsSet = new Set(safeMethods) const requestMode = /** @type {const} */ (['navigate', 'same-origin', 'no-cors', 'cors']) From 9cf7fa285fc0995c0fb2075dd5c45b76d352bd4b Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 30 Jun 2026 11:07:16 +0000 Subject: [PATCH 2/3] test: add QUERY method tests Add comprehensive tests for HTTP QUERY (RFC 10008): - QUERY with string, Buffer, stream bodies - QUERY without body and with null body - Content-Type and Accept-Query headers - Content-Length header sending - Content-Length mismatch error handling - Fetch API with QUERY method - FormData body with QUERY - Redirect 301 (QUERY stays QUERY - safe method) - Redirect 303 (QUERY changes to GET) --- test/query.js | 298 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 test/query.js diff --git a/test/query.js b/test/query.js new file mode 100644 index 00000000000..784b5f98a6d --- /dev/null +++ b/test/query.js @@ -0,0 +1,298 @@ +'use strict' + +const assert = require('node:assert') +const { test } = require('node:test') +const { createServer } = require('node:http') +const { once } = require('node:events') +const { Readable } = require('node:stream') +const { Client, errors, fetch, FormData } = require('..') + +test('QUERY with string body sends correctly', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => { + // Consume body to ensure it arrives + for await (const _ of req) {} // eslint-disable-line + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"data":{}}') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + method: 'QUERY', + path: '/', + headers: { 'content-type': 'application/json' }, + body: '{"query":"{user}"}' + }) + await response.body.text() +}) + +test('QUERY with Buffer body sends correctly', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => { + // Consume body to ensure it arrives + for await (const _ of req) {} // eslint-disable-line + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + method: 'QUERY', + path: '/', + body: Buffer.from('hello') + }) + await response.body.text() +}) + +test('QUERY with stream body sends correctly', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => { + // Consume body to ensure it arrives + for await (const _ of req) {} // eslint-disable-line + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + method: 'QUERY', + path: '/', + body: new Readable({ + read () { + this.push('streamed') + this.push(null) + } + }) + }) + await response.body.text() +}) + +test('QUERY without body works', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => { + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + method: 'QUERY', + path: '/', + body: '' + }) + await response.body.text() +}) + +test('QUERY with null body works', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => { + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + method: 'QUERY', + path: '/', + body: null + }) + await response.body.text() +}) + +test('QUERY sends Content-Type header', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + method: 'QUERY', + path: '/', + headers: { 'content-type': 'application/json' }, + body: '{}' + }) + await response.body.text() +}) + +test('QUERY can send Accept-Query header', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + method: 'QUERY', + path: '/', + headers: { 'accept-query': 'application/graphql' }, + body: '{}' + }) + await response.body.text() +}) + +test('QUERY sends content-length header', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + path: '/', + method: 'QUERY', + body: 'hello' + }) + await response.body.text() +}) + +test('QUERY with content-length mismatch should error', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.end() + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + try { + await client.request({ + path: '/', + method: 'QUERY', + headers: { 'content-length': 10 }, + body: 'asd' + }) + assert.fail('should have thrown') + } catch (err) { + assert.ok(err instanceof errors.RequestContentLengthMismatchError) + } +}) + +test('QUERY method is recognized as safe in fetch API', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + t.after(() => server.close()) + + const resp = await fetch(`http://localhost:${server.address().port}/`, { + method: 'QUERY', + headers: { 'content-type': 'text/plain' }, + body: 'hello' + }) + assert.strictEqual(resp.status, 200) + const text = await resp.text() + assert.strictEqual(text, 'ok') +}) + +test('QUERY with FormData body works', async (t) => { + const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => { + // Consume body to ensure it arrives + for await (const _ of req) {} // eslint-disable-line + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`) + t.after(() => { client.destroy(); server.close() }) + + const fd = new FormData() + fd.append('field', 'value') + + const response = await client.request({ + method: 'QUERY', + path: '/', + body: fd + }) + await response.body.text() +}) + +test('QUERY redirect 301 should NOT change method (QUERY is safe)', async (t) => { + let redirects = 0 + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + redirects++ + if (redirects === 1) { + res.writeHead(301, { location: '/redirected' }) + res.end() + return + } + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`, { + maxRedirections: 1 + }) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + method: 'QUERY', + path: '/', + body: 'hello', + headers: { 'content-type': 'text/plain' } + }) + await response.body.text() +}) + +test('QUERY redirect 303 should change method to GET', async (t) => { + let redirects = 0 + const server = createServer({ joinDuplicateHeaders: true }, (req, res) => { + redirects++ + if (redirects === 1) { + res.writeHead(303, { location: '/redirected' }) + res.end() + return + } + res.writeHead(200) + res.end('ok') + }) + + await once(server.listen(0), 'listening') + + const client = new Client(`http://localhost:${server.address().port}`, { + maxRedirections: 1 + }) + t.after(() => { client.destroy(); server.close() }) + + const response = await client.request({ + method: 'QUERY', + path: '/', + body: 'hello', + headers: { 'content-type': 'text/plain' } + }) + await response.body.text() +}) From 7a574ee379ceba6fbd6038f76dba588ac69f938d Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 30 Jun 2026 13:20:14 +0000 Subject: [PATCH 3/3] fix: remove QUERY from safeHTTPMethods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUERY responses depend on the request body (RFC 10008 ยง2.7), but the cache/deduplicate interceptors' cache keys ignore the request body. Removing QUERY from safeHTTPMethods prevents these interceptors from accepting QUERY, avoiding serving wrong responses. The fetch API's separate safeMethods list in web/fetch/constants.js still includes QUERY, which is correct for cache invalidation purposes. --- lib/core/util.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/core/util.js b/lib/core/util.js index f95122f5c1f..cdb46d651e6 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -1016,7 +1016,7 @@ module.exports = { normalizedMethodRecords, isValidPort, isHttpOrHttpsPrefixed, - safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY']), + safeHTTPMethods: Object.freeze(['GET', 'HEAD', 'OPTIONS', 'TRACE']), wrapRequestBody, setupConnectTimeout, getProtocolFromUrlString