refactor: move database and services to a dedicated utility process

Replace IPC-based database/services bridge (database.main,
database.client,
services-proxy) with a MessagePort RPC layer backed by an Electron
utilityProcess (entry.data.ts).

- Add PortRpc transport, data-process server, and crash-restart manager
- Unify all entry points to use initDataBridge(invokeDataPort)
This commit is contained in:
Bingbing
2026-07-15 15:36:39 +08:00
parent 266631e721
commit bbbf14e5e3
29 changed files with 614 additions and 274 deletions

16
.vscode/launch.json vendored
View File

@@ -40,6 +40,20 @@
"webRoot": "${workspaceFolder}/packages/insomnia/src",
"timeout": 60000
},
{
"name": "Electron: data-process",
"type": "node",
"request": "attach",
"protocol": "inspector",
"sourceMaps": true,
"presentation": {
"hidden": true,
"group": "Insomnia",
"order": 3
},
"port": 9229,
"timeout": 60000
},
{
"type": "node",
"request": "launch",
@@ -98,7 +112,7 @@
},
"stopAll": true,
"preLaunchTask": "Insomnia: Compile (Watch)",
"configurations": ["Electron: main", "Electron: renderer"]
"configurations": ["Electron: main", "Electron: renderer", "Electron: data-process"]
}
]
}

View File

@@ -4,7 +4,7 @@ import { vi } from 'vitest';
const database = createNedbDatabase();
await initDatabase(database, { inMemoryOnly: true }, true);
await initServices(servicesNodeImpl);
initServices(servicesNodeImpl);
import { v4Mock } from './__mocks__/uuid';

View File

@@ -123,6 +123,21 @@ export default async function build(options: Options) {
},
};
const dataBuildOptions: BuildOptions = {
entryPoints: ['./src/entry.data.ts'],
outfile: path.join(outdir, 'entry.data.min.js'),
bundle: true,
platform: 'node',
sourcemap: true,
format: 'cjs',
plugins: [rendererToNodePlugin],
define: {
...env,
'__IS_RENDERER__': JSON.stringify(false),
},
external: [...Object.keys(builtinModules), 'electron']
};
const mainBuildOptions: BuildOptions = {
entryPoints: ['./src/entry.main.ts'],
outfile: path.join(outdir, 'entry.main.min.js'),
@@ -194,10 +209,10 @@ export default async function build(options: Options) {
build.onEnd(() => {
buildCount++;
// first build after main/preload/hiddenWindows/pluginWindows is built
if (buildCount === 6) {
if (buildCount === 7) {
console.log('[Dev Build] Build complete, start Electron');
startElectron();
} else if (buildCount > 6) {
} else if (buildCount > 7) {
console.log(`[Dev Build] Finish rebuilding ${scriptName}, restarting Electron`);
restartElectronProcess();
} else {
@@ -230,6 +245,10 @@ export default async function build(options: Options) {
...pluginWindowPreloadBuildOptions,
plugins: [restartElectronPlugin('plugin-window-preload')],
});
const dataContext = await esbuild.context({
...dataBuildOptions,
plugins: [restartElectronPlugin('data')],
});
const restartElectronProcess = () => {
console.log('[Dev Build] Start restarting Electron');
@@ -251,6 +270,7 @@ export default async function build(options: Options) {
const hiddenWindowPreloadWatch = await hiddenPreloadContext.watch();
const pluginWindowWatch = await pluginWindowContext.watch();
const pluginWindowPreloadWatch = await pluginWindowPreloadContext.watch();
const dataWatch = await dataContext.watch();
return Promise.all([
preloadWatch,
hiddenWindowPreloadWatch,
@@ -258,6 +278,7 @@ export default async function build(options: Options) {
hiddenWindowWatch,
pluginWindowWatch,
pluginWindowPreloadWatch,
dataWatch,
]);
}
const preload = esbuild.build(preloadBuildOptions);
@@ -266,6 +287,7 @@ export default async function build(options: Options) {
const pluginWindow = esbuild.build(pluginWindowBuildOptions);
const pluginWindowPreload = esbuild.build(pluginWindowPreloadBuildOptions);
const main = esbuild.build(mainBuildOptions);
const data = esbuild.build(dataBuildOptions);
return Promise.all([
main,
preload,
@@ -273,6 +295,7 @@ export default async function build(options: Options) {
hiddenBrowserWindowPreload,
pluginWindow,
pluginWindowPreload,
data,
]).catch(err => {
console.error('[Build] Build failed:', err);
});

View File

@@ -1,16 +1,15 @@
import { initDatabase, initServices } from 'insomnia-data';
import { servicesNodeImpl } from 'insomnia-data/node';
import { createNedbDatabase, servicesNodeImpl } from 'insomnia-data/node';
import { vi } from 'vitest';
// eslint-disable-next-line no-restricted-imports
import { v4Mock } from '../insomnia-data/__mocks__/uuid';
import { nodeLibcurlMock } from './src/__mocks__/@getinsomnia/node-libcurl';
import { electronMock } from './src/__mocks__/electron';
import { mainDatabase } from './src/main/database.main';
import { initRuntime } from './src/runtimes';
import { nodeRuntime } from './src/runtimes/runtime.node';
await initDatabase(mainDatabase, { inMemoryOnly: true }, true);
await initDatabase(createNedbDatabase(), { inMemoryOnly: true }, true);
await initServices(servicesNodeImpl);
initRuntime(nodeRuntime);

View File

@@ -0,0 +1,93 @@
# data-process
Isolated utility process that owns the NeDB database and all service implementations. Main and renderer communicate with it via MessagePort RPC.
## Topology
```mermaid
flowchart LR
subgraph Renderer
R["entry.*.ts\ninitDataBridge(window.invokeDataPort)"]
end
subgraph Preload
P["attachDataPortRpc()\nContextBridge → invokeDataPort"]
end
subgraph Main
M["entry.main.ts\ninitDataBridge(mainRpc.invoke)"]
end
subgraph DataProcess["Data Process (utilityProcess)"]
S["server.ts\nNeDB + servicesNodeImpl"]
end
R -- "MessagePort (via contextBridge)" --> P
P -- "MessagePort" --> S
M -- "MessagePortMain" --> S
```
Each process creates a `PortRpc`, attaches it to its transport, then calls `initDataBridge(invoke)` to wire up database and services proxies.
### Init sequence
```mermaid
sequenceDiagram
participant M as Main
participant D as Data Process
participant P as Preload
participant R as Renderer
M->>D: fork(entry.data.min.js)
M->>D: postMessage({ dbPath })
D-->>M: postMessage({ type: 'ready' })
M->>M: mainRpc.attach(port2)
P->>M: ipcRenderer.invoke('data-process.request-port')
M->>P: webContents.postMessage('data-process.port', [port])
P->>P: rpc.attach(port)
R->>R: initDataBridge(window.invokeDataPort)
```
## File map
| File | Role |
|---|---|
| `port-rpc.ts` | Transport-agnostic RPC client. `attach` / `invalidate` / `invoke`. |
| `server.ts` | Data-process side. Listens for `new-port` messages, routes `invoke` requests to NeDB or services. |
| `data-process-manager.ts` | Main-process side. `spawnDataProcess` forks the utility process, exports `mainRpc`. `issuePort(window)` hands a port to a renderer window. Restarts on crash. |
| `data-port-preload.ts` | `attachDataPortRpc(context)` — wires up `PortRpc` in a preload script and returns `InvokeFn`. |
| `init-data-bridge.ts` | `initDataBridge(invoke, options?)` — builds database + services proxies and calls `initDatabase` / `initServices`. Used by every renderer entry and the main process. |
| `serialization.ts` | `serializeError` / `deserializeError` for structured error transport across MessagePort. |
## Usage per process
### Preload (all windows)
```ts
const invokeDataPort = attachDataPortRpc('my-window-preload');
contextBridge.exposeInMainWorld('invokeDataPort', invokeDataPort);
```
### Renderer entry
```ts
await initDataBridge(window.invokeDataPort, {
database: { onChange: listener => ... }, // optional overrides
});
```
### Main process
```ts
await spawnDataProcess(dbPath);
await initDataBridge(mainRpc.invoke, {
database: { init: async () => {}, onChange: listener => registerMainProcessChangeListener(listener) },
});
```
## Adding a new window
1. Preload: `attachDataPortRpc('my-window')`, expose result as `window.invokeDataPort`.
2. Entry: `await initDataBridge(window.invokeDataPort)`.
3. Main: `issuePort(window)` is called automatically when the window sends `data-process.request-port`.
## Debugging
In development, `spawnDataProcess` passes `--inspect=9229` to the utility process. The VSCode `Insomnia` compound attaches to it automatically alongside the main process and renderer.

View File

@@ -0,0 +1,41 @@
import type { IpcRendererEvent } from 'electron';
import { ipcRenderer } from 'electron';
import { type InvokeFn, PortRpc } from './port-rpc';
/**
* Wires up a PortRpc instance to the data-process IPC channel and returns
* the invoke function to expose via contextBridge / window.
*
* @param context - Identifies the caller in error/log messages (e.g. 'preload', 'hidden-window-preload').
*/
export function attachDataPortRpc(context: string): InvokeFn {
const rpc = new PortRpc();
ipcRenderer.on('data-process.port', (event: IpcRendererEvent) => {
const port = event.ports[0];
if (port) {
rpc.attach(
m => port.postMessage(m),
h => {
port.onmessage = (e: MessageEvent) => h(e.data);
port.start();
},
);
console.log(`[${context}] data-process port received and attached`);
} else {
console.warn(`[${context}] data-process.port event received but no port transferred`);
}
});
ipcRenderer.on('data-process.restarting', () => {
console.warn(`[${context}] data-process restarting invalidating RPC`);
rpc.invalidate('data-process restarting');
});
ipcRenderer.invoke('data-process.request-port').catch((e: unknown) => {
console.error(`[${context}] failed to request data-process port:`, e);
});
return rpc.invoke;
}

View File

@@ -0,0 +1,125 @@
import path from 'node:path';
import type { UtilityProcess } from 'electron';
import { BrowserWindow, MessageChannelMain, utilityProcess } from 'electron';
import type { ChangeListener } from 'insomnia-data';
import { isDevelopment } from '../common/constants';
import { PortRpc } from './port-rpc';
let child: UtilityProcess | null = null;
const mainProcessChangeListeners: ChangeListener[] = [];
export function registerMainProcessChangeListener(listener: ChangeListener): void {
mainProcessChangeListeners.push(listener);
}
function getDataProcessPath(): string {
return path.join(__dirname, 'entry.data.min.js');
}
// Messages from a UtilityProcess arrive as the raw value passed to
// process.parentPort.postMessage() on the child side - NOT wrapped in
// an { data: ... } envelope. Use this helper to cast safely.
function msgOf(event: Electron.MessageEvent): Record<string, unknown> {
return event as unknown as Record<string, unknown>;
}
// Main process's own RPC client for the data-process.
// After spawnDataProcess() completes, this is attached and ready to use.
export const mainRpc = new PortRpc();
export async function spawnDataProcess(dbPath: string): Promise<void> {
const inspectArgs = isDevelopment() ? ['--inspect=9229'] : [];
console.log('[data-process] spawning...', { dbPath, inspectArgs });
child = utilityProcess.fork(getDataProcessPath(), [], {
execArgv: inspectArgs,
stdio: 'pipe',
});
child.stdout?.on('data', (d: Buffer) => process.stdout.write(`[data-process:stdout] ${d}`));
child.stderr?.on('data', (d: Buffer) => process.stderr.write(`[data-process:stderr] ${d}`));
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('[data-process] timed out waiting for ready signal'));
}, 30_000);
const onMessage = (event: Electron.MessageEvent) => {
const msg = msgOf(event);
if (msg.type === 'ready') {
clearTimeout(timeout);
child!.off('message', onMessage);
console.log('[data-process] ready');
resolve();
} else if (msg.type === 'error') {
clearTimeout(timeout);
child!.off('message', onMessage);
reject(new Error(`[data-process] init failed: ${msg.message}`));
}
};
child!.on('message', onMessage);
child!.once('exit', code => {
clearTimeout(timeout);
reject(new Error(`[data-process] exited with code ${code} before ready`));
});
child!.postMessage({ dbPath });
});
// Create a dedicated MessagePort for the main process's own RPC.
const { port1, port2 } = new MessageChannelMain();
child.postMessage({ type: 'new-port' }, [port1]);
mainRpc.attach(
m => port2.postMessage(m),
h => {
port2.on('message', (e: Electron.MessageEvent) => h(e.data as any));
port2.start();
},
);
console.log('[data-process] main-process RPC port attached');
child.on('message', (event: Electron.MessageEvent) => {
const msg = msgOf(event);
if (msg.type === 'db.changes') {
BrowserWindow.getAllWindows().forEach(w => {
w.webContents.send('db.changes', msg.changes);
});
mainProcessChangeListeners.forEach(listener => listener(msg.changes as any));
}
});
child.on('exit', code => {
console.warn(`[data-process] exited with code ${code}`);
handleExit(dbPath);
});
}
export function issuePort(window: BrowserWindow): void {
if (!child) {
console.error('[data-process] cannot issue port - data process not running');
return;
}
const { port1, port2 } = new MessageChannelMain();
child.postMessage({ type: 'new-port' }, [port1]);
window.webContents.postMessage('data-process.port', null, [port2]);
console.debug('[data-process] port issued to window', { windowId: window.id });
}
function handleExit(dbPath: string): void {
console.warn('[data-process] restarting...');
mainRpc.invalidate('data-process restarting');
BrowserWindow.getAllWindows().forEach(w => w.webContents.send('data-process.restarting'));
child = null;
spawnDataProcess(dbPath)
.then(() => {
console.log('[data-process] restarted, re-issuing ports');
BrowserWindow.getAllWindows().forEach(w => issuePort(w));
})
.catch(e => {
console.error('[data-process] failed to restart:', e);
});
}

View File

@@ -0,0 +1,47 @@
import type { IDatabase, Services } from 'insomnia-data';
import { initDatabase, initServices } from 'insomnia-data';
import { type InvokeFn } from './port-rpc';
export interface DataBridgeOptions {
database?: Partial<IDatabase>;
}
/**
* Initialises the database and services bridges from a single invoke function.
*
* @param invoke - The RPC invoke function (InvokeFn from PortRpc, or mainRpc.invoke).
* May be undefined when called from a renderer — an error is thrown in that case.
* @param options.database - Optional Partial<IDatabase> overrides (e.g. onChange for main process).
*/
export async function initDataBridge(
invoke: InvokeFn | undefined,
options?: DataBridgeOptions,
): Promise<void> {
if (!invoke) {
throw new Error(
'Data port bridge is not available. This entrypoint must run in an environment with the preload bridge.',
);
}
await initDatabase(
new Proxy((options?.database ?? {}) as IDatabase, {
get(target, prop) {
if (prop in target) return target[prop as keyof IDatabase];
return (...args: unknown[]) => invoke('database', prop as string, ...args);
},
}),
);
initServices(
new Proxy({} as Services, {
get(_target, serviceName: string) {
return new Proxy({}, {
get(_target, methodName: string) {
return (...args: unknown[]) => invoke('services', `${serviceName}.${methodName}`, ...args);
},
});
},
}),
);
}

View File

@@ -0,0 +1,54 @@
import { deserializeError, type SerializedError } from './serialization';
export type InvokeFn = (namespace: 'services' | 'database', method: string, ...args: unknown[]) => Promise<unknown>;
interface DataResponse {
id: string;
ok: boolean;
result?: unknown;
error?: SerializedError;
}
/**
* Transport-agnostic RPC client for the data-process.
*
* Works with any port-like transport (MessagePort in renderer, MessagePortMain
* in main process). The caller provides `send` and `onMessage` adapters in
* `attach()`. Handles request/response correlation, error deserialization, and
* port lifecycle (invalidate on restart).
*/
export class PortRpc {
private send: ((msg: unknown) => void) | null = null;
private pending = new Map<string, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
attach(send: (msg: unknown) => void, onMessage: (handler: (data: DataResponse) => void) => void): void {
this.send = send;
onMessage(data => {
const p = this.pending.get(data.id);
if (!p) return;
this.pending.delete(data.id);
data.ok ? p.resolve(data.result) : p.reject(deserializeError(data.error!));
});
console.debug('[port-rpc] attached', { pendingCount: this.pending.size });
}
invalidate(reason: string): void {
const pendingCount = this.pending.size;
this.pending.forEach(({ reject }) => reject(new Error(reason)));
this.pending.clear();
this.send = null;
console.warn(`[port-rpc] invalidated: ${reason}`, { rejectedCount: pendingCount });
}
invoke: InvokeFn = (namespace, method, ...args) => {
if (!this.send) {
console.error(`[port-rpc] invoke failed port not available: ${namespace}.${method}`);
return Promise.reject(new Error('data port not available'));
}
const id = crypto.randomUUID();
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.send!({ id, type: 'invoke', namespace, method, args });
});
};
}

View File

@@ -0,0 +1,19 @@
export interface SerializedError {
name: string;
message: string;
stack: string;
}
export function serializeError(e: unknown): SerializedError {
if (e instanceof Error) {
return { name: e.name, message: e.message, stack: e.stack ?? '' };
}
return { name: 'Error', message: String(e), stack: '' };
}
export function deserializeError(s: SerializedError): Error {
const err = new Error(s.message);
err.name = s.name;
err.stack = s.stack;
return err;
}

View File

@@ -0,0 +1,87 @@
import type { IDatabase } from 'insomnia-data';
import { database, type Services, services } from 'insomnia-data';
import { type SerializedError, serializeError } from './serialization';
export interface DataRequest {
id: string;
type: 'invoke';
namespace: 'services' | 'database';
/** 'services' namespace: "serviceName.methodName", e.g. "request.getById"
* 'database' namespace: method name on IDatabase, e.g. "find"
*/
method: string;
args: unknown[];
}
interface DataResponseOk {
id: string;
ok: true;
result: unknown;
}
interface DataResponseError {
id: string;
ok: false;
error: SerializedError;
}
type DataResponse = DataResponseOk | DataResponseError;
async function dispatch(req: DataRequest): Promise<unknown> {
if (req.namespace === 'database') {
const fn = database[req.method as keyof IDatabase];
if (typeof fn !== 'function') {
throw new TypeError(`Unknown database method: ${req.method}`);
}
return (fn as (...args: unknown[]) => unknown).call(database, ...req.args);
}
if (req.namespace === 'services') {
const [serviceName, methodName] = req.method.split('.');
if (!serviceName || !methodName) {
throw new TypeError(`Invalid services method format: ${req.method}`);
}
const service = services[serviceName as keyof Services];
if (!service) {
throw new TypeError(`Unknown service: ${serviceName}`);
}
const fn = service[methodName as keyof typeof service];
if (typeof fn !== 'function') {
throw new TypeError(`Unknown service method: ${serviceName}.${methodName}`);
}
return (fn as (...args: unknown[]) => unknown).call(service, ...req.args);
}
throw new TypeError(`Unknown namespace: ${req.namespace}`);
}
async function handleRequest(port: Electron.MessagePortMain, req: DataRequest): Promise<void> {
let response: DataResponse;
try {
const raw = await dispatch(req);
response = { id: req.id, ok: true, result: raw };
} catch (e) {
response = { id: req.id, ok: false, error: serializeError(e) };
}
port.postMessage(response);
}
function attachPort(port: Electron.MessagePortMain): void {
console.debug('[data-process] port attached');
port.on('message', (event: Electron.MessageEvent) => {
handleRequest(port, event.data as DataRequest).catch(e => {
console.error('[data-process] unhandled error in handleRequest:', e);
});
});
port.start();
}
export function startDataProcessServer(): void {
process.parentPort.on('message', (event: Electron.MessageEvent) => {
const msg = event.data as Record<string, unknown>;
if (msg?.type === 'new-port') {
const port = event.ports[0];
if (port) {
attachPort(port);
}
}
});
}

View File

@@ -2,19 +2,18 @@ import './ui/renderer-listeners';
import './ui/log';
import { configureFetch } from 'insomnia-api';
import { initDatabase, initServices, services } from 'insomnia-data';
import { services } from 'insomnia-data';
import { startTransition, StrictMode } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
import { insomniaFetch } from '~/common/insomnia-fetch';
import { setTemplatingDbAuthToken } from '~/common/templating/liquid-extension-worker';
import { initDataBridge } from '~/data-process/init-data-bridge';
import { initRuntime } from '~/runtimes';
import { rendererRuntime } from '~/runtimes/runtime.renderer';
import { migrateFromLocalStorage, type SessionData, setSessionData, setVaultSessionData } from '~/ui/account/session';
import { database as clientDatabase } from '~/ui/database.client';
import { applyColorScheme } from '~/ui/plugins/misc';
import { createServicesProxy } from '~/ui/services-proxy';
import { clearOAuthWindowSessionId } from '~/ui/spawn-oauth-window';
import { getInitialEntry } from '~/ui/utils/router';
@@ -33,19 +32,9 @@ initializeSentry();
// Fetch the templating-db auth token once so it's available for every templating call in this window.
setTemplatingDbAuthToken(await window.main.templatingDb.getAuthToken());
// Initialize database for renderer process
await initDatabase(clientDatabase);
// Initialize services for renderer process.
// With contextIsolation the preload exposes a flat invoke (a Proxy can't cross
// the bridge), so rebuild the Proxy here. Without it, the Proxy is on window directly.
const dataServices =
window._dataServices ?? (window._dataServicesInvoke ? createServicesProxy(window._dataServicesInvoke) : undefined);
if (!dataServices) {
throw new Error(
'Services bridge is not available. This entrypoint must run in an environment with the preload bridge.',
);
}
initServices(dataServices);
await initDataBridge(window.invokeDataPort, {
database: { init: async () => {}, onChange: () => {} },
});
initRuntime(rendererRuntime);
configureFetch(options => insomniaFetch({ ...options, onDeepLink: (uri: string) => window.main.openDeepLink(uri) }));

View File

@@ -0,0 +1,53 @@
import { net } from 'electron/utility';
import { initDatabase, initServices } from 'insomnia-data';
import { createNedbDatabase, flushChangesImpl, servicesNodeImpl } from 'insomnia-data/node';
import { configureV3ClientDefaults } from './common/configure-v3-client';
import { setFetchImplementation } from './common/insomnia-fetch';
import { startDataProcessServer } from './data-process/server';
process.on('uncaughtException', err => {
process.stdout.write(`[data-process] uncaughtException: ${err.stack ?? err.message}\n`);
});
process.on('unhandledRejection', reason => {
const msg = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
process.stdout.write(`[data-process] unhandledRejection: ${msg}\n`);
});
process.parentPort.once('message', async (event: Electron.MessageEvent) => {
const { dbPath } = event.data as { dbPath: string };
const keepAlive = setInterval(() => {}, 30_000);
try {
const dataProcessDatabase = createNedbDatabase(nedbDatabase => ({
...nedbDatabase,
init: async (config = {}, forceReset = false) => {
await nedbDatabase.init({ dbPath, ...config }, forceReset);
},
flushChanges: async function (id = 0, fake = false) {
const changes = await flushChangesImpl(id, fake);
if (changes) {
process.parentPort.postMessage({ type: 'db.changes', changes });
}
},
}));
await initDatabase(dataProcessDatabase);
configureV3ClientDefaults();
// net.fetch picks up the proxy + OS certs like the renderer; node fetch does neither.
// only works post-ready, which is fine — nothing calls this earlier. 'omit' = no cookies, same as before.
setFetchImplementation((input, init) =>
net.fetch(input, { ...init, credentials: 'omit', bypassCustomProtocolHandlers: true }),
);
initServices(servicesNodeImpl);
startDataProcessServer();
process.parentPort.postMessage({ type: 'ready' });
} catch (err) {
clearInterval(keepAlive);
const e = err instanceof Error ? err : new Error(String(err));
process.stdout.write(`[data-process] init failed: ${e.stack ?? e.message}\n`);
process.parentPort.postMessage({ type: 'error', message: e.message });
}
});

View File

@@ -3,8 +3,6 @@ import * as fs from 'node:fs';
import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
import type { Compression } from 'insomnia-data';
import { servicesProxy } from '~/ui/renderer-services-proxy';
import {
asyncTasksAllSettled,
OriginalPromise,
@@ -13,6 +11,7 @@ import {
resetAsyncTasks,
stopMonitorAsyncTasks,
} from '../../insomnia-scripting-environment/src/objects';
import { attachDataPortRpc } from './data-process/data-port-preload';
// this will also import lots of node_modules into the preload script, consider moving this file insomnia-scripting-environment
import { requireInterceptor } from './scripting/require-interceptor';
@@ -64,9 +63,15 @@ const bridge: HiddenBrowserWindowToMainBridgeAPI = {
if (process.contextIsolated) {
contextBridge.exposeInMainWorld('bridge', bridge);
contextBridge.exposeInMainWorld('Promise', ProxiedPromise);
contextBridge.exposeInMainWorld('_dataServices', servicesProxy);
} else {
window.bridge = bridge;
window.Promise = ProxiedPromise;
window._dataServices = servicesProxy;
}
const rpc = attachDataPortRpc('hidden-window-preload');
if (process.contextIsolated) {
contextBridge.exposeInMainWorld('invokeDataPort', rpc);
} else {
window.invokeDataPort = rpc;
}

View File

@@ -1,8 +1,8 @@
import * as Sentry from '@sentry/electron/renderer';
import { SENTRY_OPTIONS } from 'insomnia/src/common/sentry';
import { initServices } from 'insomnia-data';
import type { RequestContext } from '../../insomnia-scripting-environment/src/objects';
import { initDataBridge } from './data-process/init-data-bridge';
import { initRuntime } from './runtimes';
import { rendererRuntime } from './runtimes/runtime.renderer';
import { runScript } from './scripting/run-script';
@@ -20,19 +20,13 @@ Sentry.init({
...SENTRY_OPTIONS,
});
// Initialize services for hidden renderer process
if (!window._dataServices) {
throw new Error(
'window._dataServices is not available. This entrypoint must run in an environment with the preload bridge.',
);
}
initServices(window._dataServices);
// Remove the global services reference after initialization to improve security by preventing unintended access from the global scope.
delete window._dataServices;
// Initialize data bridge for hidden renderer process
const initPromise = initDataBridge(window.invokeDataPort);
initRuntime(rendererRuntime);
window.bridge.onmessage(
async (data: { script: string; context: RequestContext }, callback: ({ error }: { error: string }) => void) => {
await initPromise;
window.bridge.setBusy(true);
try {

View File

@@ -8,12 +8,12 @@ import contextMenu from 'electron-context-menu';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
import { configureFetch } from 'insomnia-api';
import type { Stats } from 'insomnia-data';
import { initDatabase, initServices, models, services } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { isMac } from 'insomnia-data/common';
import { servicesNodeImpl } from 'insomnia-data/node';
import { insomniaFetch, setFetchImplementation } from '~/common/insomnia-fetch';
import { mainDatabase } from '~/main/database.main';
import { issuePort, mainRpc, registerMainProcessChangeListener, spawnDataProcess } from '~/data-process/data-process-manager';
import { initDataBridge } from '~/data-process/init-data-bridge';
import { initElectronStorage } from '~/main/electron-storage';
import { runGitCredentialsMigration } from '~/main/git/migrations';
import { registerPathHandlers } from '~/main/ipc/path';
@@ -31,7 +31,7 @@ import { backupIfNewerVersionAvailable } from './main/backup';
import { registerSyncHandlers } from './main/cloud-sync/ipc';
import { registerGitServiceAPI } from './main/git-service';
import { registerCookieHandlers } from './main/ipc/cookies';
import { ipcMainOn, ipcMainOnce, registerElectronHandlers } from './main/ipc/electron';
import { ipcMainHandle, ipcMainOn, ipcMainOnce, registerElectronHandlers } from './main/ipc/electron';
import { registerElectronStorageHandlers } from './main/ipc/electron-storage';
import { registergRPCHandlers } from './main/ipc/grpc';
import { registerMainHandlers } from './main/ipc/main';
@@ -137,10 +137,14 @@ app.on('ready', async () => {
}
}
// Init some important things first
await initDatabase(mainDatabase);
// Initialize services for main process
initServices(servicesNodeImpl);
ipcMainHandle('data-process.request-port', event => {
const window = BrowserWindow.fromWebContents(event.sender);
if (window) issuePort(window);
});
await spawnDataProcess(dataPath);
await initDataBridge(mainRpc.invoke, {
database: { init: async () => {}, onChange: listener => registerMainProcessChangeListener(listener) },
});
initRuntime(nodeRuntime);
await _createModelInstances();
// proxy has to be set up before backup's net.fetch below

View File

@@ -1,12 +1,14 @@
import { ipcRenderer } from 'electron';
// Provide window.app so plugin-loading code (which checks __IS_RENDERER__) can resolve the userData path without needing the main renderer's full preload.
import { attachDataPortRpc } from './data-process/data-port-preload';
window.app = {
getPath: (name: string) => ipcRenderer.sendSync('getPath', name) as string,
getAppPath: () => ipcRenderer.sendSync('getAppPath') as string,
process: { platform: process.platform as NodeJS.Platform },
};
window.invokeDataPort = attachDataPortRpc('plugin-window-preload');
// Bridge plugin UI calls to the main renderer window via IPC.
// The plugin window has no visible DOM; these methods forward to the main renderer.
window.showAlert = (options?: Record<string, any>) => {

View File

@@ -1,12 +1,10 @@
import { ipcRenderer } from 'electron';
import { initDatabase, initServices } from 'insomnia-data';
import { setTemplatingDbAuthToken } from './common/templating/liquid-extension-worker';
import { pluginWindowDatabase } from './main/database.plugin-window';
import { initDataBridge } from './data-process/init-data-bridge';
import { invokePluginMethod } from './plugins/invoke-method';
import { initRuntime } from './runtimes';
import { rendererRuntime } from './runtimes/runtime.renderer';
import { servicesProxy } from './ui/renderer-services-proxy';
interface PluginInvokeMessage {
id: string;
@@ -25,14 +23,15 @@ ipcRenderer.on('plugins.invoke', async (_event, { id, method, args }: PluginInvo
}
});
// Initialize database (via IPC proxy) and services before signalling readiness.
// Initialize database (via data-port) and services before signalling readiness.
// getPlugins() calls services.settings.get(), which requires this to be done first.
(async () => {
try {
// Fetch the auth token before any plugin code can call fetchFromTemplateWorkerDatabase.
setTemplatingDbAuthToken(await ipcRenderer.invoke('templatingDb.getAuthToken'));
await initDatabase(pluginWindowDatabase);
initServices(servicesProxy);
await initDataBridge(window.invokeDataPort, {
database: { init: async () => {}, onChange: () => {} },
});
initRuntime(rendererRuntime);
ipcRenderer.send('plugins.windowReady');
} catch (err) {

View File

@@ -12,11 +12,11 @@ import type {
import type { GenerateMcpSamplingResponseFunction } from '~/common/plugins/types';
import type { RenderedRequest } from '~/common/templating/types';
import { invariant } from '~/common/utils/invariant';
import { attachDataPortRpc } from '~/data-process/data-port-preload';
import { invokeWithNormalizedError } from '~/main/ipc/invoke';
import type { LLMBackend, LLMConfig, LLMConfigServiceAPI } from '~/main/llm-config-service';
import type { PluginInvokeMethod } from '~/plugins/invoke-method';
import { isUserAbortResolveMergeConflictError, UserAbortResolveMergeConflictError } from '~/sync/vcs/errors';
import { servicesProxy } from '~/ui/renderer-services-proxy';
import type { SyncBridgeAPI } from './main/cloud-sync/ipc';
import type { GitServiceAPI } from './main/git-service';
@@ -29,8 +29,11 @@ import type { CurlBridgeAPI } from './main/network/curl';
import type { McpBridgeAPI } from './main/network/mcp';
import type { SocketIOBridgeAPI } from './main/network/socket-io';
import type { WebSocketBridgeAPI } from './main/network/websocket';
const ports = new Map<'hiddenWindowPort', MessagePort>();
const invokeDataPort = attachDataPortRpc('preload');
type PluginMethodResult<T extends PluginInvokeMethod> = T extends keyof PluginsBridgeAPI
? Awaited<ReturnType<PluginsBridgeAPI[T]>>
: never;
@@ -496,10 +499,6 @@ const clipboard: Window['clipboard'] = {
const webUtils: Window['webUtils'] = {
getPathForFile: (file: File) => webUtilities.getPathForFile(file),
};
const database: Window['database'] = {
invoke: (fnName, ...args) => invokeWithNormalizedError('database.invoke', fnName, ...args),
};
const env: Window['env'] = {
// GitLab OAuth — redirect URI, client ID, and API URL allow dev/enterprise overrides
INSOMNIA_GITLAB_REDIRECT_URI: process.env.INSOMNIA_GITLAB_REDIRECT_URI,
@@ -547,14 +546,7 @@ if (process.contextIsolated) {
contextBridge.exposeInMainWorld('clipboard', clipboard);
contextBridge.exposeInMainWorld('webUtils', webUtils);
contextBridge.exposeInMainWorld('path', path);
contextBridge.exposeInMainWorld('database', database);
// A Proxy cannot be cloned across the contextBridge, so expose a flat invoke
// function and rebuild the services Proxy in the isolated renderer world.
contextBridge.exposeInMainWorld(
'_dataServicesInvoke',
(serviceName: string, methodName: string, ...args: unknown[]) =>
invokeWithNormalizedError('services.invoke', serviceName, methodName, ...args),
);
contextBridge.exposeInMainWorld('invokeDataPort', invokeDataPort);
contextBridge.exposeInMainWorld('env', env);
} else {
window.main = main;
@@ -564,7 +556,6 @@ if (process.contextIsolated) {
window.clipboard = clipboard;
window.webUtils = webUtils;
window.path = path;
window.database = database;
window._dataServices = servicesProxy;
window.invokeDataPort = invokeDataPort;
window.env = env;
}

View File

@@ -6,12 +6,12 @@
*/
import { initDatabase, models, services as insoservices } from 'insomnia-data';
import { createNedbDatabase } from 'insomnia-data/node';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// eslint-disable-next-line no-restricted-imports
import { resetV4Counter } from '../../../../insomnia-data/__mocks__/uuid';
import { database as db } from '../../common/database';
import { mainDatabase } from '../../main/database.main';
import type { KonnectControlPlane, KonnectRoute, KonnectService } from '../api';
import { syncKonnect } from '../sync';
@@ -114,7 +114,7 @@ const trackAnalyticsEvent = vi.fn();
beforeEach(async () => {
// Re-init with fresh in-memory NeDB buckets — clean slate for every test.
await initDatabase(mainDatabase, { inMemoryOnly: true }, true);
await initDatabase(createNedbDatabase(), { inMemoryOnly: true }, true);
resetV4Counter();
vi.stubGlobal('window', { main: { trackAnalyticsEvent } });
});

View File

@@ -1,37 +0,0 @@
import electron from 'electron';
import type { DataStoreOptions, IDatabase } from 'insomnia-data';
import { createNedbDatabase, flushChangesImpl } from 'insomnia-data/node';
export const mainDatabase: IDatabase = createNedbDatabase(nedbDatabase => ({
...nedbDatabase,
init: async (config: DataStoreOptions = {}, forceReset = false) => {
const dbPath = process.env['INSOMNIA_DATA_PATH'] || electron.app.getPath('userData');
await nedbDatabase.init(
{
dbPath,
...config,
},
forceReset,
);
// Register IPC handler for renderer process bridge calls
electron.ipcMain.handle('database.invoke', async (_e, fnName: string, ...args: unknown[]) => {
const fn = mainDatabase[fnName as keyof IDatabase] as (...args: unknown[]) => unknown;
if (typeof fn !== 'function') {
throw new TypeError(`Unknown database method: ${fnName}`);
}
return fn(...args);
});
},
flushChanges: async function (id = 0, fake = false) {
const changes = await flushChangesImpl(id, fake);
if (changes) {
const windows = electron.BrowserWindow.getAllWindows();
for (const window of windows) {
window.webContents.send('db.changes', changes);
}
}
},
}));

View File

@@ -1,22 +0,0 @@
import { ipcRenderer } from 'electron';
import type { IDatabase } from 'insomnia-data';
// Routes all database calls to the main process via the 'database.invoke' IPC handler
// that mainDatabase registers on startup. The plugin window must not open a second
// NeDB connection to the same files, so this proxy is the correct approach.
export const pluginWindowDatabase: IDatabase = new Proxy({} as IDatabase, {
get(_target, fnName) {
if (typeof fnName === 'symbol') {
return;
}
if (fnName === 'init') {
// Main process already initialised the database.
return async () => {};
}
if (fnName === 'flushChanges') {
// The plugin window never needs to broadcast db.changes to other windows.
return async () => ({ changes: [], deletedIds: [] });
}
return (...args: unknown[]) => ipcRenderer.invoke('database.invoke', fnName, ...args);
},
});

View File

@@ -1,3 +0,0 @@
export interface DatabaseBridgeAPI {
invoke: <T = any>(fnName: string, ...args: any[]) => Promise<T>;
}

View File

@@ -42,7 +42,6 @@ export type HandleChannels =
| 'createPlugin'
| 'curlRequest'
| 'database.caCertificate.create'
| 'services.invoke'
| 'extractJsonFileFromPostmanDataDumpArchive'
| 'generateCommitsFromDiff'
| 'generateMockRouteDataFromSpec'
@@ -206,7 +205,8 @@ export type HandleChannels =
| 'cookies.toString'
| 'cookies.getCookiesForUrl'
| 'cookies.addSetCookies'
| 'cookies.getResponseCookiesFromHeaders';
| 'cookies.getResponseCookiesFromHeaders'
| 'data-process.request-port';
export const ipcMainHandle = (
channel: HandleChannels,
@@ -269,6 +269,7 @@ export type MainOnChannels =
export type RendererOnChannels =
| 'contextMenuCommand'
| 'data-process.restarting'
| 'db.changes'
| 'edit:undo'
| 'edit:redo'

View File

@@ -18,7 +18,7 @@ import {
import type { UtilityProcess } from 'electron/main';
import { availableTargets, HTTPSnippet } from 'httpsnippet';
import iconv from 'iconv-lite';
import type { AuthTypeOAuth2, OAuth2Token, RequestHeader, Services, TestResults } from 'insomnia-data';
import type { AuthTypeOAuth2, OAuth2Token, RequestHeader, TestResults } from 'insomnia-data';
import { services } from 'insomnia-data';
import { runTests } from 'insomnia-testing/src/run/run';
@@ -399,18 +399,6 @@ export function registerMainHandlers() {
ipcMainHandle('createPlugin', async (_, options: { pluginName: string; mainJs: string }) => {
return createPlugin(options.pluginName, options.mainJs);
});
ipcMainHandle('services.invoke', async (_, serviceName: string, methodName: string, ...args: unknown[]) => {
const service = services[serviceName as keyof Services];
if (!service) {
throw new TypeError(`Unknown service: ${serviceName}`);
}
const fn = service[methodName as keyof typeof service];
if (typeof fn !== 'function') {
throw new TypeError(`Unknown service method: ${serviceName}.${methodName}`);
}
const result = await (fn as (...args: unknown[]) => unknown).call(service, ...args);
return Buffer.isBuffer(result) ? new Uint8Array(result) : result;
});
ipcMainHandle('multipartBufferToArray', async (_, options) => {
return multipartBufferToArray(options);
});

View File

@@ -1,95 +0,0 @@
// Bridge Database implementation for renderer process
// Uses window.database.invoke API exposed by contextBridge from preload
import type { AllTypes, BaseModel, IDatabase, Operation, Query } from 'insomnia-data';
/**
* Bridge database implementation for renderer process.
* Uses window.database.invoke() API exposed by contextBridge in preload.
*/
export const database: IDatabase = {
batchModifyDocs: async function (operation: Operation) {
return window.database.invoke<void>('batchModifyDocs', operation);
},
bufferChanges: async function (millis = 1000) {
return window.database.invoke<number>('bufferChanges', millis);
},
bufferChangesIndefinitely: async function () {
return window.database.invoke<number>('bufferChangesIndefinitely');
},
count: async function <T extends BaseModel>(type: AllTypes, query: Query<T> = {}) {
return window.database.invoke<number>('count', type, query);
},
docCreate: async <T extends BaseModel>(type: AllTypes, ...patches: Partial<T>[]) => {
return window.database.invoke<T>('docCreate', type, ...patches);
},
docUpdate: async <T extends BaseModel>(originalDoc: T, ...patches: Partial<T>[]) => {
return window.database.invoke<T>('docUpdate', originalDoc, ...patches);
},
duplicate: async function <T extends BaseModel>(originalDoc: T, patch: Partial<T> = {}) {
return window.database.invoke<T>('duplicate', originalDoc, patch);
},
find: async function <T extends BaseModel>(
type: AllTypes,
query: Query<T> | string = {},
sort: Record<string, any> = { created: 1 },
limit = 0,
): Promise<T[]> {
return window.database.invoke<T[]>('find', type, query, sort, limit);
},
findOne: async function <T extends BaseModel>(
type: AllTypes,
query: Query<T> | string = {},
sort: Record<string, any> = { created: 1 },
): Promise<T | undefined> {
return window.database.invoke<T | undefined>('findOne', type, query, sort);
},
flushChanges: async function (id = 0, fake = false) {
return window.database.invoke<void>('flushChanges', id, fake);
},
init: async () => {
// No-op for renderer process - main process handles initialization
},
insert: async function <T extends BaseModel>(doc: T) {
return window.database.invoke<T>('insert', doc);
},
onChange: () => {
// No-op for renderer - change listeners are handled via IPC
},
remove: async function <T extends BaseModel>(doc: T) {
return window.database.invoke<void>('remove', doc);
},
removeWhere: async function <T extends BaseModel>(type: AllTypes, query: Query<T>) {
return window.database.invoke<void>('removeWhere', type, query);
},
unsafeRemove: async function <T extends BaseModel>(doc: T) {
return window.database.invoke<void>('unsafeRemove', doc);
},
update: async function <T extends BaseModel>(doc: T, patches: Partial<T>[] = []) {
return window.database.invoke<T>('update', doc, patches);
},
withAncestors: async function <T extends BaseModel>(doc: T | undefined, types: AllTypes[] = []) {
return window.database.invoke<T[]>('withAncestors', doc, types);
},
getWithDescendants: async function <T extends BaseModel>(doc: T, types: AllTypes[] = []) {
return window.database.invoke<BaseModel[]>('getWithDescendants', doc, types);
},
};

View File

@@ -1,6 +0,0 @@
import { invokeWithNormalizedError } from '~/main/ipc/invoke';
import { createServicesProxy } from '~/ui/services-proxy';
export const servicesProxy = createServicesProxy((serviceName, methodName, ...args) =>
invokeWithNormalizedError<unknown>('services.invoke', serviceName, methodName, ...args),
);

View File

@@ -1,20 +0,0 @@
import type { Services } from 'insomnia-data';
export type ServicesInvoke = (serviceName: string, methodName: string, ...args: unknown[]) => Promise<unknown>;
// Build the services proxy from a generic invoke function. Kept free of any
// `electron` import so it can run in the isolated renderer world, where the
// bridged invoke is the only available transport (see entry.client.tsx).
export const createServicesProxy = (invoke: ServicesInvoke): Services =>
new Proxy({} as Services, {
get(_target, serviceName: string) {
return new Proxy(
{},
{
get(_target, methodName: string) {
return (...args: unknown[]) => invoke(serviceName, methodName, ...args);
},
},
);
},
});

View File

@@ -1,9 +1,8 @@
/// <reference types="vite/client" />
import type { HiddenBrowserWindowToMainBridgeAPI } from '../src/hidden-window-preload';
import type { RendererToMainBridgeAPI } from '../src/main/ipc/main';
import type { DatabaseBridgeAPI } from '../src/main/ipc/database';
import type { InvokeFn } from '../src/data-process/port-rpc';
import type { DiffMatchPatch, DiffOp } from 'diff-match-patch-ts';
import type { Services } from 'insomnia-data';
type RendererEnv = {
INSOMNIA_GITLAB_REDIRECT_URI: string | undefined;
@@ -38,11 +37,7 @@ declare global {
env: RendererEnv;
main: RendererToMainBridgeAPI;
bridge: HiddenBrowserWindowToMainBridgeAPI;
database: DatabaseBridgeAPI;
// This is a temporary measure to provide access to services on the global window object. It will be removed in the future once all usages are updated to import services directly from the insomnia-data package.
_dataServices?: Services;
// Under contextIsolation the services Proxy can't cross the bridge; the preload exposes this flat invoke instead and the renderer rebuilds the Proxy.
_dataServicesInvoke?: (serviceName: string, methodName: string, ...args: unknown[]) => Promise<unknown>;
invokeDataPort: InvokeFn;
dialog: Pick<Electron.Dialog, 'showOpenDialog' | 'showSaveDialog'>;
app: Pick<Electron.App, 'getPath' | 'getAppPath'> & { process: { platform: NodeJS.Platform } };
shell: Pick<Electron.Shell, 'showItemInFolder' | 'openPath'>;