Own live provider host lifecycle

This commit is contained in:
Meta Vens 2026-07-10 13:47:42 +01:00
commit 3bce6d8911
8 changed files with 151 additions and 2 deletions

View file

@ -19,6 +19,12 @@ Electron hosts that use the standard Eve IPC channels can point their
entry exposes only `window.eveProvider`; provider-specific APIs do not share the
renderer boundary.
`startEveElectronProviderHost` owns the main-process runtime lifecycle: secure
window construction, generic provider client creation, Eve IPC registration,
receipt reconciliation, provider readiness, renderer loading, and cleanup.
Provider repos supply a resolved target, dependencies, renderer path, and any
provider-owned daemon development or asset hooks.
```powershell
npm install
npm test

View file

@ -16,7 +16,8 @@
"embeddedDocuments",
"pluginProjection",
"securePreloadBridge",
"windowLifecycle"
"windowLifecycle",
"liveProviderHost"
],
"supportedPlugins": [
{
@ -47,9 +48,14 @@
"src/eve-electron-shell.mjs",
"src/window-host.mjs",
"src/eve-provider-preload.cjs",
"src/eve-provider-preload-entry.cjs",
"src/eve-electron-renderer.mjs",
"src/live-provider-host.mjs",
"test/shell.test.mjs",
"test/window-host.test.mjs",
"test/preload.test.cjs"
"test/preload.test.cjs",
"test/renderer.test.mjs",
"test/live-provider-host.test.mjs"
]
}
],

View file

@ -19,6 +19,10 @@
"types": "./src/eve-electron-renderer.d.mts",
"import": "./src/eve-electron-renderer.mjs"
},
"./live-provider-host": {
"types": "./src/live-provider-host.d.mts",
"import": "./src/live-provider-host.mjs"
},
"./preload": "./src/eve-provider-preload.cjs",
"./preload-entry": "./src/eve-provider-preload-entry.cjs"
},

1
src/index.d.ts vendored
View file

@ -2,6 +2,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, IpcMain, Shell } f
export * from "./cultmesh-provider-client.js";
export * from "./eve-electron-renderer.mjs";
export * from "./live-provider-host.mjs";
export interface EveElectronWindowOptions extends BrowserWindowConstructorOptions {
browserWindow?: BrowserWindowConstructorOptions;

View file

@ -2,3 +2,4 @@ export * from "./eve-electron-shell.mjs";
export * from "./eve-electron-renderer.mjs";
export * from "./cultmesh-provider-client.mjs";
export * from "./window-host.mjs";
export * from "./live-provider-host.mjs";

View file

@ -0,0 +1,3 @@
export declare const eveProviderIpcChannels: Readonly<Record<string, string>>;
export declare function registerEveProviderIpc(ipcMain: any, client: any, options?: { receiptTimeoutMs?: number }): () => void;
export declare function startEveElectronProviderHost(options: any): Promise<{ client: any; close(): Promise<void>; window: any }>;

101
src/live-provider-host.mjs Normal file
View file

@ -0,0 +1,101 @@
import { fileURLToPath } from "node:url";
import { EveCultMeshProviderClient } from "./cultmesh-provider-client.mjs";
import { createEveElectronWindow, registerEveWindowControls } from "./window-host.mjs";
export const eveProviderIpcChannels = Object.freeze({
providerAdvertisement: "eve:provider-advertisement",
surface: "eve:surface",
document: "eve:embedded-document",
submitCommand: "eve:submit-command",
windowControl: "eve-electron:window-control",
});
export function registerEveProviderIpc(ipcMain, client, { receiptTimeoutMs = 5000 } = {}) {
const handlers = [
[eveProviderIpcChannels.providerAdvertisement, () => client.providerAdvertisement()],
[eveProviderIpcChannels.surface, (_event, request) => client.surface(request?.surfaceId)],
[eveProviderIpcChannels.document, (_event, request) => client.resolveDocument(request)],
[eveProviderIpcChannels.submitCommand, async (_event, request) => {
const submission = await client.submitCommand(request);
const receipt = await waitForReceipt(client, submission, receiptTimeoutMs);
return { ...submission, ...receipt, accepted: receiptAccepted(receipt) };
}],
];
for (const [channel, handler] of handlers) ipcMain.handle(channel, handler);
return () => {
for (const [channel] of handlers) ipcMain.removeHandler(channel);
};
}
export async function startEveElectronProviderHost(options) {
const { app, BrowserWindow, ipcMain, shell } = options.electron;
await app.whenReady();
const client = (options.createProviderClient || ((target, dependencies, clientOptions) =>
new EveCultMeshProviderClient(target, dependencies, clientOptions)))(
options.providerTarget,
options.dependencies,
{ runtimeId: options.runtimeId || "eve-electron" },
);
const window = createEveElectronWindow({
...(options.window || {}),
preload: options.preload || fileURLToPath(new URL("./eve-provider-preload-entry.cjs", import.meta.url)),
}, { BrowserWindow, shell });
const removeProviderIpc = registerEveProviderIpc(ipcMain, client, options.receipts);
const removeWindowIpc = registerEveWindowControls(ipcMain, BrowserWindow, eveProviderIpcChannels.windowControl);
let closed = false;
const close = async () => {
if (closed) return;
closed = true;
removeProviderIpc();
removeWindowIpc();
await client.close();
if (!window.isDestroyed?.()) window.destroy();
};
app.on("before-quit", () => void close());
app.on("window-all-closed", () => app.quit());
try {
await waitForProvider(client, options.providerTimeoutMs ?? 30000);
await window.loadFile(options.renderer, { query: { surface: options.surfaceId || "" } });
return { client, close, window };
} catch (error) {
await close();
throw error;
}
}
async function waitForProvider(client, timeoutMs) {
const started = Date.now();
let lastError;
while (Date.now() - started < timeoutMs) {
try {
return await client.providerAdvertisement();
} catch (error) {
lastError = error;
await delay(100);
}
}
throw lastError || new Error("Timed out waiting for Eve provider advertisement.");
}
async function waitForReceipt(client, submission, timeoutMs) {
const started = Date.now();
let lastError;
while (Date.now() - started < timeoutMs) {
try {
return await client.receipt(submission);
} catch (error) {
lastError = error;
await delay(50);
}
}
throw lastError || new Error(`Timed out waiting for Eve receipt ${submission.commandId || ""}.`);
}
function receiptAccepted(receipt) {
const state = String(receipt?.state || "").toLowerCase();
return state === "accepted" || state === "reconciled";
}
function delay(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}

View file

@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { eveProviderIpcChannels, registerEveProviderIpc } from "../src/live-provider-host.mjs";
test("registers one generic Eve IPC authority and removes it cleanly", async () => {
const handlers = new Map();
const ipcMain = { handle: (channel, handler) => handlers.set(channel, handler), removeHandler: channel => handlers.delete(channel) };
const client = {
providerAdvertisement: async () => ({ providerId: "fixture" }),
surface: async surfaceId => ({ surfaceId }),
resolveDocument: async request => request,
submitCommand: async () => ({ commandId: "command-1", receiptSchema: "receipt.v1", receiptRecordRef: "receipts:command-1" }),
receipt: async () => ({ commandId: "command-1", state: "reconciled" }),
};
const remove = registerEveProviderIpc(ipcMain, client);
assert.deepEqual([...handlers.keys()], [
eveProviderIpcChannels.providerAdvertisement,
eveProviderIpcChannels.surface,
eveProviderIpcChannels.document,
eveProviderIpcChannels.submitCommand,
]);
assert.deepEqual(await handlers.get(eveProviderIpcChannels.submitCommand)(null, { command: "move" }), {
commandId: "command-1", receiptSchema: "receipt.v1", receiptRecordRef: "receipts:command-1", state: "reconciled", accepted: true,
});
remove();
assert.equal(handlers.size, 0);
});