@@ -27,6 +27,8 @@ export interface SandboxResponse {
2727export interface RequestBridgeOptions {
2828 /** Maximum number of requests per bridge instance. Default: 50. */
2929 maxRequests ?: number ;
30+ /** Maximum request body size in bytes. Default: 1MB. */
31+ maxRequestBytes ?: number ;
3032 /** Maximum response body size in bytes. Default: 10MB. */
3133 maxResponseBytes ?: number ;
3234 /** Allowed headers whitelist. When undefined, uses default blocklist. */
@@ -35,6 +37,10 @@ export interface RequestBridgeOptions {
3537 exposedResponseHeaders ?: string [ ] ;
3638}
3739
40+ export interface RequestBridgeContext {
41+ signal ?: AbortSignal ;
42+ }
43+
3844const ALLOWED_METHODS = new Set ( [
3945 "GET" , "POST" , "PUT" , "PATCH" , "DELETE" , "HEAD" , "OPTIONS" ,
4046] ) ;
@@ -63,8 +69,45 @@ const BLOCKED_HEADER_PATTERNS = [
6369] ;
6470
6571const DEFAULT_MAX_REQUESTS = 50 ;
72+ const DEFAULT_MAX_REQUEST_BYTES = 1024 * 1024 ; // 1MB
6673const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024 ; // 10MB
6774
75+ function requestAbortedError ( ) : Error {
76+ return new Error ( "Request aborted" ) ;
77+ }
78+
79+ function throwIfAborted ( signal : AbortSignal | undefined ) : void {
80+ if ( signal ?. aborted ) {
81+ throw requestAbortedError ( ) ;
82+ }
83+ }
84+
85+ function utf8ByteLength ( text : string ) : number {
86+ return Buffer . byteLength ( text , "utf8" ) ;
87+ }
88+
89+ async function abortable < T > (
90+ operation : Promise < T > ,
91+ signal : AbortSignal | undefined ,
92+ ) : Promise < T > {
93+ if ( ! signal ) return await operation ;
94+ throwIfAborted ( signal ) ;
95+
96+ let onAbort : ( ( ) => void ) | undefined ;
97+ const aborted = new Promise < T > ( ( _resolve , reject ) => {
98+ onAbort = ( ) => reject ( requestAbortedError ( ) ) ;
99+ signal . addEventListener ( "abort" , onAbort , { once : true } ) ;
100+ } ) ;
101+
102+ try {
103+ return await Promise . race ( [ operation , aborted ] ) ;
104+ } finally {
105+ if ( onAbort ) {
106+ signal . removeEventListener ( "abort" , onAbort ) ;
107+ }
108+ }
109+ }
110+
68111/**
69112 * Read a response body as text, aborting early if it exceeds maxBytes.
70113 * Streams the body in chunks to avoid buffering the entire response
@@ -73,11 +116,13 @@ const DEFAULT_MAX_RESPONSE_BYTES = 10 * 1024 * 1024; // 10MB
73116async function readResponseWithLimit (
74117 response : Response ,
75118 maxBytes : number ,
119+ signal ?: AbortSignal ,
76120) : Promise < string > {
121+ throwIfAborted ( signal ) ;
77122 const reader = response . body ?. getReader ( ) ;
78123 if ( ! reader ) {
79124 // No body stream — fall back to .text() (e.g., empty responses)
80- const text = await response . text ( ) ;
125+ const text = await abortable ( response . text ( ) , signal ) ;
81126 if ( text . length > maxBytes ) {
82127 throw new Error (
83128 `Response too large: ${ text . length } bytes exceeds limit of ${ maxBytes } bytes` ,
@@ -88,20 +133,28 @@ async function readResponseWithLimit(
88133
89134 const chunks : Uint8Array [ ] = [ ] ;
90135 let totalBytes = 0 ;
136+ let shouldCancel = false ;
91137 try {
92138 // Streaming read — must be sequential
93139 for ( ; ; ) {
94- const { done, value } = await reader . read ( ) ; // oxlint-disable-line no-await-in-loop
140+ const { done, value } = await abortable ( reader . read ( ) , signal ) ; // oxlint-disable-line no-await-in-loop
95141 if ( done ) break ;
96142 totalBytes += value . byteLength ;
97143 if ( totalBytes > maxBytes ) {
144+ shouldCancel = true ;
98145 throw new Error (
99146 `Response too large: exceeded limit of ${ maxBytes } bytes` ,
100147 ) ;
101148 }
102149 chunks . push ( value ) ;
103150 }
151+ } catch ( error ) {
152+ shouldCancel = true ;
153+ throw error ;
104154 } finally {
155+ if ( shouldCancel ) {
156+ await reader . cancel ( ) . catch ( ( ) => { } ) ;
157+ }
105158 reader . releaseLock ( ) ;
106159 }
107160
@@ -195,7 +248,10 @@ function filterResponseHeaders(
195248 * Bridges sandbox API calls to the host request handler (Hono app.request, fetch, etc.).
196249 */
197250/** Bridge function with an exposed request count. */
198- export type RequestBridgeFn = ( ( options : SandboxRequestOptions ) => Promise < SandboxResponse > ) & {
251+ export type RequestBridgeFn = ( (
252+ options : SandboxRequestOptions ,
253+ context ?: RequestBridgeContext ,
254+ ) => Promise < SandboxResponse > ) & {
199255 /** Number of requests made through this bridge instance. */
200256 readonly requestCount : number ;
201257} ;
@@ -206,6 +262,7 @@ export function createRequestBridge(
206262 options : RequestBridgeOptions = { } ,
207263) : RequestBridgeFn {
208264 const maxRequests = options . maxRequests ?? DEFAULT_MAX_REQUESTS ;
265+ const maxRequestBytes = options . maxRequestBytes ?? DEFAULT_MAX_REQUEST_BYTES ;
209266 const maxResponseBytes = options . maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES ;
210267 const allowedHeaders = options . allowedHeaders
211268 ? new Set ( options . allowedHeaders . map ( ( h ) => h . toLowerCase ( ) ) )
@@ -216,8 +273,13 @@ export function createRequestBridge(
216273
217274 let requestCount = 0 ;
218275
219- const bridge = async ( opts : SandboxRequestOptions ) : Promise < SandboxResponse > => {
276+ const bridge = async (
277+ opts : SandboxRequestOptions ,
278+ context ?: RequestBridgeContext ,
279+ ) : Promise < SandboxResponse > => {
280+ const signal = context ?. signal ;
220281 const { method, path, query, body, headers } = opts ;
282+ throwIfAborted ( signal ) ;
221283
222284 // Validate request count
223285 if ( ++ requestCount > maxRequests ) {
@@ -252,23 +314,35 @@ export function createRequestBridge(
252314 const init : RequestInit = {
253315 method : upperMethod ,
254316 headers : { ...filteredHeaders } ,
317+ signal,
255318 } ;
256319
257320 if ( body !== undefined && body !== null ) {
258- init . body = JSON . stringify ( body ) ;
321+ const bodyJson = JSON . stringify ( body ) ;
322+ const bodyBytes = utf8ByteLength ( bodyJson ) ;
323+ if ( bodyBytes > maxRequestBytes ) {
324+ throw new Error (
325+ `Request body too large: ${ bodyBytes } bytes exceeds limit of ${ maxRequestBytes } bytes` ,
326+ ) ;
327+ }
328+ init . body = bodyJson ;
259329 ( init . headers as Record < string , string > ) [ "content-type" ] =
260330 ( init . headers as Record < string , string > ) [ "content-type" ] ?? "application/json" ;
261331 }
262332
263333 // Call the host handler
264- const response = await handler ( url . toString ( ) , init ) ;
334+ const response = await abortable (
335+ Promise . resolve ( handler ( url . toString ( ) , init ) ) ,
336+ signal ,
337+ ) ;
338+ throwIfAborted ( signal ) ;
265339
266340 const responseHeaders = filterResponseHeaders ( response . headers , exposedResponseHeaders ) ;
267341
268342 // Read response body with streaming size limit to avoid host OOM.
269343 // Abort as soon as accumulated bytes exceed the limit.
270344 const contentType = response . headers . get ( "content-type" ) ?? "" ;
271- const text = await readResponseWithLimit ( response , maxResponseBytes ) ;
345+ const text = await readResponseWithLimit ( response , maxResponseBytes , signal ) ;
272346
273347 let responseBody : unknown ;
274348 if ( contentType . includes ( "application/json" ) ) {
0 commit comments