From d1fc70d724250a1a94422912688e389519dff685 Mon Sep 17 00:00:00 2001
From: James George <25279263+jamesgeorge007@users.noreply.github.com>
Date: Tue, 12 May 2026 11:17:31 +0530
Subject: [PATCH 1/5] fix: preserve script imports and avoid WebKit lookbehind
(#6306)
---
.../src/__tests__/e2e/commands/test.spec.ts | 28 +-
.../collection-level-scripts-coll.json | 48 ++-
...n-level-scripts-duplicate-import-coll.json | 38 ++
.../collection-level-scripts-legacy-coll.json | 114 ++++++
.../src/__tests__/unit/scripting.spec.ts | 75 +++-
.../hoppscotch-cli/src/utils/collections.ts | 2 +-
packages/hoppscotch-cli/src/utils/mutators.ts | 3 -
.../hoppscotch-cli/src/utils/pre-request.ts | 2 +-
.../hoppscotch-cli/src/utils/scripting.ts | 71 ----
packages/hoppscotch-cli/src/utils/test.ts | 2 +-
.../src/components/MonacoScriptEditor.vue | 2 +-
.../src/components/collections/Properties.vue | 2 +-
.../src/components/collections/index.vue | 14 +-
.../components/http/InheritedScriptsModal.vue | 2 +-
.../src/components/http/PreRequestScript.vue | 2 +-
.../src/components/http/RequestOptions.vue | 2 +-
.../src/components/http/Tests.vue | 2 +-
.../src/composables/codemirror.ts | 2 +-
.../src/helpers/RequestRunner.ts | 5 +-
.../src/helpers/__tests__/scripting.spec.ts | 64 ++++
.../src/helpers/scripting.ts | 85 -----
.../helpers/teams/TeamCollectionAdapter.ts | 2 +-
.../src/helpers/teams/TeamsSearch.service.ts | 2 +-
.../src/newstore/collections.ts | 2 +-
.../src/pages/view/_id/_version.vue | 2 +-
.../src/services/team-collection.service.ts | 2 +-
.../test-runner/test-runner.service.ts | 2 +-
packages/hoppscotch-js-sandbox/package.json | 8 +-
packages/hoppscotch-js-sandbox/scripting.d.ts | 1 +
.../__tests__/combined/script-imports.spec.ts | 344 ++++++++++++++++++
.../src/node/test-runner/index.ts | 30 +-
.../hoppscotch-js-sandbox/src/scripting.ts | 11 +
.../src/utils/scripting.ts | 282 ++++++++++++++
.../src/web/test-runner/index.ts | 30 +-
packages/hoppscotch-js-sandbox/vite.config.ts | 1 +
pnpm-lock.yaml | 3 +
36 files changed, 1068 insertions(+), 219 deletions(-)
create mode 100644 packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json
create mode 100644 packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json
delete mode 100644 packages/hoppscotch-cli/src/utils/scripting.ts
create mode 100644 packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts
delete mode 100644 packages/hoppscotch-common/src/helpers/scripting.ts
create mode 100644 packages/hoppscotch-js-sandbox/scripting.d.ts
create mode 100644 packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts
create mode 100644 packages/hoppscotch-js-sandbox/src/scripting.ts
create mode 100644 packages/hoppscotch-js-sandbox/src/utils/scripting.ts
diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts
index 42f7df625cd..48b80b6652b 100644
--- a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts
+++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts
@@ -540,7 +540,7 @@ describe("hopp test [options] ", { timeout: 100000 }, () => {
fs.unlinkSync(junitPath);
}, 600000); // 600 second (10 minute) timeout
- test("Inherited collection-level scripts run in order across both sandboxes", async () => {
+ test("Inherited collection-level scripts run in order on the experimental sandbox (default)", async () => {
const args = `test ${getTestJsonFilePath(
"collection-level-scripts-coll.json",
"collection"
@@ -549,11 +549,35 @@ describe("hopp test [options] ", { timeout: 100000 }, () => {
const defaultResult = await runCLIWithNetworkRetry(args);
if (defaultResult === null) return;
expect(defaultResult.error).toBeNull();
+ });
+
+ // The legacy sandbox uses a non-module evaluator that rejects top-level
+ // ESM imports at parse time, so it runs against a pruned fixture that
+ // omits the import-using request.
+ test("Inherited collection-level scripts run in order on the legacy sandbox", async () => {
+ const args = `test ${getTestJsonFilePath(
+ "collection-level-scripts-legacy-coll.json",
+ "collection"
+ )} --legacy-sandbox`;
- const legacyResult = await runCLIWithNetworkRetry(`${args} --legacy-sandbox`);
+ const legacyResult = await runCLIWithNetworkRetry(args);
if (legacyResult === null) return;
expect(legacyResult.error).toBeNull();
});
+
+ test("Surfaces a SyntaxError when the same import binding appears in multiple scripts in a request's cascade", async () => {
+ const args = `test ${getTestJsonFilePath(
+ "collection-level-scripts-duplicate-import-coll.json",
+ "collection"
+ )}`;
+ const { error, stderr } = await runCLI(args);
+
+ expect(error).not.toBeNull();
+ expect(stderr).toContain("PRE_REQUEST_SCRIPT_ERROR");
+ expect(stderr).toContain(
+ "'dup' is imported from different sources across scripts in this request's chain"
+ );
+ });
});
describe("Test `hopp test --env ` command:", () => {
diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json
index 38bae83959e..e0904941099 100644
--- a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json
+++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json
@@ -54,6 +54,50 @@
"requestVariables": [],
"responses": {},
"description": null
+ },
+ {
+ "v": "17",
+ "id": "cl-script-req-with-import",
+ "name": "request-with-top-level-import",
+ "method": "GET",
+ "endpoint": "https://echo.hoppscotch.io",
+ "params": [],
+ "headers": [],
+ "preRequestScript": "import { value } from \"data:text/javascript,export const value = 'esm-import-ok'\";\npw.env.set(\"IMPORTED_VALUE\", value);\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->req-with-import\");",
+ "testScript": "pw.env.set(\"TEST_ORDER\", \"req-with-import\");\npw.test(\"top-level ESM import in pre-request script resolved\", () => {\n pw.expect(pw.env.get(\"IMPORTED_VALUE\")).toBe(\"esm-import-ok\");\n});\npw.test(\"cascade order preserved with import-using request\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->req-with-import\");\n});",
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "body": {
+ "contentType": null,
+ "body": null
+ },
+ "requestVariables": [],
+ "responses": {},
+ "description": null
+ },
+ {
+ "v": "17",
+ "id": "cl-script-req-with-test-import",
+ "name": "request-with-test-script-imports",
+ "method": "GET",
+ "endpoint": "https://echo.hoppscotch.io",
+ "params": [],
+ "headers": [],
+ "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->req-with-test-import\");",
+ "testScript": "import lodash from \"data:text/javascript,export default { pick: (obj, keys) => keys.reduce((acc, k) => (k in obj ? Object.assign(acc, { [k]: obj[k] }) : acc), {}) }\";\nimport axios from \"data:text/javascript,export default { name: 'axios-stub', version: '1.6.0' }\";\nimport { format } from \"data:text/javascript,export const format = (_d, fmt) => fmt.replace('yyyy', '2026').replace('MM', '05').replace('dd', '07')\";\nimport * as ns from \"data:text/javascript,export const a = 1; export const b = 2\";\nimport combo, { tag } from \"data:text/javascript,export default 7; export const tag = 'mixed'\";\nconst picked = lodash.pick({ id: 1, name: \"hopp\", email: \"x@y.z\", extra: \"drop\" }, [\"id\", \"name\", \"email\"]);\npw.env.set(\"TEST_IMPORT_PICKED\", JSON.stringify(picked));\npw.env.set(\"TEST_IMPORT_AXIOS\", axios.name);\npw.env.set(\"TEST_IMPORT_FORMATTED\", format(new Date(), \"yyyy-MM-dd\"));\npw.env.set(\"TEST_IMPORT_NAMESPACE_SUM\", String(ns.a + ns.b));\npw.env.set(\"TEST_IMPORT_MIXED\", String(combo) + \"-\" + tag);\npw.env.set(\"TEST_ORDER\", \"req-with-test-import\");\npw.test(\"test-script default imports resolve\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_AXIOS\")).toBe(\"axios-stub\");\n});\npw.test(\"test-script named import resolves\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_FORMATTED\")).toBe(\"2026-05-07\");\n});\npw.test(\"test-script namespace import resolves\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_NAMESPACE_SUM\")).toBe(\"3\");\n});\npw.test(\"test-script mixed default and named import resolves\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_MIXED\")).toBe(\"7-mixed\");\n});\npw.test(\"test-script imports run alongside test logic\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_PICKED\")).toBe(JSON.stringify({ id: 1, name: \"hopp\", email: \"x@y.z\" }));\n});",
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "body": {
+ "contentType": null,
+ "body": null
+ },
+ "requestVariables": [],
+ "responses": {},
+ "description": null
}
],
"auth": {
@@ -80,7 +124,7 @@
"params": [],
"headers": [],
"preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-req-in-sibling\");",
- "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-sibling\");\npw.test(\"sibling-folder cascade is root->sibling-folder->this-request (no target-folder leak)\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->sibling-folder->sibling-req-in-sibling\");\n});\npw.test(\"target-folder pre-script ran exactly twice (one per request in target-folder)\", () => {\n pw.expect(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\")).toBe(\"2\");\n});",
+ "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-sibling\");\npw.test(\"sibling-folder cascade is root->sibling-folder->this-request (no target-folder leak)\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->sibling-folder->sibling-req-in-sibling\");\n});\npw.test(\"target-folder pre-script ran once per request in target-folder\", () => {\n pw.expect(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\")).toBe(\"4\");\n});",
"auth": {
"authType": "inherit",
"authActive": true
@@ -110,5 +154,5 @@
},
"headers": [],
"preRequestScript": "pw.env.set(\"ROOT_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", \"root\");",
- "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->root\");\npw.test(\"test-script cascade ran in request->folder->root order for every request\", () => {\n pw.expect([\"target-req->target-folder->root\", \"sibling-req-in-target->target-folder->root\", \"sibling-req-in-sibling->sibling-folder->root\"].includes(pw.env.get(\"TEST_ORDER\"))).toBe(true);\n});"
+ "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->root\");\npw.test(\"test-script cascade ran in request->folder->root order for every request\", () => {\n pw.expect([\"target-req->target-folder->root\", \"sibling-req-in-target->target-folder->root\", \"req-with-import->target-folder->root\", \"req-with-test-import->target-folder->root\", \"sibling-req-in-sibling->sibling-folder->root\"].includes(pw.env.get(\"TEST_ORDER\"))).toBe(true);\n});"
}
diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json
new file mode 100644
index 00000000000..ae5ba7fd375
--- /dev/null
+++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json
@@ -0,0 +1,38 @@
+{
+ "v": 12,
+ "name": "collection-level-scripts-duplicate-import-coll",
+ "variables": [],
+ "description": null,
+ "folders": [],
+ "requests": [
+ {
+ "v": "17",
+ "id": "cl-script-dup-req",
+ "name": "request-with-duplicate-import-binding",
+ "method": "GET",
+ "endpoint": "https://echo.hoppscotch.io",
+ "params": [],
+ "headers": [],
+ "preRequestScript": "import dup from \"data:text/javascript,export default 2\";\npw.env.set(\"REQ_BINDING\", String(dup));",
+ "testScript": "",
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "body": {
+ "contentType": null,
+ "body": null
+ },
+ "requestVariables": [],
+ "responses": {},
+ "description": null
+ }
+ ],
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "headers": [],
+ "preRequestScript": "import dup from \"data:text/javascript,export default 1\";\npw.env.set(\"ROOT_BINDING\", String(dup));",
+ "testScript": ""
+}
diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json
new file mode 100644
index 00000000000..0d53b17598c
--- /dev/null
+++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json
@@ -0,0 +1,114 @@
+{
+ "v": 12,
+ "name": "collection-level-scripts-legacy-coll",
+ "variables": [],
+ "description": null,
+ "folders": [
+ {
+ "v": 12,
+ "name": "target-folder",
+ "variables": [],
+ "description": null,
+ "folders": [],
+ "requests": [
+ {
+ "v": "17",
+ "id": "cl-script-req-1",
+ "name": "target-request",
+ "method": "GET",
+ "endpoint": "https://echo.hoppscotch.io",
+ "params": [],
+ "headers": [],
+ "preRequestScript": "pw.env.set(\"REQ_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->target-req\");",
+ "testScript": "pw.env.set(\"TEST_ORDER\", \"target-req\");\npw.env.set(\"ORDER_AT_REQ\", pw.env.get(\"TEST_ORDER\"));\npw.test(\"pre-script cascade ran in root->target-folder->target-req order\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->target-req\");\n});\npw.test(\"all cascade pre-scripts committed env vars\", () => {\n pw.expect(pw.env.get(\"ROOT_RAN\")).toBe(\"yes\");\n pw.expect(pw.env.get(\"TARGET_FOLDER_RAN\")).toBe(\"yes\");\n pw.expect(pw.env.get(\"REQ_RAN\")).toBe(\"yes\");\n});\npw.test(\"request-level test observed request position in test-cascade\", () => {\n pw.expect(pw.env.get(\"ORDER_AT_REQ\")).toBe(\"target-req\");\n});",
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "body": {
+ "contentType": null,
+ "body": null
+ },
+ "requestVariables": [],
+ "responses": {},
+ "description": null
+ },
+ {
+ "v": "17",
+ "id": "cl-script-req-2",
+ "name": "sibling-request-in-target-folder",
+ "method": "GET",
+ "endpoint": "https://echo.hoppscotch.io",
+ "params": [],
+ "headers": [],
+ "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-req-in-target\");",
+ "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-target\");\npw.test(\"sibling request cascade is root->target-folder->this-request\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->sibling-req-in-target\");\n});",
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "body": {
+ "contentType": null,
+ "body": null
+ },
+ "requestVariables": [],
+ "responses": {},
+ "description": null
+ }
+ ],
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "headers": [],
+ "preRequestScript": "pw.env.set(\"TARGET_FOLDER_RAN\", \"yes\");\npw.env.set(\"TARGET_FOLDER_RUN_COUNT\", String((parseInt(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\") || \"0\", 10)) + 1));\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->target-folder\");",
+ "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->target-folder\");\npw.env.set(\"ORDER_AT_TARGET_FOLDER\", pw.env.get(\"TEST_ORDER\"));"
+ },
+ {
+ "v": 12,
+ "name": "sibling-folder",
+ "variables": [],
+ "description": null,
+ "folders": [],
+ "requests": [
+ {
+ "v": "17",
+ "id": "cl-script-req-3",
+ "name": "sibling-request-in-sibling-folder",
+ "method": "GET",
+ "endpoint": "https://echo.hoppscotch.io",
+ "params": [],
+ "headers": [],
+ "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-req-in-sibling\");",
+ "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-sibling\");\npw.test(\"sibling-folder cascade is root->sibling-folder->this-request (no target-folder leak)\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->sibling-folder->sibling-req-in-sibling\");\n});\npw.test(\"target-folder pre-script ran exactly twice (one per request in target-folder)\", () => {\n pw.expect(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\")).toBe(\"2\");\n});",
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "body": {
+ "contentType": null,
+ "body": null
+ },
+ "requestVariables": [],
+ "responses": {},
+ "description": null
+ }
+ ],
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "headers": [],
+ "preRequestScript": "pw.env.set(\"SIBLING_FOLDER_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-folder\");",
+ "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->sibling-folder\");"
+ }
+ ],
+ "requests": [],
+ "auth": {
+ "authType": "inherit",
+ "authActive": true
+ },
+ "headers": [],
+ "preRequestScript": "pw.env.set(\"ROOT_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", \"root\");",
+ "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->root\");\npw.test(\"test-script cascade ran in request->folder->root order for every request\", () => {\n pw.expect([\"target-req->target-folder->root\", \"sibling-req-in-target->target-folder->root\", \"sibling-req-in-sibling->sibling-folder->root\"].includes(pw.env.get(\"TEST_ORDER\"))).toBe(true);\n});"
+}
diff --git a/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts
index a2aa4d46a89..ad9bedff1f0 100644
--- a/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts
+++ b/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts
@@ -4,7 +4,7 @@ import {
combineScriptsWithIIFE,
stripModulePrefix,
MODULE_PREFIX,
-} from "../../utils/scripting";
+} from "@hoppscotch/js-sandbox/scripting";
describe("scripting", () => {
describe("stripModulePrefix", () => {
@@ -164,5 +164,78 @@ describe("scripting", () => {
);
expect(result).toContain("await (async function() {");
});
+
+ test("hoists top-level imports outside the IIFE wrapper", () => {
+ const script = `import { value } from "data:text/javascript,export const value=1";\npw.env.set("x", value);`;
+ const result = combineScriptsWithIIFE([script]);
+
+ const importIdx = result.indexOf("import { value }");
+ const tryIdx = result.indexOf("try {");
+ expect(importIdx).toBeGreaterThanOrEqual(0);
+ expect(importIdx).toBeLessThan(tryIdx);
+ expect(result).toContain('pw.env.set("x", value);');
+ });
+
+ test("preserves imports across an inheritance chain", () => {
+ const root = `import { rootVal } from "data:text/javascript,export const rootVal=1";`;
+ const folder = `import { folderVal } from "data:text/javascript,export const folderVal=2";`;
+ const request = `import { reqVal } from "data:text/javascript,export const reqVal=3";\npw.env.set("sum", String(rootVal + folderVal + reqVal));`;
+ const result = combineScriptsWithIIFE([root, folder, request]);
+
+ expect(result).toContain("import { rootVal }");
+ expect(result).toContain("import { folderVal }");
+ expect(result).toContain("import { reqVal }");
+
+ const tryIdx = result.indexOf("try {");
+ expect(result.indexOf("import { rootVal }")).toBeLessThan(tryIdx);
+ expect(result.indexOf("import { folderVal }")).toBeLessThan(tryIdx);
+ expect(result.indexOf("import { reqVal }")).toBeLessThan(tryIdx);
+ });
+
+ test("dedupes identical imports across scripts to a single emit", () => {
+ const folder = `import lodash from "data:text/javascript,export default {}";`;
+ const request = `import lodash from "data:text/javascript,export default {}";`;
+ const result = combineScriptsWithIIFE([folder, request]);
+
+ const importMatches = result.match(/^import lodash from /gm) ?? [];
+ expect(importMatches).toHaveLength(1);
+ expect(result).not.toContain("imported from different sources");
+ });
+
+ test("emits a synthetic SyntaxError when same name imports clash across sources", () => {
+ const folder = `import lodash from "data:text/javascript,export default 'A'";`;
+ const request = `import lodash from "data:text/javascript,export default 'B'";`;
+ const result = combineScriptsWithIIFE([folder, request]);
+
+ expect(result).toContain(
+ "'lodash' is imported from different sources across scripts in this request's chain"
+ );
+ expect(result).not.toContain("import lodash");
+ });
+
+ test("leaves output unchanged when no scripts use imports", () => {
+ const result = combineScriptsWithIIFE(["const x = 1;", "const y = 2;"]);
+ expect(result.startsWith("const __hoppReporter")).toBe(true);
+ expect(result).not.toContain("import ");
+ });
+
+ test("legacy target preserves original wrapping (no import hoisting)", () => {
+ const script = `import { value } from "data:text/javascript,export const value=1";`;
+ const result = combineScriptsWithIIFE([script], "legacy");
+ expect(result).toContain("import { value }");
+ expect(result).toMatch(/^;\(function\(\) \{/);
+ });
+
+ test("hoists imports even when the script body uses top-level return", () => {
+ // IIFE semantics let user scripts early-return; the AST parse must
+ // permit that or imports stay trapped inside the wrapper.
+ const script = `import { value } from "data:text/javascript,export const value=1";\nif (!value) return;\npw.env.set("OK", "yes");`;
+ const result = combineScriptsWithIIFE([script]);
+ const importIdx = result.indexOf("import { value }");
+ const tryIdx = result.indexOf("try {");
+ expect(importIdx).toBeGreaterThanOrEqual(0);
+ expect(importIdx).toBeLessThan(tryIdx);
+ expect(result).toContain("if (!value) return;");
+ });
});
});
diff --git a/packages/hoppscotch-cli/src/utils/collections.ts b/packages/hoppscotch-cli/src/utils/collections.ts
index 313e957ed7b..276d1542c65 100644
--- a/packages/hoppscotch-cli/src/utils/collections.ts
+++ b/packages/hoppscotch-cli/src/utils/collections.ts
@@ -34,7 +34,7 @@ import {
processRequest,
} from "./request";
import { getTestMetrics } from "./test";
-import { filterValidScripts } from "./scripting";
+import { filterValidScripts } from "@hoppscotch/js-sandbox/scripting";
const { WARN, FAIL, INFO } = exceptionColors;
diff --git a/packages/hoppscotch-cli/src/utils/mutators.ts b/packages/hoppscotch-cli/src/utils/mutators.ts
index 245486cd174..153e66b24a9 100644
--- a/packages/hoppscotch-cli/src/utils/mutators.ts
+++ b/packages/hoppscotch-cli/src/utils/mutators.ts
@@ -9,9 +9,6 @@ import { FormDataEntry } from "../types/request";
import { isHoppErrnoException } from "./checks";
import { getResourceContents } from "./getters";
-// Re-export from the canonical implementation in scripting.ts
-export { stripModulePrefix } from "./scripting";
-
const getValidRequests = (
collections: HoppCollection[],
collectionFilePath: string
diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts
index 283f3721519..83979862ebb 100644
--- a/packages/hoppscotch-cli/src/utils/pre-request.ts
+++ b/packages/hoppscotch-cli/src/utils/pre-request.ts
@@ -36,7 +36,7 @@ import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array";
import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters";
import { stripComments } from "./jsonc";
import { toFormData } from "./mutators";
-import { combineScriptsWithIIFE, filterValidScripts } from "./scripting";
+import { combineScriptsWithIIFE, filterValidScripts } from "@hoppscotch/js-sandbox/scripting";
/**
* Runs pre-request-script runner over given request which extracts set ENVs and
diff --git a/packages/hoppscotch-cli/src/utils/scripting.ts b/packages/hoppscotch-cli/src/utils/scripting.ts
deleted file mode 100644
index 5b04271bf18..00000000000
--- a/packages/hoppscotch-cli/src/utils/scripting.ts
+++ /dev/null
@@ -1,71 +0,0 @@
-/**
- * Module prefix added by Monaco editor for TypeScript module mode.
- * Enables IntelliSense and isolates variables across editor instances.
- */
-export const MODULE_PREFIX = "export {};\n" as const;
-
-/**
- * Strips `export {};` prefix (with or without newline) from scripts before execution
- * (non-module context) or when exporting collections.
- */
-export const stripModulePrefix = (script: string): string => {
- if (script.startsWith(MODULE_PREFIX)) {
- return script.slice(MODULE_PREFIX.length);
- }
- if (script.startsWith("export {};")) {
- return script.slice("export {};".length);
- }
- return script;
-};
-
-export type CombineScriptsTarget = "experimental" | "legacy";
-
-const wrapScript = (script: string, target: CombineScriptsTarget): string => {
- const stripped = stripModulePrefix(script.trim());
- if (!stripped) return "";
- const asyncKeyword = target === "experimental" ? "async " : "";
- return `${asyncKeyword}function() {\n${stripped}\n}`;
-};
-
-/**
- * Combines inherited scripts into a sequential chain. Each script runs in
- * its own function for scope isolation.
- *
- * - `experimental`: `await (async function(){...})();` lines, evaluated in
- * an async host context so each `await` settles before the next runs.
- * - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await`
- * is rejected at parse time.
- */
-export const combineScriptsWithIIFE = (
- scripts: string[],
- target: CombineScriptsTarget = "experimental"
-): string => {
- const fns = scripts.map((s) => wrapScript(s, target)).filter((s) => s);
- if (fns.length === 0) return "";
- if (target === "experimental") {
- // Wrap the awaited chain in try/catch so top-level throws / rejected
- // awaits reach the host reporter; faraday-cage otherwise swallows
- // async-boundary errors via its keepAlive loop.
- const body = fns.map((fn) => `await (${fn})();`).join("\n");
- return [
- "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;",
- "try {",
- body,
- "} catch (__hoppScriptExecutionError) {",
- " __hoppReporter(__hoppScriptExecutionError);",
- "}",
- ].join("\n");
- }
- // Leading `;` guards against ASI: a prior `})` on the host line would
- // otherwise be read as a call against our IIFE expression.
- return fns.map((fn) => `;(${fn}).call(this);`).join("\n");
-};
-
-export const filterValidScripts = (
- scripts: (string | undefined | null)[]
-): string[] =>
- scripts.filter(
- (script): script is string =>
- typeof script === "string" &&
- stripModulePrefix(script).trim().length > 0
- );
diff --git a/packages/hoppscotch-cli/src/utils/test.ts b/packages/hoppscotch-cli/src/utils/test.ts
index d885b85060e..33da6fd2600 100644
--- a/packages/hoppscotch-cli/src/utils/test.ts
+++ b/packages/hoppscotch-cli/src/utils/test.ts
@@ -18,7 +18,7 @@ import { HoppEnvs } from "../types/request";
import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response";
import { getDurationInSeconds } from "./getters";
import { createHoppFetchHook } from "./hopp-fetch";
-import { combineScriptsWithIIFE, filterValidScripts } from "./scripting";
+import { combineScriptsWithIIFE, filterValidScripts } from "@hoppscotch/js-sandbox/scripting";
/**
* Executes test script and runs testDescriptorParser to generate test-report using
diff --git a/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue b/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue
index 0c9c15d1bd3..0b1e602cbfe 100644
--- a/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue
+++ b/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue
@@ -16,7 +16,7 @@ import { v4 as uuidv4 } from "uuid"
import { computed, onMounted, onUnmounted, ref } from "vue"
import { useColorMode } from "~/composables/theming"
-import { MODULE_PREFIX } from "~/helpers/scripting"
+import { MODULE_PREFIX } from "@hoppscotch/js-sandbox/scripting"
// Import type definitions as raw strings
import postRequestTypes from "~/types/post-request.d.ts?raw"
diff --git a/packages/hoppscotch-common/src/components/collections/Properties.vue b/packages/hoppscotch-common/src/components/collections/Properties.vue
index 9d97acb71f1..5edb531c2d7 100644
--- a/packages/hoppscotch-common/src/components/collections/Properties.vue
+++ b/packages/hoppscotch-common/src/components/collections/Properties.vue
@@ -237,7 +237,7 @@ import {
HoppRESTHeaders,
GQLHeader,
} from "@hoppscotch/data"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { PersistenceService } from "~/services/persistence"
diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue
index 9ef25a8711f..f9387c91fbe 100644
--- a/packages/hoppscotch-common/src/components/collections/index.vue
+++ b/packages/hoppscotch-common/src/components/collections/index.vue
@@ -305,7 +305,7 @@ import {
makeHoppRESTResponseOriginalRequest,
} from "@hoppscotch/data"
import { useService } from "dioc/vue"
-import { MODULE_PREFIX_REGEX_JSON_SERIALIZED } from "~/helpers/scripting"
+import { stripJsonSerializedModulePrefix } from "@hoppscotch/js-sandbox/scripting"
import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
@@ -3158,10 +3158,8 @@ const exportData = async (collection: HoppCollection | TeamCollection) => {
const collectionJSON = JSON.stringify(collection, stripRefIdReplacer, 2)
// Strip `export {};\n` from `testScript` and `preRequestScript` fields
- const cleanedCollectionJSON = collectionJSON.replace(
- MODULE_PREFIX_REGEX_JSON_SERIALIZED,
- ""
- )
+ const cleanedCollectionJSON =
+ stripJsonSerializedModulePrefix(collectionJSON)
const name = (collection as HoppCollection).name
@@ -3187,10 +3185,8 @@ const exportData = async (collection: HoppCollection | TeamCollection) => {
)
// Strip `export {};\n` from `testScript` and `preRequestScript` fields
- const cleanedCollectionJSON = collectionJSONString.replace(
- MODULE_PREFIX_REGEX_JSON_SERIALIZED,
- ""
- )
+ const cleanedCollectionJSON =
+ stripJsonSerializedModulePrefix(collectionJSONString)
await initializeDownloadCollection(
cleanedCollectionJSON,
diff --git a/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue b/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue
index 3e3b8c2810d..3f0b348bb97 100644
--- a/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue
+++ b/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue
@@ -59,7 +59,7 @@ import { useI18n } from "@composables/i18n"
import { useNestedSetting } from "~/composables/settings"
import { refAutoReset } from "@vueuse/core"
import { computed, reactive, ref, watch } from "vue"
-import { stripModulePrefix } from "~/helpers/scripting"
+import { stripModulePrefix } from "@hoppscotch/js-sandbox/scripting"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
diff --git a/packages/hoppscotch-common/src/components/http/PreRequestScript.vue b/packages/hoppscotch-common/src/components/http/PreRequestScript.vue
index a94a1c461b0..12fab90da0f 100644
--- a/packages/hoppscotch-common/src/components/http/PreRequestScript.vue
+++ b/packages/hoppscotch-common/src/components/http/PreRequestScript.vue
@@ -124,7 +124,7 @@ import { useReadonlyStream } from "~/composables/stream"
import { invokeAction } from "~/helpers/actions"
import completer from "~/helpers/editor/completion/preRequest"
import linter from "~/helpers/editor/linting/preRequest"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { toggleNestedSetting } from "~/newstore/settings"
import { platform } from "~/platform"
diff --git a/packages/hoppscotch-common/src/components/http/RequestOptions.vue b/packages/hoppscotch-common/src/components/http/RequestOptions.vue
index d9ac58a7add..91db5289a97 100644
--- a/packages/hoppscotch-common/src/components/http/RequestOptions.vue
+++ b/packages/hoppscotch-common/src/components/http/RequestOptions.vue
@@ -104,7 +104,7 @@ import { useVModel } from "@vueuse/core"
import { computed } from "vue"
import { defineActionHandler } from "~/helpers/actions"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { AggregateEnvironment } from "~/newstore/environments"
diff --git a/packages/hoppscotch-common/src/components/http/Tests.vue b/packages/hoppscotch-common/src/components/http/Tests.vue
index f00afa83e80..5ef7852e968 100644
--- a/packages/hoppscotch-common/src/components/http/Tests.vue
+++ b/packages/hoppscotch-common/src/components/http/Tests.vue
@@ -122,7 +122,7 @@ import { useReadonlyStream } from "~/composables/stream"
import { invokeAction } from "~/helpers/actions"
import completer from "~/helpers/editor/completion/testScript"
import linter from "~/helpers/editor/linting/testScript"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import testSnippets from "~/helpers/testSnippets"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { toggleNestedSetting } from "~/newstore/settings"
diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts
index 5bc30c4d80e..11275235574 100644
--- a/packages/hoppscotch-common/src/composables/codemirror.ts
+++ b/packages/hoppscotch-common/src/composables/codemirror.ts
@@ -49,7 +49,7 @@ import { isJSONContentType } from "@helpers/utils/contenttypes"
import { useStreamSubscriber } from "@composables/stream"
import { Completer } from "@helpers/editor/completion"
import { LinterDefinition } from "@helpers/editor/linting/linter"
-import { MODULE_PREFIX } from "@helpers/scripting"
+import { MODULE_PREFIX } from "@hoppscotch/js-sandbox/scripting"
import {
basicSetup,
baseTheme,
diff --git a/packages/hoppscotch-common/src/helpers/RequestRunner.ts b/packages/hoppscotch-common/src/helpers/RequestRunner.ts
index 299d2f832f9..b4aba50cd2f 100644
--- a/packages/hoppscotch-common/src/helpers/RequestRunner.ts
+++ b/packages/hoppscotch-common/src/helpers/RequestRunner.ts
@@ -27,7 +27,10 @@ import { map } from "fp-ts/Either"
import { runPreRequestScript, runTestScript } from "@hoppscotch/js-sandbox/web"
import { useSetting } from "~/composables/settings"
import { getService } from "~/modules/dioc"
-import { combineScriptsWithIIFE, hasActualScript } from "~/helpers/scripting"
+import {
+ combineScriptsWithIIFE,
+ hasActualScript,
+} from "@hoppscotch/js-sandbox/scripting"
import { createHoppFetchHook } from "~/helpers/hopp-fetch"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import {
diff --git a/packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts
new file mode 100644
index 00000000000..b24eacf53fb
--- /dev/null
+++ b/packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts
@@ -0,0 +1,64 @@
+import { describe, expect, test } from "vitest"
+
+import {
+ hasActualScript,
+ stripJsonSerializedModulePrefix,
+} from "@hoppscotch/js-sandbox/scripting"
+
+describe("hasActualScript", () => {
+ test("returns false for null, undefined, or empty input", () => {
+ expect(hasActualScript(null)).toBe(false)
+ expect(hasActualScript(undefined)).toBe(false)
+ expect(hasActualScript("")).toBe(false)
+ })
+
+ test("returns false for whitespace-only input", () => {
+ expect(hasActualScript(" ")).toBe(false)
+ expect(hasActualScript("\n\t \n")).toBe(false)
+ })
+
+ test("returns false when only the Monaco module prefix is present", () => {
+ expect(hasActualScript("export {};\n")).toBe(false)
+ expect(hasActualScript("export {};")).toBe(false)
+ expect(hasActualScript("export {};\n ")).toBe(false)
+ })
+
+ test("returns true when script body exists after the prefix", () => {
+ expect(hasActualScript("export {};\nconst x = 1;")).toBe(true)
+ expect(hasActualScript("const x = 1;")).toBe(true)
+ })
+})
+
+describe("stripJsonSerializedModulePrefix", () => {
+ test("strips `export {};\\n` from JSON string values", () => {
+ const json = JSON.stringify({
+ preRequestScript: "export {};\nconst x = 1;",
+ testScript: "export {};const y = 2;",
+ })
+ const out = stripJsonSerializedModulePrefix(json)
+ const parsed = JSON.parse(out) as Record
+ expect(parsed.preRequestScript).toBe("const x = 1;")
+ expect(parsed.testScript).toBe("const y = 2;")
+ })
+
+ test("leaves values without the prefix untouched", () => {
+ const json = JSON.stringify({
+ name: "request name",
+ preRequestScript: "const z = 3;",
+ })
+ expect(stripJsonSerializedModulePrefix(json)).toBe(json)
+ })
+
+ test("preserves spacing between key delimiter and the stripped value", () => {
+ const json = `{"preRequestScript": "export {};const a = 1;"}`
+ const out = stripJsonSerializedModulePrefix(json)
+ expect(out).toBe(`{"preRequestScript": "const a = 1;"}`)
+ })
+
+ test("does not strip when the prefix appears mid-value", () => {
+ const json = JSON.stringify({
+ preRequestScript: "const a = 1;\nexport {};\nconst b = 2;",
+ })
+ expect(stripJsonSerializedModulePrefix(json)).toBe(json)
+ })
+})
diff --git a/packages/hoppscotch-common/src/helpers/scripting.ts b/packages/hoppscotch-common/src/helpers/scripting.ts
deleted file mode 100644
index 39b5f4c1120..00000000000
--- a/packages/hoppscotch-common/src/helpers/scripting.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-/**
- * Module prefix added by Monaco editor for TypeScript module mode.
- * Enables IntelliSense and isolates variables across editor instances.
- */
-export const MODULE_PREFIX = "export {};\n" as const
-
-/**
- * Strips `export {};\n` prefix from scripts before legacy sandbox execution
- * (non-module context) or when exporting collections.
- */
-export const stripModulePrefix = (script: string): string => {
- if (script.startsWith(MODULE_PREFIX)) {
- return script.slice(MODULE_PREFIX.length)
- }
- if (script.startsWith("export {};")) {
- return script.slice("export {};".length)
- }
- return script
-}
-
-/**
- * Anchored to JSON value-opening delimiters so it only matches inside JSON
- * string values during collection export, not inside script source. Matches
- * both `export {};\\n` and `export {};` (`\\n` is the literal backslash-n
- * pair, not a newline).
- */
-export const MODULE_PREFIX_REGEX_JSON_SERIALIZED =
- /(?<=:\s*")export \{\};(?:\\n)?/g
-
-export type CombineScriptsTarget = "experimental" | "legacy"
-
-const wrapScript = (script: string, target: CombineScriptsTarget): string => {
- const stripped = stripModulePrefix(script.trim())
- if (!stripped) return ""
- const asyncKeyword = target === "experimental" ? "async " : ""
- return `${asyncKeyword}function() {\n${stripped}\n}`
-}
-
-/**
- * Combines inherited scripts into a sequential chain. Each script runs in
- * its own function for scope isolation.
- *
- * - `experimental`: `await (async function(){...})();` lines, evaluated in
- * an async host context so each `await` settles before the next runs.
- * - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await`
- * is rejected at parse time.
- */
-export const combineScriptsWithIIFE = (
- scripts: string[],
- target: CombineScriptsTarget = "experimental"
-): string => {
- const fns = scripts.map((s) => wrapScript(s, target)).filter((s) => s)
- if (fns.length === 0) return ""
- if (target === "experimental") {
- // Wrap the entire awaited chain in try/catch so a top-level throw (or a
- // rejected await) surfaces synchronously via the host reporter.
- // faraday-cage swallows rejected keepAlive promises and does not await
- // afterScriptExecutionHooks, so this is the only reliable channel for
- // async-boundary errors to reach the host caller.
- //
- // The reporter is captured in a const before the try so a user script
- // that tampers with `globalThis.__hoppReportScriptExecutionError`
- // inside the try body cannot suppress the report. Bootstrap installs
- // the property as non-writable and non-configurable for defense in
- // depth; the lexical capture makes that redundant but explicit.
- const body = fns.map((fn) => `await (${fn})();`).join("\n")
- return [
- "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;",
- "try {",
- body,
- "} catch (__hoppScriptExecutionError) {",
- " __hoppReporter(__hoppScriptExecutionError);",
- "}",
- ].join("\n")
- }
- // Leading `;` guards against ASI: a prior `})` on the host line would
- // otherwise be read as a call against our IIFE expression.
- return fns.map((fn) => `;(${fn}).call(this);`).join("\n")
-}
-
-// Monaco prepends "export {};\n" to empty scripts — strip before checking.
-export const hasActualScript = (script: string | undefined | null): boolean => {
- if (!script) return false
- return stripModulePrefix(script.trim()).length > 0
-}
diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts
index 0850fbea532..94ff86993ce 100644
--- a/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts
+++ b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts
@@ -2,7 +2,7 @@ import * as E from "fp-ts/Either"
import { BehaviorSubject, Subscription } from "rxjs"
import { HoppCollectionVariable, translateToNewRequest } from "@hoppscotch/data"
import { pull, remove } from "lodash-es"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import { CollectionDataProps } from "~/helpers/backend/helpers"
import { Subscription as WSubscription } from "wonka"
import { runGQLQuery, runGQLSubscription } from "../backend/GQLClient"
diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts
index d7fa53df851..9a95ffc59dd 100644
--- a/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts
+++ b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts
@@ -8,7 +8,7 @@ import { Service } from "dioc"
import * as E from "fp-ts/Either"
import { Ref, ref } from "vue"
import { getSingleCollection, TeamCollection } from "./TeamCollection"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import { platform } from "~/platform"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
diff --git a/packages/hoppscotch-common/src/newstore/collections.ts b/packages/hoppscotch-common/src/newstore/collections.ts
index f33567a78b7..1db29f39d9b 100644
--- a/packages/hoppscotch-common/src/newstore/collections.ts
+++ b/packages/hoppscotch-common/src/newstore/collections.ts
@@ -11,7 +11,7 @@ import {
GQLHeader,
} from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import { pluck } from "rxjs/operators"
import { resolveSaveContextOnRequestReorder } from "~/helpers/collection/request"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
diff --git a/packages/hoppscotch-common/src/pages/view/_id/_version.vue b/packages/hoppscotch-common/src/pages/view/_id/_version.vue
index cb6aa9441ea..ea93548ebfc 100644
--- a/packages/hoppscotch-common/src/pages/view/_id/_version.vue
+++ b/packages/hoppscotch-common/src/pages/view/_id/_version.vue
@@ -53,7 +53,7 @@ import {
translateToNewEnvironmentVariables,
} from "@hoppscotch/data"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import {
PublishedDocREST,
PublishedDocsVersion,
diff --git a/packages/hoppscotch-common/src/services/team-collection.service.ts b/packages/hoppscotch-common/src/services/team-collection.service.ts
index d6decf27bbd..d4d0833fe2f 100644
--- a/packages/hoppscotch-common/src/services/team-collection.service.ts
+++ b/packages/hoppscotch-common/src/services/team-collection.service.ts
@@ -29,7 +29,7 @@ import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { ref, watch } from "vue"
import { Service } from "dioc"
import { updateInheritedPropertiesForAffectedRequests } from "~/helpers/collection/collection"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import { CollectionDataProps } from "~/helpers/backend/helpers"
export const TEAMS_BACKEND_PAGE_SIZE = 10
diff --git a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts
index 2a2033af741..d2e8f6052fd 100644
--- a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts
+++ b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts
@@ -5,7 +5,7 @@ import {
HoppRESTRequest,
} from "@hoppscotch/data"
import { Service } from "dioc"
-import { hasActualScript } from "~/helpers/scripting"
+import { hasActualScript } from "@hoppscotch/js-sandbox/scripting"
import * as E from "fp-ts/Either"
import { cloneDeep } from "lodash-es"
import { nextTick, Ref } from "vue"
diff --git a/packages/hoppscotch-js-sandbox/package.json b/packages/hoppscotch-js-sandbox/package.json
index 474b3b2df44..be4b439e4cc 100644
--- a/packages/hoppscotch-js-sandbox/package.json
+++ b/packages/hoppscotch-js-sandbox/package.json
@@ -5,7 +5,7 @@
"type": "module",
"files": [
"dist",
- "index.d.ts"
+ "*.d.ts"
],
"exports": {
".": {
@@ -20,6 +20,11 @@
"types": "./dist/node.d.ts",
"import": "./dist/node.js",
"require": "./dist/node.cjs"
+ },
+ "./scripting": {
+ "types": "./dist/scripting.d.ts",
+ "import": "./dist/scripting.js",
+ "require": "./dist/scripting.cjs"
}
},
"types": "./index.d.ts",
@@ -52,6 +57,7 @@
"dependencies": {
"@hoppscotch/data": "workspace:^",
"@types/lodash-es": "4.17.12",
+ "acorn": "8.16.0",
"chai": "6.2.2",
"faraday-cage": "0.1.0",
"fp-ts": "2.16.11",
diff --git a/packages/hoppscotch-js-sandbox/scripting.d.ts b/packages/hoppscotch-js-sandbox/scripting.d.ts
new file mode 100644
index 00000000000..876119fd820
--- /dev/null
+++ b/packages/hoppscotch-js-sandbox/scripting.d.ts
@@ -0,0 +1 @@
+export * from "./dist/scripting"
diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts
new file mode 100644
index 00000000000..07eb6137e5c
--- /dev/null
+++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts
@@ -0,0 +1,344 @@
+import * as E from "fp-ts/Either"
+import { describe, expect, test } from "vitest"
+import { combineScriptsWithIIFE } from "~/utils/scripting"
+import { runPreRequest, runTestAndGetEnvs } from "~/utils/test-helpers"
+
+const envs = { global: [], selected: [] }
+
+describe("script ESM imports — pre-request scripts", () => {
+ test("named import binding is reachable from the script body", async () => {
+ const script = combineScriptsWithIIFE([
+ `import { value } from "data:text/javascript,export const value = 'esm-ok'";\npw.env.set("IMPORTED_VALUE", value);`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ const updated = result.right.selected.find(
+ (v) => v.key === "IMPORTED_VALUE"
+ )
+ expect(updated?.currentValue).toBe("esm-ok")
+ }
+ })
+
+ test("default import binding is reachable from the script body", async () => {
+ const script = combineScriptsWithIIFE([
+ `import obj from "data:text/javascript,export default { greet: 'hi' }";\npw.env.set("GREETING", obj.greet);`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ const updated = result.right.selected.find((v) => v.key === "GREETING")
+ expect(updated?.currentValue).toBe("hi")
+ }
+ })
+
+ test("multiple imports across cascade reach the consuming script body", async () => {
+ const script = combineScriptsWithIIFE([
+ `import { rootVal } from "data:text/javascript,export const rootVal = 1";`,
+ `import { folderVal } from "data:text/javascript,export const folderVal = 2";`,
+ `import { reqVal } from "data:text/javascript,export const reqVal = 3";\npw.env.set("SUM", String(rootVal + folderVal + reqVal));`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ const updated = result.right.selected.find((v) => v.key === "SUM")
+ expect(updated?.currentValue).toBe("6")
+ }
+ })
+
+ test("namespace import binding is reachable from the script body", async () => {
+ const script = combineScriptsWithIIFE([
+ `import * as ns from "data:text/javascript,export const a = 1; export const b = 2";\npw.env.set("NS_SUM", String(ns.a + ns.b));`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ const updated = result.right.selected.find((v) => v.key === "NS_SUM")
+ expect(updated?.currentValue).toBe("3")
+ }
+ })
+
+ test("mixed default + named imports resolve from one source", async () => {
+ const script = combineScriptsWithIIFE([
+ `import obj, { extra } from "data:text/javascript,export default { v: 7 }; export const extra = 5";\npw.env.set("MIXED", String(obj.v + extra));`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ const updated = result.right.selected.find((v) => v.key === "MIXED")
+ expect(updated?.currentValue).toBe("12")
+ }
+ })
+
+ test("identical imports across scripts are deduped to a single emit", async () => {
+ const sharedSource = `data:text/javascript,export default 'shared'`
+ const script = combineScriptsWithIIFE([
+ `import shared from "${sharedSource}";\npw.env.set("FROM_FIRST", shared);`,
+ `import shared from "${sharedSource}";\npw.env.set("FROM_SECOND", shared);`,
+ ])
+
+ const importMatches = script.match(/^import shared from /gm) ?? []
+ expect(importMatches).toHaveLength(1)
+
+ const result = await runPreRequest(script, envs)()
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ expect(
+ result.right.selected.find((v) => v.key === "FROM_FIRST")?.currentValue
+ ).toBe("shared")
+ expect(
+ result.right.selected.find((v) => v.key === "FROM_SECOND")?.currentValue
+ ).toBe("shared")
+ }
+ })
+
+ // Dedup is by literal string match; cosmetic differences (whitespace, quote
+ // style, alias-renames) from the same source are NOT deduped and surface as
+ // a duplicate-declaration error from the module evaluator.
+ test("cosmetically different but semantically identical imports are NOT deduped", async () => {
+ const script = combineScriptsWithIIFE([
+ `import dup from "data:text/javascript,export default 1";`,
+ `import dup from "data:text/javascript,export default 1";`,
+ ])
+
+ const importMatches = script.match(/^import dup\s+from /gm) ?? []
+ expect(importMatches).toHaveLength(2)
+ })
+
+ // Mixing import shapes for the same local name from the same source
+ // (e.g. `import * as foo` + `import { foo }`) emits both lines. The friendly
+ // pre-cage check only fires on cross-source collisions, so this surfaces as
+ // a duplicate-declaration error from the module evaluator.
+ test("namespace + named imports for the same local name emit both lines", async () => {
+ const sharedSource = `data:text/javascript,export const foo = 1`
+ const script = combineScriptsWithIIFE([
+ `import * as foo from "${sharedSource}";`,
+ `import { foo } from "${sharedSource}";`,
+ ])
+
+ const importMatches = script.match(/^import .*foo.* from /gm) ?? []
+ expect(importMatches).toHaveLength(2)
+ })
+
+ test("same-name imports from different sources surface a SyntaxError", async () => {
+ const script = combineScriptsWithIIFE([
+ `import dup from "data:text/javascript,export default 1";`,
+ `import dup from "data:text/javascript,export default 2";\npw.env.set("SHOULD_NOT_RUN", "yes");`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeLeft()
+ if (E.isLeft(result)) {
+ expect(result.left).toMatch(
+ /'dup' is imported from different sources across scripts in this request's chain/
+ )
+ }
+ })
+
+ test("parse failure surfaces the original Acorn message, not a misleading wrapper error", async () => {
+ // Pre-fix: wrapper would re-evaluate the raw script inside an IIFE
+ // and surface a misleading "import declarations may only appear at
+ // top level" error instead of the actual syntax error.
+ const script = combineScriptsWithIIFE([
+ `import { foo } from "data:text/javascript,export const foo = 1";\nconst x = ;`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeLeft()
+ if (E.isLeft(result)) {
+ expect(result.left).toMatch(/\[Hoppscotch\] Script failed to parse/)
+ expect(result.left).not.toMatch(
+ /import declarations may only appear at top level/
+ )
+ }
+ })
+
+ test("import-only cascade emits clean output without an empty try/catch", () => {
+ // Import-only cascade: no awaited bodies → no try/catch needed.
+ const script = combineScriptsWithIIFE([
+ `import "data:text/javascript,globalThis.__a = 1";`,
+ `import "data:text/javascript,globalThis.__b = 2";`,
+ ])
+
+ expect(script).not.toContain("try {")
+ expect(script).not.toContain("__hoppReporter")
+ expect(script).toContain('import "data:text/javascript,globalThis.__a = 1"')
+ expect(script).toContain('import "data:text/javascript,globalThis.__b = 2"')
+ })
+
+ test("import-only cascade with cross-source clash still surfaces the friendly conflict error", async () => {
+ // The import-only short-circuit must not bypass conflict detection.
+ const script = combineScriptsWithIIFE([
+ `import dup from "data:text/javascript,export default 1";`,
+ `import dup from "data:text/javascript,export default 2";`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeLeft()
+ if (E.isLeft(result)) {
+ expect(result.left).toMatch(
+ /'dup' is imported from different sources across scripts in this request's chain/
+ )
+ }
+ })
+
+ test("user import binding to a wrapper-reserved name surfaces a friendly error", async () => {
+ const script = combineScriptsWithIIFE([
+ `import __hoppReporter from "data:text/javascript,export default {}";\npw.env.set("SHOULD_NOT_RUN", "yes");`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeLeft()
+ if (E.isLeft(result)) {
+ expect(result.left).toMatch(
+ /'__hoppReporter' is reserved by Hoppscotch's script wrapper/
+ )
+ }
+ })
+
+ test("user import binding 'globalThis' is also reserved", async () => {
+ // Wrapper reads `globalThis.__hoppReportScriptExecutionError`; a user
+ // import shadowing `globalThis` would silently break error reporting.
+ const script = combineScriptsWithIIFE([
+ `import globalThis from "data:text/javascript,export default {}";`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeLeft()
+ if (E.isLeft(result)) {
+ expect(result.left).toMatch(
+ /'globalThis' is reserved by Hoppscotch's script wrapper/
+ )
+ }
+ })
+
+ test("named re-export-from declarations are hoisted alongside imports", async () => {
+ const script = combineScriptsWithIIFE([
+ `export { value } from "data:text/javascript,export const value = 're-export-ok'";\nimport { value as v } from "data:text/javascript,export const value = 're-export-ok'";\npw.env.set("RE_EXPORT", v);`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ const updated = result.right.selected.find((v) => v.key === "RE_EXPORT")
+ expect(updated?.currentValue).toBe("re-export-ok")
+ }
+ })
+
+ test("export-all-from declarations are hoisted alongside imports", async () => {
+ const script = combineScriptsWithIIFE([
+ `export * from "data:text/javascript,export const a = 1";\nimport { a } from "data:text/javascript,export const a = 1";\npw.env.set("EXPORT_ALL", String(a));`,
+ ])
+
+ const result = await runPreRequest(script, envs)()
+
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ const updated = result.right.selected.find((v) => v.key === "EXPORT_ALL")
+ expect(updated?.currentValue).toBe("1")
+ }
+ })
+})
+
+describe("script ESM imports — test scripts", () => {
+ test("named import binding resolves in test script", async () => {
+ const script = combineScriptsWithIIFE([
+ `import { value } from "data:text/javascript,export const value = 'test-esm-ok'";\npw.env.set("IMPORTED_VALUE", value);`,
+ ])
+
+ const result = await runTestAndGetEnvs(script, envs)()
+
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ const updated = result.right.selected.find(
+ (v) => v.key === "IMPORTED_VALUE"
+ )
+ expect(updated?.currentValue).toBe("test-esm-ok")
+ }
+ })
+
+ test("default import binding resolves in test script", async () => {
+ const script = combineScriptsWithIIFE([
+ `import obj from "data:text/javascript,export default { greet: 'hello-test' }";\npw.env.set("GREETING", obj.greet);`,
+ ])
+
+ const result = await runTestAndGetEnvs(script, envs)()
+
+ expect(result).toBeRight()
+ if (E.isRight(result)) {
+ const updated = result.right.selected.find((v) => v.key === "GREETING")
+ expect(updated?.currentValue).toBe("hello-test")
+ }
+ })
+
+ test("malformed test script surfaces a friendly SyntaxError pre-cage", async () => {
+ const result = await runTestAndGetEnvs("const x = ;", envs)()
+
+ expect(result).toBeLeft()
+ if (E.isLeft(result)) {
+ expect(result.left).toMatch(/Script execution failed:.*SyntaxError/)
+ }
+ })
+})
+
+// Live network coverage against esm.sh — opt-in to keep CI deterministic.
+const networkTest = process.env.HOPP_NETWORK_TESTS === "1" ? test : test.skip
+
+describe("script ESM imports — live esm.sh (opt-in)", () => {
+ networkTest(
+ "real-world ESM import shape resolves end-to-end",
+ async () => {
+ const script = combineScriptsWithIIFE([
+ [
+ `import lodash from "https://esm.sh/lodash@4.17.21";`,
+ `import axios from "https://esm.sh/axios@1.6.0";`,
+ `import { format } from "https://esm.sh/date-fns@2.30.0";`,
+ `pw.env.set("PICKED", JSON.stringify(lodash.pick({ a: 1, b: 2 }, ["a"])));`,
+ `pw.env.set("AXIOS_TYPE", typeof axios);`,
+ `pw.env.set("FORMATTED", format(new Date(2026, 4, 7), "yyyy-MM-dd"));`,
+ ].join("\n"),
+ ])
+
+ // Soft-pass on esm.sh degradation — the assertions only run when the
+ // module loader actually delivers a usable result.
+ let result
+ try {
+ result = await runTestAndGetEnvs(script, envs)()
+ } catch (e) {
+ console.warn("[skip] esm.sh appears degraded:", e)
+ return
+ }
+ if (E.isLeft(result)) {
+ console.warn("[skip] esm.sh appears degraded:", result.left)
+ return
+ }
+
+ expect(
+ result.right.selected.find((v) => v.key === "PICKED")?.currentValue
+ ).toBe(JSON.stringify({ a: 1 }))
+ expect(
+ result.right.selected.find((v) => v.key === "AXIOS_TYPE")?.currentValue
+ ).toMatch(/object|function/)
+ expect(
+ result.right.selected.find((v) => v.key === "FORMATTED")?.currentValue
+ ).toBe("2026-05-07")
+ },
+ 30_000
+ )
+})
diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts
index 059fa6de19c..213a021f5bc 100644
--- a/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts
+++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts
@@ -3,6 +3,7 @@ import * as TE from "fp-ts/TaskEither"
import { pipe } from "fp-ts/function"
import { RunPostRequestScriptOptions, TestResponse, TestResult } from "~/types"
+import { parseScriptForSyntax } from "~/utils/scripting"
import { preventCyclicObjects } from "~/utils/shared"
import { runPostRequestScriptWithFaradayCage } from "./experimental"
@@ -12,20 +13,6 @@ export const runTestScript = (
testScript: string,
options: RunPostRequestScriptOptions
): TE.TaskEither => {
- // Pre-parse the script to catch syntax errors before execution
- // Use AsyncFunction to support top-level await (required for hopp.fetch, etc.)
- try {
- // eslint-disable-next-line no-new-func
- const AsyncFunction = Object.getPrototypeOf(
- async function () {}
- ).constructor
- new (AsyncFunction as any)(testScript)
- } catch (e) {
- const err = e as Error
- const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}`
- return TE.left(`Script execution failed: ${reason}`)
- }
-
const responseObjHandle = preventCyclicObjects(options.response)
if (E.isLeft(responseObjHandle)) {
@@ -35,6 +22,21 @@ export const runTestScript = (
const resolvedResponse = responseObjHandle.right
const { envs, experimentalScriptingSandbox = true } = options
+ // Pre-parse before sandbox spin-up so syntax errors surface as a friendly
+ // host-side message. Each target uses the grammar that matches its eventual
+ // executor: experimental → ESM module (top-level imports + await accepted);
+ // legacy → script mode (top-level imports + await rejected).
+ try {
+ parseScriptForSyntax(
+ testScript,
+ experimentalScriptingSandbox ? "experimental" : "legacy"
+ )
+ } catch (e) {
+ const err = e as Error
+ const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}`
+ return TE.left(`Script execution failed: ${reason}`)
+ }
+
if (experimentalScriptingSandbox) {
const { request, hoppFetchHook } = options as Extract<
RunPostRequestScriptOptions,
diff --git a/packages/hoppscotch-js-sandbox/src/scripting.ts b/packages/hoppscotch-js-sandbox/src/scripting.ts
new file mode 100644
index 00000000000..d75185ac261
--- /dev/null
+++ b/packages/hoppscotch-js-sandbox/src/scripting.ts
@@ -0,0 +1,11 @@
+// Subpath barrel for string helpers; lets consumers skip the runner-module
+// Vite worker imports. Relative path keeps the emitted `.d.ts` portable.
+export {
+ MODULE_PREFIX,
+ combineScriptsWithIIFE,
+ filterValidScripts,
+ hasActualScript,
+ stripJsonSerializedModulePrefix,
+ stripModulePrefix,
+ type CombineScriptsTarget,
+} from "./utils/scripting"
diff --git a/packages/hoppscotch-js-sandbox/src/utils/scripting.ts b/packages/hoppscotch-js-sandbox/src/utils/scripting.ts
new file mode 100644
index 00000000000..b692653b463
--- /dev/null
+++ b/packages/hoppscotch-js-sandbox/src/utils/scripting.ts
@@ -0,0 +1,282 @@
+import {
+ Parser,
+ type ExportAllDeclaration,
+ type ExportNamedDeclaration,
+ type ImportDeclaration,
+ type Program,
+} from "acorn"
+
+// Monaco prepends this to TS-mode editor buffers so each script parses as a
+// module. Strip it before legacy execution and before serializing to JSON.
+export const MODULE_PREFIX = "export {};\n" as const
+
+/**
+ * Strips `export {};\n` prefix from scripts before legacy sandbox execution
+ * (non-module context) or when exporting collections.
+ */
+export const stripModulePrefix = (script: string): string => {
+ if (script.startsWith(MODULE_PREFIX)) {
+ return script.slice(MODULE_PREFIX.length)
+ }
+ if (script.startsWith("export {};")) {
+ return script.slice("export {};".length)
+ }
+ return script
+}
+
+/**
+ * Strips the JSON-serialized `export {};` prefix (with optional `\n` literal)
+ * from the start of any JSON string value during collection export.
+ * Capture-and-reinsert is used in place of a lookbehind so the regex parses
+ * on WebKit < 16.4 (Tauri's macOS WKWebView before Ventura 13.3).
+ */
+export const stripJsonSerializedModulePrefix = (json: string): string =>
+ json.replace(/(:\s*")export \{\};(?:\\n)?/g, "$1")
+
+export type CombineScriptsTarget = "experimental" | "legacy"
+
+// Shared parser options. The experimental path admits ESM grammar (top-level
+// imports + await) so they reach faraday-cage's module evaluator. The legacy
+// path mirrors its executor's script-mode grammar — top-level `await` and
+// `import` are rejected pre-cage to match what the legacy evaluator would.
+const PARSE_OPTIONS = {
+ experimental: {
+ ecmaVersion: "latest",
+ sourceType: "module",
+ allowReturnOutsideFunction: true,
+ },
+ legacy: {
+ ecmaVersion: "latest",
+ sourceType: "script",
+ allowReturnOutsideFunction: true,
+ },
+} as const
+
+export const parseScriptForSyntax = (
+ script: string,
+ target: CombineScriptsTarget = "experimental"
+): void => {
+ Parser.parse(script, PARSE_OPTIONS[target])
+}
+
+type ImportBinding = {
+ name: string
+ source: string
+}
+
+type ExtractedImports = {
+ importStatements: string[]
+ body: string
+ bindings: ImportBinding[]
+ // Set when Acorn rejects the script, so the wrapper surfaces the original
+ // parse error instead of a downstream "import declarations may only appear
+ // at top level" from re-evaluating the unmodified body inside an IIFE.
+ parseError?: string
+}
+
+// Wrapper-declared module-scope names + `globalThis` (which the wrapper
+// reads for the reporter). User imports binding these would duplicate-
+// declare or shadow them post-hoist, so we reject pre-cage.
+const RESERVED_WRAPPER_NAMES = new Set(["__hoppReporter", "globalThis"])
+
+// Top-level node shapes that resolve a module URL and therefore must reach
+// module scope outside the IIFE wrapper: `import` declarations, plus
+// re-export-from forms (`export { x } from "y"`, `export * from "y"`,
+// `export * as ns from "y"`). Local-only `export const` / `export { x }`
+// don't carry a `source`, so they stay in the body.
+type ModuleResolvingDeclaration =
+ | ImportDeclaration
+ | (ExportNamedDeclaration & {
+ source: NonNullable
+ })
+ | ExportAllDeclaration
+
+const isModuleResolvingDeclaration = (
+ n: Program["body"][number]
+): n is ModuleResolvingDeclaration =>
+ n.type === "ImportDeclaration" ||
+ n.type === "ExportAllDeclaration" ||
+ (n.type === "ExportNamedDeclaration" && n.source !== null)
+
+// Lifts top-level module-resolving declarations so they can be hoisted to
+// module scope; the IIFE wrapper would otherwise reject them as `SyntaxError`.
+const extractTopLevelImports = (script: string): ExtractedImports => {
+ const empty: ExtractedImports = {
+ importStatements: [],
+ body: script,
+ bindings: [],
+ }
+ if (!script.trim()) return empty
+
+ let ast: Program
+ try {
+ ast = Parser.parse(script, PARSE_OPTIONS.experimental)
+ } catch (err) {
+ return {
+ ...empty,
+ parseError: err instanceof Error ? err.message : String(err),
+ }
+ }
+
+ const moduleNodes = ast.body.filter(isModuleResolvingDeclaration)
+ if (moduleNodes.length === 0) return empty
+
+ let body = ""
+ let cursor = 0
+ for (const node of moduleNodes) {
+ body += script.slice(cursor, node.start)
+ cursor = node.end
+ }
+ body += script.slice(cursor)
+
+ // Only `import` declarations introduce local bindings that could collide
+ // across cascade levels. Re-exports rebind to the consumer, not to a
+ // local name, so they don't participate in the duplicate-binding check.
+ const bindings: ImportBinding[] = moduleNodes
+ .filter((n): n is ImportDeclaration => n.type === "ImportDeclaration")
+ .flatMap((n) =>
+ n.specifiers.map((s) => ({
+ name: s.local.name,
+ source: String(n.source.value ?? ""),
+ }))
+ )
+
+ return {
+ importStatements: moduleNodes.map((n) => script.slice(n.start, n.end)),
+ body,
+ bindings,
+ }
+}
+
+const wrapLegacyScript = (script: string): string => {
+ const stripped = stripModulePrefix(script.trim())
+ if (!stripped) return ""
+ return `function() {\n${stripped}\n}`
+}
+
+/**
+ * Combines inherited scripts into a sequential chain. Each script runs in
+ * its own function for scope isolation.
+ *
+ * - `experimental`: `await (async function(){...})();` lines, evaluated in
+ * an async host context so each `await` settles before the next runs.
+ * Top-level `import` and `export … from` declarations are hoisted out of
+ * the IIFEs so module resolution (e.g. faraday-cage's esmModuleLoader)
+ * can see them. Identical import statements across scripts are deduped;
+ * same-name imports from different sources, parse failures, and bindings
+ * that collide with wrapper internals all surface a friendly `SyntaxError`
+ * pre-cage.
+ * - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await`
+ * is rejected at parse time.
+ *
+ * Side-effect imports run at module-evaluation time, before any cascade
+ * body. The body-order guarantee (root → folder → request) does not extend
+ * to top-level effects in imported modules. Value imports are unaffected.
+ */
+export const combineScriptsWithIIFE = (
+ scripts: string[],
+ target: CombineScriptsTarget = "experimental"
+): string => {
+ if (target === "legacy") {
+ const fns = scripts.map(wrapLegacyScript).filter((s) => s)
+ if (fns.length === 0) return ""
+ // Leading `;` guards against ASI: a prior `})` on the host line would
+ // otherwise be read as a call against our IIFE expression.
+ return fns.map((fn) => `;(${fn}).call(this);`).join("\n")
+ }
+
+ const extracted = scripts.map((s) =>
+ extractTopLevelImports(stripModulePrefix(s.trim()))
+ )
+
+ const fns = extracted
+ .map(({ body }) =>
+ body.trim() ? `async function() {\n${body.trim()}\n}` : ""
+ )
+ .filter((s) => s)
+
+ // Identical import statements (literal string match across scripts) are
+ // deduped to a single emitted line. Same name from different sources is a
+ // real conflict and surfaces a friendly `SyntaxError` pre-cage.
+ const allImports = [
+ ...new Set(extracted.flatMap((e) => e.importStatements)),
+ ].filter(Boolean)
+
+ const parseError = extracted.find((e) => e.parseError)?.parseError
+
+ const sourcesByName = new Map>()
+ for (const { name, source } of extracted.flatMap((e) => e.bindings)) {
+ if (!sourcesByName.has(name)) sourcesByName.set(name, new Set())
+ sourcesByName.get(name)!.add(source)
+ }
+ const conflictingName = [...sourcesByName.entries()].find(
+ ([, sources]) => sources.size > 1
+ )?.[0]
+
+ const allBindingNames = new Set(
+ extracted.flatMap((e) => e.bindings.map((b) => b.name))
+ )
+ const reservedConflict = [...RESERVED_WRAPPER_NAMES].find((n) =>
+ allBindingNames.has(n)
+ )
+
+ if (fns.length === 0 && allImports.length === 0 && !parseError) return ""
+
+ // Errors short-circuit before synthesis; reserved-name check sits before
+ // the import-only return so reserved bindings still surface.
+ if (parseError !== undefined) {
+ return synthesizeReporterWrapper(
+ `throw new SyntaxError(${JSON.stringify(`[Hoppscotch] Script failed to parse: ${parseError}`)});`
+ )
+ }
+
+ if (conflictingName !== undefined) {
+ return synthesizeReporterWrapper(
+ `throw new SyntaxError(${JSON.stringify(`[Hoppscotch] '${conflictingName}' is imported from different sources across scripts in this request's chain. Please import it from a single source, or rename one of the imports to resolve the conflict.`)});`
+ )
+ }
+
+ if (reservedConflict !== undefined) {
+ return synthesizeReporterWrapper(
+ `throw new SyntaxError(${JSON.stringify(`[Hoppscotch] '${reservedConflict}' is reserved by Hoppscotch's script wrapper and cannot be used as an import binding. Please rename the import.`)});`
+ )
+ }
+
+ // Import-only cascade: skip the try/catch — no awaited bodies to route
+ // errors from. Module-evaluation errors propagate via faraday-cage.
+ if (fns.length === 0) return allImports.join("\n")
+
+ // Wrap the awaited chain in try/catch so top-level throws / rejected
+ // awaits reach the host reporter; faraday-cage otherwise swallows
+ // async-boundary errors via its keepAlive loop.
+ const body = fns.map((fn) => `await (${fn})();`).join("\n")
+ const tryBlock = synthesizeReporterWrapper(body)
+
+ if (allImports.length === 0) return tryBlock
+
+ return [allImports.join("\n"), tryBlock].join("\n")
+}
+
+const synthesizeReporterWrapper = (bodyLines: string): string =>
+ [
+ "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;",
+ "try {",
+ bodyLines,
+ "} catch (__hoppScriptExecutionError) {",
+ " __hoppReporter(__hoppScriptExecutionError);",
+ "}",
+ ].join("\n")
+
+// Monaco prepends "export {};\n" to empty scripts — strip before checking.
+export const hasActualScript = (script: string | undefined | null): boolean => {
+ if (!script) return false
+ return stripModulePrefix(script.trim()).length > 0
+}
+
+export const filterValidScripts = (
+ scripts: (string | undefined | null)[]
+): string[] =>
+ scripts.filter(
+ (script): script is string =>
+ typeof script === "string" && stripModulePrefix(script).trim().length > 0
+ )
diff --git a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts
index 73d5b87df3e..fc7a14d6b02 100644
--- a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts
+++ b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts
@@ -12,6 +12,7 @@ import {
TestResult,
} from "~/types"
import { acquireCage, resetCage, isInfraError } from "~/utils/cage"
+import { parseScriptForSyntax } from "~/utils/scripting"
import { preventCyclicObjects } from "~/utils/shared"
import { Cookie, HoppRESTRequest } from "@hoppscotch/data"
@@ -209,20 +210,6 @@ export const runTestScript = async (
testScript: string,
options: RunPostRequestScriptOptions
): Promise> => {
- // Pre-parse the script to catch syntax errors before execution
- // Use AsyncFunction to support top-level await (required for hopp.fetch, etc.)
- try {
- // eslint-disable-next-line no-new-func
- const AsyncFunction = Object.getPrototypeOf(
- async function () {}
- ).constructor
- new (AsyncFunction as any)(testScript)
- } catch (e) {
- const err = e as Error
- const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}`
- return E.left(`Script execution failed: ${reason}`)
- }
-
const responseObjHandle = preventCyclicObjects(options.response)
if (E.isLeft(responseObjHandle)) {
@@ -233,6 +220,21 @@ export const runTestScript = async (
const { envs, experimentalScriptingSandbox = true } = options
+ // Pre-parse before sandbox spin-up so syntax errors surface as a friendly
+ // host-side message. Each target uses the grammar that matches its eventual
+ // executor: experimental → ESM module (top-level imports + await accepted);
+ // legacy → script mode (top-level imports + await rejected).
+ try {
+ parseScriptForSyntax(
+ testScript,
+ experimentalScriptingSandbox ? "experimental" : "legacy"
+ )
+ } catch (e) {
+ const err = e as Error
+ const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}`
+ return E.left(`Script execution failed: ${reason}`)
+ }
+
if (experimentalScriptingSandbox) {
const { request, cookies, hoppFetchHook } = options as Extract<
RunPostRequestScriptOptions,
diff --git a/packages/hoppscotch-js-sandbox/vite.config.ts b/packages/hoppscotch-js-sandbox/vite.config.ts
index c47ddd4c7d6..7bb2360dc3e 100644
--- a/packages/hoppscotch-js-sandbox/vite.config.ts
+++ b/packages/hoppscotch-js-sandbox/vite.config.ts
@@ -9,6 +9,7 @@ export default defineConfig({
entry: {
web: "./src/web/index.ts",
node: "./src/node/index.ts",
+ scripting: "./src/scripting.ts",
},
name: "js-sandbox",
formats: ["es", "cjs"],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 810356c4e19..1ad7e914aeb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1231,6 +1231,9 @@ importers:
'@types/lodash-es':
specifier: 4.17.12
version: 4.17.12
+ acorn:
+ specifier: 8.16.0
+ version: 8.16.0
chai:
specifier: 6.2.2
version: 6.2.2
From e04ef445c4bad5dd1222f701a07e06ba1f714c42 Mon Sep 17 00:00:00 2001
From: Shreyas
Date: Tue, 12 May 2026 13:33:34 +0530
Subject: [PATCH 2/5] feat(desktop): settings phase 3a - keyboard layout
strategy (#6289)
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
---
packages/hoppscotch-common/locales/en.json | 11 +-
.../hoppscotch-common/src/components.d.ts | 3 +-
.../src/components/settings/Desktop.vue | 76 ++++++-
.../src/composables/desktop-settings.ts | 17 ++
.../src/helpers/__tests__/keybindings.spec.ts | 198 ++++++++++++++++++
.../src/helpers/keybindings.ts | 119 ++++++++---
.../src/helpers/keyboard-strategy.ts | 30 +++
.../src/platform/desktop-settings.ts | 8 +
packages/hoppscotch-selfhost-web/src/main.ts | 146 +++++--------
9 files changed, 480 insertions(+), 128 deletions(-)
create mode 100644 packages/hoppscotch-common/src/helpers/__tests__/keybindings.spec.ts
create mode 100644 packages/hoppscotch-common/src/helpers/keyboard-strategy.ts
diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json
index 2a73e6e4752..e44b6607cad 100644
--- a/packages/hoppscotch-common/locales/en.json
+++ b/packages/hoppscotch-common/locales/en.json
@@ -1289,7 +1289,16 @@
"delete_account": "Delete account",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"desktop": "Desktop",
- "desktop_description": "Preferences that apply only to the Hoppscotch desktop app.",
+ "desktop_description": "Update behavior and keyboard handling for the Hoppscotch desktop app.",
+ "desktop_keyboard": "Keyboard",
+ "desktop_keyboard_strategy_label": "Match shortcuts by typed letter or physical position",
+ "desktop_keyboard_strategy_description": "On non-QWERTY layouts, the same letter can come from different physical keys. The default works for most layouts; switch options if shortcuts don't fire as expected on yours.",
+ "desktop_keyboard_strategy_hybrid": "Smart (recommended)",
+ "desktop_keyboard_strategy_hybrid_description": "Use the typed letter for Latin characters; fall back to the physical key position for non-Latin layouts (Cyrillic, CJK).",
+ "desktop_keyboard_strategy_key": "Typed letter",
+ "desktop_keyboard_strategy_key_description": "Always use the typed letter. Pick this if shortcuts don't work as expected on your layout.",
+ "desktop_keyboard_strategy_code": "Physical key position",
+ "desktop_keyboard_strategy_code_description": "Always use the US-QWERTY physical position. Pick this if you have QWERTY muscle memory on a non-Latin layout.",
"desktop_updates": "Updates",
"disable_encode_mode_tooltip": "Never encode the parameters in the request",
"disable_update_checks": "Disable automatic update checks",
diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts
index 5cf1795bf38..357e2240678 100644
--- a/packages/hoppscotch-common/src/components.d.ts
+++ b/packages/hoppscotch-common/src/components.d.ts
@@ -201,6 +201,7 @@ declare module 'vue' {
HttpExampleResponseTab: typeof import('./components/http/example/ResponseTab.vue')['default']
HttpHeaders: typeof import('./components/http/Headers.vue')['default']
HttpImportCurl: typeof import('./components/http/ImportCurl.vue')['default']
+ HttpInheritedScriptsModal: typeof import('./components/http/InheritedScriptsModal.vue')['default']
HttpKeyValue: typeof import('./components/http/KeyValue.vue')['default']
HttpParameters: typeof import('./components/http/Parameters.vue')['default']
HttpPreRequestScript: typeof import('./components/http/PreRequestScript.vue')['default']
@@ -245,7 +246,6 @@ declare module 'vue' {
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default']
IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default']
- IconLucideFileSymlink: typeof import('~icons/lucide/file-symlink')['default']
IconLucideFileText: typeof import('~icons/lucide/file-text')['default']
IconLucideFileX: typeof import('~icons/lucide/file-x')['default']
IconLucideFolder: typeof import('~icons/lucide/folder')['default']
@@ -257,7 +257,6 @@ declare module 'vue' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideLoader2: typeof import('~icons/lucide/loader2')['default']
- IconLucideLock: typeof import('~icons/lucide/lock')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default']
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
diff --git a/packages/hoppscotch-common/src/components/settings/Desktop.vue b/packages/hoppscotch-common/src/components/settings/Desktop.vue
index 659dae2f993..a7384e0b4b0 100644
--- a/packages/hoppscotch-common/src/components/settings/Desktop.vue
+++ b/packages/hoppscotch-common/src/components/settings/Desktop.vue
@@ -71,14 +71,58 @@
+
+
+
+
+ {{ t("settings.desktop_keyboard") }}
+
+
+
+
+ {{ t("settings.desktop_keyboard_strategy_label") }}
+
+
+ {{ t("settings.desktop_keyboard_strategy_description") }}
+
+
+
+
+
+
+ {{ option.description }}
+
+
+
+
+