libretto 0.6.10 → 0.6.12

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.
Files changed (119) hide show
  1. package/README.md +4 -0
  2. package/README.template.md +4 -0
  3. package/dist/cli/cli.js +4 -3
  4. package/dist/cli/commands/ai.js +3 -2
  5. package/dist/cli/commands/browser.js +17 -17
  6. package/dist/cli/commands/execution.js +254 -234
  7. package/dist/cli/commands/experiments.js +100 -0
  8. package/dist/cli/commands/setup.js +20 -34
  9. package/dist/cli/commands/shared.js +10 -0
  10. package/dist/cli/commands/snapshot.js +81 -9
  11. package/dist/cli/commands/status.js +5 -4
  12. package/dist/cli/core/ai-model.js +6 -3
  13. package/dist/cli/core/browser.js +300 -121
  14. package/dist/cli/core/config.js +4 -2
  15. package/dist/cli/core/context.js +4 -0
  16. package/dist/cli/core/daemon/config.js +0 -6
  17. package/dist/cli/core/daemon/daemon.js +535 -89
  18. package/dist/cli/core/daemon/ipc.js +170 -129
  19. package/dist/cli/core/daemon/snapshot.js +72 -6
  20. package/dist/cli/core/experiments.js +66 -0
  21. package/dist/cli/core/session.js +5 -4
  22. package/dist/cli/core/skill-version.js +2 -1
  23. package/dist/cli/core/snapshot-analyzer.js +4 -3
  24. package/dist/cli/core/workflow-runner/runner.js +147 -0
  25. package/dist/cli/core/workflow-runtime.js +60 -0
  26. package/dist/cli/router.js +4 -1
  27. package/dist/shared/debug/pause-handler.d.ts +9 -0
  28. package/dist/shared/debug/pause-handler.js +15 -0
  29. package/dist/shared/debug/pause.d.ts +1 -2
  30. package/dist/shared/debug/pause.js +13 -36
  31. package/dist/shared/ipc/child-process-transport.d.ts +7 -0
  32. package/dist/shared/ipc/child-process-transport.js +60 -0
  33. package/dist/shared/ipc/child-process-transport.spec.d.ts +2 -0
  34. package/dist/shared/ipc/child-process-transport.spec.js +68 -0
  35. package/dist/shared/ipc/ipc.d.ts +46 -0
  36. package/dist/shared/ipc/ipc.js +165 -0
  37. package/dist/shared/ipc/ipc.spec.d.ts +2 -0
  38. package/dist/shared/ipc/ipc.spec.js +114 -0
  39. package/dist/shared/ipc/socket-transport.d.ts +9 -0
  40. package/dist/shared/ipc/socket-transport.js +143 -0
  41. package/dist/shared/ipc/socket-transport.spec.d.ts +2 -0
  42. package/dist/shared/ipc/socket-transport.spec.js +117 -0
  43. package/dist/shared/package-manager.d.ts +7 -0
  44. package/dist/shared/package-manager.js +60 -0
  45. package/dist/shared/paths/paths.d.ts +1 -8
  46. package/dist/shared/paths/paths.js +1 -49
  47. package/dist/shared/snapshot/capture-snapshot.d.ts +9 -0
  48. package/dist/shared/snapshot/capture-snapshot.js +463 -0
  49. package/dist/shared/snapshot/diff-snapshots.d.ts +72 -0
  50. package/dist/shared/snapshot/diff-snapshots.js +358 -0
  51. package/dist/shared/snapshot/render-snapshot.d.ts +39 -0
  52. package/dist/shared/snapshot/render-snapshot.js +651 -0
  53. package/dist/shared/snapshot/snapshot.spec.d.ts +2 -0
  54. package/dist/shared/snapshot/snapshot.spec.js +333 -0
  55. package/dist/shared/snapshot/types.d.ts +40 -0
  56. package/dist/shared/snapshot/types.js +0 -0
  57. package/dist/shared/snapshot/wait-for-page-stable.d.ts +17 -0
  58. package/dist/shared/snapshot/wait-for-page-stable.js +281 -0
  59. package/dist/shared/state/session-state.d.ts +1 -0
  60. package/dist/shared/state/session-state.js +1 -0
  61. package/docs/experiments.md +67 -0
  62. package/package.json +4 -2
  63. package/skills/libretto/SKILL.md +3 -1
  64. package/skills/libretto-readonly/SKILL.md +1 -1
  65. package/src/cli/AGENTS.md +7 -0
  66. package/src/cli/cli.ts +4 -3
  67. package/src/cli/commands/ai.ts +3 -2
  68. package/src/cli/commands/browser.ts +13 -11
  69. package/src/cli/commands/execution.ts +303 -271
  70. package/src/cli/commands/experiments.ts +120 -0
  71. package/src/cli/commands/setup.ts +18 -36
  72. package/src/cli/commands/shared.ts +20 -0
  73. package/src/cli/commands/snapshot.ts +99 -11
  74. package/src/cli/commands/status.ts +5 -4
  75. package/src/cli/core/ai-model.ts +6 -3
  76. package/src/cli/core/browser.ts +369 -147
  77. package/src/cli/core/config.ts +3 -1
  78. package/src/cli/core/context.ts +4 -0
  79. package/src/cli/core/daemon/config.ts +35 -19
  80. package/src/cli/core/daemon/daemon.ts +686 -106
  81. package/src/cli/core/daemon/ipc.ts +330 -214
  82. package/src/cli/core/daemon/snapshot.ts +106 -8
  83. package/src/cli/core/experiments.ts +85 -0
  84. package/src/cli/core/session.ts +5 -4
  85. package/src/cli/core/skill-version.ts +2 -1
  86. package/src/cli/core/snapshot-analyzer.ts +4 -3
  87. package/src/cli/core/workflow-runner/runner.ts +237 -0
  88. package/src/cli/core/workflow-runtime.ts +85 -0
  89. package/src/cli/router.ts +4 -1
  90. package/src/shared/debug/pause-handler.ts +20 -0
  91. package/src/shared/debug/pause.ts +14 -48
  92. package/src/shared/ipc/AGENTS.md +24 -0
  93. package/src/shared/ipc/child-process-transport.spec.ts +86 -0
  94. package/src/shared/ipc/child-process-transport.ts +96 -0
  95. package/src/shared/ipc/ipc.spec.ts +161 -0
  96. package/src/shared/ipc/ipc.ts +288 -0
  97. package/src/shared/ipc/socket-transport.spec.ts +141 -0
  98. package/src/shared/ipc/socket-transport.ts +189 -0
  99. package/src/shared/package-manager.ts +76 -0
  100. package/src/shared/paths/paths.ts +0 -72
  101. package/src/shared/snapshot/capture-snapshot.ts +615 -0
  102. package/src/shared/snapshot/diff-snapshots.ts +579 -0
  103. package/src/shared/snapshot/render-snapshot.ts +962 -0
  104. package/src/shared/snapshot/snapshot.spec.ts +388 -0
  105. package/src/shared/snapshot/types.ts +43 -0
  106. package/src/shared/snapshot/wait-for-page-stable.ts +425 -0
  107. package/src/shared/state/session-state.ts +1 -0
  108. package/dist/cli/core/daemon/index.js +0 -16
  109. package/dist/cli/core/daemon/spawn.js +0 -90
  110. package/dist/cli/core/pause-signals.js +0 -29
  111. package/dist/cli/workers/run-integration-runtime.js +0 -235
  112. package/dist/cli/workers/run-integration-worker-protocol.js +0 -17
  113. package/dist/cli/workers/run-integration-worker.js +0 -64
  114. package/src/cli/core/daemon/index.ts +0 -24
  115. package/src/cli/core/daemon/spawn.ts +0 -171
  116. package/src/cli/core/pause-signals.ts +0 -35
  117. package/src/cli/workers/run-integration-runtime.ts +0 -326
  118. package/src/cli/workers/run-integration-worker-protocol.ts +0 -19
  119. package/src/cli/workers/run-integration-worker.ts +0 -72
@@ -0,0 +1,147 @@
1
+ import { installPauseHandler } from "../../../shared/debug/pause-handler.js";
2
+ import {
3
+ getAbsoluteIntegrationPath,
4
+ installHeadedWorkflowVisualization,
5
+ loadDefaultWorkflow
6
+ } from "../workflow-runtime.js";
7
+ class WorkflowController {
8
+ constructor(config) {
9
+ this.config = config;
10
+ }
11
+ status = { state: "idle" };
12
+ pendingPause;
13
+ started = false;
14
+ start(workflowConfig) {
15
+ if (this.started) {
16
+ throw new Error("Workflow controller has already started.");
17
+ }
18
+ this.started = true;
19
+ this.status = { state: "running" };
20
+ void this.run(workflowConfig);
21
+ }
22
+ pause(args) {
23
+ if (this.pendingPause) {
24
+ throw new Error("Workflow is already paused.");
25
+ }
26
+ return new Promise((resolve) => {
27
+ this.pendingPause = { resolve };
28
+ this.status = { state: "paused", ...args };
29
+ this.config.onOutcome?.(this.status);
30
+ });
31
+ }
32
+ resume() {
33
+ if (!this.pendingPause) {
34
+ throw new Error("Workflow is not paused.");
35
+ }
36
+ const pendingPause = this.pendingPause;
37
+ this.pendingPause = void 0;
38
+ this.status = { state: "running" };
39
+ pendingPause.resolve();
40
+ }
41
+ getStatus() {
42
+ return this.status;
43
+ }
44
+ async run(workflowConfig) {
45
+ const restoreOutput = this.captureProcessOutput();
46
+ try {
47
+ const absolutePath = getAbsoluteIntegrationPath(
48
+ workflowConfig.integrationPath
49
+ );
50
+ const workflow = workflowConfig.loadedWorkflow ?? await loadDefaultWorkflow(absolutePath);
51
+ const workflowLogger = this.config.logger.withScope("integration-run", {
52
+ integrationPath: absolutePath,
53
+ workflowName: workflow.name,
54
+ session: this.config.session
55
+ });
56
+ console.log(
57
+ `Running workflow "${workflow.name}" from ${absolutePath} (${this.config.headed ? "headed" : "headless"})...`
58
+ );
59
+ if (this.config.headed && workflowConfig.visualize !== false) {
60
+ await installHeadedWorkflowVisualization({
61
+ context: this.config.context,
62
+ logger: workflowLogger
63
+ });
64
+ }
65
+ await this.config.context.addInitScript(() => {
66
+ globalThis.__name = (target, value) => Object.defineProperty(target, "name", {
67
+ value,
68
+ configurable: true
69
+ });
70
+ });
71
+ const workflowContext = {
72
+ session: this.config.session,
73
+ page: this.config.page
74
+ };
75
+ const uninstallPauseHandler = installPauseHandler(
76
+ (pauseArgs) => this.pause({
77
+ ...pauseArgs,
78
+ url: this.config.page.isClosed() ? void 0 : this.config.page.url()
79
+ })
80
+ );
81
+ try {
82
+ await workflow.run(workflowContext, workflowConfig.params ?? {});
83
+ } catch (error) {
84
+ this.emitOutcome({
85
+ state: "finished",
86
+ result: "failed",
87
+ message: error instanceof Error ? error.message : String(error),
88
+ phase: "workflow"
89
+ });
90
+ return;
91
+ } finally {
92
+ uninstallPauseHandler();
93
+ }
94
+ this.emitOutcome({
95
+ state: "finished",
96
+ result: "completed",
97
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
98
+ });
99
+ } catch (error) {
100
+ this.emitOutcome({
101
+ state: "finished",
102
+ result: "failed",
103
+ message: error instanceof Error ? error.message : String(error),
104
+ phase: "setup"
105
+ });
106
+ } finally {
107
+ restoreOutput();
108
+ }
109
+ }
110
+ emitOutcome(outcome) {
111
+ this.resolvePendingPause();
112
+ this.status = outcome;
113
+ this.config.onOutcome?.(outcome);
114
+ }
115
+ resolvePendingPause() {
116
+ const pendingPause = this.pendingPause;
117
+ if (!pendingPause) return;
118
+ this.pendingPause = void 0;
119
+ pendingPause.resolve();
120
+ }
121
+ captureProcessOutput() {
122
+ const stdout = process.stdout;
123
+ const stderr = process.stderr;
124
+ const originalStdoutWrite = stdout.write;
125
+ const originalStderrWrite = stderr.write;
126
+ stdout.write = ((...writeArgs) => {
127
+ const [chunk] = writeArgs;
128
+ this.config.onLog?.({ stream: "stdout", text: chunkToString(chunk) });
129
+ return Reflect.apply(originalStdoutWrite, stdout, writeArgs);
130
+ });
131
+ stderr.write = ((...writeArgs) => {
132
+ const [chunk] = writeArgs;
133
+ this.config.onLog?.({ stream: "stderr", text: chunkToString(chunk) });
134
+ return Reflect.apply(originalStderrWrite, stderr, writeArgs);
135
+ });
136
+ return () => {
137
+ stdout.write = originalStdoutWrite;
138
+ stderr.write = originalStderrWrite;
139
+ };
140
+ }
141
+ }
142
+ function chunkToString(chunk) {
143
+ return Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
144
+ }
145
+ export {
146
+ WorkflowController
147
+ };
@@ -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
+ };
@@ -4,13 +4,16 @@ import { billingCommands } from "./commands/billing.js";
4
4
  import { browserCommands } from "./commands/browser.js";
5
5
  import { deployCommand } from "./commands/deploy.js";
6
6
  import { executionCommands } from "./commands/execution.js";
7
+ import { experimentsCommand } from "./commands/experiments.js";
7
8
  import { setupCommand } from "./commands/setup.js";
8
9
  import { statusCommand } from "./commands/status.js";
9
10
  import { snapshotCommand } from "./commands/snapshot.js";
11
+ import { librettoCommand } from "../shared/package-manager.js";
10
12
  import { SimpleCLI } from "./framework/simple-cli.js";
11
13
  const cliRoutes = {
12
14
  ...browserCommands,
13
15
  deploy: deployCommand,
16
+ experiments: experimentsCommand,
14
17
  ...executionCommands,
15
18
  ai: aiCommands,
16
19
  auth: authCommands,
@@ -20,7 +23,7 @@ const cliRoutes = {
20
23
  snapshot: snapshotCommand
21
24
  };
22
25
  function createCLIApp() {
23
- return SimpleCLI.define("libretto", cliRoutes);
26
+ return SimpleCLI.define(librettoCommand(), cliRoutes);
24
27
  }
25
28
  export {
26
29
  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, writes a `.paused` signal file and polls for a `.resume` signal,
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 { existsSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
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
- const runningSessions = listRunningSessions();
11
- const lines = ["pause(session) requires a non-empty session ID."];
12
- if (runningSessions.length > 0) {
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 signalPaths = getPauseSignalPaths(session);
28
- const { pausedSignalPath, resumeSignalPath } = signalPaths;
29
- await mkdir(getSessionDir(session), { recursive: true });
30
- await removeSignalIfExists(resumeSignalPath);
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 removeSignalIfExists(resumeSignalPath);
46
- await removeSignalIfExists(pausedSignalPath);
47
- console.log("[pause] Resume signal received. Continuing workflow...");
21
+ await handler({
22
+ session,
23
+ pausedAt: (/* @__PURE__ */ new Date()).toISOString()
24
+ });
48
25
  }
49
26
  export {
50
27
  pause
@@ -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,2 @@
1
+
2
+ export { }
@@ -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
+ };