mirror of
https://github.com/Kong/insomnia.git
synced 2026-10-11 16:43:22 -04:00
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:
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 });
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user