libretto 0.6.11 → 0.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -8
- package/README.template.md +7 -8
- package/dist/cli/cli.js +0 -22
- package/dist/cli/commands/browser.js +18 -24
- package/dist/cli/commands/execution.js +254 -234
- package/dist/cli/commands/experiments.js +100 -0
- package/dist/cli/commands/setup.js +3 -310
- package/dist/cli/commands/shared.js +10 -0
- package/dist/cli/commands/snapshot.js +46 -64
- package/dist/cli/commands/status.js +1 -40
- package/dist/cli/core/browser.js +303 -124
- package/dist/cli/core/config.js +5 -6
- package/dist/cli/core/context.js +4 -0
- package/dist/cli/core/daemon/config.js +0 -6
- package/dist/cli/core/daemon/daemon.js +497 -90
- package/dist/cli/core/daemon/ipc.js +170 -129
- package/dist/cli/core/daemon/snapshot.js +48 -9
- package/dist/cli/core/experiments.js +39 -0
- package/dist/cli/core/session.js +5 -4
- package/dist/cli/core/skill-version.js +2 -1
- package/dist/cli/core/workflow-runner/runner.js +147 -0
- package/dist/cli/core/workflow-runtime.js +60 -0
- package/dist/cli/index.js +0 -2
- package/dist/cli/router.js +4 -3
- package/dist/shared/debug/pause-handler.d.ts +9 -0
- package/dist/shared/debug/pause-handler.js +15 -0
- package/dist/shared/debug/pause.d.ts +1 -2
- package/dist/shared/debug/pause.js +13 -36
- package/dist/shared/instrumentation/instrument.js +4 -4
- package/dist/shared/ipc/child-process-transport.d.ts +7 -0
- package/dist/shared/ipc/child-process-transport.js +60 -0
- package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
- package/dist/shared/ipc/child-process-transport.spec.js +68 -0
- package/dist/shared/ipc/ipc.d.ts +46 -0
- package/dist/shared/ipc/ipc.js +165 -0
- package/dist/shared/ipc/ipc.spec.d.ts +2 -0
- package/dist/shared/ipc/ipc.spec.js +114 -0
- package/dist/shared/ipc/socket-transport.d.ts +9 -0
- package/dist/shared/ipc/socket-transport.js +143 -0
- package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
- package/dist/shared/ipc/socket-transport.spec.js +117 -0
- package/dist/shared/package-manager.d.ts +7 -0
- package/dist/shared/package-manager.js +60 -0
- package/dist/shared/paths/paths.d.ts +1 -8
- package/dist/shared/paths/paths.js +1 -49
- package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
- package/dist/shared/snapshot/capture-snapshot.js +463 -0
- package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
- package/dist/shared/snapshot/diff-snapshots.js +358 -0
- package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
- package/dist/shared/snapshot/render-snapshot.js +651 -0
- package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
- package/dist/shared/snapshot/snapshot.spec.js +333 -0
- package/dist/shared/snapshot/types.d.ts +40 -0
- package/dist/shared/snapshot/types.js +0 -0
- package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
- package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
- package/dist/shared/state/session-state.d.ts +1 -0
- package/dist/shared/state/session-state.js +1 -0
- package/docs/experiments.md +67 -0
- package/docs/releasing.md +8 -6
- package/package.json +5 -2
- package/skills/libretto/SKILL.md +19 -19
- package/skills/libretto/references/configuration-file-reference.md +6 -12
- package/skills/libretto/references/pages-and-page-targeting.md +1 -1
- package/skills/libretto-readonly/SKILL.md +2 -9
- package/src/cli/AGENTS.md +7 -0
- package/src/cli/cli.ts +0 -23
- package/src/cli/commands/browser.ts +14 -18
- package/src/cli/commands/execution.ts +303 -271
- package/src/cli/commands/experiments.ts +120 -0
- package/src/cli/commands/setup.ts +3 -400
- package/src/cli/commands/shared.ts +20 -0
- package/src/cli/commands/snapshot.ts +54 -94
- package/src/cli/commands/status.ts +1 -48
- package/src/cli/core/browser.ts +372 -150
- package/src/cli/core/config.ts +4 -5
- package/src/cli/core/context.ts +4 -0
- package/src/cli/core/daemon/config.ts +35 -19
- package/src/cli/core/daemon/daemon.ts +645 -107
- package/src/cli/core/daemon/ipc.ts +319 -214
- package/src/cli/core/daemon/snapshot.ts +71 -15
- package/src/cli/core/experiments.ts +56 -0
- package/src/cli/core/resolve-model.ts +5 -0
- package/src/cli/core/session.ts +5 -4
- package/src/cli/core/skill-version.ts +2 -1
- package/src/cli/core/workflow-runner/runner.ts +237 -0
- package/src/cli/core/workflow-runtime.ts +86 -0
- package/src/cli/index.ts +0 -1
- package/src/cli/router.ts +4 -3
- package/src/shared/debug/pause-handler.ts +20 -0
- package/src/shared/debug/pause.ts +14 -48
- package/src/shared/instrumentation/instrument.ts +4 -4
- package/src/shared/ipc/AGENTS.md +24 -0
- package/src/shared/ipc/child-process-transport.spec.ts +86 -0
- package/src/shared/ipc/child-process-transport.ts +96 -0
- package/src/shared/ipc/ipc.spec.ts +161 -0
- package/src/shared/ipc/ipc.ts +288 -0
- package/src/shared/ipc/socket-transport.spec.ts +141 -0
- package/src/shared/ipc/socket-transport.ts +189 -0
- package/src/shared/package-manager.ts +76 -0
- package/src/shared/paths/paths.ts +0 -72
- package/src/shared/snapshot/capture-snapshot.ts +615 -0
- package/src/shared/snapshot/diff-snapshots.ts +579 -0
- package/src/shared/snapshot/render-snapshot.ts +962 -0
- package/src/shared/snapshot/snapshot.spec.ts +388 -0
- package/src/shared/snapshot/types.ts +43 -0
- package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
- package/src/shared/state/session-state.ts +1 -0
- package/dist/cli/commands/ai.js +0 -109
- package/dist/cli/core/ai-model.js +0 -192
- package/dist/cli/core/api-snapshot-analyzer.js +0 -86
- package/dist/cli/core/daemon/index.js +0 -16
- package/dist/cli/core/daemon/spawn.js +0 -90
- package/dist/cli/core/pause-signals.js +0 -29
- package/dist/cli/core/snapshot-analyzer.js +0 -666
- package/dist/cli/workers/run-integration-runtime.js +0 -235
- package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
- package/dist/cli/workers/run-integration-worker.js +0 -64
- package/scripts/summarize-evals.mjs +0 -135
- package/src/cli/commands/ai.ts +0 -143
- package/src/cli/core/ai-model.ts +0 -298
- package/src/cli/core/api-snapshot-analyzer.ts +0 -110
- package/src/cli/core/daemon/index.ts +0 -24
- package/src/cli/core/daemon/spawn.ts +0 -171
- package/src/cli/core/pause-signals.ts +0 -35
- package/src/cli/core/snapshot-analyzer.ts +0 -855
- package/src/cli/workers/run-integration-runtime.ts +0 -326
- package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
- package/src/cli/workers/run-integration-worker.ts +0 -72
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { cwd } from "node:process";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { instrumentContext } from "../../index.js";
|
|
6
|
+
import {
|
|
7
|
+
getDefaultWorkflowFromModuleExports,
|
|
8
|
+
getWorkflowsFromModuleExports
|
|
9
|
+
} from "../../shared/workflow/workflow.js";
|
|
10
|
+
const TSCONFIG_HINT = "TypeScript compilation failed. Pass --tsconfig <path> to run against a specific tsconfig.";
|
|
11
|
+
function isTsxCompileError(error) {
|
|
12
|
+
return error instanceof Error && (error.name === "TransformError" || error.message.startsWith("Cannot resolve tsconfig at path:"));
|
|
13
|
+
}
|
|
14
|
+
function getAbsoluteIntegrationPath(integrationPath) {
|
|
15
|
+
const absolutePath = isAbsolute(integrationPath) ? integrationPath : resolve(cwd(), integrationPath);
|
|
16
|
+
if (!existsSync(absolutePath)) {
|
|
17
|
+
throw new Error(`Integration file does not exist: ${absolutePath}`);
|
|
18
|
+
}
|
|
19
|
+
return absolutePath;
|
|
20
|
+
}
|
|
21
|
+
async function loadDefaultWorkflow(absolutePath) {
|
|
22
|
+
let loadedModule;
|
|
23
|
+
try {
|
|
24
|
+
loadedModule = await import(pathToFileURL(absolutePath).href);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
const compileHint = isTsxCompileError(error) ? `
|
|
28
|
+
${TSCONFIG_HINT}` : absolutePath.endsWith(".ts") || absolutePath.endsWith(".tsx") ? `
|
|
29
|
+
${TSCONFIG_HINT}` : "";
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Failed to import integration module at ${absolutePath}: ${message}${compileHint}`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const defaultWorkflow = getDefaultWorkflowFromModuleExports(loadedModule);
|
|
35
|
+
if (defaultWorkflow) {
|
|
36
|
+
return defaultWorkflow;
|
|
37
|
+
}
|
|
38
|
+
const availableWorkflowNames = getWorkflowsFromModuleExports(loadedModule).map(
|
|
39
|
+
(candidate) => candidate.name
|
|
40
|
+
);
|
|
41
|
+
if (availableWorkflowNames.length === 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`No default-exported workflow found in ${absolutePath}. Export the workflow with \`export default workflow("name", handler)\`.`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(
|
|
47
|
+
`No default-exported workflow found in ${absolutePath}. libretto run only uses the file's default export. Available named workflows: ${availableWorkflowNames.join(", ")}`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
async function installHeadedWorkflowVisualization(args) {
|
|
51
|
+
await (args.instrument ?? instrumentContext)(args.context, {
|
|
52
|
+
visualize: true,
|
|
53
|
+
logger: args.logger
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
getAbsoluteIntegrationPath,
|
|
58
|
+
installHeadedWorkflowVisualization,
|
|
59
|
+
loadDefaultWorkflow
|
|
60
|
+
};
|
package/dist/cli/index.js
CHANGED
package/dist/cli/router.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import { aiCommands } from "./commands/ai.js";
|
|
2
1
|
import { authCommands } from "./commands/auth.js";
|
|
3
2
|
import { billingCommands } from "./commands/billing.js";
|
|
4
3
|
import { browserCommands } from "./commands/browser.js";
|
|
5
4
|
import { deployCommand } from "./commands/deploy.js";
|
|
6
5
|
import { executionCommands } from "./commands/execution.js";
|
|
6
|
+
import { experimentsCommand } from "./commands/experiments.js";
|
|
7
7
|
import { setupCommand } from "./commands/setup.js";
|
|
8
8
|
import { statusCommand } from "./commands/status.js";
|
|
9
9
|
import { snapshotCommand } from "./commands/snapshot.js";
|
|
10
|
+
import { librettoCommand } from "../shared/package-manager.js";
|
|
10
11
|
import { SimpleCLI } from "./framework/simple-cli.js";
|
|
11
12
|
const cliRoutes = {
|
|
12
13
|
...browserCommands,
|
|
13
14
|
deploy: deployCommand,
|
|
15
|
+
experiments: experimentsCommand,
|
|
14
16
|
...executionCommands,
|
|
15
|
-
ai: aiCommands,
|
|
16
17
|
auth: authCommands,
|
|
17
18
|
billing: billingCommands,
|
|
18
19
|
setup: setupCommand,
|
|
@@ -20,7 +21,7 @@ const cliRoutes = {
|
|
|
20
21
|
snapshot: snapshotCommand
|
|
21
22
|
};
|
|
22
23
|
function createCLIApp() {
|
|
23
|
-
return SimpleCLI.define(
|
|
24
|
+
return SimpleCLI.define(librettoCommand(), cliRoutes);
|
|
24
25
|
}
|
|
25
26
|
export {
|
|
26
27
|
cliRoutes,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
type ActivePauseHandler = (args: {
|
|
2
|
+
session: string;
|
|
3
|
+
pausedAt: string;
|
|
4
|
+
url?: string;
|
|
5
|
+
}) => Promise<void>;
|
|
6
|
+
declare function installPauseHandler(handler: ActivePauseHandler): () => void;
|
|
7
|
+
declare function getActivePauseHandler(): ActivePauseHandler | undefined;
|
|
8
|
+
|
|
9
|
+
export { type ActivePauseHandler, getActivePauseHandler, installPauseHandler };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
let activePauseHandler;
|
|
2
|
+
function installPauseHandler(handler) {
|
|
3
|
+
const previousHandler = activePauseHandler;
|
|
4
|
+
activePauseHandler = handler;
|
|
5
|
+
return () => {
|
|
6
|
+
activePauseHandler = previousHandler;
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function getActivePauseHandler() {
|
|
10
|
+
return activePauseHandler;
|
|
11
|
+
}
|
|
12
|
+
export {
|
|
13
|
+
getActivePauseHandler,
|
|
14
|
+
installPauseHandler
|
|
15
|
+
};
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Standalone pause function.
|
|
3
3
|
*
|
|
4
4
|
* - In production (`NODE_ENV === "production"`), returns immediately (no-op).
|
|
5
|
-
* - Otherwise,
|
|
6
|
-
* using the same file-based mechanism as the CLI runner.
|
|
5
|
+
* - Otherwise, delegates to the active Libretto workflow runtime pause handler.
|
|
7
6
|
*
|
|
8
7
|
* Import directly: `import { pause } from "libretto";`
|
|
9
8
|
*/
|
|
@@ -1,21 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { getSessionDir } from "../../cli/core/context.js";
|
|
4
|
-
import {
|
|
5
|
-
getPauseSignalPaths,
|
|
6
|
-
removeSignalIfExists
|
|
7
|
-
} from "../../cli/core/pause-signals.js";
|
|
8
|
-
import { listRunningSessions } from "../../cli/core/session.js";
|
|
1
|
+
import { getActivePauseHandler } from "./pause-handler.js";
|
|
2
|
+
import { librettoCommand } from "../package-manager.js";
|
|
9
3
|
function throwMissingSessionError() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
lines.push("", "Running sessions:");
|
|
14
|
-
for (const s of runningSessions) {
|
|
15
|
-
lines.push(` ${s.session}`);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
throw new Error(lines.join("\n"));
|
|
4
|
+
throw new Error(
|
|
5
|
+
`pause(session) requires a non-empty session ID. Pass ctx.session from inside your workflow: await pause(ctx.session). To list running sessions, run: ${librettoCommand("status")}.`
|
|
6
|
+
);
|
|
19
7
|
}
|
|
20
8
|
async function pause(session) {
|
|
21
9
|
if (process.env.NODE_ENV === "production") {
|
|
@@ -24,27 +12,16 @@ async function pause(session) {
|
|
|
24
12
|
if (typeof session !== "string" || session.trim().length === 0) {
|
|
25
13
|
throwMissingSessionError();
|
|
26
14
|
}
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const details = {
|
|
32
|
-
sessionName: session,
|
|
33
|
-
pausedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
34
|
-
url: "unknown"
|
|
35
|
-
};
|
|
36
|
-
await writeFile(pausedSignalPath, JSON.stringify(details, null, 2), "utf8");
|
|
37
|
-
console.log(`[pause] Paused (session: ${session})`);
|
|
38
|
-
console.log("[pause] Waiting for resume signal...");
|
|
39
|
-
const RESUME_POLL_INTERVAL_MS = 250;
|
|
40
|
-
while (!existsSync(resumeSignalPath)) {
|
|
41
|
-
await new Promise(
|
|
42
|
-
(resolve) => setTimeout(resolve, RESUME_POLL_INTERVAL_MS)
|
|
15
|
+
const handler = getActivePauseHandler();
|
|
16
|
+
if (!handler) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`pause(session) can only suspend an active Libretto workflow. Run the workflow with ${librettoCommand("run <integrationFile>")} and call pause(ctx.session) from inside the workflow.`
|
|
43
19
|
);
|
|
44
20
|
}
|
|
45
|
-
await
|
|
46
|
-
|
|
47
|
-
|
|
21
|
+
await handler({
|
|
22
|
+
session,
|
|
23
|
+
pausedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
24
|
+
});
|
|
48
25
|
}
|
|
49
26
|
export {
|
|
50
27
|
pause
|
|
@@ -82,12 +82,12 @@ function wrapLocatorActions(locator, page, opts) {
|
|
|
82
82
|
try {
|
|
83
83
|
const result = await orig(...args);
|
|
84
84
|
if (opts.visualize) {
|
|
85
|
-
enqueue(page, () => visualizeAfterAction(page));
|
|
85
|
+
void enqueue(page, () => visualizeAfterAction(page));
|
|
86
86
|
}
|
|
87
87
|
return result;
|
|
88
88
|
} catch (err) {
|
|
89
89
|
if (opts.visualize) {
|
|
90
|
-
enqueue(page, () => visualizeAfterAction(page));
|
|
90
|
+
void enqueue(page, () => visualizeAfterAction(page));
|
|
91
91
|
}
|
|
92
92
|
if (POINTER_ACTIONS.has(method) && isTimeoutError(err)) {
|
|
93
93
|
await enrichTimeoutError(err, locator, page);
|
|
@@ -226,12 +226,12 @@ async function installInstrumentation(page, options) {
|
|
|
226
226
|
try {
|
|
227
227
|
const result = await orig(...args);
|
|
228
228
|
if (visualize) {
|
|
229
|
-
enqueue(page, () => visualizeAfterAction(page));
|
|
229
|
+
void enqueue(page, () => visualizeAfterAction(page));
|
|
230
230
|
}
|
|
231
231
|
return result;
|
|
232
232
|
} catch (err) {
|
|
233
233
|
if (visualize) {
|
|
234
|
-
enqueue(page, () => visualizeAfterAction(page));
|
|
234
|
+
void enqueue(page, () => visualizeAfterAction(page));
|
|
235
235
|
}
|
|
236
236
|
if (POINTER_ACTIONS.has(method) && isTimeoutError(err) && typeof args[0] === "string") {
|
|
237
237
|
await enrichTimeoutError(err, page.locator(args[0]), page);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ChildProcess } from 'node:child_process';
|
|
2
|
+
import { IpcTransport, IpcProtocolMessage } from './ipc.js';
|
|
3
|
+
|
|
4
|
+
declare function createChildProcessIpcTransport(child: ChildProcess): IpcTransport<IpcProtocolMessage>;
|
|
5
|
+
declare function createParentProcessIpcTransport(): IpcTransport<IpcProtocolMessage>;
|
|
6
|
+
|
|
7
|
+
export { createChildProcessIpcTransport, createParentProcessIpcTransport };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
function createChildProcessIpcTransport(child) {
|
|
2
|
+
return createProcessIpcTransport(child, () => {
|
|
3
|
+
if (child.connected) child.disconnect();
|
|
4
|
+
});
|
|
5
|
+
}
|
|
6
|
+
function createParentProcessIpcTransport() {
|
|
7
|
+
if (!process.send) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
"Cannot create child-process IPC transport: process.send is not available. Start the process with an IPC channel."
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return createProcessIpcTransport(process, () => {
|
|
13
|
+
if (process.connected) process.disconnect?.();
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
function createProcessIpcTransport(target, close) {
|
|
17
|
+
return {
|
|
18
|
+
send(message) {
|
|
19
|
+
if (!target.send) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
"Cannot send IPC message: process IPC channel is closed."
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
target.send(message);
|
|
25
|
+
},
|
|
26
|
+
listen(callback) {
|
|
27
|
+
const onMessage = (message) => {
|
|
28
|
+
if (isIpcProtocolMessage(message)) callback(message);
|
|
29
|
+
};
|
|
30
|
+
target.on("message", onMessage);
|
|
31
|
+
return () => target.off("message", onMessage);
|
|
32
|
+
},
|
|
33
|
+
onClose(callback) {
|
|
34
|
+
const onDisconnect = () => callback();
|
|
35
|
+
target.on("disconnect", onDisconnect);
|
|
36
|
+
return () => target.off("disconnect", onDisconnect);
|
|
37
|
+
},
|
|
38
|
+
close
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function isIpcProtocolMessage(message) {
|
|
42
|
+
if (!isRecord(message)) return false;
|
|
43
|
+
if (message.type === "ipc-request") {
|
|
44
|
+
return typeof message.id === "string" && typeof message.method === "string" && Array.isArray(message.args);
|
|
45
|
+
}
|
|
46
|
+
if (message.type === "ipc-response") {
|
|
47
|
+
return typeof message.id === "string" && typeof message.method === "string" && (message.error === void 0 || isSerializedError(message.error));
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
function isSerializedError(value) {
|
|
52
|
+
return isRecord(value) && typeof value.name === "string" && typeof value.message === "string" && (value.stack === void 0 || typeof value.stack === "string");
|
|
53
|
+
}
|
|
54
|
+
function isRecord(value) {
|
|
55
|
+
return typeof value === "object" && value !== null;
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
createChildProcessIpcTransport,
|
|
59
|
+
createParentProcessIpcTransport
|
|
60
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { expect, test } from "vitest";
|
|
5
|
+
import { createIpcPeer } from "./ipc.js";
|
|
6
|
+
import { createChildProcessIpcTransport } from "./child-process-transport.js";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
test("throws a clear error without a child-process IPC channel", async () => {
|
|
9
|
+
const modulePath = fileURLToPath(
|
|
10
|
+
new URL("./child-process-transport.ts", import.meta.url)
|
|
11
|
+
);
|
|
12
|
+
await expect(
|
|
13
|
+
execFileAsync(
|
|
14
|
+
process.execPath,
|
|
15
|
+
[
|
|
16
|
+
"--import",
|
|
17
|
+
"tsx",
|
|
18
|
+
"-e",
|
|
19
|
+
`import { createParentProcessIpcTransport } from ${JSON.stringify(modulePath)}; createParentProcessIpcTransport();`
|
|
20
|
+
],
|
|
21
|
+
{ cwd: process.cwd() }
|
|
22
|
+
)
|
|
23
|
+
).rejects.toMatchObject({
|
|
24
|
+
stderr: expect.stringContaining("process.send is not available")
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
test("lets parent and child process call each other", async () => {
|
|
28
|
+
const child = spawn(
|
|
29
|
+
process.execPath,
|
|
30
|
+
["--import", "tsx", "--input-type=module", "-e", getChildFixtureSource()],
|
|
31
|
+
{ stdio: ["ignore", "ignore", "ignore", "ipc"] }
|
|
32
|
+
);
|
|
33
|
+
child.send({ type: "not-an-ipc-protocol-message" });
|
|
34
|
+
const peer = createIpcPeer(
|
|
35
|
+
createChildProcessIpcTransport(child),
|
|
36
|
+
{
|
|
37
|
+
greet(name) {
|
|
38
|
+
return `hello ${name}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
);
|
|
42
|
+
await expect(peer.call.double(21)).resolves.toBe(42);
|
|
43
|
+
await expect(peer.call.askParent("Ada")).resolves.toBe("hello Ada");
|
|
44
|
+
peer.destroy();
|
|
45
|
+
await new Promise((resolve) => child.once("exit", () => resolve()));
|
|
46
|
+
});
|
|
47
|
+
function getChildFixtureSource() {
|
|
48
|
+
const ipcModule = new URL("./ipc.ts", import.meta.url).href;
|
|
49
|
+
const transportModule = new URL(
|
|
50
|
+
"./child-process-transport.ts",
|
|
51
|
+
import.meta.url
|
|
52
|
+
).href;
|
|
53
|
+
return `
|
|
54
|
+
import { createIpcPeer } from ${JSON.stringify(ipcModule)};
|
|
55
|
+
import { createParentProcessIpcTransport } from ${JSON.stringify(transportModule)};
|
|
56
|
+
|
|
57
|
+
const peer = createIpcPeer(createParentProcessIpcTransport(), {
|
|
58
|
+
double(value) {
|
|
59
|
+
return value * 2;
|
|
60
|
+
},
|
|
61
|
+
async askParent(name) {
|
|
62
|
+
return peer.call.greet(name);
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
process.on("disconnect", () => peer.destroy());
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
type IpcTransport<T = unknown> = {
|
|
2
|
+
send(message: T): void | Promise<void>;
|
|
3
|
+
listen(callback: (message: T) => void): () => void;
|
|
4
|
+
onClose?(callback: (error?: Error) => void): () => void;
|
|
5
|
+
close?(): void;
|
|
6
|
+
};
|
|
7
|
+
type FunctionMap<T> = {
|
|
8
|
+
[K in keyof T]: (...args: never[]) => unknown;
|
|
9
|
+
};
|
|
10
|
+
type UnwrapPromise<T> = T extends Promise<infer Result> ? Result : T;
|
|
11
|
+
type MaybeAsync<T extends (...args: never[]) => unknown> = T extends (...args: infer Args) => infer Result ? (...args: Args) => UnwrapPromise<Result> | Promise<UnwrapPromise<Result>> : never;
|
|
12
|
+
type IpcPeerHandlers<Local extends FunctionMap<Local>> = {
|
|
13
|
+
[K in keyof Local]: MaybeAsync<Local[K]>;
|
|
14
|
+
};
|
|
15
|
+
type IpcPeerCalls<Remote extends FunctionMap<Remote>> = {
|
|
16
|
+
[K in keyof Remote]: Remote[K] extends (...args: infer Args) => infer Result ? (...args: Args) => Promise<UnwrapPromise<Result>> : never;
|
|
17
|
+
};
|
|
18
|
+
type IpcPeer<Remote extends FunctionMap<Remote>> = {
|
|
19
|
+
call: IpcPeerCalls<Remote>;
|
|
20
|
+
destroy(): void;
|
|
21
|
+
};
|
|
22
|
+
type IpcRequestMessage = {
|
|
23
|
+
type: "ipc-request";
|
|
24
|
+
id: string;
|
|
25
|
+
method: string;
|
|
26
|
+
args: unknown[];
|
|
27
|
+
};
|
|
28
|
+
type SerializedError = {
|
|
29
|
+
name: string;
|
|
30
|
+
message: string;
|
|
31
|
+
stack?: string;
|
|
32
|
+
code?: string | number;
|
|
33
|
+
cause?: SerializedError;
|
|
34
|
+
errors?: SerializedError[];
|
|
35
|
+
};
|
|
36
|
+
type IpcResponseMessage = {
|
|
37
|
+
type: "ipc-response";
|
|
38
|
+
id: string;
|
|
39
|
+
method: string;
|
|
40
|
+
data?: unknown;
|
|
41
|
+
error?: SerializedError;
|
|
42
|
+
};
|
|
43
|
+
type IpcProtocolMessage = IpcRequestMessage | IpcResponseMessage;
|
|
44
|
+
declare function createIpcPeer<Remote extends FunctionMap<Remote>, Local extends FunctionMap<Local>>(transport: IpcTransport<IpcProtocolMessage>, handlers: IpcPeerHandlers<Local>): IpcPeer<Remote>;
|
|
45
|
+
|
|
46
|
+
export { type IpcPeer, type IpcPeerCalls, type IpcPeerHandlers, type IpcProtocolMessage, type IpcTransport, createIpcPeer };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
function createIpcPeer(transport, handlers) {
|
|
3
|
+
const pending = /* @__PURE__ */ new Map();
|
|
4
|
+
let destroyed = false;
|
|
5
|
+
const stopListening = transport.listen((message) => {
|
|
6
|
+
if (message.type === "ipc-request") {
|
|
7
|
+
void handleRequest(message);
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
handleResponse(message);
|
|
11
|
+
});
|
|
12
|
+
const stopCloseListener = transport.onClose?.((error) => {
|
|
13
|
+
destroy(error ?? new Error("IPC transport closed"));
|
|
14
|
+
});
|
|
15
|
+
async function handleRequest(message) {
|
|
16
|
+
if (destroyed) return;
|
|
17
|
+
const handler = handlers[message.method];
|
|
18
|
+
if (!handler) {
|
|
19
|
+
await sendResponse({
|
|
20
|
+
type: "ipc-response",
|
|
21
|
+
id: message.id,
|
|
22
|
+
method: message.method,
|
|
23
|
+
error: serializeError(
|
|
24
|
+
new Error(`No handler registered for method: ${message.method}`)
|
|
25
|
+
)
|
|
26
|
+
});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const data = await Promise.resolve(handler(...message.args));
|
|
31
|
+
await sendResponse({
|
|
32
|
+
type: "ipc-response",
|
|
33
|
+
id: message.id,
|
|
34
|
+
method: message.method,
|
|
35
|
+
data
|
|
36
|
+
});
|
|
37
|
+
} catch (error) {
|
|
38
|
+
await sendResponse({
|
|
39
|
+
type: "ipc-response",
|
|
40
|
+
id: message.id,
|
|
41
|
+
method: message.method,
|
|
42
|
+
error: serializeError(error)
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function sendResponse(message) {
|
|
47
|
+
try {
|
|
48
|
+
await transport.send(message);
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function handleResponse(message) {
|
|
53
|
+
const request = pending.get(message.id);
|
|
54
|
+
if (!request) return;
|
|
55
|
+
pending.delete(message.id);
|
|
56
|
+
if (message.error) {
|
|
57
|
+
request.reject(deserializeRemoteError(request.method, message.error));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
request.resolve(message.data);
|
|
61
|
+
}
|
|
62
|
+
const call = new Proxy({}, {
|
|
63
|
+
get: (_target, method) => {
|
|
64
|
+
if (typeof method !== "string") return void 0;
|
|
65
|
+
return (...args) => {
|
|
66
|
+
if (destroyed) {
|
|
67
|
+
return Promise.reject(new Error("IPC peer destroyed"));
|
|
68
|
+
}
|
|
69
|
+
const id = `${method}-${randomUUID()}`;
|
|
70
|
+
const promise = new Promise((resolve, reject) => {
|
|
71
|
+
pending.set(id, { method, resolve, reject });
|
|
72
|
+
});
|
|
73
|
+
void Promise.resolve(
|
|
74
|
+
transport.send({
|
|
75
|
+
type: "ipc-request",
|
|
76
|
+
id,
|
|
77
|
+
method,
|
|
78
|
+
args
|
|
79
|
+
})
|
|
80
|
+
).catch((error) => {
|
|
81
|
+
const request = pending.get(id);
|
|
82
|
+
if (!request) return;
|
|
83
|
+
pending.delete(id);
|
|
84
|
+
request.reject(error);
|
|
85
|
+
});
|
|
86
|
+
return promise;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
function destroy(error = new Error("IPC peer destroyed")) {
|
|
91
|
+
if (destroyed) return;
|
|
92
|
+
destroyed = true;
|
|
93
|
+
stopListening();
|
|
94
|
+
stopCloseListener?.();
|
|
95
|
+
transport.close?.();
|
|
96
|
+
for (const request of pending.values()) {
|
|
97
|
+
request.reject(error);
|
|
98
|
+
}
|
|
99
|
+
pending.clear();
|
|
100
|
+
}
|
|
101
|
+
return { call, destroy };
|
|
102
|
+
}
|
|
103
|
+
function serializeError(error, seen = /* @__PURE__ */ new WeakSet()) {
|
|
104
|
+
if (typeof error === "object" && error !== null) {
|
|
105
|
+
if (seen.has(error)) {
|
|
106
|
+
return {
|
|
107
|
+
name: "Error",
|
|
108
|
+
message: "[Circular]"
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
seen.add(error);
|
|
112
|
+
}
|
|
113
|
+
if (error instanceof Error) {
|
|
114
|
+
const serialized = {
|
|
115
|
+
name: error.name,
|
|
116
|
+
message: error.message,
|
|
117
|
+
stack: error.stack
|
|
118
|
+
};
|
|
119
|
+
const errorWithCode = error;
|
|
120
|
+
if (typeof errorWithCode.code === "string" || typeof errorWithCode.code === "number") {
|
|
121
|
+
serialized.code = errorWithCode.code;
|
|
122
|
+
}
|
|
123
|
+
if (error.cause !== void 0) {
|
|
124
|
+
serialized.cause = serializeError(error.cause, seen);
|
|
125
|
+
}
|
|
126
|
+
if (error instanceof AggregateError) {
|
|
127
|
+
serialized.errors = error.errors.map(
|
|
128
|
+
(aggregateError) => serializeError(aggregateError, seen)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return serialized;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
name: "NonError",
|
|
135
|
+
message: String(error)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function deserializeRemoteError(method, remoteError) {
|
|
139
|
+
const error = deserializeSerializedError(
|
|
140
|
+
remoteError,
|
|
141
|
+
`${method} > ${remoteError.message}`
|
|
142
|
+
);
|
|
143
|
+
error.stack = [new Error(method).stack, remoteError.stack].filter((stack) => typeof stack === "string").join("\n");
|
|
144
|
+
return error;
|
|
145
|
+
}
|
|
146
|
+
function deserializeSerializedError(serialized, message = serialized.message) {
|
|
147
|
+
const cause = serialized.cause ? deserializeSerializedError(serialized.cause) : void 0;
|
|
148
|
+
const error = serialized.name === "AggregateError" && serialized.errors ? new AggregateError(
|
|
149
|
+
serialized.errors.map(
|
|
150
|
+
(aggregateError) => deserializeSerializedError(aggregateError)
|
|
151
|
+
),
|
|
152
|
+
message,
|
|
153
|
+
{ cause }
|
|
154
|
+
) : new Error(message, { cause });
|
|
155
|
+
error.name = serialized.name;
|
|
156
|
+
error.stack = serialized.stack;
|
|
157
|
+
const errorWithCode = error;
|
|
158
|
+
if (serialized.code !== void 0) {
|
|
159
|
+
errorWithCode.code = serialized.code;
|
|
160
|
+
}
|
|
161
|
+
return error;
|
|
162
|
+
}
|
|
163
|
+
export {
|
|
164
|
+
createIpcPeer
|
|
165
|
+
};
|