Skip to content

Commit 1280661

Browse files
authored
feat(request): add cloneRawRequest utility for request cloning (#4382)
**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 <kamaal.f1@gmail.com>
1 parent fb2a7ef commit 1280661

3 files changed

Lines changed: 263 additions & 1 deletion

File tree

src/request.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { HonoRequest } from './request'
1+
import { HTTPException } from './http-exception'
2+
import { cloneRawRequest, HonoRequest } from './request'
23
import type { RouterRoute } from './types'
34

45
type RecursiveRecord<K extends string, T> = {
@@ -378,3 +379,126 @@ describe('Body methods with caching', () => {
378379
})
379380
})
380381
})
382+
383+
describe('cloneRawRequest', () => {
384+
test('clones unconsumed request object', async () => {
385+
const req = new HonoRequest(
386+
new Request('http://localhost', {
387+
method: 'POST',
388+
headers: {
389+
'Content-Type': 'application/json',
390+
'X-Custom-Header': 'custom-value',
391+
},
392+
body: text,
393+
cache: 'no-cache',
394+
credentials: 'include',
395+
integrity: 'sha256-test',
396+
mode: 'cors',
397+
redirect: 'follow',
398+
referrer: 'http://example.com',
399+
referrerPolicy: 'origin',
400+
})
401+
)
402+
403+
const clonedReq = await cloneRawRequest(req)
404+
405+
expect(clonedReq.method).toBe('POST')
406+
expect(clonedReq.url).toBe('http://localhost/')
407+
expect(await clonedReq.text()).toBe(text)
408+
expect(clonedReq.headers.get('Content-Type')).toBe('application/json')
409+
expect(clonedReq.headers.get('X-Custom-Header')).toBe('custom-value')
410+
expect(clonedReq.cache).toBe('no-cache')
411+
expect(clonedReq.credentials).toBe('include')
412+
expect(clonedReq.integrity).toBe('sha256-test')
413+
expect(clonedReq.mode).toBe('cors')
414+
expect(clonedReq.redirect).toBe('follow')
415+
expect(clonedReq.referrer).toBe('http://example.com/')
416+
expect(clonedReq.referrerPolicy).toBe('origin')
417+
expect(req.raw, 'cloned request should be a different object reference').not.toBe(clonedReq)
418+
expect(req.raw, 'cloned request should contain the same properties').toMatchObject(clonedReq)
419+
})
420+
421+
test('clones consumed request object', async () => {
422+
const req = new HonoRequest(
423+
new Request('http://localhost', {
424+
method: 'POST',
425+
headers: {
426+
Authorization: 'Bearer token123',
427+
},
428+
body: text,
429+
mode: 'same-origin',
430+
credentials: 'same-origin',
431+
})
432+
)
433+
await req.json()
434+
435+
const clonedReq = await cloneRawRequest(req)
436+
437+
expect(clonedReq.method).toBe('POST')
438+
expect(clonedReq.url).toBe('http://localhost/')
439+
expect(await clonedReq.json()).toEqual(json)
440+
expect(clonedReq.headers.get('Authorization')).toBe('Bearer token123')
441+
expect(clonedReq.mode).toBe('same-origin')
442+
expect(clonedReq.credentials).toBe('same-origin')
443+
expect(req.raw, 'cloned request should be a different object reference').not.toBe(clonedReq)
444+
expect(req.raw, 'cloned request should contain the same properties').toMatchObject(clonedReq)
445+
})
446+
447+
test('clones GET request without body', async () => {
448+
const req = new HonoRequest(
449+
new Request('http://localhost', {
450+
method: 'GET',
451+
headers: {
452+
'User-Agent': 'test-agent',
453+
},
454+
cache: 'default',
455+
redirect: 'manual',
456+
referrerPolicy: 'no-referrer',
457+
})
458+
)
459+
460+
const clonedReq = await cloneRawRequest(req)
461+
462+
expect(clonedReq.method).toBe('GET')
463+
expect(clonedReq.url).toBe('http://localhost/')
464+
expect(clonedReq.headers.get('User-Agent')).toBe('test-agent')
465+
expect(clonedReq.cache).toBe('default')
466+
expect(clonedReq.redirect).toBe('manual')
467+
expect(clonedReq.referrerPolicy).toBe('no-referrer')
468+
expect(req.raw, 'cloned request should be a different object reference').not.toBe(clonedReq)
469+
expect(req.raw, 'cloned request should contain the same properties').toMatchObject(clonedReq)
470+
})
471+
472+
test('clones request when raw body was consumed directly without HonoRequest methods', async () => {
473+
const req = new HonoRequest(
474+
new Request('http://localhost', {
475+
method: 'POST',
476+
headers: {
477+
'Content-Type': 'application/json',
478+
},
479+
body: text,
480+
})
481+
)
482+
483+
// Consume the raw request body directly, bypassing HonoRequest methods
484+
// This means bodyCache will be empty
485+
await req.raw.text()
486+
487+
expect(req.raw.bodyUsed).toBe(true)
488+
expect(Object.keys(req.bodyCache).length).toBe(0)
489+
490+
let error: HTTPException | undefined = undefined
491+
try {
492+
await cloneRawRequest(req)
493+
} catch (e) {
494+
expect(e).toBeInstanceOf(HTTPException)
495+
error = e as HTTPException
496+
}
497+
498+
expect(error).not.toBeUndefined()
499+
expect((error as HTTPException).status).toBe(500)
500+
expect((error as HTTPException).message).toContain(
501+
'Cannot clone request: body was already consumed and not cached'
502+
)
503+
})
504+
})

src/request.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { HTTPException } from './http-exception'
23
import { GET_MATCH_RESULT } from './request/constants'
34
import type { Result } from './router'
45
import type {
@@ -25,6 +26,11 @@ type Body = {
2526
}
2627
type BodyCache = Partial<Body & { parsedBody: BodyData }>
2728

29+
type OptionalRequestInitProperties = 'window' | 'priority'
30+
type RequiredRequestInit = Required<Omit<RequestInit, OptionalRequestInitProperties>> & {
31+
[Key in OptionalRequestInitProperties]?: RequestInit[Key]
32+
}
33+
2834
const tryDecodeURIComponent = (str: string) => tryDecode(str, decodeURIComponent_)
2935

3036
export class HonoRequest<P extends string = '/', I extends Input['out'] = {}> {
@@ -417,3 +423,65 @@ export class HonoRequest<P extends string = '/', I extends Input['out'] = {}> {
417423
return this.#matchResult[0].map(([[, route]]) => route)[this.routeIndex].path
418424
}
419425
}
426+
427+
/**
428+
* Clones a HonoRequest's underlying raw Request object.
429+
*
430+
* This utility handles both consumed and unconsumed request bodies:
431+
* - If the request body hasn't been consumed, it uses the native `clone()` method
432+
* - If the request body has been consumed, it reconstructs a new Request using cached body data
433+
*
434+
* This is particularly useful when you need to:
435+
* - Process the same request body multiple times
436+
* - Pass requests to external services after validation
437+
*
438+
* @param req - The HonoRequest object to clone
439+
* @returns A Promise that resolves to a new Request object with the same properties
440+
* @throws {HTTPException} If the request body was consumed directly via `req.raw`
441+
* without using HonoRequest methods (e.g., `req.json()`, `req.text()`), making it
442+
* impossible to reconstruct the body from cache
443+
*
444+
* @example
445+
* ```ts
446+
* // Clone after consuming the body (e.g., after validation)
447+
* app.post('/forward',
448+
* validator('json', (data) => data),
449+
* async (c) => {
450+
* const validated = c.req.valid('json')
451+
* // Body has been consumed, but cloneRawRequest still works
452+
* const clonedReq = await cloneRawRequest(c.req)
453+
* return fetch('http://backend-service.com', clonedReq)
454+
* }
455+
* )
456+
* ```
457+
*/
458+
export const cloneRawRequest = async (req: HonoRequest): Promise<Request> => {
459+
if (!req.raw.bodyUsed) {
460+
return req.raw.clone()
461+
}
462+
463+
const cacheKey = (Object.keys(req.bodyCache) as Array<keyof Body>)[0]
464+
if (!cacheKey) {
465+
throw new HTTPException(500, {
466+
message:
467+
'Cannot clone request: body was already consumed and not cached. Please use HonoRequest methods (e.g., req.json(), req.text()) instead of consuming req.raw directly.',
468+
})
469+
}
470+
471+
const requestInit: RequiredRequestInit = {
472+
body: await req[cacheKey](),
473+
cache: req.raw.cache,
474+
credentials: req.raw.credentials,
475+
headers: req.header(),
476+
integrity: req.raw.integrity,
477+
keepalive: req.raw.keepalive,
478+
method: req.method,
479+
mode: req.raw.mode,
480+
redirect: req.raw.redirect,
481+
referrer: req.raw.referrer,
482+
referrerPolicy: req.raw.referrerPolicy,
483+
signal: req.raw.signal,
484+
}
485+
486+
return new Request(req.url, requestInit)
487+
}

src/validator/validator.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { z } from 'zod'
44
import type { Context } from '../context'
55
import { Hono } from '../hono'
66
import { HTTPException } from '../http-exception'
7+
import { cloneRawRequest } from '../request'
78
import type {
89
ErrorHandler,
910
ExtractSchema,
@@ -1298,3 +1299,72 @@ describe('Transform', () => {
12981299
type TestActual = Actual
12991300
})
13001301
})
1302+
1303+
describe('Raw Request cloning after validation', () => {
1304+
it('Should allow the `cloneRawRequest` util to clone the request object after validation', async () => {
1305+
const app = new Hono()
1306+
1307+
app.post(
1308+
'/json-validation',
1309+
validator('json', (data) => data),
1310+
async (c) => {
1311+
const clonedReq = await cloneRawRequest(c.req)
1312+
const clonedJSON = await clonedReq.json()
1313+
1314+
return c.json({
1315+
originalMethod: c.req.raw.method,
1316+
clonedMethod: clonedReq.method,
1317+
clonedUrl: clonedReq.url,
1318+
clonedHeaders: {
1319+
contentType: clonedReq.headers.get('Content-Type'),
1320+
customHeader: clonedReq.headers.get('X-Custom-Header'),
1321+
},
1322+
originalCache: c.req.raw.cache,
1323+
clonedCache: clonedReq.cache,
1324+
originalCredentials: c.req.raw.credentials,
1325+
clonedCredentials: clonedReq.credentials,
1326+
originalMode: c.req.raw.mode,
1327+
clonedMode: clonedReq.mode,
1328+
originalRedirect: c.req.raw.redirect,
1329+
clonedRedirect: clonedReq.redirect,
1330+
originalReferrerPolicy: c.req.raw.referrerPolicy,
1331+
clonedReferrerPolicy: clonedReq.referrerPolicy,
1332+
cloned: JSON.stringify(clonedJSON) === JSON.stringify(await c.req.json()),
1333+
payload: clonedJSON,
1334+
})
1335+
}
1336+
)
1337+
1338+
const testData = { message: 'test', userId: 123 }
1339+
const res = await app.request('/json-validation', {
1340+
method: 'POST',
1341+
headers: {
1342+
'Content-Type': 'application/json',
1343+
'X-Custom-Header': 'test-value',
1344+
},
1345+
body: JSON.stringify(testData),
1346+
cache: 'no-cache',
1347+
credentials: 'include',
1348+
mode: 'cors',
1349+
redirect: 'follow',
1350+
referrerPolicy: 'origin',
1351+
})
1352+
1353+
expect(res.status).toBe(200)
1354+
1355+
const result = await res.json()
1356+
1357+
expect(result.originalMethod).toBe('POST')
1358+
expect(result.clonedMethod).toBe('POST')
1359+
expect(result.clonedUrl).toBe('http://localhost/json-validation')
1360+
expect(result.clonedHeaders.contentType).toBe('application/json')
1361+
expect(result.clonedHeaders.customHeader).toBe('test-value')
1362+
expect(result.clonedCache).toBe(result.originalCache)
1363+
expect(result.clonedCredentials).toBe(result.originalCredentials)
1364+
expect(result.clonedMode).toBe(result.originalMode)
1365+
expect(result.clonedRedirect).toBe(result.originalRedirect)
1366+
expect(result.clonedReferrerPolicy).toBe(result.originalReferrerPolicy)
1367+
expect(result.cloned).toBe(true)
1368+
expect(result.payload).toMatchObject(testData)
1369+
})
1370+
})

0 commit comments

Comments
 (0)