Skip to content

Commit 2ebb54b

Browse files
committed
feat: constructing new Request object method
**Problem** After using Hono validators, the `raw` Request obejct gets consumed during body parsing, making it unusable for external libraries like `better-auth`. This results in the error: ```shell TypeError: Cannot construct a Request with a Request object that has already been used. ``` **Root Cause** The issue occurs in the `#cachedBody` method in `HonoRequest`. When parsing request bodies (json, text, etc.), the method directly calls parsing methods on the raw Request object, which consumes its body stream. Once consumed, the Request cannot be cloned or reused by external libraries. **Solution** In a `new` method within `HonoRequest`, constructing a new Request object that can be used even after it has been consumed by the validator middleware. If the `raw` Request object has already been consumed, then we store the cached body in to the newly constructed Request object, and if it hasn't then consume the body, cache it and then return it along the newly constructed Request object. Because we have created a new method, this should not degrade current performance and should not introduce any breaking change. Signed-off-by: Kamaal Farah <[email protected]>
1 parent 6792789 commit 2ebb54b

4 files changed

Lines changed: 221 additions & 2 deletions

File tree

src/request.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,97 @@ const text = '{"foo":"bar"}'
187187
const json = { foo: 'bar' }
188188
const buffer = new TextEncoder().encode('{"foo":"bar"}').buffer
189189

190+
describe('Raw Request preservation', () => {
191+
test('raw Request remains unconsumed after body parsing', async () => {
192+
const originalRequest = new Request('http://localhost/', {
193+
method: 'POST',
194+
body: JSON.stringify({ test: 'data' }),
195+
headers: { 'Content-Type': 'application/json' },
196+
})
197+
const req = new HonoRequest(originalRequest)
198+
199+
await req.json()
200+
await req.text()
201+
await req.arrayBuffer()
202+
203+
expect(() => req.raw.clone(), 'The raw request should still be usable').not.toThrow()
204+
expect(
205+
() => new Request(req.raw.url, { method: req.raw.method, headers: req.raw.headers }),
206+
'Should be able to create a new Request from the raw request'
207+
).not.toThrow()
208+
expect(req.raw.method).toBe('POST')
209+
expect(req.raw.url).toBe('http://localhost/')
210+
expect(req.raw.headers.get('Content-Type')).toBe('application/json')
211+
})
212+
213+
test('raw Request can be cloned multiple times after body parsing', async () => {
214+
const originalRequest = new Request('http://localhost/', {
215+
method: 'POST',
216+
body: JSON.stringify({ data: 'test' }),
217+
headers: { 'Content-Type': 'application/json' },
218+
})
219+
const req = new HonoRequest(originalRequest)
220+
221+
await req.json()
222+
await req.text()
223+
const clone1 = req.raw.clone()
224+
const clone2 = req.raw.clone()
225+
const clone3 = req.raw.clone()
226+
227+
expect(clone1).toBeInstanceOf(Request)
228+
expect(clone2).toBeInstanceOf(Request)
229+
expect(clone3).toBeInstanceOf(Request)
230+
expect(clone1.method).toBe(clone2.method)
231+
expect(clone1.url).toBe(clone2.url)
232+
expect(clone1.method).toBe(clone3.method)
233+
})
234+
235+
test('external libraries can use raw Request after validation', async () => {
236+
const originalRequest = new Request('http://localhost/api/test', {
237+
method: 'POST',
238+
body: JSON.stringify({ username: 'test', password: 'secret' }),
239+
headers: { 'Content-Type': 'application/json' },
240+
})
241+
const req = new HonoRequest(originalRequest)
242+
243+
const data = await req.json()
244+
expect(data).toStrictEqual({ username: 'test', password: 'secret' })
245+
246+
function externalLibraryRequest() {
247+
return new Request(req.raw.url, {
248+
method: req.raw.method,
249+
headers: req.raw.headers,
250+
})
251+
}
252+
253+
const newRequest = externalLibraryRequest()
254+
255+
expect(newRequest.method).toBe('POST')
256+
expect(newRequest.url).toBe('http://localhost/api/test')
257+
expect(newRequest.headers.get('Content-Type')).toBe('application/json')
258+
})
259+
260+
test('different body types can be accessed after raw Request preservation', async () => {
261+
const formData = new FormData()
262+
formData.append('field1', 'value1')
263+
formData.append('field2', 'value2')
264+
const originalRequest = new Request('http://localhost', {
265+
method: 'POST',
266+
body: formData,
267+
})
268+
269+
const req = new HonoRequest(originalRequest)
270+
271+
const parsedFormData = await req.formData()
272+
273+
expect(parsedFormData.get('field1')).toBe('value1')
274+
expect(parsedFormData.get('field2')).toBe('value2')
275+
expect(() => req.raw.clone(), 'Raw request should still be usable').not.toThrow()
276+
const clonedForExternal = req.raw.clone()
277+
expect(clonedForExternal.method).toBe('POST')
278+
})
279+
})
280+
190281
describe('Body methods with caching', () => {
191282
test('req.text()', async () => {
192283
const req = new HonoRequest(

src/request.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,19 @@ type Body = {
2323
blob: Blob
2424
formData: FormData
2525
}
26-
type BodyCache = Partial<Body & { parsedBody: BodyData }>
26+
type BodyCache = Partial<
27+
{
28+
[K in keyof Body]: Promise<Body[K]>
29+
} & { parsedBody: BodyData }
30+
>
31+
32+
const BODY_TYPE_TO_TRANSFORMER_MAPPING: { [K in keyof Body]: (body: unknown) => Body[K] } = {
33+
json: (body) => JSON.stringify(body),
34+
text: (body) => body as string,
35+
arrayBuffer: (body) => body as ArrayBuffer,
36+
blob: (body) => body as Blob,
37+
formData: (body) => body as FormData,
38+
}
2739

2840
const tryDecodeURIComponent = (str: string) => tryDecode(str, decodeURIComponent_)
2941

@@ -230,6 +242,41 @@ export class HonoRequest<P extends string = '/', I extends Input['out'] = {}> {
230242
return (bodyCache[key] = raw[key]())
231243
}
232244

245+
/**
246+
* `.new()` creates a new Request object with the same properties as the current request.
247+
* This method reconstructs the request body from cached data if it has been consumed,
248+
* or consumes and caches the body if it hasn't been consumed yet but is available.
249+
*
250+
* @example
251+
* ```ts
252+
* app.post('/clone', async (c) => {
253+
* const newRequest = await c.req.new()
254+
* // Use the new request for further processing
255+
* })
256+
* ```
257+
*/
258+
new = async () => {
259+
const requestInit: RequestInit = { method: this.method, headers: this.header() }
260+
const cachedKeys = Object.keys(this.bodyCache) as Array<keyof typeof this.bodyCache>
261+
// If no body is cached but the raw request has a body and it's not consumed, consume it
262+
if (cachedKeys.length === 0 && this.raw.body && !this.raw.bodyUsed) {
263+
const bodyPromise = this.raw.text()
264+
this.bodyCache.text = bodyPromise
265+
const body = await bodyPromise
266+
requestInit.body = body
267+
return new Request(this.url, requestInit)
268+
}
269+
270+
const firstKey = cachedKeys[0] as keyof Body | undefined
271+
// If no cached body, return request without body
272+
if (firstKey == null) return new Request(this.url, requestInit)
273+
274+
const cachedBody = await this.bodyCache[firstKey]
275+
requestInit.body = BODY_TYPE_TO_TRANSFORMER_MAPPING[firstKey](cachedBody) ?? cachedBody
276+
277+
return new Request(this.url, requestInit)
278+
}
279+
233280
/**
234281
* `.json()` can parse Request body of type `application/json`
235282
*

src/validator/validator.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,87 @@ describe('Clone Request object', () => {
10921092
expect(await res.json()).toEqual({ foo: 'bar' })
10931093
})
10941094
})
1095+
1096+
describe('Raw Request preservation after validation', () => {
1097+
it('Should preserve raw Request after JSON validation for external libraries', async () => {
1098+
const app = new Hono()
1099+
1100+
app.post(
1101+
'/json-validation',
1102+
validator('json', (value) => value),
1103+
async (c) => {
1104+
function createExternalRequest() {
1105+
return new Request(c.req.raw.url, {
1106+
method: c.req.raw.method,
1107+
headers: c.req.raw.headers,
1108+
})
1109+
}
1110+
1111+
const externalRequest = createExternalRequest()
1112+
const clone1 = c.req.raw.clone()
1113+
const clone2 = c.req.raw.clone()
1114+
1115+
return c.json({
1116+
success: true,
1117+
externalRequestMethod: externalRequest.method,
1118+
originalMethod: c.req.raw.method,
1119+
cloned: clone1 instanceof Request && clone2 instanceof Request,
1120+
})
1121+
}
1122+
)
1123+
1124+
const testData = { message: 'test', userId: 123 }
1125+
const res = await app.request('/json-validation', {
1126+
method: 'POST',
1127+
headers: { 'Content-Type': 'application/json' },
1128+
body: JSON.stringify(testData),
1129+
})
1130+
1131+
expect(res.status).toBe(200)
1132+
1133+
const result = await res.json()
1134+
1135+
expect(result.success).toBe(true)
1136+
expect(result.externalRequestMethod).toBe('POST')
1137+
expect(result.originalMethod).toBe('POST')
1138+
expect(result.cloned).toBe(true)
1139+
})
1140+
1141+
it('Should preserve raw Request after form validation for external libraries', async () => {
1142+
const app = new Hono()
1143+
1144+
app.post(
1145+
'/form-validation',
1146+
validator('form', (value) => value),
1147+
async (c) => {
1148+
expect(() => {
1149+
return new Request(c.req.raw.url, {
1150+
method: c.req.raw.method,
1151+
headers: c.req.raw.headers,
1152+
})
1153+
}, 'Test that raw request can be used by external libraries after form validation').not.toThrow()
1154+
expect(() => c.req.raw.clone()).not.toThrow()
1155+
1156+
return c.json({ success: true })
1157+
}
1158+
)
1159+
1160+
const formData = new FormData()
1161+
formData.append('username', 'testuser')
1162+
formData.append('email', '[email protected]')
1163+
1164+
const res = await app.request('/form-validation', {
1165+
method: 'POST',
1166+
body: formData,
1167+
})
1168+
1169+
expect(res.status).toBe(200)
1170+
1171+
const result = await res.json()
1172+
1173+
expect(result.success).toBe(true)
1174+
})
1175+
})
10951176
})
10961177

10971178
describe('Async validator function', () => {

src/validator/validator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export const validator = <
9797
try {
9898
const arrayBuffer = await c.req.arrayBuffer()
9999
formData = await bufferToFormData(arrayBuffer, contentType)
100-
c.req.bodyCache.formData = formData
100+
c.req.bodyCache.formData = Promise.resolve(formData)
101101
} catch (e) {
102102
let message = 'Malformed FormData request.'
103103
message += e instanceof Error ? ` ${e.message}` : ` ${String(e)}`

0 commit comments

Comments
 (0)