poc: async host bridge + insomnia.sendRequest() parity for QuickJS sandbox (PR 2)

Bridges insomnia.sendRequest() to a real HTTP request instead of throwing,
reusing the existing insomnia-templating-worker-database:// fetch protocol
and its network.sendRequestWithoutSideEffects handler (the same one the
template-tag sandbox already uses) rather than adding a second protocol
scheme. This is the same fetch-to-custom-protocol pattern
templating.worker.ts uses to reach the main process from a dedicated Web
Worker, generalized to a second async host bridge inside
quickjs-script-engine.ts (a VM promise resolved off a real fetch(),
settled via executePendingJobs() — no asyncify).

- quickjs-script-engine.ts: installSendRequestBridge() + BOOTSTRAP's
  insomnia.sendRequest() now normalizes a URL string or a plain
  {url, method, headers, body} object, calls the bridge, and rebuilds a
  response object ({code, status, headers, body, responseTime, json(),
  text()}) — supports both the Postman-style (error, response) callback
  and awaiting the returned promise. insomnia.test()/pm.test() still
  throws (unchanged).
- run-script-quickjs.ts (client): fetches the templating-db auth token
  once via window.main.templatingDb.getAuthToken() (same pattern as
  ui/worker/templating-handler.ts) and forwards it on every postMessage.
- quickjs-script.worker.ts: passes the auth token through to the engine.

Known gaps (documented in quickjs-script-engine.ts, deferred to a later
parity PR): no auth/client-certs/cookies/multipart-or-urlencoded bodies,
and the response object doesn't yet match the hidden-window Response
class's full surface (chai assertions, originalRequest, cookies).

Ref: PR 2 of the rollout plan — https://gist.github.com/jackkav/3ebf8768bf84be024a3a138874919354
This commit is contained in:
jackkav committed 2026-08-13 10:03:01 +02:00
1 parent 7733b42b93
commit 37d19c1da8
5 files changed
+276 -17

No files matched your search

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { RequestContext } from '../../../insomnia-scripting-environment/src/objects/interfaces';
import { runScriptInQuickJs } from './quickjs-script-engine';
@@ -82,17 +82,150 @@ describe('runScriptInQuickJs', () => {
expect(result.logs.some(row => row.includes('hello from quickjs'))).toBe(true);
});
it('throws a clear error when calling the unsupported sendRequest API', async () => {
const context = baseContext();
await expect(
runScriptInQuickJs({ script: 'await insomnia.sendRequest("https://example.com");', context }),
).rejects.toThrow(/insomnia\.sendRequest\(\) is not supported/);
});
it('propagates script errors with a useful message', async () => {
const context = baseContext();
await expect(runScriptInQuickJs({ script: 'throw new Error("boom");', context })).rejects.toThrow('boom');
});
});
describe('runScriptInQuickJs sendRequest bridge', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
const stubFetchOnce = (response: { ok: boolean; status?: number; body: unknown }) => {
const fetchMock = vi.fn().mockResolvedValue({
ok: response.ok,
status: response.status ?? (response.ok ? 200 : 500),
text: () => Promise.resolve(JSON.stringify(response.body)),
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
};
it('sends a real request through the bridge and resolves with a response object', async () => {
stubFetchOnce({
ok: true,
body: {
code: 200,
status: 'OK',
headers: [{ name: 'content-type', value: 'application/json' }],
body: '{"hello":"world"}',
responseTime: 12,
},
});
const context = baseContext();
const result = await runScriptInQuickJs({
script: `
const response = await insomnia.sendRequest('https://example.com');
insomnia.environment.set('code', response.code);
insomnia.environment.set('parsedBody', response.json());
`,
context,
});
expect(result.environment.data).toMatchObject({ code: 200, parsedBody: { hello: 'world' } });
});
it('sends the auth token as a header on the bridge fetch call', async () => {
const fetchMock = stubFetchOnce({
ok: true,
body: { code: 204, status: 'No Content', headers: [], body: '', responseTime: 1 },
});
const context = baseContext();
await runScriptInQuickJs({
script: `await insomnia.sendRequest('https://example.com');`,
context,
authToken: 'super-secret-token',
});
expect(fetchMock).toHaveBeenCalledWith(
'insomnia-templating-worker-database://network.sendrequestwithoutsideeffects',
expect.objectContaining({ headers: { 'x-insomnia-templating-auth': 'super-secret-token' } }),
);
});
it('normalizes a plain-object request with headers and a string body', async () => {
const fetchMock = stubFetchOnce({
ok: true,
body: { code: 200, status: 'OK', headers: [], body: '', responseTime: 1 },
});
const context = baseContext();
await runScriptInQuickJs({
script: `
await insomnia.sendRequest({
url: 'https://example.com/items',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{"a":1}',
});
`,
context,
});
const sentBody = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(sentBody).toEqual({
options: {
request: {
url: 'https://example.com/items',
method: 'POST',
headers: [{ name: 'Content-Type', value: 'application/json' }],
body: { mimeType: 'text/plain', text: '{"a":1}' },
},
caCertficatePath: null,
},
});
});
it('supports the Postman-style (error, response) callback signature', async () => {
stubFetchOnce({ ok: true, body: { code: 200, status: 'OK', headers: [], body: 'ok', responseTime: 1 } });
const context = baseContext();
const result = await runScriptInQuickJs({
script: `
await new Promise((resolve) => {
insomnia.sendRequest('https://example.com', (error, response) => {
insomnia.environment.set('callbackError', error ?? null);
insomnia.environment.set('callbackBody', response ? response.body : null);
resolve();
});
});
`,
context,
});
expect(result.environment.data).toMatchObject({ callbackError: null, callbackBody: 'ok' });
});
it('rejects with the bridge-reported error when the host handler fails', async () => {
stubFetchOnce({ ok: false, status: 500, body: { error: 'DNS resolution failed' } });
const context = baseContext();
await expect(
runScriptInQuickJs({ script: `await insomnia.sendRequest('https://unreachable.invalid');`, context }),
).rejects.toThrow('DNS resolution failed');
});
it('reports the bridge error to the callback instead of throwing when a callback is given', async () => {
stubFetchOnce({ ok: false, status: 500, body: { error: 'DNS resolution failed' } });
const context = baseContext();
const result = await runScriptInQuickJs({
script: `
await new Promise((resolve) => {
insomnia.sendRequest('https://unreachable.invalid', (error) => {
insomnia.environment.set('callbackError', error);
resolve();
});
});
`,
context,
});
expect((result.environment.data as Record<string, unknown>).callbackError).toBe('DNS resolution failed');
});
});
@@ -14,15 +14,26 @@ import { getQuickJSModule } from '../templating/sandbox/quickjs-runtime';
* minimal API surface is bridged in — enough to demonstrate the architecture, not enough to replace
* the hidden-window sandbox yet. Gated behind `settings.useQuickJsScriptSandbox` (default off).
*
* Not supported (throws inside the script if called): insomnia.sendRequest(), insomnia.test()/
* pm.test(), collectionVariables, vault, cookies, client certificates, and mutating the request.
* insomnia.sendRequest() bridges to the real `network.sendRequestWithoutSideEffects` host handler
* (the same one the template-tag sandbox uses) over the `insomnia-templating-worker-database://`
* fetch-based protocol — see `installSendRequestBridge` below. It supports only a minimal request
* shape (a URL string, or `{ url, method, headers, body }` with a plain string body) — no auth,
* client certificates, cookies, or multipart/urlencoded bodies yet, and the response object exposes
* only `code`/`status`/`headers`/`body`/`responseTime`/`json()`/`text()` (no chai assertions, no
* `originalRequest`). Full parity with the hidden-window `Response` class is deferred.
*
* Not supported (throws inside the script if called): insomnia.test()/pm.test(), collectionVariables,
* vault, cookies, client certificates, and mutating the request.
*/
export const runScriptInQuickJs = async ({
script,
context,
authToken,
}: {
script: string;
context: RequestContext;
/** Auth token for the `insomnia-templating-worker-database://` bridge — required for sendRequest. */
authToken?: string;
}): Promise<RequestContext> => {
const QuickJS = await getQuickJSModule();
const vm = QuickJS.newContext();
@@ -43,6 +54,7 @@ export const runScriptInQuickJs = async ({
installConsole(vm, scriptConsole);
installKeyValueBridge(vm, '__envGet', '__envSet', environmentData);
installKeyValueBridge(vm, '__varGet', '__varSet', variablesData);
installSendRequestBridge(vm, authToken);
setGlobalString(vm, '__requestJSON', JSON.stringify(context.request ?? {}));
evalOrThrow(vm, BOOTSTRAP, '<quickjs-script-bootstrap>');
@@ -88,7 +100,43 @@ globalThis.insomnia = {
}
return value;
})(JSON.parse(__requestJSON)),
sendRequest: () => { throw new Error(${JSON.stringify(unsupportedApiMessage('insomnia.sendRequest()'))}); },
sendRequest: (request, callback) => {
const normalized = typeof request === 'string'
? { url: request, method: 'GET' }
: {
url: request.url,
method: request.method || 'GET',
headers: Array.isArray(request.headers)
? request.headers
: Object.entries(request.headers || {}).map(([name, value]) => ({ name, value: String(value) })),
body: request.body !== undefined ? { mimeType: 'text/plain', text: String(request.body) } : undefined,
};
const bodyJson = JSON.stringify({ options: { request: normalized, caCertficatePath: null } });
const promise = __sendRequest(bodyJson).then((envelopeJson) => {
const envelope = JSON.parse(envelopeJson);
if (!envelope.ok) {
throw new Error(envelope.error);
}
const result = envelope.value;
return {
code: result.code,
status: result.status,
headers: result.headers,
body: result.body,
responseTime: result.responseTime,
json: () => JSON.parse(result.body),
text: () => result.body,
};
});
if (typeof callback === 'function') {
promise.then(
(response) => callback(undefined, response),
(err) => callback(err.message),
);
return undefined;
}
return promise;
},
test: () => { throw new Error(${JSON.stringify(unsupportedApiMessage('insomnia.test()/pm.test()'))}); },
};
globalThis.$ = globalThis.insomnia;
@@ -114,6 +162,57 @@ const installConsole = (vm: QuickJSContext, scriptConsole: Console): void => {
consoleHandle.dispose();
};
// The same fetch-based bridge the template-tag sandbox uses (`common/templating/liquid-extension-worker.ts`),
// reused here rather than adding a second protocol scheme — this endpoint already sends a real
// request via curl without persisting it to request/response history, which is exactly what an
// ad-hoc `insomnia.sendRequest()` call needs.
const SEND_REQUEST_ENDPOINT = 'insomnia-templating-worker-database://network.sendrequestwithoutsideeffects';
const TEMPLATING_DB_AUTH_HEADER = 'x-insomnia-templating-auth';
const sendRequestViaFetch = async (requestBodyJson: string, authToken?: string): Promise<Record<string, unknown>> => {
const resp = await fetch(SEND_REQUEST_ENDPOINT, {
method: 'post',
headers: authToken ? { [TEMPLATING_DB_AUTH_HEADER]: authToken } : undefined,
body: requestBodyJson,
});
const parsed = JSON.parse(await resp.text());
if (!resp.ok) {
throw new Error(typeof parsed?.error === 'string' ? parsed.error : `sendRequest failed with status ${resp.status}`);
}
return parsed;
};
/** Registers the async `__sendRequest(bodyJson)` bridge backing `insomnia.sendRequest()` in BOOTSTRAP. */
const installSendRequestBridge = (vm: QuickJSContext, authToken?: string): void => {
const fn = vm.newFunction('__sendRequest', bodyHandle => {
const bodyJson = vm.getString(bodyHandle);
const deferred = vm.newPromise();
sendRequestViaFetch(bodyJson, authToken).then(
value => resolveDeferredWithString(vm, deferred, JSON.stringify({ ok: true, value })),
err =>
resolveDeferredWithString(
vm,
deferred,
JSON.stringify({ ok: false, error: err instanceof Error ? err.message : String(err) }),
),
);
// The settled VM promise schedules a job; pump it so the awaiting script resumes.
deferred.settled.then(() => vm.runtime.executePendingJobs());
return deferred.handle;
});
setGlobal(vm, '__sendRequest', fn);
};
const resolveDeferredWithString = (
vm: QuickJSContext,
deferred: ReturnType<QuickJSContext['newPromise']>,
value: string,
): void => {
const handle = vm.newString(value);
deferred.resolve(handle);
handle.dispose();
};
/** Registers `<getName>(key)`/`<setName>(key, jsonValue)` host functions backed by a plain object. */
const installKeyValueBridge = (
vm: QuickJSContext,
@@ -12,6 +12,8 @@ interface WorkerRequest {
id: string;
script: string;
context: RequestContext;
/** Auth token for the `insomnia-templating-worker-database://` bridge — see run-script-quickjs.ts. */
authToken?: string;
}
interface WorkerErrorResponse {
@@ -27,9 +29,9 @@ interface WorkerSuccessResponse {
export type WorkerResponse = WorkerSuccessResponse | WorkerErrorResponse;
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const { id, script, context } = event.data;
const { id, script, context, authToken } = event.data;
try {
const result = await runScriptInQuickJs({ script, context });
const result = await runScriptInQuickJs({ script, context, authToken });
self.postMessage({ id, result } satisfies WorkerSuccessResponse);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
@@ -53,6 +53,10 @@ class FakeWorker {
}
}
// runScriptInQuickJs now awaits the (mocked) auth token before touching the worker, so callers must
// let that microtask resolve before inspecting postedMessages or emitting worker events.
const flushMicrotasks = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0));
describe('runScriptInQuickJs (worker client)', () => {
let fakeWorker: FakeWorker;
@@ -62,6 +66,9 @@ describe('runScriptInQuickJs (worker client)', () => {
'Worker',
vi.fn(() => fakeWorker),
);
vi.stubGlobal('window', {
main: { templatingDb: { getAuthToken: vi.fn().mockResolvedValue('test-token') } },
});
// The module keeps a lazily-created singleton worker at module scope — reset it between tests
// by re-importing fresh so each test gets its own FakeWorker instance.
vi.resetModules();
@@ -76,9 +83,11 @@ describe('runScriptInQuickJs (worker client)', () => {
const context = baseContext();
const promise = runScriptInQuickJs({ script: 'console.log(1)', context });
await flushMicrotasks();
expect(fakeWorker.postedMessages).toHaveLength(1);
const { id } = fakeWorker.postedMessages[0];
const { id, authToken } = fakeWorker.postedMessages[0];
expect(typeof id).toBe('string');
expect(authToken).toBe('test-token');
const result = { ...context, logs: ['log: 1\n'] };
fakeWorker.emitMessage({ id, result });
@@ -91,6 +100,7 @@ describe('runScriptInQuickJs (worker client)', () => {
const context = baseContext();
const promise = runScriptInQuickJs({ script: 'throw new Error("boom")', context });
await flushMicrotasks();
const { id } = fakeWorker.postedMessages[0];
fakeWorker.emitMessage({ id, error: { message: 'boom', name: 'Error' } });
@@ -102,6 +112,7 @@ describe('runScriptInQuickJs (worker client)', () => {
const context = baseContext();
const promise = runScriptInQuickJs({ script: 'console.log(1)', context });
await flushMicrotasks();
fakeWorker.emitMessage({ id: 'not-a-real-id', result: {} });
const { id } = fakeWorker.postedMessages[0];
@@ -116,6 +127,7 @@ describe('runScriptInQuickJs (worker client)', () => {
const context = baseContext();
const promise = runScriptInQuickJs({ script: 'while(true){}', context });
await flushMicrotasks();
fakeWorker.emitError('script execution context has been aborted');
await expect(promise).rejects.toThrow(/QuickJS sandbox worker crashed/);
@@ -126,6 +138,7 @@ describe('runScriptInQuickJs (worker client)', () => {
const context = baseContext();
const firstCall = runScriptInQuickJs({ script: 'while(true){}', context });
await flushMicrotasks();
fakeWorker.emitError('boom');
await expect(firstCall).rejects.toThrow();
@@ -136,6 +149,7 @@ describe('runScriptInQuickJs (worker client)', () => {
);
const secondCall = runScriptInQuickJs({ script: 'console.log(1)', context });
await flushMicrotasks();
expect(secondWorker.postedMessages).toHaveLength(1);
const { id } = secondWorker.postedMessages[0];
const result = { ...context, logs: [] };
@@ -12,6 +12,16 @@ import type { WorkerResponse } from './quickjs-script.worker';
let worker: Worker | null = null;
const pending = new Map<string, { resolve: (value: RequestContext) => void; reject: (err: Error) => void }>();
// The Worker has no window.main access, so this module fetches the auth token once (same pattern as
// ui/worker/templating-handler.ts) and forwards it on every postMessage instead.
let authTokenPromise: Promise<string> | null = null;
const getAuthToken = (): Promise<string> => {
if (!authTokenPromise) {
authTokenPromise = window.main.templatingDb.getAuthToken();
}
return authTokenPromise;
};
const getWorker = (): Worker => {
if (worker) {
return worker;
@@ -57,16 +67,17 @@ const getWorker = (): Worker => {
return instance;
};
export const runScriptInQuickJs = ({
export const runScriptInQuickJs = async ({
script,
context,
}: {
script: string;
context: RequestContext;
}): Promise<RequestContext> => {
const authToken = await getAuthToken();
const id = `quickjs-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return new Promise<RequestContext>((resolve, reject) => {
pending.set(id, { resolve, reject });
getWorker().postMessage({ id, script, context });
getWorker().postMessage({ id, script, context, authToken });
});
};