Skip to content

Commit 017281a

Browse files
committed
feat(request): add cloneRawRequest utility and tests for request cloning
**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** Adding a utility function to clone HonoRequest's underlying raw Request object, handling both consumed and unconsumed request bodies. Signed-off-by: Kamaal Farah <[email protected]>
1 parent 3b8642b commit 017281a

File tree

3 files changed

+158
-1
lines changed

3 files changed

+158
-1
lines changed

src/request.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { HonoRequest } from './request'
1+
import { cloneRawRequest, HonoRequest } from './request'
22
import type { RouterRoute } from './types'
33

44
type RecursiveRecord<K extends string, T> = {
@@ -378,3 +378,53 @@ describe('Body methods with caching', () => {
378378
})
379379
})
380380
})
381+
382+
describe('cloneRawRequest', () => {
383+
test('clones unconsumed request object', async () => {
384+
const req = new HonoRequest(
385+
new Request('http://localhost', {
386+
method: 'POST',
387+
body: text,
388+
})
389+
)
390+
391+
const clonedReq = await cloneRawRequest(req)
392+
393+
expect(clonedReq.method).toBe('POST')
394+
expect(await clonedReq.text()).toBe(text)
395+
expect(req.raw, 'cloned request should be a different object reference').not.toBe(clonedReq)
396+
expect(req.raw, 'cloned request should contain the same properties').toMatchObject(clonedReq)
397+
})
398+
399+
test('clones consumed request object', async () => {
400+
const req = new HonoRequest(
401+
new Request('http://localhost', {
402+
method: 'POST',
403+
body: text,
404+
})
405+
)
406+
await req.json()
407+
408+
const clonedReq = await cloneRawRequest(req)
409+
410+
expect(clonedReq.method).toBe('POST')
411+
expect(await clonedReq.json()).toEqual(json)
412+
expect(req.raw, 'cloned request should be a different object reference').not.toBe(clonedReq)
413+
expect(req.raw, 'cloned request should contain the same properties').toMatchObject(clonedReq)
414+
})
415+
416+
test('clones GET request without body', async () => {
417+
const req = new HonoRequest(
418+
new Request('http://localhost', {
419+
method: 'GET',
420+
})
421+
)
422+
423+
const clonedReq = await cloneRawRequest(req)
424+
425+
expect(clonedReq.method).toBe('GET')
426+
expect(clonedReq.url).toBe('http://localhost')
427+
expect(req.raw, 'cloned request should be a different object reference').not.toBe(clonedReq)
428+
expect(req.raw, 'cloned request should contain the same properties').toMatchObject(clonedReq)
429+
})
430+
})

src/request.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,46 @@ type BodyCache = Partial<Body & { parsedBody: BodyData }>
2727

2828
const tryDecodeURIComponent = (str: string) => tryDecode(str, decodeURIComponent_)
2929

30+
/**
31+
* Clones a HonoRequest's underlying raw Request object.
32+
*
33+
* This utility handles both consumed and unconsumed request bodies:
34+
* - If the request body hasn't been consumed, it uses the native `clone()` method
35+
* - If the request body has been consumed, it reconstructs a new Request using cached body data
36+
*
37+
* This is particularly useful when you need to:
38+
* - Process the same request body multiple times
39+
* - Pass requests to external services after validation
40+
*
41+
* @param req - The HonoRequest object to clone
42+
* @returns A Promise that resolves to a new Request object with the same properties
43+
*
44+
* @example
45+
* ```ts
46+
* // Clone after consuming the body (e.g., after validation)
47+
* app.post('/forward',
48+
* validator('json', (data) => data),
49+
* async (c) => {
50+
* const validated = c.req.valid('json')
51+
* // Body has been consumed, but cloneRawRequest still works
52+
* const clonedReq = await cloneRawRequest(c.req)
53+
* return fetch('http://backend-service.com', clonedReq)
54+
* }
55+
* )
56+
* ```
57+
*/
58+
export const cloneRawRequest = async (req: HonoRequest): Promise<Request> => {
59+
if (!req.raw.bodyUsed) {
60+
return req.raw.clone()
61+
}
62+
63+
return new Request(req.raw.url, {
64+
method: req.raw.method,
65+
headers: req.raw.headers,
66+
body: await req.blob(),
67+
})
68+
}
69+
3070
export class HonoRequest<P extends string = '/', I extends Input['out'] = {}> {
3171
/**
3272
* `.raw` can get the raw Request object.

src/validator/validator.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ZodSchema } from 'zod'
33
import { z } from 'zod'
44
import { Hono } from '../hono'
55
import { HTTPException } from '../http-exception'
6+
import { cloneRawRequest } from '../request'
67
import type {
78
ErrorHandler,
89
ExtractSchema,
@@ -1207,3 +1208,69 @@ describe('Transform', () => {
12071208
type verify = Expect<Equal<Expected, Actual>>
12081209
})
12091210
})
1211+
1212+
describe('Raw Request cloning after validation', () => {
1213+
it('Should allow the `cloneRawRequest` util to clone the request object after validation', async () => {
1214+
const app = new Hono()
1215+
1216+
app.post(
1217+
'/json-validation',
1218+
validator('json', (data) => data),
1219+
async (c) => {
1220+
const clonedReq = await cloneRawRequest(c.req)
1221+
const clonedJSON = await clonedReq.json()
1222+
1223+
return c.json({
1224+
originalMethod: c.req.raw.method,
1225+
cloned: JSON.stringify(clonedJSON) === JSON.stringify(await c.req.json()),
1226+
payload: clonedJSON,
1227+
})
1228+
}
1229+
)
1230+
1231+
const testData = { message: 'test', userId: 123 }
1232+
const res = await app.request('/json-validation', {
1233+
method: 'POST',
1234+
headers: { 'Content-Type': 'application/json' },
1235+
body: JSON.stringify(testData),
1236+
})
1237+
1238+
expect(res.status).toBe(200)
1239+
1240+
const result = await res.json()
1241+
1242+
expect(result.originalMethod).toBe('POST')
1243+
expect(result.cloned).toBe(true)
1244+
expect(result.payload).toMatchObject(testData)
1245+
})
1246+
1247+
it('Should allow the `cloneRawRequest` util to clone the request object without validation', async () => {
1248+
const app = new Hono()
1249+
1250+
app.post('/no-validation', async (c) => {
1251+
const clonedReq = await cloneRawRequest(c.req)
1252+
const clonedJSON = await clonedReq.json()
1253+
1254+
return c.json({
1255+
originalMethod: c.req.raw.method,
1256+
cloned: JSON.stringify(clonedJSON) === JSON.stringify(await c.req.json()),
1257+
payload: clonedJSON,
1258+
})
1259+
})
1260+
1261+
const testData = { message: 'no validation', userId: 456 }
1262+
const res = await app.request('/no-validation', {
1263+
method: 'POST',
1264+
headers: { 'Content-Type': 'application/json' },
1265+
body: JSON.stringify(testData),
1266+
})
1267+
1268+
expect(res.status).toBe(200)
1269+
1270+
const result = await res.json()
1271+
1272+
expect(result.originalMethod).toBe('POST')
1273+
expect(result.cloned).toBe(true)
1274+
expect(result.payload).toMatchObject(testData)
1275+
})
1276+
})

0 commit comments

Comments
 (0)