Skip to content

Commit c315ee2

Browse files
jackkavCopilot
andcommitted
Move restricted template rendering to plugin window
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9310997 commit c315ee2

13 files changed

Lines changed: 231 additions & 278 deletions

packages/insomnia/PLUGIN_SYSTEM_POC.md

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -882,15 +882,11 @@ Co-locate unit tests with the plugin execution code in `packages/insomnia/src/pl
882882
packages/insomnia/src/plugins/create.ts imports fs and path from Node and is called directly from two renderer entry points: the create-plugin modal and root.tsx (theme installation). This is the most straightforward fix — move the filesystem writes to an IPC handler
883883
in the main process and call it via window.main.
884884

885-
2. Template tag extensions still run inside the renderer's Web Worker
885+
2. Template tag extensions now run in the plugin window
886886

887-
This is the largest remaining piece. Nunjucks rendering runs in a Web Worker (ui/worker/templating-handler.ts), but the plugin template tag extensions (base-extension-worker.ts) are instantiated and executed inside that worker, which lives inside the renderer process.
888-
The worker already has nodeIntegrationInWorker: false, so the web worker is sandboxed — but the template tag plugin code still lives on the renderer side of the fence. For nodeIntegration: false on the renderer, all plugin code (including template tags) needs to move
889-
out.
887+
Restricted templating no longer spins up a renderer-owned Web Worker. Instead, the renderer serializes the render context and calls `window.main.plugins.renderTemplate(...)`, which executes the shared Nunjucks pipeline inside the plugin window.
890888

891-
The cleanest solution — and the one you're already thinking about — is to move the entire templating pipeline into the plugin window. Template tags and request/action plugins would then share the same Node.js process and DB proxy. The custom
892-
insomnia-templating-worker-database:// protocol (currently used by the web worker to reach the main process for DB calls, network requests, file reads, etc.) could be replaced entirely with the existing IPC database proxy. The renderer side becomes a thin caller:
893-
serialize the render context, send it over IPC, get back a rendered string.
889+
That means template tags now live on the same side of the process boundary as the other plugin surfaces (actions, hooks, bundled main actions) and can reuse the existing plugin window IPC/database proxy. The renderer side is now just a thin caller that sends the render input over IPC and receives the rendered string back.
894890

895891
3. webviewTag: true on the main window
896892

@@ -912,7 +908,7 @@ a malicious API response could attempt to exploit the webview. The right long-te
912908
├─────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
913909
│ 1 │ Move createPlugin to main process │ Add IPC handler, replace fs/path calls with window.main.createPlugin(...) │
914910
├─────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
915-
│ 2 │ Move templating pipeline to plugin window │ The plugin window replaces the web worker; renderer calls window.main.plugins.renderTemplate(context) over IPC; drop the insomnia-templating-worker-database:// protocol
911+
│ 2 │ Move templating pipeline to plugin window │ Completed — restricted rendering now calls window.main.plugins.renderTemplate(context) over IPC and template tags execute in the plugin window
916912
├─────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
917913
│ 3 │ Replace <webview> with sandboxed <iframe> │ Removes the last reason for webviewTag: true │
918914
├─────┼───────────────────────────────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤

packages/insomnia/src/entry.plugin-window-preload.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { ipcRenderer } from 'electron';
22

3+
import { invokeWithNormalizedError } from './main/ipc/invoke';
4+
import type { RendererToMainBridgeAPI } from './main/ipc/main';
5+
36
// Provide window.app so plugin-loading code (which checks process.type === 'renderer')
47
// can resolve the userData path without needing the main renderer's full preload.
58
window.app = {
@@ -8,6 +11,13 @@ window.app = {
811
process: { platform: process.platform as NodeJS.Platform },
912
};
1013

14+
window.main = {
15+
secureReadFile: (options: { path: string }) => invokeWithNormalizedError('secureReadFile', options),
16+
openInBrowser: (url: string) => ipcRenderer.send('openInBrowser', url),
17+
curlRequest: (options: Parameters<RendererToMainBridgeAPI['curlRequest']>[0]) =>
18+
invokeWithNormalizedError('curlRequest', options),
19+
} as Pick<RendererToMainBridgeAPI, 'secureReadFile' | 'openInBrowser' | 'curlRequest'> as RendererToMainBridgeAPI;
20+
1121
// Bridge plugin UI calls to the main renderer window via IPC.
1222
// The plugin window has no visible DOM; these methods forward to the main renderer.
1323
window.showAlert = (options?: Record<string, any>) => {
@@ -35,6 +45,10 @@ window.dialog = {
3545

3646
window.clipboard = {
3747
readText: () => ipcRenderer.sendSync('readText') as string,
38-
writeText: (text: string) => { ipcRenderer.send('writeText', text); },
39-
clear: () => { ipcRenderer.send('clear'); },
48+
writeText: (text: string) => {
49+
ipcRenderer.send('writeText', text);
50+
},
51+
clear: () => {
52+
ipcRenderer.send('clear');
53+
},
4054
};

packages/insomnia/src/entry.plugin-window.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,27 @@ interface PluginInvokeMessage {
1212
args: unknown;
1313
}
1414

15+
function serializeInvocationError(error: unknown) {
16+
if (!(error instanceof Error)) {
17+
return { message: String(error) };
18+
}
19+
20+
return {
21+
name: error.name,
22+
message: error.message,
23+
stack: error.stack,
24+
...Object.fromEntries(Object.entries(error)),
25+
};
26+
}
27+
1528
ipcRenderer.on('plugins.invoke', async (_event, { id, method, args }: PluginInvokeMessage) => {
1629
try {
1730
const result = await invokePluginMethod(method, args);
1831
ipcRenderer.send('plugins.invokeResult', { id, result });
1932
} catch (error) {
2033
const errMsg = error instanceof Error ? error.message : String(error);
2134
console.error(`[plugin-window] Error in ${(error as any)?.method ?? method}: ${errMsg}`);
22-
ipcRenderer.send('plugins.invokeResult', { id, error: errMsg });
35+
ipcRenderer.send('plugins.invokeResult', { id, error: serializeInvocationError(error) });
2336
}
2437
});
2538

packages/insomnia/src/entry.preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
ExecutePluginActionArgs,
2424
ExecutePluginMainActionArgs,
2525
PluginsBridgeAPI,
26+
RenderTemplateArgs,
2627
RunTemplateTagActionArgs,
2728
} from './plugins/bridge-types';
2829
import type { PluginInvokeMethod } from './plugins/invoke-method';
@@ -376,6 +377,7 @@ const main: Window['main'] = {
376377
executeAction: (args: ExecutePluginActionArgs) => invokePluginBridgeMethod('executeAction', args),
377378
getTemplateTags: () => invokePluginBridgeMethod('getTemplateTags'),
378379
runTemplateTagAction: (args: RunTemplateTagActionArgs) => invokePluginBridgeMethod('runTemplateTagAction', args),
380+
renderTemplate: (args: RenderTemplateArgs) => invokePluginBridgeMethod('renderTemplate', args),
379381
getBundlePlugins: () => invokePluginBridgeMethod('getBundlePlugins'),
380382
executePluginMainAction: (args: ExecutePluginMainActionArgs) =>
381383
invokePluginBridgeMethod('executePluginMainAction', args),

packages/insomnia/src/main/plugin-window.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,28 @@ let cachedHasRequestHooks: boolean | null = null;
1414
let cachedHasResponseHooks: boolean | null = null;
1515
const promptPendingRequests = new Map<string, (value: string | null) => void>();
1616

17+
function normalizeInvocationError(error: unknown) {
18+
if (!error || typeof error !== 'object') {
19+
return new Error(String(error));
20+
}
21+
22+
if (error instanceof Error) {
23+
return error;
24+
}
25+
26+
const normalized = new Error('message' in error && typeof error.message === 'string' ? error.message : String(error));
27+
28+
if ('name' in error && typeof error.name === 'string') {
29+
normalized.name = error.name;
30+
}
31+
if ('stack' in error && typeof error.stack === 'string') {
32+
normalized.stack = error.stack;
33+
}
34+
35+
Object.assign(normalized, error);
36+
return normalized;
37+
}
38+
1739
// Bridge observability counters. Kept in-memory and exposed via the
1840
// `plugins.getBridgeMetrics` IPC handler so devs / smoke tests / support
1941
// dumps can read the live state without scraping logs.
@@ -142,7 +164,7 @@ function ensureIpcListeners() {
142164

143165
ipcMain.on(
144166
'plugins.invokeResult',
145-
(event, { id, result, error }: { id: string; result?: unknown; error?: string }) => {
167+
(event, { id, result, error }: { id: string; result?: unknown; error?: unknown }) => {
146168
if (event.sender !== pluginWindow?.webContents) {
147169
return;
148170
}
@@ -154,7 +176,7 @@ function ensureIpcListeners() {
154176
const duration = Date.now() - pending.startedAt;
155177
if (error) {
156178
recordInvocation(pending.method, 'error', duration);
157-
pending.reject(new Error(error));
179+
pending.reject(normalizeInvocationError(error));
158180
} else {
159181
recordInvocation(pending.method, 'ok', duration);
160182
pending.resolve(result);
@@ -302,6 +324,7 @@ export function registerPluginIpcHandlers() {
302324
ipcMain.handle('plugins.executeAction', (_event, args) => invokeInPluginWindow('executeAction', args));
303325
ipcMain.handle('plugins.getTemplateTags', () => invokeInPluginWindow('getTemplateTags'));
304326
ipcMain.handle('plugins.runTemplateTagAction', (_event, args) => invokeInPluginWindow('runTemplateTagAction', args));
327+
ipcMain.handle('plugins.renderTemplate', (_event, args) => invokeInPluginWindow('renderTemplate', args));
305328
ipcMain.handle('plugins.getBundlePlugins', () => invokeInPluginWindow('getBundlePlugins'));
306329
ipcMain.handle('plugins.executePluginMainAction', (_event, args) =>
307330
invokeInPluginWindow('executePluginMainAction', args),

packages/insomnia/src/plugins/__tests__/invoke-method.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ vi.mock('../context/store', () => ({ init: vi.fn().mockReturnValue({ store: {} }
77
vi.mock('../context/network', () => ({ init: vi.fn().mockReturnValue({ network: {} }) }));
88
vi.mock('../context/request', () => ({ init: vi.fn().mockReturnValue({ request: {} }) }));
99
vi.mock('../context/response', () => ({ init: vi.fn().mockReturnValue({ response: {} }) }));
10+
vi.mock('../../templating', () => ({ render: vi.fn(), reload: vi.fn() }));
1011
vi.mock('~/insomnia-data', () => ({
1112
services: {
1213
settings: { get: vi.fn() },
@@ -20,6 +21,7 @@ vi.mock('~/insomnia-data', () => ({
2021
},
2122
}));
2223

24+
import { render as renderTemplate } from '../../templating';
2325
import type { Plugin } from '../index';
2426
import { _testOnlySetPlugins } from '../index';
2527
import { invokePluginMethod } from '../invoke-method';
@@ -41,7 +43,9 @@ afterEach(() => {
4143

4244
describe('invokePluginMethod', () => {
4345
it('serializes request action metadata', async () => {
44-
_testOnlySetPlugins([makePlugin({ module: { requestActions: [{ label: 'Run', icon: 'bolt', action: vi.fn() }] } })]);
46+
_testOnlySetPlugins([
47+
makePlugin({ module: { requestActions: [{ label: 'Run', icon: 'bolt', action: vi.fn() }] } }),
48+
]);
4549

4650
await expect(invokePluginMethod('getRequestActions')).resolves.toEqual([
4751
{ label: 'Run', icon: 'bolt', pluginName: 'test-plugin' },
@@ -79,4 +83,59 @@ describe('invokePluginMethod', () => {
7983
}),
8084
).rejects.toThrow('[plugin=test-plugin] boom');
8185
});
86+
87+
it('rehydrates serialized render context before rendering templates', async () => {
88+
vi.mocked(renderTemplate).mockResolvedValue('rendered');
89+
90+
await expect(
91+
invokePluginMethod('renderTemplate', {
92+
input: '{{ foo }}',
93+
path: 'body.text',
94+
ignoreUndefinedEnvVariable: false,
95+
context: {
96+
foo: 'bar',
97+
serializedFunctions: {
98+
requestId: 'req_1',
99+
workspaceId: 'wrk_1',
100+
environmentId: 'env_1',
101+
extraInfo: { requestChain: ['req_1'] },
102+
globalEnvironmentId: 'genv_1',
103+
keysContext: { keyContext: { foo: 'Base Env' } },
104+
projectId: 'proj_1',
105+
purpose: 'send',
106+
settings: { dataFolders: [] },
107+
},
108+
},
109+
}),
110+
).resolves.toBe('rendered');
111+
112+
expect(renderTemplate).toHaveBeenCalledWith(
113+
'{{ foo }}',
114+
expect.objectContaining({
115+
path: 'body.text',
116+
ignoreUndefinedEnvVariable: false,
117+
context: expect.objectContaining({
118+
foo: 'bar',
119+
getMeta: expect.any(Function),
120+
getEnvironmentId: expect.any(Function),
121+
getExtraInfo: expect.any(Function),
122+
getGlobalEnvironmentId: expect.any(Function),
123+
getKeysContext: expect.any(Function),
124+
getProjectId: expect.any(Function),
125+
getPurpose: expect.any(Function),
126+
getSettings: expect.any(Function),
127+
}),
128+
}),
129+
);
130+
131+
const renderContext = vi.mocked(renderTemplate).mock.calls[0]?.[1]?.context as Record<string, any>;
132+
expect(renderContext.getMeta()).toEqual({ requestId: 'req_1', workspaceId: 'wrk_1' });
133+
expect(renderContext.getEnvironmentId()).toBe('env_1');
134+
expect(renderContext.getExtraInfo()).toEqual({ requestChain: ['req_1'] });
135+
expect(renderContext.getGlobalEnvironmentId()).toBe('genv_1');
136+
expect(renderContext.getKeysContext()).toEqual({ keyContext: { foo: 'Base Env' } });
137+
expect(renderContext.getProjectId()).toBe('proj_1');
138+
expect(renderContext.getPurpose()).toBe('send');
139+
expect(renderContext.getSettings()).toEqual({ dataFolders: [] });
140+
});
82141
});

packages/insomnia/src/plugins/bridge-types.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ResponsePatch } from '../main/network/libcurl-promise';
2-
import type { RenderedRequest } from '../templating/types';
2+
import type { BaseRenderContext, RenderedRequest,RenderPurpose } from '../templating/types';
33
import type { PluginTheme } from './misc';
44

55
export interface SerializablePlugin {
@@ -38,6 +38,29 @@ export interface RunTemplateTagActionArgs {
3838
actionName: string;
3939
}
4040

41+
export interface SerializedRenderContextFunctions {
42+
requestId?: string;
43+
workspaceId?: string;
44+
environmentId?: string;
45+
extraInfo?: ReturnType<BaseRenderContext['getExtraInfo']>;
46+
globalEnvironmentId?: string;
47+
keysContext: ReturnType<BaseRenderContext['getKeysContext']>;
48+
projectId?: string;
49+
purpose?: RenderPurpose;
50+
settings?: Record<string, unknown>;
51+
}
52+
53+
export type SerializedRenderContext = Record<string, any> & {
54+
serializedFunctions: SerializedRenderContextFunctions;
55+
};
56+
57+
export interface RenderTemplateArgs {
58+
input: string;
59+
context: SerializedRenderContext;
60+
path: string;
61+
ignoreUndefinedEnvVariable: boolean;
62+
}
63+
4164
export type PluginActionType = 'request' | 'requestGroup' | 'workspace' | 'document';
4265

4366
export interface ExecutePluginActionArgs {
@@ -80,6 +103,7 @@ export interface PluginsBridgeAPI {
80103
executeAction: (args: ExecutePluginActionArgs) => Promise<void>;
81104
getTemplateTags: () => Promise<SerializableTemplateTagMeta[]>;
82105
runTemplateTagAction: (args: RunTemplateTagActionArgs) => Promise<void>;
106+
renderTemplate: (args: RenderTemplateArgs) => Promise<string>;
83107
getBundlePlugins: () => Promise<SerializablePlugin[]>;
84108
executePluginMainAction: (args: ExecutePluginMainActionArgs) => Promise<unknown>;
85109
hasRequestHooks: () => Promise<boolean>;

packages/insomnia/src/plugins/invoke-method.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import * as templating from '../templating';
12
import type {
23
ApplyRequestHooksArgs,
34
ApplyResponseHooksArgs,
45
ExecutePluginActionArgs,
56
ExecutePluginMainActionArgs,
7+
RenderTemplateArgs,
68
RunTemplateTagActionArgs,
79
} from './bridge-types';
810
import * as pluginApp from './context/app';
@@ -40,13 +42,33 @@ export type PluginInvokeMethod =
4042
| 'executeAction'
4143
| 'getTemplateTags'
4244
| 'runTemplateTagAction'
45+
| 'renderTemplate'
4346
| 'getBundlePlugins'
4447
| 'executePluginMainAction'
4548
| 'hasRequestHooks'
4649
| 'hasResponseHooks'
4750
| 'applyRequestHooks'
4851
| 'applyResponseHooks';
4952

53+
function rehydrateRenderContext(context: RenderTemplateArgs['context']) {
54+
const { serializedFunctions, ...renderContext } = context;
55+
56+
return {
57+
...renderContext,
58+
getMeta: () => ({
59+
requestId: serializedFunctions.requestId,
60+
workspaceId: serializedFunctions.workspaceId,
61+
}),
62+
getEnvironmentId: () => serializedFunctions.environmentId,
63+
getExtraInfo: () => serializedFunctions.extraInfo,
64+
getGlobalEnvironmentId: () => serializedFunctions.globalEnvironmentId,
65+
getKeysContext: () => serializedFunctions.keysContext,
66+
getProjectId: () => serializedFunctions.projectId,
67+
getPurpose: () => serializedFunctions.purpose,
68+
getSettings: () => serializedFunctions.settings,
69+
};
70+
}
71+
5072
function serializePlugin(p: Plugin) {
5173
return {
5274
name: p.name,
@@ -76,6 +98,7 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn
7698

7799
case 'reloadPlugins': {
78100
await reloadPlugins();
101+
templating.reload();
79102
return null;
80103
}
81104

@@ -174,6 +197,15 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn
174197
return null;
175198
}
176199

200+
case 'renderTemplate': {
201+
const { input, context, path, ignoreUndefinedEnvVariable } = args as RenderTemplateArgs;
202+
return templating.render(input, {
203+
context: rehydrateRenderContext(context),
204+
path,
205+
ignoreUndefinedEnvVariable,
206+
});
207+
}
208+
177209
case 'hasRequestHooks': {
178210
const hooks = await getRequestHooks();
179211
return hooks.length > 0;

0 commit comments

Comments
 (0)