fix(common): preserve file uploads in experimental scripting sandbox (#5512)

This commit is contained in:
James George 2025-10-26 23:34:43 +05:30 committed by GitHub
parent 68d1db7e74
commit 567344a9e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1242 additions and 8 deletions

View file

@ -63,6 +63,7 @@ import {
} from "./workers/sandbox.worker"
import { transformInheritedCollectionVariablesToAggregateEnv } from "./utils/inheritedCollectionVarTransformer"
import { isJSONContentType } from "./utils/contenttypes"
import { applyScriptRequestUpdates } from "./experimental-sandbox-integration"
const sandboxWorker = new Worker(
new URL("./workers/sandbox.worker.ts", import.meta.url),
@ -468,10 +469,10 @@ export function runRESTRequest$(
secret,
}))
const finalRequest = {
...resolvedRequest,
...(preRequestScriptResult.right.updatedRequest ?? {}),
}
const finalRequest = applyScriptRequestUpdates(
resolvedRequest,
preRequestScriptResult.right.updatedRequest
)
// Propagate changes to request variables from the scripting context to the UI
tab.value.document.request.requestVariables = finalRequest.requestVariables
@ -686,10 +687,10 @@ export function runTestRunnerRequest(
)
// Calculate the final updated request after pre-request script changes
const finalRequest = {
...request,
...(preRequestScriptResult.right.updatedRequest ?? {}),
}
const finalRequest = applyScriptRequestUpdates(
request,
preRequestScriptResult.right.updatedRequest
)
const effectiveRequest = await getEffectiveRESTRequest(finalRequest, {
id: "env-id",

View file

@ -0,0 +1,104 @@
import { HoppRESTRequest } from "@hoppscotch/data"
/**
* Applies pre-request script modifications to the original request.
*
* For legacy sandbox: Returns original request unchanged (`updatedRequest` is `undefined`).
* For experimental sandbox: Merges script changes while preserving file uploads
* lost during JSON serialization.
*
* Context: When the experimental scripting sandbox is enabled, requests are
* sent to a Web Worker for pre-request script execution. The request undergoes
* JSON serialization which converts File/Blob objects to empty objects `{}`.
* A Zod transform then converts file fields with empty arrays to text fields
* (`isFile: false`, `value: ""`).
*
* This function uses hybrid matching to handle both:
* - Duplicate keys (e.g., multiple fields with `key="file"`) via index matching
* - Field reordering by scripts via key-based fallback
*
* @param originalRequest The original request with file uploads intact
* @param updatedRequest The request returned from sandbox (undefined for legacy, modified for experimental)
* @returns Merged request with file uploads preserved and script changes applied
*
* @see https://github.com/hoppscotch/hoppscotch/issues/5443
* @see FormDataKeyValue schema in ~/hoppscotch-data/src/rest/v/9/body.ts
*/
export const applyScriptRequestUpdates = (
originalRequest: HoppRESTRequest,
updatedRequest?: HoppRESTRequest
): HoppRESTRequest => {
if (!updatedRequest) {
return originalRequest
}
const originalBody = originalRequest.body
const updatedBody = updatedRequest.body
if (
originalBody.contentType === "multipart/form-data" &&
updatedBody.contentType === "multipart/form-data"
) {
const originalFormData = originalBody.body
const updatedFormData = updatedBody.body
const usedIndices = new Set<number>()
const mergedFormData = updatedFormData.map((updatedField, index) => {
// Hybrid matching: try position first (handles duplicate keys like "file", "file", "file"),
// then search by key (handles field reordering by scripts)
const samePositionMatch =
index < originalFormData.length &&
!usedIndices.has(index) &&
originalFormData[index].key === updatedField.key
const matchedIndex = samePositionMatch
? index
: originalFormData.findIndex(
(field, i) => !usedIndices.has(i) && field.key === updatedField.key
)
// If matched, restore file data from original (only `originalField` has `isFile=true`)
if (matchedIndex >= 0) {
usedIndices.add(matchedIndex)
const originalField = originalFormData[matchedIndex]
if (originalField.isFile) {
return {
...updatedField,
value: originalField.value,
isFile: true as const,
...(originalField.contentType && {
contentType: originalField.contentType,
}),
} as typeof updatedField
}
}
return updatedField
})
return {
...originalRequest,
...updatedRequest,
body: { ...updatedBody, body: mergedFormData },
}
}
if (
originalBody.contentType === "application/octet-stream" &&
updatedBody.contentType === "application/octet-stream" &&
originalBody.body instanceof Blob
) {
return {
...originalRequest,
...updatedRequest,
body: { ...updatedBody, body: originalBody.body },
}
}
// No files to preserve
return {
...originalRequest,
...updatedRequest,
}
}