Skip to content

Commit 9148279

Browse files
committed
fix: resolve "Body already used" when accessing request in derive/resolve hooks
When derive() or resolve() hooks access request.arrayBuffer()/text()/json(), they fail with "Body already used" because Elysia parses the body before running these hooks. This fix: 1. Adds `request` tracking to Sucrose inference to detect when hooks access the request body 2. When inference.request is true, clones the request before parsing so the original stream remains available for hooks 3. Saves rawBody from the clone and parses body from it, while keeping the original request available for derive/resolve hooks 4. Adds derive and resolve hooks to sucrose analysis (they were missing) Fixes #1628
1 parent f751caf commit 9148279

File tree

3 files changed

+192
-76
lines changed

3 files changed

+192
-76
lines changed

src/compose.ts

Lines changed: 160 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,18 @@ export const composeHandler = ({
874874

875875
fnLiteral += '\ntry{'
876876

877+
// When hooks access request.body/arrayBuffer, preserve raw body before parsing
878+
// Clone request first so hooks can still read from it
879+
// Skip if custom parse hooks exist - they'll read the request themselves
880+
// Note: FormData requests cannot be cloned for reuse - derive/resolve cannot
881+
// re-read the body for multipart/form-data requests (protocol limitation)
882+
if (inference.request && !hooks.parse?.length) {
883+
fnLiteral +=
884+
`const _ct=c.request.headers.get('content-type')\n` +
885+
`if(!_ct||!_ct.includes('multipart/form-data')){` +
886+
`c.rawBody=await c.request.clone().arrayBuffer()}\n`
887+
}
888+
877889
let parser: string | undefined =
878890
typeof hooks.parse === 'string'
879891
? hooks.parse
@@ -904,55 +916,108 @@ export const composeHandler = ({
904916

905917
const isOptionalBody = !!validator.body?.isOptional
906918

907-
switch (parser) {
908-
case 'json':
909-
case 'application/json':
910-
fnLiteral += adapter.parser.json(isOptionalBody)
911-
break
919+
// When inference.request is true and rawBody exists, parse from it
920+
// For FormData (no rawBody), use standard parser
921+
if (inference.request) {
922+
switch (parser) {
923+
case 'json':
924+
case 'application/json':
925+
if (isOptionalBody)
926+
fnLiteral += 'if(c.rawBody){try{c.body=JSON.parse(new TextDecoder().decode(c.rawBody))}catch{}}else{try{c.body=await c.request.json()}catch{}}\n'
927+
else
928+
fnLiteral += 'c.body=c.rawBody?JSON.parse(new TextDecoder().decode(c.rawBody)):await c.request.json()\n'
929+
break
930+
931+
case 'text':
932+
case 'text/plain':
933+
fnLiteral += 'c.body=c.rawBody?new TextDecoder().decode(c.rawBody):await c.request.text()\n'
934+
break
935+
936+
case 'urlencoded':
937+
case 'application/x-www-form-urlencoded':
938+
fnLiteral += 'c.body=c.rawBody?parseQuery(new TextDecoder().decode(c.rawBody)):parseQuery(await c.request.text())\n'
939+
break
940+
941+
case 'arrayBuffer':
942+
case 'application/octet-stream':
943+
fnLiteral += 'c.body=c.rawBody??await c.request.arrayBuffer()\n'
944+
break
945+
946+
case 'formdata':
947+
case 'multipart/form-data':
948+
// FormData reads from original request (cannot be cloned for reuse)
949+
fnLiteral += adapter.parser.formData(isOptionalBody)
950+
break
951+
952+
default:
953+
// Custom parser - let it access rawBody via context
954+
if (parser in app['~parser']) {
955+
fnLiteral += hasHeaders
956+
? `let contentType = c.headers['content-type']`
957+
: `let contentType = c.request.headers.get('content-type')`
912958

913-
case 'text':
914-
case 'text/plain':
915-
fnLiteral += adapter.parser.text(isOptionalBody)
959+
fnLiteral +=
960+
`\nif(contentType){` +
961+
`const index=contentType.indexOf(';')\n` +
962+
`if(index!==-1)contentType=contentType.substring(0,index)}\n` +
963+
`else{contentType=''}` +
964+
`c.contentType=contentType\n` +
965+
`let result=parser['${parser}'](c, contentType)\n` +
966+
`if(result instanceof Promise)result=await result\n` +
967+
`if(result instanceof ElysiaCustomStatusResponse)throw result\n` +
968+
`if(result!==undefined)c.body=result\n` +
969+
'delete c.contentType\n'
970+
}
971+
}
972+
} else {
973+
switch (parser) {
974+
case 'json':
975+
case 'application/json':
976+
fnLiteral += adapter.parser.json(isOptionalBody)
977+
break
916978

917-
break
979+
case 'text':
980+
case 'text/plain':
981+
fnLiteral += adapter.parser.text(isOptionalBody)
918982

919-
case 'urlencoded':
920-
case 'application/x-www-form-urlencoded':
921-
fnLiteral += adapter.parser.urlencoded(isOptionalBody)
983+
break
922984

923-
break
985+
case 'urlencoded':
986+
case 'application/x-www-form-urlencoded':
987+
fnLiteral += adapter.parser.urlencoded(isOptionalBody)
924988

925-
case 'arrayBuffer':
926-
case 'application/octet-stream':
927-
fnLiteral += adapter.parser.arrayBuffer(isOptionalBody)
989+
break
928990

929-
break
991+
case 'arrayBuffer':
992+
case 'application/octet-stream':
993+
fnLiteral += adapter.parser.arrayBuffer(isOptionalBody)
930994

931-
case 'formdata':
932-
case 'multipart/form-data':
933-
fnLiteral += adapter.parser.formData(isOptionalBody)
934-
break
995+
break
935996

936-
default:
937-
if ((parser[0] as string) in app['~parser']) {
938-
fnLiteral += hasHeaders
939-
? `let contentType = c.headers['content-type']`
940-
: `let contentType = c.request.headers.get('content-type')`
997+
case 'formdata':
998+
case 'multipart/form-data':
999+
fnLiteral += adapter.parser.formData(isOptionalBody)
1000+
break
9411001

942-
fnLiteral +=
943-
`\nif(contentType){` +
944-
`const index=contentType.indexOf(';')\n` +
945-
`if(index!==-1)contentType=contentType.substring(0,index)}\n` +
946-
`else{contentType=''}` +
947-
`c.contentType=contentType\n` +
948-
`let result=parser['${parser}'](c, contentType)\n` +
949-
`if(result instanceof Promise)result=await result\n` +
950-
`if(result instanceof ElysiaCustomStatusResponse)throw result\n` +
951-
`if(result!==undefined)c.body=result\n` +
952-
'delete c.contentType\n'
953-
}
1002+
default:
1003+
if (parser in app['~parser']) {
1004+
fnLiteral += hasHeaders
1005+
? `let contentType = c.headers['content-type']`
1006+
: `let contentType = c.request.headers.get('content-type')`
9541007

955-
break
1008+
fnLiteral +=
1009+
`\nif(contentType){` +
1010+
`const index=contentType.indexOf(';')\n` +
1011+
`if(index!==-1)contentType=contentType.substring(0,index)}\n` +
1012+
`else{contentType=''}` +
1013+
`c.contentType=contentType\n` +
1014+
`let result=parser['${parser}'](c, contentType)\n` +
1015+
`if(result instanceof Promise)result=await result\n` +
1016+
`if(result instanceof ElysiaCustomStatusResponse)throw result\n` +
1017+
`if(result!==undefined)c.body=result\n` +
1018+
'delete c.contentType\n'
1019+
}
1020+
}
9561021
}
9571022

9581023
reporter.resolve()
@@ -977,31 +1042,62 @@ export const composeHandler = ({
9771042
hasDefaultParser = true
9781043
const isOptionalBody = !!validator.body?.isOptional
9791044

980-
fnLiteral +=
981-
`if(contentType)` +
982-
`switch(contentType.charCodeAt(12)){` +
983-
`\ncase 106:` +
984-
adapter.parser.json(isOptionalBody) +
985-
'break' +
986-
`\n` +
987-
`case 120:` +
988-
adapter.parser.urlencoded(isOptionalBody) +
989-
`break` +
990-
`\n` +
991-
`case 111:` +
992-
adapter.parser.arrayBuffer(isOptionalBody) +
993-
`break` +
994-
`\n` +
995-
`case 114:` +
996-
adapter.parser.formData(isOptionalBody) +
997-
`break` +
998-
`\n` +
999-
`default:` +
1000-
`if(contentType.charCodeAt(0)===116){` +
1001-
adapter.parser.text(isOptionalBody) +
1002-
`}` +
1003-
`break\n` +
1004-
`}`
1045+
// When rawBody is preserved, parse from it; for FormData use cloned request
1046+
if (inference.request) {
1047+
fnLiteral +=
1048+
`if(contentType)` +
1049+
`switch(contentType.charCodeAt(12)){` +
1050+
`\ncase 106:` + // application/json
1051+
(isOptionalBody
1052+
? 'if(c.rawBody){try{c.body=JSON.parse(new TextDecoder().decode(c.rawBody))}catch{}}else{try{c.body=await c.request.json()}catch{}}\n'
1053+
: 'c.body=c.rawBody?JSON.parse(new TextDecoder().decode(c.rawBody)):await c.request.json()\n') +
1054+
'break' +
1055+
`\n` +
1056+
`case 120:` + // application/x-www-form-urlencoded
1057+
'c.body=c.rawBody?parseQuery(new TextDecoder().decode(c.rawBody)):parseQuery(await c.request.text())\n' +
1058+
`break` +
1059+
`\n` +
1060+
`case 111:` + // application/octet-stream
1061+
'c.body=c.rawBody??await c.request.arrayBuffer()\n' +
1062+
`break` +
1063+
`\n` +
1064+
`case 114:` + // multipart/form-data
1065+
adapter.parser.formData(isOptionalBody) +
1066+
`break` +
1067+
`\n` +
1068+
`default:` +
1069+
`if(contentType.charCodeAt(0)===116){` + // text/plain
1070+
'c.body=c.rawBody?new TextDecoder().decode(c.rawBody):await c.request.text()\n' +
1071+
`}` +
1072+
`break\n` +
1073+
`}`
1074+
} else {
1075+
fnLiteral +=
1076+
`if(contentType)` +
1077+
`switch(contentType.charCodeAt(12)){` +
1078+
`\ncase 106:` +
1079+
adapter.parser.json(isOptionalBody) +
1080+
'break' +
1081+
`\n` +
1082+
`case 120:` +
1083+
adapter.parser.urlencoded(isOptionalBody) +
1084+
`break` +
1085+
`\n` +
1086+
`case 111:` +
1087+
adapter.parser.arrayBuffer(isOptionalBody) +
1088+
`break` +
1089+
`\n` +
1090+
`case 114:` +
1091+
adapter.parser.formData(isOptionalBody) +
1092+
`break` +
1093+
`\n` +
1094+
`default:` +
1095+
`if(contentType.charCodeAt(0)===116){` +
1096+
adapter.parser.text(isOptionalBody) +
1097+
`}` +
1098+
`break\n` +
1099+
`}`
1100+
}
10051101
}
10061102

10071103
const reporter = report('parse', {

src/sucrose.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export namespace Sucrose {
1717
route: boolean
1818
url: boolean
1919
path: boolean
20+
/** Whether code accesses request.body, request.arrayBuffer, etc. */
21+
request: boolean
2022
}
2123

2224
export interface LifeCycle extends Partial<LifeCycleStore> {
@@ -291,6 +293,7 @@ export const findParameterReference = (
291293
if (parameters.route) inference.route = true
292294
if (parameters.url) inference.url = true
293295
if (parameters.path) inference.path = true
296+
if (parameters.request) inference.request = true
294297

295298
if (hasParenthesis) return `{ ${Object.keys(parameters).join(', ')} }`
296299

@@ -471,6 +474,7 @@ export const inferBodyReference = (
471474
if (parameters.url) inference.url = true
472475
if (parameters.route) inference.route = true
473476
if (parameters.path) inference.path = true
477+
if (parameters.request) inference.request = true
474478

475479
continue
476480
}
@@ -498,6 +502,8 @@ export const inferBodyReference = (
498502
if (!inference.route && access('route', alias)) inference.route = true
499503
if (!inference.url && access('url', alias)) inference.url = true
500504
if (!inference.path && access('path', alias)) inference.path = true
505+
if (!inference.request && access('request', alias))
506+
inference.request = true
501507

502508
if (
503509
inference.query &&
@@ -508,7 +514,8 @@ export const inferBodyReference = (
508514
inference.server &&
509515
inference.route &&
510516
inference.url &&
511-
inference.path
517+
inference.path &&
518+
inference.request
512519
)
513520
break
514521
}
@@ -645,7 +652,8 @@ export const mergeInference = (a: Sucrose.Inference, b: Sucrose.Inference) => {
645652
server: a.server || b.server,
646653
url: a.url || b.url,
647654
route: a.route || b.route,
648-
path: a.path || b.path
655+
path: a.path || b.path,
656+
request: a.request || b.request
649657
}
650658
}
651659

@@ -660,7 +668,8 @@ export const sucrose = (
660668
server: false,
661669
url: false,
662670
route: false,
663-
path: false
671+
path: false,
672+
request: false
664673
},
665674
settings: Sucrose.Settings = {}
666675
): Sucrose.Inference => {
@@ -674,6 +683,8 @@ export const sucrose = (
674683
if (lifeCycle.afterHandle?.length) events.push(...lifeCycle.afterHandle)
675684
if (lifeCycle.mapResponse?.length) events.push(...lifeCycle.mapResponse)
676685
if (lifeCycle.afterResponse?.length) events.push(...lifeCycle.afterResponse)
686+
if (lifeCycle.derive?.length) events.push(...lifeCycle.derive)
687+
if (lifeCycle.resolve?.length) events.push(...lifeCycle.resolve)
677688

678689
if (lifeCycle.handler && typeof lifeCycle.handler === 'function')
679690
events.push(lifeCycle.handler as Handler)
@@ -710,7 +721,8 @@ export const sucrose = (
710721
server: false,
711722
url: false,
712723
route: false,
713-
path: false
724+
path: false,
725+
request: false
714726
}
715727

716728
const [parameter, body] = separateFunction(content)

0 commit comments

Comments
 (0)