diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..58adc42 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,11 @@ +export class RequestError extends Error { + constructor( + message: string, + options?: { + cause?: unknown + } + ) { + super(message, options) + this.name = 'RequestError' + } +} diff --git a/src/listener.ts b/src/listener.ts index a9ea3dd..d1985d4 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -4,7 +4,7 @@ import type { Http2ServerResponse } from 'node:http2' import type { Writable } from 'node:stream' import type { IncomingMessageWithWrapBodyStream } from './request' import { - abortControllerKey, + abortRequest, newRequest, Request as LightweightRequest, wrapBodyStream, @@ -260,13 +260,14 @@ export const getRequestListener = ( // Detect if request was aborted. outgoing.on('close', () => { - const abortController = req[abortControllerKey] as AbortController | undefined - if (abortController) { - if (incoming.errored) { - req[abortControllerKey].abort(incoming.errored.toString()) - } else if (!outgoing.writableFinished) { - req[abortControllerKey].abort('Client connection prematurely closed.') - } + let abortReason: string | undefined + if (incoming.errored) { + abortReason = incoming.errored.toString() + } else if (!outgoing.writableFinished) { + abortReason = 'Client connection prematurely closed.' + } + if (abortReason !== undefined) { + req[abortRequest](abortReason) } // incoming is not consumed to the end diff --git a/src/request.ts b/src/request.ts index ea34768..0130d81 100644 --- a/src/request.ts +++ b/src/request.ts @@ -6,18 +6,10 @@ import { Http2ServerRequest } from 'node:http2' import { Readable } from 'node:stream' import type { ReadableStreamDefaultReader } from 'node:stream/web' import type { TLSSocket } from 'node:tls' +import { RequestError } from './error' +import { buildUrl } from './url' -export class RequestError extends Error { - constructor( - message: string, - options?: { - cause?: unknown - } - ) { - super(message, options) - this.name = 'RequestError' - } -} +export { RequestError } export const toRequestError = (e: unknown): RequestError => { if (e instanceof RequestError) { @@ -30,6 +22,15 @@ export const GlobalRequest = global.Request export class Request extends GlobalRequest { constructor(input: string | Request, options?: RequestInit) { if (typeof input === 'object' && getRequestCache in input) { + // Match native Request behavior: + // constructing from a consumed Request is allowed only when init.body is non-null. + const hasReplacementBody = + options !== undefined && 'body' in options && (options as RequestInit).body != null + if ((input as any)[bodyConsumedDirectlyKey] && !hasReplacementBody) { + throw new TypeError( + 'Cannot construct a Request with a Request object that has already been used.' + ) + } input = (input as any)[getRequestCache]() } // Check if body is ReadableStream like. This makes it compatbile with ReadableStream polyfills. @@ -123,10 +124,249 @@ const urlKey = Symbol('urlKey') const headersKey = Symbol('headersKey') export const abortControllerKey = Symbol('abortControllerKey') export const getAbortController = Symbol('getAbortController') +export const abortRequest = Symbol('abortRequest') +const bodyBufferKey = Symbol('bodyBuffer') +const bodyReadPromiseKey = Symbol('bodyReadPromise') +const bodyConsumedDirectlyKey = Symbol('bodyConsumedDirectly') +const bodyLockReaderKey = Symbol('bodyLockReader') +const abortReasonKey = Symbol('abortReason') + +const throwBodyUnusable = (): never => { + throw new TypeError('Body is unusable') +} + +const rejectBodyUnusable = (): Promise => { + return Promise.reject(throwBodyUnusable()) +} + +const textDecoder = new TextDecoder() + +const consumeBodyDirectOnce = ( + request: Record +): Promise | undefined => { + if (request[bodyConsumedDirectlyKey]) { + return Promise.reject(throwBodyUnusable()) + } + request[bodyConsumedDirectlyKey] = true + return undefined +} + +const toArrayBuffer = (buf: Buffer): ArrayBuffer => { + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer +} + +const contentType = (request: Record): string => { + return ( + (request[headersKey] ||= newHeadersFromIncoming(request[incomingKey])).get('content-type') || '' + ) +} + +type DirectBodyReadMethod = 'text' | 'arrayBuffer' | 'blob' + +const methodTokenRegExp = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/ + +const normalizeIncomingMethod = (method: unknown): string => { + if (typeof method !== 'string' || method.length === 0) { + return 'GET' + } + + // fast path for already-uppercase common methods from Node.js. + switch (method) { + case 'DELETE': + case 'GET': + case 'HEAD': + case 'OPTIONS': + case 'POST': + case 'PUT': + return method + } + + const upper = method.toUpperCase() + switch (upper) { + case 'DELETE': + case 'GET': + case 'HEAD': + case 'OPTIONS': + case 'POST': + case 'PUT': + return upper + default: + return method + } +} + +const validateDirectReadMethod = (method: string): TypeError | undefined => { + if (!methodTokenRegExp.test(method)) { + return new TypeError(`'${method}' is not a valid HTTP method.`) + } + // Keep TRACE workaround behavior, but preserve native rejection for other + // forbidden methods when using the direct-read fast path. + // Only exact upper-case TRACE is treated as the existing workaround target. + const normalized = method.toUpperCase() + if ( + normalized === 'CONNECT' || + normalized === 'TRACK' || + (normalized === 'TRACE' && method !== 'TRACE') + ) { + return new TypeError(`'${method}' HTTP method is unsupported.`) + } + return undefined +} + +const readBodyWithFastPath = ( + request: Record, + method: DirectBodyReadMethod, + fromBuffer: (buf: Buffer, request: Record) => T | Promise +): Promise => { + if (request[bodyConsumedDirectlyKey]) { + return rejectBodyUnusable() + } + + const methodName = request.method as string + if (methodName === 'GET' || methodName === 'HEAD') { + return request[getRequestCache]()[method]() + } + + const methodValidationError = validateDirectReadMethod(methodName) + if (methodValidationError) { + return Promise.reject(methodValidationError) + } + + if (request[requestCache]) { + // Keep TRACE direct-read behavior stable even if non-body properties + // created requestCache earlier (e.g. signal access). + if (methodName !== 'TRACE') { + const cachedRequest = request[requestCache] as Request + return cachedRequest[method]() as Promise + } + } + + const alreadyUsedError = consumeBodyDirectOnce(request) + if (alreadyUsedError) { + return alreadyUsedError + } + + const raw = readRawBodyIfAvailable(request) + if (raw) { + const result = Promise.resolve(fromBuffer(raw, request)) + request[bodyBufferKey] = undefined + return result + } + + return readBodyDirect(request).then((buf) => { + const result = fromBuffer(buf, request) + request[bodyBufferKey] = undefined + return result + }) +} + +const readRawBodyIfAvailable = (request: Record): Buffer | undefined => { + const incoming = request[incomingKey] as IncomingMessage | Http2ServerRequest + if ('rawBody' in incoming && (incoming as any).rawBody instanceof Buffer) { + return (incoming as any).rawBody as Buffer + } + return undefined +} + +// Read body directly from the IncomingMessage stream, bypassing Request object creation. +// Precondition: the caller (listener.ts) must ensure that the IncomingMessage stream is +// properly cleaned up (e.g. via incoming.resume()) when the response ends or the connection +// closes. This function does not call incoming.destroy() on abort. +const readBodyDirect = (request: Record): Promise => { + if (request[bodyBufferKey]) { + return Promise.resolve(request[bodyBufferKey] as Buffer) + } + if (request[bodyReadPromiseKey]) { + return request[bodyReadPromiseKey] as Promise + } + + const incoming = request[incomingKey] as IncomingMessage | Http2ServerRequest + if (Readable.isDisturbed(incoming)) { + return rejectBodyUnusable() + } + + const promise = new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + let settled = false + + const finish = (callback: () => void) => { + if (settled) { + return + } + settled = true + cleanup() + callback() + } + + const onData = (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + const onEnd = () => { + finish(() => { + const buffer = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks) + request[bodyBufferKey] = buffer + resolve(buffer) + }) + } + const onError = (error: unknown) => { + finish(() => { + reject(error) + }) + } + const onClose = () => { + if (incoming.readableEnded) { + onEnd() + return + } + finish(() => { + if (incoming.errored) { + reject(incoming.errored) + return + } + const reason = request[abortReasonKey] + if (reason !== undefined) { + reject(reason instanceof Error ? reason : new Error(String(reason))) + return + } + reject(new Error('Client connection prematurely closed.')) + }) + } + const cleanup = () => { + incoming.off('data', onData) + incoming.off('end', onEnd) + incoming.off('error', onError) + incoming.off('close', onClose) + request[bodyReadPromiseKey] = undefined + } + + incoming.on('data', onData) + incoming.on('end', onEnd) + incoming.on('error', onError) + incoming.on('close', onClose) + + // If the stream has already settled before listeners were attached, + // no further events will fire, so resolve/reject from the current state. + queueMicrotask(() => { + if (settled) { + return + } + if (incoming.readableEnded) { + onEnd() + } else if (incoming.errored) { + onError(incoming.errored) + } else if (incoming.destroyed) { + onClose() + } + }) + }) + + request[bodyReadPromiseKey] = promise + return promise +} const requestPrototype: Record = { get method() { - return this[incomingKey].method || 'GET' + return normalizeIncomingMethod(this[incomingKey].method) }, get url() { @@ -137,25 +377,95 @@ const requestPrototype: Record = { return (this[headersKey] ||= newHeadersFromIncoming(this[incomingKey])) }, + [abortRequest](reason: unknown) { + if (this[abortReasonKey] === undefined) { + this[abortReasonKey] = reason + } + const abortController = this[abortControllerKey] as AbortController | undefined + if (abortController && !abortController.signal.aborted) { + abortController.abort(reason) + } + }, + [getAbortController]() { - this[getRequestCache]() + this[abortControllerKey] ||= new AbortController() + if (this[abortReasonKey] !== undefined && !this[abortControllerKey].signal.aborted) { + this[abortControllerKey].abort(this[abortReasonKey]) + } return this[abortControllerKey] }, [getRequestCache]() { - this[abortControllerKey] ||= new AbortController() - return (this[requestCache] ||= newRequestFromIncoming( + const abortController = this[getAbortController]() + if (this[requestCache]) { + return this[requestCache] + } + + const method = this.method + + // If body was already consumed directly, create a minimal Request with an empty body + // to avoid holding the body buffer in memory via ReadableStream re-wrapping. + if (this[bodyConsumedDirectlyKey] && !(method === 'GET' || method === 'HEAD')) { + this[bodyBufferKey] = undefined + const init = { + method: method === 'TRACE' ? 'GET' : method, + headers: this.headers, + signal: abortController.signal, + } as RequestInit + if (method !== 'TRACE') { + init.body = new ReadableStream({ + start(c) { + c.close() + }, + }) + ;(init as any).duplex = 'half' + } + const req = new Request(this[urlKey], init) + if (method === 'TRACE') { + Object.defineProperty(req, 'method', { + get() { + return 'TRACE' + }, + }) + } + return (this[requestCache] = req) + } + + return (this[requestCache] = newRequestFromIncoming( this.method, this[urlKey], this.headers, this[incomingKey], - this[abortControllerKey] + abortController )) }, + + get body() { + if (!this[bodyConsumedDirectlyKey]) { + return this[getRequestCache]().body + } + const request = this[getRequestCache]() + if (!this[bodyLockReaderKey] && request.body) { + // Web standard requires body.locked === true when bodyUsed === true. + // After direct consumption (text/json/arrayBuffer/blob), getRequestCache() returns + // a Request with an empty ReadableStream body. We lock it here so that + // body.locked reflects the consumed state correctly. + this[bodyLockReaderKey] = request.body.getReader() + } + return request.body + }, + + get bodyUsed() { + if (this[bodyConsumedDirectlyKey]) { + return true + } + if (this[requestCache]) { + return this[requestCache].bodyUsed + } + return false + }, } ;[ - 'body', - 'bodyUsed', 'cache', 'credentials', 'destination', @@ -173,13 +483,50 @@ const requestPrototype: Record = { }, }) }) -;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { +;['clone', 'formData'].forEach((k) => { Object.defineProperty(requestPrototype, k, { value: function () { + if (this[bodyConsumedDirectlyKey]) { + if (k === 'clone') { + throwBodyUnusable() + } + return rejectBodyUnusable() + } return this[getRequestCache]()[k]() }, }) }) +// Direct body reading for text/arrayBuffer/blob/json: bypass getRequestCache() +// → new AbortController() → newHeadersFromIncoming() → new Request(url, init) +// → Readable.toWeb() chain for common body parsing cases. +Object.defineProperty(requestPrototype, 'text', { + value: function (): Promise { + return readBodyWithFastPath(this, 'text', (buf) => textDecoder.decode(buf)) + }, +}) +Object.defineProperty(requestPrototype, 'arrayBuffer', { + value: function (): Promise { + return readBodyWithFastPath(this, 'arrayBuffer', (buf) => toArrayBuffer(buf)) + }, +}) +Object.defineProperty(requestPrototype, 'blob', { + value: function (): Promise { + return readBodyWithFastPath(this, 'blob', (buf, request) => { + const type = contentType(request) + const init = type ? { headers: { 'content-type': type } } : undefined + return new Response(buf, init).blob() + }) + }, +}) +// json() reuses text() fast path to keep body consumption logic centralized. +Object.defineProperty(requestPrototype, 'json', { + value: function (): Promise { + if (this[bodyConsumedDirectlyKey]) { + return rejectBodyUnusable() + } + return this.text().then(JSON.parse) + }, +}) Object.setPrototypeOf(requestPrototype, Request.prototype) export const newRequest = ( @@ -228,15 +575,15 @@ export const newRequest = ( scheme = incoming.socket && (incoming.socket as TLSSocket).encrypted ? 'https' : 'http' } - const url = new URL(`${scheme}://${host}${incomingUrl}`) - - // check by length for performance. - // if suspicious, check by host. host header sometimes contains port. - if (url.hostname.length !== host.length && url.hostname !== host.replace(/:\d+$/, '')) { - throw new RequestError('Invalid host header') + try { + req[urlKey] = buildUrl(scheme, host, incomingUrl) + } catch (e) { + if (e instanceof RequestError) { + throw e + } else { + throw new RequestError('Invalid URL', { cause: e }) + } } - req[urlKey] = url.href - return req } diff --git a/src/url.ts b/src/url.ts new file mode 100644 index 0000000..8d6247f --- /dev/null +++ b/src/url.ts @@ -0,0 +1,110 @@ +import { RequestError } from './error' + +const isPathDelimiter = (charCode: number): boolean => + charCode === 0x2f || charCode === 0x3f || charCode === 0x23 + +// `/.`, `/..` (including `%2e` variants, which are handled by `%` detection) are normalized by `new URL()`. +const hasDotSegment = (url: string, dotIndex: number): boolean => { + const prev = dotIndex === 0 ? 0x2f : url.charCodeAt(dotIndex - 1) + if (prev !== 0x2f) { + return false + } + + const nextIndex = dotIndex + 1 + if (nextIndex === url.length) { + return true + } + + const next = url.charCodeAt(nextIndex) + if (isPathDelimiter(next)) { + return true + } + if (next !== 0x2e) { + return false + } + + const nextNextIndex = dotIndex + 2 + if (nextNextIndex === url.length) { + return true + } + return isPathDelimiter(url.charCodeAt(nextNextIndex)) +} + +const allowedRequestUrlChar = new Uint8Array(128) +for (let c = 0x30; c <= 0x39; c++) { + allowedRequestUrlChar[c] = 1 +} +for (let c = 0x41; c <= 0x5a; c++) { + allowedRequestUrlChar[c] = 1 +} +for (let c = 0x61; c <= 0x7a; c++) { + allowedRequestUrlChar[c] = 1 +} +;(() => { + const chars = '-./:?#[]@!$&\'()*+,;=~_' + for (let i = 0; i < chars.length; i++) { + allowedRequestUrlChar[chars.charCodeAt(i)] = 1 + } +})() + +const safeHostChar = new Uint8Array(128) +// 0-9 +for (let c = 0x30; c <= 0x39; c++) { + safeHostChar[c] = 1 +} +// a-z +for (let c = 0x61; c <= 0x7a; c++) { + safeHostChar[c] = 1 +} +;(() => { + const chars = '.-_' + for (let i = 0; i < chars.length; i++) { + safeHostChar[chars.charCodeAt(i)] = 1 + } +})() + +export const buildUrl = (scheme: string, host: string, incomingUrl: string) => { + const url = `${scheme}://${host}${incomingUrl}` + + let needsHostValidationByURL = false + for (let i = 0, len = host.length; i < len; i++) { + const c = host.charCodeAt(i) + if (c > 0x7f || safeHostChar[c] === 0) { + needsHostValidationByURL = true + break + } + } + + if (needsHostValidationByURL) { + const urlObj = new URL(url) + + // if suspicious, check by host. host header sometimes contains port. + if ( + urlObj.hostname.length !== host.length && + urlObj.hostname !== (host.includes(':') ? host.replace(/:\d+$/, '') : host).toLowerCase() + ) { + throw new RequestError('Invalid host header') + } + return urlObj.href + } else if (incomingUrl.length === 0) { + return url + '/' + } else { + if (incomingUrl.charCodeAt(0) !== 0x2f) { + // '/' + throw new RequestError('Invalid URL') + } + + for (let i = 1, len = incomingUrl.length; i < len; i++) { + const c = incomingUrl.charCodeAt(i) + if ( + c > 0x7f || + allowedRequestUrlChar[c] === 0 || + (c === 0x2e && hasDotSegment(incomingUrl, i)) + ) { + return new URL(url).href + } + } + + return url + } +} diff --git a/src/utils.ts b/src/utils.ts index 7962eee..5cb6074 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -69,17 +69,23 @@ export const buildOutgoingHttpHeaders = ( headers = new Headers(headers ?? undefined) } - const cookies = [] - for (const [k, v] of headers) { - if (k === 'set-cookie') { - cookies.push(v) - } else { + if (headers.has('set-cookie')) { + const cookies = [] + for (const [k, v] of headers) { + if (k === 'set-cookie') { + cookies.push(v) + } else { + res[k] = v + } + } + if (cookies.length > 0) { + res['set-cookie'] = cookies + } + } else { + for (const [k, v] of headers) { res[k] = v } } - if (cookies.length > 0) { - res['set-cookie'] = cookies - } res['content-type'] ??= 'text/plain; charset=UTF-8' return res diff --git a/test/request.test.ts b/test/request.test.ts index 6d87c91..e6d3cd6 100644 --- a/test/request.test.ts +++ b/test/request.test.ts @@ -4,6 +4,7 @@ import { Http2ServerRequest } from 'node:http2' import { Socket } from 'node:net' import { Duplex } from 'node:stream' import { + abortRequest, newRequest, Request as LightweightRequest, GlobalRequest, @@ -121,6 +122,559 @@ describe('Request', () => { expect(req[abortControllerKey]).toBeDefined() // initialized }) + it('should apply abort state lazily when signal is accessed after json() body read', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/json', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/json'], + rawBody: Buffer.from('{"foo":"bar"}'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + expect(await req.json()).toEqual({ foo: 'bar' }) + expect(req[abortControllerKey]).toBeUndefined() + + req[abortRequest]('Client connection prematurely closed.') + expect(req[abortControllerKey]).toBeUndefined() + + expect(req.signal.aborted).toBe(true) + }) + + it('should reject direct body read when incoming stream is destroyed mid-read', async () => { + const socket = new Socket() + const incomingMessage = new IncomingMessage(socket) + incomingMessage.method = 'POST' + incomingMessage.headers = { + host: 'localhost', + } + incomingMessage.rawHeaders = ['host', 'localhost'] + incomingMessage.url = '/foo.txt' + const req = newRequest(incomingMessage) + + const textPromise = req.json() + incomingMessage.destroy(new Error('Client connection prematurely closed.')) + + await expect(textPromise).rejects.toBeInstanceOf(Error) + }) + + it('should reject direct body read when incoming stream is destroyed without error', async () => { + const socket = new Socket() + const incomingMessage = new IncomingMessage(socket) + incomingMessage.method = 'POST' + incomingMessage.headers = { + host: 'localhost', + } + incomingMessage.rawHeaders = ['host', 'localhost'] + incomingMessage.url = '/foo.txt' + const req = newRequest(incomingMessage) + + const textPromise = req.text() + incomingMessage.destroy() + + await expect(textPromise).rejects.toBeInstanceOf(Error) + }) + + it('should reject direct body read for unsupported methods like native Request', async () => { + for (const method of ['CONNECT', 'TRACK']) { + const req = newRequest({ + method, + headers: { + host: 'localhost', + 'content-type': 'text/plain', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await expect(req.text()).rejects.toBeInstanceOf(TypeError) + } + }) + + it('should allow direct body read even when aborted before rawBody read', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/json', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/json'], + rawBody: Buffer.from('{"foo":"bar"}'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + req[abortRequest]('Client connection prematurely closed.') + + await expect(req.json()).resolves.toEqual({ foo: 'bar' }) + }) + + it('should allow json() read even after signal is accessed first and then aborted', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/json', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/json'], + rawBody: Buffer.from('{"foo":"bar"}'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + req[abortRequest]('Client connection prematurely closed.') + expect(req.signal.aborted).toBe(true) + + await expect(req.json()).resolves.toEqual({ foo: 'bar' }) + }) + + it('should keep bodyUsed consistent after aborted read regardless of signal access order', async () => { + const reqWithoutSignal = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/json', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/json'], + rawBody: Buffer.from('{"foo":"bar"}'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + reqWithoutSignal[abortRequest]('Client connection prematurely closed.') + await expect(reqWithoutSignal.json()).resolves.toEqual({ foo: 'bar' }) + expect(reqWithoutSignal.bodyUsed).toBe(true) + + const reqWithSignal = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/json', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/json'], + rawBody: Buffer.from('{"foo":"bar"}'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + expect(reqWithSignal.signal.aborted).toBe(false) + reqWithSignal[abortRequest]('Client connection prematurely closed.') + expect(reqWithSignal.signal.aborted).toBe(true) + await expect(reqWithSignal.json()).resolves.toEqual({ foo: 'bar' }) + expect(reqWithSignal.bodyUsed).toBe(true) + }) + + it('should allow clone() and formData() after abort like native Request', async () => { + const reqForClone = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'text/plain', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + reqForClone[abortRequest]('Client connection prematurely closed.') + const cloned = reqForClone.clone() + await expect(cloned.text()).resolves.toBe('foo') + + const reqForFormData = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/x-www-form-urlencoded', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/x-www-form-urlencoded'], + rawBody: Buffer.from('a=1&b=2'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + reqForFormData[abortRequest]('Client connection prematurely closed.') + const formData = await reqForFormData.formData() + expect(formData.get('a')).toBe('1') + expect(formData.get('b')).toBe('2') + }) + + it('should reject direct body read when incoming stream has already been consumed', async () => { + const socket = new Socket() + const incomingMessage = new IncomingMessage(socket) + incomingMessage.method = 'POST' + incomingMessage.headers = { + host: 'localhost', + } + incomingMessage.rawHeaders = ['host', 'localhost'] + incomingMessage.url = '/foo.txt' + incomingMessage.push('foo') + incomingMessage.push(null) + + for await (const chunk of incomingMessage) { + // consume body before lightweight request reads it + expect(chunk).toBeDefined() + } + + const req = newRequest(incomingMessage) + await expect(req.text()).rejects.toBeInstanceOf(TypeError) + }) + + it('should resolve direct body read when stream already ended before first read', async () => { + const socket = new Socket() + const incomingMessage = new IncomingMessage(socket) + incomingMessage.method = 'POST' + incomingMessage.headers = { + host: 'localhost', + } + incomingMessage.rawHeaders = ['host', 'localhost'] + incomingMessage.url = '/foo.txt' + + const req = newRequest(incomingMessage) + const ended = new Promise((resolve) => { + incomingMessage.once('end', () => { + resolve() + }) + }) + incomingMessage.push(null) + incomingMessage.resume() + await ended + + await expect(req.text()).resolves.toBe('') + expect(req.bodyUsed).toBe(true) + }) + + it('should reject on second direct json() read', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/json', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/json'], + rawBody: Buffer.from('{"foo":"bar"}'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await expect(req.json()).resolves.toEqual({ foo: 'bar' }) + await expect(req.json()).rejects.toBeInstanceOf(TypeError) + }) + + it('should set bodyUsed and reject clone() after direct json() read', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/json', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/json'], + rawBody: Buffer.from('{"foo":"bar"}'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await expect(req.json()).resolves.toEqual({ foo: 'bar' }) + expect(req.bodyUsed).toBe(true) + expect(() => req.clone()).toThrow(TypeError) + await expect(req.text()).rejects.toBeInstanceOf(TypeError) + }) + + it('should support UTF-8 BOM in direct json() read', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/json', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/json'], + rawBody: Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('{"foo":"bar"}')]), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await expect(req.json()).resolves.toEqual({ foo: 'bar' }) + }) + + it('should set bodyUsed and reject clone() after direct text() read', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'text/plain', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await expect(req.text()).resolves.toBe('foo') + expect(req.bodyUsed).toBe(true) + expect(() => req.clone()).toThrow(TypeError) + await expect(req.arrayBuffer()).rejects.toBeInstanceOf(TypeError) + }) + + it('should reject second direct text() read even after accessing signal', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'text/plain', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await expect(req.text()).resolves.toBe('foo') + expect(req.signal.aborted).toBe(false) + await expect(req.text()).rejects.toBeInstanceOf(TypeError) + }) + + it('should reject second direct json() read even after accessing signal', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/json', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/json'], + rawBody: Buffer.from('{"foo":"bar"}'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await expect(req.json()).resolves.toEqual({ foo: 'bar' }) + expect(req.signal.aborted).toBe(false) + await expect(req.json()).rejects.toBeInstanceOf(TypeError) + }) + + it('should set bodyUsed and reject clone() after direct arrayBuffer() read', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'application/octet-stream', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'application/octet-stream'], + rawBody: Buffer.from([1, 2, 3]), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + const ab = await req.arrayBuffer() + expect(new Uint8Array(ab)).toEqual(new Uint8Array([1, 2, 3])) + expect(req.bodyUsed).toBe(true) + expect(() => req.clone()).toThrow(TypeError) + await expect(req.text()).rejects.toBeInstanceOf(TypeError) + }) + + it('should preserve content type in blob() result', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'text/plain', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await expect(req.blob().then((blob: Blob) => blob.type)).resolves.toBe('text/plain') + }) + + it('should normalize content type in blob() result like native Request', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'text/plain; charset=UTF-8', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain; charset=UTF-8'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + const expectedType = await new GlobalRequest('http://localhost/foo.txt', { + method: 'POST', + headers: { + 'content-type': 'text/plain; charset=UTF-8', + }, + body: 'foo', + }) + .blob() + .then((blob: Blob) => blob.type) + + await expect(req.blob().then((blob: Blob) => blob.type)).resolves.toBe(expectedType) + }) + + it('should set bodyUsed and reject clone() after direct blob() read', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'text/plain', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + const blob = await req.blob() + expect(blob.type).toBe('text/plain') + expect(await blob.text()).toBe('foo') + expect(req.bodyUsed).toBe(true) + expect(() => req.clone()).toThrow(TypeError) + await expect(req.json()).rejects.toBeInstanceOf(TypeError) + }) + + it('should allow constructing Request from consumed lightweight request when body is replaced', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'text/plain', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await req.text() + + const req2 = new LightweightRequest(req, { + method: 'POST', + body: 'bar', + }) + await expect(req2.text()).resolves.toBe('bar') + }) + + it('should throw when constructing Request from consumed lightweight request without body replacement', async () => { + const req = newRequest({ + method: 'POST', + headers: { + host: 'localhost', + 'content-type': 'text/plain', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + await req.text() + + expect(() => { + new LightweightRequest(req) + }).toThrow(TypeError) + }) + + it('should preserve TRACE workaround after direct read when accessing signal', async () => { + const socket = new Socket() + const incomingMessage = new IncomingMessage(socket) + incomingMessage.method = 'TRACE' + incomingMessage.headers = { + host: 'localhost', + 'content-type': 'text/plain', + } + incomingMessage.rawHeaders = ['host', 'localhost', 'content-type', 'text/plain'] + incomingMessage.url = '/foo.txt' + ;(incomingMessage as IncomingMessage & { rawBody: Buffer }).rawBody = Buffer.from('foo') + const req = newRequest(incomingMessage) + + await expect(req.text()).resolves.toBe('foo') + expect(() => req.signal).not.toThrow() + }) + + it('should keep TRACE direct body read behavior regardless of signal access order', async () => { + const createTraceRequest = () => { + const socket = new Socket() + const incomingMessage = new IncomingMessage(socket) + incomingMessage.method = 'TRACE' + incomingMessage.headers = { + host: 'localhost', + 'content-type': 'text/plain', + } + incomingMessage.rawHeaders = ['host', 'localhost', 'content-type', 'text/plain'] + incomingMessage.url = '/foo.txt' + ;(incomingMessage as IncomingMessage & { rawBody: Buffer }).rawBody = Buffer.from('foo') + return newRequest(incomingMessage) + } + + const reqBeforeSignal = createTraceRequest() + await expect(reqBeforeSignal.text()).resolves.toBe('foo') + + const reqAfterSignal = createTraceRequest() + expect(() => reqAfterSignal.signal).not.toThrow() + await expect(reqAfterSignal.text()).resolves.toBe('foo') + }) + + it('should reject non-uppercase trace consistently regardless of access order', async () => { + const createTraceLikeRequest = (method: string) => { + const socket = new Socket() + const incomingMessage = new IncomingMessage(socket) + incomingMessage.method = method + incomingMessage.headers = { + host: 'localhost', + 'content-type': 'text/plain', + } + incomingMessage.rawHeaders = ['host', 'localhost', 'content-type', 'text/plain'] + incomingMessage.url = '/foo.txt' + ;(incomingMessage as IncomingMessage & { rawBody: Buffer }).rawBody = Buffer.from('foo') + return newRequest(incomingMessage) + } + + for (const method of ['trace', 'TrAcE']) { + const reqBeforeSignal = createTraceLikeRequest(method) + await expect(reqBeforeSignal.text()).rejects.toBeInstanceOf(TypeError) + + const reqAfterSignal = createTraceLikeRequest(method) + expect(() => reqAfterSignal.signal).toThrow(/HTTP method is unsupported/) + } + }) + + it('should preserve non-standard method casing like native Request', async () => { + for (const method of ['patch', 'CuStOm']) { + const req = newRequest({ + method, + headers: { + host: 'localhost', + }, + rawHeaders: ['host', 'localhost'], + url: '/foo.txt', + } as IncomingMessage) + + const expected = new GlobalRequest('http://localhost/foo.txt', { method }).method + expect(req.method).toBe(expected) + } + }) + + it('should normalize lowercase methods and keep GET behavior consistent', async () => { + const req = newRequest({ + method: 'get', + headers: { + host: 'localhost', + 'content-type': 'text/plain', + }, + rawHeaders: ['host', 'localhost', 'content-type', 'text/plain'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + expect(req.method).toBe('GET') + expect(() => req.signal).not.toThrow() + await expect(req.text()).resolves.toBe('') + expect(req.bodyUsed).toBe(false) + }) + + it('should keep default GET behavior when incoming.method is missing', async () => { + const req = newRequest({ + headers: { + host: 'localhost', + }, + rawHeaders: ['host', 'localhost'], + rawBody: Buffer.from('foo'), + url: '/foo.txt', + } as IncomingMessage & { rawBody: Buffer }) + + expect(req.method).toBe('GET') + expect(await req.text()).toBe('') + expect(req.bodyUsed).toBe(false) + expect(() => req.signal).not.toThrow() + }) + it('Should throw error if host header contains path', async () => { expect(() => { newRequest({ @@ -154,6 +708,17 @@ describe('Request', () => { }).toThrow(RequestError) }) + it('Should throw error if host header port is invalid', async () => { + expect(() => { + newRequest({ + headers: { + host: 'localhost:65536', + }, + url: '/foo.txt', + } as IncomingMessage) + }).toThrow(RequestError) + }) + it('Should be created request body from `req.rawBody` if it exists', async () => { const rawBody = Buffer.from('foo') const socket = new Socket() diff --git a/test/url.test.ts b/test/url.test.ts new file mode 100644 index 0000000..e1aeffc --- /dev/null +++ b/test/url.test.ts @@ -0,0 +1,70 @@ +import { RequestError } from '../src/error' +import { buildUrl } from '../src/url' + +describe('buildUrl', () => { + describe('IPv6 host', () => { + it('Should throw error for unmatched closing bracket in host', async () => { + expect(() => { + buildUrl('http', 'host]', '/foo.txt') + }).toThrow(new TypeError('Invalid URL')) + }) + + it('Should throw error for unmatched opening bracket in host', async () => { + expect(() => { + buildUrl('http', '[host', '/foo.txt') + }).toThrow(new TypeError('Invalid URL')) + }) + }) + + describe('URL normalization', () => { + test.each([ + ['[::1]', '/foo.txt'], + ['[::1]:8080', '/foo.txt'], + ['localhost', '/'], + ['localhost', '/foo/bar/baz'], + ['localhost', '/foo_bar'], + ['localhost', '/foo//bar'], + ['localhost', '/static/%2e%2e/foo.txt'], + ['localhost', '/static\\..\\foo.txt'], + ['localhost', '/..'], + ['localhost', '/foo/.'], + ['localhost', '/foo/bar/..'], + ['localhost', '/a/b/../../c'], + ['localhost', '/a/../../../b'], + ['localhost', '/a/b/c/../../../'], + ['localhost', '/./foo.txt'], + ['localhost', '/foo/../bar.txt'], + ['localhost', '/a/./b/../c?q=%2E%2E#hash'], + ['localhost', '/foo/%2E/bar/../baz'], + ['localhost', '/hello%20world'], + ['localhost', '/foo%23bar'], + ['localhost', '/foo"bar'], + ['localhost', '/%2e%2E/foo'], + ['localhost', '/caf%C3%A9'], + ['localhost', '/foo%2fbar/..//baz'], + ['localhost', '/foo?q=../bar'], + ['localhost', '/path?q=hello%20world'], + ['localhost', '/file.txt'], + ['localhost', ''], + ['LOCALHOST', '/foo.txt'], + ['LOCALHOST:80', '/foo.txt'], + ['LOCALHOST:443', '/foo.txt'], + ['LOCALHOST:8080', '/foo.txt'], + ['Localhost:3000', '/foo.txt'], + ])('Should normalize %s to %s', async (host, url) => { + expect(buildUrl('http', host, url)).toBe(new URL(url, `http://${host}`).href) + }) + + it('Should throw a RequestError for non-origin-form request-target', async () => { + expect(() => { + buildUrl('http', 'localhost', '*') + }).toThrow(new RequestError('Invalid URL')) + }) + + it('Should throw a RequestError for invalid host header', async () => { + expect(() => { + buildUrl('http', 'localhost/foo', '/bar') + }).toThrow(new RequestError('Invalid host header')) + }) + }) +})