libretto 0.6.11 → 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.
- package/README.md +4 -0
- package/README.template.md +4 -0
- package/dist/cli/cli.js +4 -3
- package/dist/cli/commands/ai.js +3 -2
- package/dist/cli/commands/browser.js +17 -17
- package/dist/cli/commands/execution.js +254 -234
- package/dist/cli/commands/experiments.js +100 -0
- package/dist/cli/commands/setup.js +20 -34
- package/dist/cli/commands/shared.js +10 -0
- package/dist/cli/commands/snapshot.js +81 -9
- package/dist/cli/commands/status.js +5 -4
- package/dist/cli/core/ai-model.js +6 -3
- package/dist/cli/core/browser.js +300 -121
- package/dist/cli/core/config.js +4 -2
- package/dist/cli/core/context.js +4 -0
- package/dist/cli/core/daemon/config.js +0 -6
- package/dist/cli/core/daemon/daemon.js +535 -89
- package/dist/cli/core/daemon/ipc.js +170 -129
- package/dist/cli/core/daemon/snapshot.js +72 -6
- package/dist/cli/core/experiments.js +66 -0
- package/dist/cli/core/session.js +5 -4
- package/dist/cli/core/skill-version.js +2 -1
- package/dist/cli/core/snapshot-analyzer.js +4 -3
- package/dist/cli/core/workflow-runner/runner.js +147 -0
- package/dist/cli/core/workflow-runtime.js +60 -0
- package/dist/cli/router.js +4 -1
- 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/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/package.json +4 -2
- package/skills/libretto/SKILL.md +3 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/AGENTS.md +7 -0
- package/src/cli/cli.ts +4 -3
- package/src/cli/commands/ai.ts +3 -2
- package/src/cli/commands/browser.ts +13 -11
- package/src/cli/commands/execution.ts +303 -271
- package/src/cli/commands/experiments.ts +120 -0
- package/src/cli/commands/setup.ts +18 -36
- package/src/cli/commands/shared.ts +20 -0
- package/src/cli/commands/snapshot.ts +99 -11
- package/src/cli/commands/status.ts +5 -4
- package/src/cli/core/ai-model.ts +6 -3
- package/src/cli/core/browser.ts +369 -147
- package/src/cli/core/config.ts +3 -1
- package/src/cli/core/context.ts +4 -0
- package/src/cli/core/daemon/config.ts +35 -19
- package/src/cli/core/daemon/daemon.ts +686 -106
- package/src/cli/core/daemon/ipc.ts +330 -214
- package/src/cli/core/daemon/snapshot.ts +106 -8
- package/src/cli/core/experiments.ts +85 -0
- package/src/cli/core/session.ts +5 -4
- package/src/cli/core/skill-version.ts +2 -1
- package/src/cli/core/snapshot-analyzer.ts +4 -3
- package/src/cli/core/workflow-runner/runner.ts +237 -0
- package/src/cli/core/workflow-runtime.ts +85 -0
- package/src/cli/router.ts +4 -1
- package/src/shared/debug/pause-handler.ts +20 -0
- package/src/shared/debug/pause.ts +14 -48
- 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/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/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/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/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,288 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export type IpcTransport<T = unknown> = {
|
|
4
|
+
send(message: T): void | Promise<void>;
|
|
5
|
+
listen(callback: (message: T) => void): () => void;
|
|
6
|
+
onClose?(callback: (error?: Error) => void): () => void;
|
|
7
|
+
close?(): void;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type FunctionMap<T> = {
|
|
11
|
+
[K in keyof T]: (...args: never[]) => unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type UnwrapPromise<T> = T extends Promise<infer Result> ? Result : T;
|
|
15
|
+
|
|
16
|
+
type MaybeAsync<T extends (...args: never[]) => unknown> = T extends (
|
|
17
|
+
...args: infer Args
|
|
18
|
+
) => infer Result
|
|
19
|
+
? (...args: Args) => UnwrapPromise<Result> | Promise<UnwrapPromise<Result>>
|
|
20
|
+
: never;
|
|
21
|
+
|
|
22
|
+
export type IpcPeerHandlers<Local extends FunctionMap<Local>> = {
|
|
23
|
+
[K in keyof Local]: MaybeAsync<Local[K]>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type IpcPeerCalls<Remote extends FunctionMap<Remote>> = {
|
|
27
|
+
[K in keyof Remote]: Remote[K] extends (...args: infer Args) => infer Result
|
|
28
|
+
? (...args: Args) => Promise<UnwrapPromise<Result>>
|
|
29
|
+
: never;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type IpcPeer<Remote extends FunctionMap<Remote>> = {
|
|
33
|
+
call: IpcPeerCalls<Remote>;
|
|
34
|
+
destroy(): void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type IpcRequestMessage = {
|
|
38
|
+
type: "ipc-request";
|
|
39
|
+
id: string;
|
|
40
|
+
method: string;
|
|
41
|
+
args: unknown[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type SerializedError = {
|
|
45
|
+
name: string;
|
|
46
|
+
message: string;
|
|
47
|
+
stack?: string;
|
|
48
|
+
code?: string | number;
|
|
49
|
+
cause?: SerializedError;
|
|
50
|
+
errors?: SerializedError[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
type IpcResponseMessage = {
|
|
54
|
+
type: "ipc-response";
|
|
55
|
+
id: string;
|
|
56
|
+
method: string;
|
|
57
|
+
data?: unknown;
|
|
58
|
+
error?: SerializedError;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type IpcProtocolMessage = IpcRequestMessage | IpcResponseMessage;
|
|
62
|
+
|
|
63
|
+
type PendingRequest = {
|
|
64
|
+
method: string;
|
|
65
|
+
resolve(value: unknown): void;
|
|
66
|
+
reject(error: unknown): void;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function createIpcPeer<
|
|
70
|
+
Remote extends FunctionMap<Remote>,
|
|
71
|
+
Local extends FunctionMap<Local>,
|
|
72
|
+
>(
|
|
73
|
+
transport: IpcTransport<IpcProtocolMessage>,
|
|
74
|
+
handlers: IpcPeerHandlers<Local>,
|
|
75
|
+
): IpcPeer<Remote> {
|
|
76
|
+
const pending = new Map<string, PendingRequest>();
|
|
77
|
+
let destroyed = false;
|
|
78
|
+
|
|
79
|
+
const stopListening = transport.listen((message) => {
|
|
80
|
+
if (message.type === "ipc-request") {
|
|
81
|
+
void handleRequest(message);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
handleResponse(message);
|
|
86
|
+
});
|
|
87
|
+
const stopCloseListener = transport.onClose?.((error) => {
|
|
88
|
+
destroy(error ?? new Error("IPC transport closed"));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
async function handleRequest(message: IpcRequestMessage): Promise<void> {
|
|
92
|
+
if (destroyed) return;
|
|
93
|
+
|
|
94
|
+
const handler = handlers[message.method as keyof Local];
|
|
95
|
+
if (!handler) {
|
|
96
|
+
await sendResponse({
|
|
97
|
+
type: "ipc-response",
|
|
98
|
+
id: message.id,
|
|
99
|
+
method: message.method,
|
|
100
|
+
error: serializeError(
|
|
101
|
+
new Error(`No handler registered for method: ${message.method}`),
|
|
102
|
+
),
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
const data = await Promise.resolve(handler(...(message.args as never[])));
|
|
109
|
+
await sendResponse({
|
|
110
|
+
type: "ipc-response",
|
|
111
|
+
id: message.id,
|
|
112
|
+
method: message.method,
|
|
113
|
+
data,
|
|
114
|
+
});
|
|
115
|
+
} catch (error) {
|
|
116
|
+
await sendResponse({
|
|
117
|
+
type: "ipc-response",
|
|
118
|
+
id: message.id,
|
|
119
|
+
method: message.method,
|
|
120
|
+
error: serializeError(error),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function sendResponse(message: IpcResponseMessage): Promise<void> {
|
|
126
|
+
try {
|
|
127
|
+
await transport.send(message);
|
|
128
|
+
} catch {
|
|
129
|
+
// The caller has no response channel for response-send failures.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function handleResponse(message: IpcResponseMessage): void {
|
|
134
|
+
const request = pending.get(message.id);
|
|
135
|
+
if (!request) return;
|
|
136
|
+
|
|
137
|
+
pending.delete(message.id);
|
|
138
|
+
|
|
139
|
+
if (message.error) {
|
|
140
|
+
request.reject(deserializeRemoteError(request.method, message.error));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
request.resolve(message.data);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const call = new Proxy({} as IpcPeerCalls<Remote>, {
|
|
148
|
+
get: (_target, method: string | symbol) => {
|
|
149
|
+
if (typeof method !== "string") return undefined;
|
|
150
|
+
|
|
151
|
+
return (...args: unknown[]) => {
|
|
152
|
+
if (destroyed) {
|
|
153
|
+
return Promise.reject(new Error("IPC peer destroyed"));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const id = `${method}-${randomUUID()}`;
|
|
157
|
+
|
|
158
|
+
const promise = new Promise<unknown>((resolve, reject) => {
|
|
159
|
+
pending.set(id, { method, resolve, reject });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
void Promise.resolve(
|
|
163
|
+
transport.send({
|
|
164
|
+
type: "ipc-request",
|
|
165
|
+
id,
|
|
166
|
+
method,
|
|
167
|
+
args,
|
|
168
|
+
}),
|
|
169
|
+
).catch((error: unknown) => {
|
|
170
|
+
const request = pending.get(id);
|
|
171
|
+
if (!request) return;
|
|
172
|
+
pending.delete(id);
|
|
173
|
+
request.reject(error);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return promise;
|
|
177
|
+
};
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
function destroy(error = new Error("IPC peer destroyed")): void {
|
|
182
|
+
if (destroyed) return;
|
|
183
|
+
destroyed = true;
|
|
184
|
+
stopListening();
|
|
185
|
+
stopCloseListener?.();
|
|
186
|
+
transport.close?.();
|
|
187
|
+
|
|
188
|
+
for (const request of pending.values()) {
|
|
189
|
+
request.reject(error);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
pending.clear();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { call, destroy };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function serializeError(
|
|
199
|
+
error: unknown,
|
|
200
|
+
seen = new WeakSet<object>(),
|
|
201
|
+
): SerializedError {
|
|
202
|
+
if (typeof error === "object" && error !== null) {
|
|
203
|
+
if (seen.has(error)) {
|
|
204
|
+
return {
|
|
205
|
+
name: "Error",
|
|
206
|
+
message: "[Circular]",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
seen.add(error);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (error instanceof Error) {
|
|
214
|
+
const serialized: SerializedError = {
|
|
215
|
+
name: error.name,
|
|
216
|
+
message: error.message,
|
|
217
|
+
stack: error.stack,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const errorWithCode = error as Error & { code?: unknown };
|
|
221
|
+
if (
|
|
222
|
+
typeof errorWithCode.code === "string" ||
|
|
223
|
+
typeof errorWithCode.code === "number"
|
|
224
|
+
) {
|
|
225
|
+
serialized.code = errorWithCode.code;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (error.cause !== undefined) {
|
|
229
|
+
serialized.cause = serializeError(error.cause, seen);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (error instanceof AggregateError) {
|
|
233
|
+
serialized.errors = error.errors.map((aggregateError: unknown) =>
|
|
234
|
+
serializeError(aggregateError, seen),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return serialized;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
name: "NonError",
|
|
243
|
+
message: String(error),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function deserializeRemoteError(
|
|
248
|
+
method: string,
|
|
249
|
+
remoteError: SerializedError,
|
|
250
|
+
): Error {
|
|
251
|
+
const error = deserializeSerializedError(
|
|
252
|
+
remoteError,
|
|
253
|
+
`${method} > ${remoteError.message}`,
|
|
254
|
+
);
|
|
255
|
+
error.stack = [new Error(method).stack, remoteError.stack]
|
|
256
|
+
.filter((stack): stack is string => typeof stack === "string")
|
|
257
|
+
.join("\n");
|
|
258
|
+
return error;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function deserializeSerializedError(
|
|
262
|
+
serialized: SerializedError,
|
|
263
|
+
message = serialized.message,
|
|
264
|
+
): Error {
|
|
265
|
+
const cause = serialized.cause
|
|
266
|
+
? deserializeSerializedError(serialized.cause)
|
|
267
|
+
: undefined;
|
|
268
|
+
const error =
|
|
269
|
+
serialized.name === "AggregateError" && serialized.errors
|
|
270
|
+
? new AggregateError(
|
|
271
|
+
serialized.errors.map((aggregateError) =>
|
|
272
|
+
deserializeSerializedError(aggregateError),
|
|
273
|
+
),
|
|
274
|
+
message,
|
|
275
|
+
{ cause },
|
|
276
|
+
)
|
|
277
|
+
: new Error(message, { cause });
|
|
278
|
+
|
|
279
|
+
error.name = serialized.name;
|
|
280
|
+
error.stack = serialized.stack;
|
|
281
|
+
|
|
282
|
+
const errorWithCode = error as Error & { code?: string | number };
|
|
283
|
+
if (serialized.code !== undefined) {
|
|
284
|
+
errorWithCode.code = serialized.code;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return error;
|
|
288
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { mkdtemp, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { expect, test as base } from "vitest";
|
|
6
|
+
import { createIpcPeer, type IpcPeer } from "./ipc.js";
|
|
7
|
+
import {
|
|
8
|
+
connectToIpcSocket,
|
|
9
|
+
listenForIpcConnections,
|
|
10
|
+
} from "./socket-transport.js";
|
|
11
|
+
|
|
12
|
+
const test = base.extend<{
|
|
13
|
+
socketPath: string;
|
|
14
|
+
}>({
|
|
15
|
+
socketPath: async ({}, use) => {
|
|
16
|
+
const directory = await mkdtemp(join(tmpdir(), "libretto-ipc-"));
|
|
17
|
+
await use(join(directory, "daemon.sock"));
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type ClientApi = {
|
|
22
|
+
ping(): string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type ServerApi = {
|
|
26
|
+
double(value: number): Promise<number>;
|
|
27
|
+
wait(): Promise<string>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
test("sends concurrent calls over one socket", async ({ socketPath }) => {
|
|
31
|
+
await writeFile(socketPath, "stale");
|
|
32
|
+
|
|
33
|
+
const serverPeers: Array<IpcPeer<ClientApi>> = [];
|
|
34
|
+
const server = await listenForIpcConnections(socketPath, (transport) => {
|
|
35
|
+
serverPeers.push(
|
|
36
|
+
createIpcPeer<ClientApi, ServerApi>(transport, {
|
|
37
|
+
async double(value) {
|
|
38
|
+
return value * 2;
|
|
39
|
+
},
|
|
40
|
+
async wait() {
|
|
41
|
+
return "done";
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
const client = createIpcPeer<ServerApi, ClientApi>(
|
|
47
|
+
await connectToIpcSocket(socketPath),
|
|
48
|
+
{
|
|
49
|
+
ping() {
|
|
50
|
+
return "pong";
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
await expect(
|
|
56
|
+
Promise.all([client.call.double(2), client.call.double(21)]),
|
|
57
|
+
).resolves.toEqual([4, 42]);
|
|
58
|
+
|
|
59
|
+
client.destroy();
|
|
60
|
+
for (const peer of serverPeers) peer.destroy();
|
|
61
|
+
await new Promise<void>((resolve, reject) => {
|
|
62
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
63
|
+
});
|
|
64
|
+
await expect(stat(socketPath)).rejects.toThrow();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("rejects pending calls when the socket closes", async ({ socketPath }) => {
|
|
68
|
+
const serverPeers: Array<IpcPeer<ClientApi>> = [];
|
|
69
|
+
const server = await listenForIpcConnections(socketPath, (transport) => {
|
|
70
|
+
serverPeers.push(
|
|
71
|
+
createIpcPeer<ClientApi, ServerApi>(transport, {
|
|
72
|
+
async double(value) {
|
|
73
|
+
return value * 2;
|
|
74
|
+
},
|
|
75
|
+
async wait() {
|
|
76
|
+
return new Promise(() => {});
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
const client = createIpcPeer<ServerApi, ClientApi>(
|
|
82
|
+
await connectToIpcSocket(socketPath),
|
|
83
|
+
{
|
|
84
|
+
ping() {
|
|
85
|
+
return "pong";
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
const pending = client.call.wait();
|
|
90
|
+
|
|
91
|
+
for (const peer of serverPeers) peer.destroy();
|
|
92
|
+
|
|
93
|
+
await expect(pending).rejects.toThrow(/IPC transport closed|ECONNRESET/);
|
|
94
|
+
|
|
95
|
+
client.destroy();
|
|
96
|
+
await new Promise<void>((resolve, reject) => {
|
|
97
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("closes malformed socket messages without stopping the server", async ({
|
|
102
|
+
socketPath,
|
|
103
|
+
}) => {
|
|
104
|
+
const server = await listenForIpcConnections(socketPath, (transport) => {
|
|
105
|
+
createIpcPeer<ClientApi, ServerApi>(transport, {
|
|
106
|
+
async double(value) {
|
|
107
|
+
return value * 2;
|
|
108
|
+
},
|
|
109
|
+
async wait() {
|
|
110
|
+
return "done";
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const malformedClient = createConnection(socketPath);
|
|
116
|
+
await new Promise<void>((resolve, reject) => {
|
|
117
|
+
malformedClient.once("connect", resolve);
|
|
118
|
+
malformedClient.once("error", reject);
|
|
119
|
+
});
|
|
120
|
+
const malformedClosed = new Promise<void>((resolve) => {
|
|
121
|
+
malformedClient.once("close", () => resolve());
|
|
122
|
+
});
|
|
123
|
+
malformedClient.write("not-json\n");
|
|
124
|
+
await malformedClosed;
|
|
125
|
+
|
|
126
|
+
const validClient = createIpcPeer<ServerApi, ClientApi>(
|
|
127
|
+
await connectToIpcSocket(socketPath),
|
|
128
|
+
{
|
|
129
|
+
ping() {
|
|
130
|
+
return "pong";
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
await expect(validClient.call.double(4)).resolves.toBe(8);
|
|
136
|
+
|
|
137
|
+
validClient.destroy();
|
|
138
|
+
await new Promise<void>((resolve, reject) => {
|
|
139
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
createServer,
|
|
4
|
+
createConnection,
|
|
5
|
+
type Server,
|
|
6
|
+
type Socket,
|
|
7
|
+
} from "node:net";
|
|
8
|
+
import { dirname } from "node:path";
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
10
|
+
import type { IpcProtocolMessage, IpcTransport } from "./ipc.js";
|
|
11
|
+
|
|
12
|
+
function createJsonSocketTransport(
|
|
13
|
+
socket: Socket,
|
|
14
|
+
): IpcTransport<IpcProtocolMessage> {
|
|
15
|
+
socket.setEncoding("utf8");
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
send(message) {
|
|
19
|
+
return new Promise<void>((resolve, reject) => {
|
|
20
|
+
const line = `${JSON.stringify(message)}\n`;
|
|
21
|
+
socket.write(line, (error) => {
|
|
22
|
+
if (error) reject(error);
|
|
23
|
+
else resolve();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
listen(callback) {
|
|
28
|
+
let buffer = "";
|
|
29
|
+
const onData = (chunk: string) => {
|
|
30
|
+
buffer += chunk;
|
|
31
|
+
|
|
32
|
+
while (true) {
|
|
33
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
34
|
+
if (newlineIndex === -1) break;
|
|
35
|
+
|
|
36
|
+
const line = buffer.slice(0, newlineIndex);
|
|
37
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
38
|
+
if (line.length === 0) continue;
|
|
39
|
+
|
|
40
|
+
let message: unknown;
|
|
41
|
+
try {
|
|
42
|
+
message = JSON.parse(line) as unknown;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
socket.destroy(
|
|
45
|
+
new Error(
|
|
46
|
+
`Invalid IPC socket message: ${error instanceof Error ? error.message : String(error)}`,
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!isIpcProtocolMessage(message)) {
|
|
53
|
+
socket.destroy(new Error("Invalid IPC socket protocol message."));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
callback(message);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
socket.on("data", onData);
|
|
62
|
+
return () => socket.off("data", onData);
|
|
63
|
+
},
|
|
64
|
+
onClose(callback) {
|
|
65
|
+
let closeError: Error | undefined;
|
|
66
|
+
const onError = (error: Error) => {
|
|
67
|
+
closeError = error;
|
|
68
|
+
};
|
|
69
|
+
const onClose = () => callback(closeError);
|
|
70
|
+
|
|
71
|
+
socket.on("error", onError);
|
|
72
|
+
socket.on("close", onClose);
|
|
73
|
+
return () => {
|
|
74
|
+
socket.off("error", onError);
|
|
75
|
+
socket.off("close", onClose);
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
close() {
|
|
79
|
+
socket.destroy();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function connectToIpcSocket(
|
|
85
|
+
socketPath: string,
|
|
86
|
+
): Promise<IpcTransport<IpcProtocolMessage>> {
|
|
87
|
+
const socket = await connectSocket(socketPath);
|
|
88
|
+
return createJsonSocketTransport(socket);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createIpcSocketServer(
|
|
92
|
+
onConnection: (transport: IpcTransport<IpcProtocolMessage>) => void,
|
|
93
|
+
): Server {
|
|
94
|
+
return createServer((socket) => {
|
|
95
|
+
onConnection(createJsonSocketTransport(socket));
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function listenForIpcConnections(
|
|
100
|
+
socketPath: string,
|
|
101
|
+
onConnection: (transport: IpcTransport<IpcProtocolMessage>) => void,
|
|
102
|
+
): Promise<Server> {
|
|
103
|
+
const server = createIpcSocketServer(onConnection);
|
|
104
|
+
await listenOnIpcSocket(server, socketPath);
|
|
105
|
+
return server;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function listenOnIpcSocket(
|
|
109
|
+
server: Server,
|
|
110
|
+
socketPath: string,
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
await mkdir(dirname(socketPath), { recursive: true });
|
|
113
|
+
await rm(socketPath, { force: true });
|
|
114
|
+
|
|
115
|
+
const originalClose = server.close.bind(server);
|
|
116
|
+
server.close = ((callback?: (error?: Error) => void) => {
|
|
117
|
+
return originalClose((error?: Error) => {
|
|
118
|
+
void rm(socketPath, { force: true }).finally(() => callback?.(error));
|
|
119
|
+
});
|
|
120
|
+
}) as Server["close"];
|
|
121
|
+
|
|
122
|
+
await new Promise<void>((resolve, reject) => {
|
|
123
|
+
const onError = (error: Error) => {
|
|
124
|
+
server.off("listening", onListening);
|
|
125
|
+
reject(error);
|
|
126
|
+
};
|
|
127
|
+
const onListening = () => {
|
|
128
|
+
server.off("error", onError);
|
|
129
|
+
resolve();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
server.once("error", onError);
|
|
133
|
+
server.once("listening", onListening);
|
|
134
|
+
server.listen(socketPath);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function connectSocket(socketPath: string): Promise<Socket> {
|
|
139
|
+
const socket = createConnection(socketPath);
|
|
140
|
+
|
|
141
|
+
return new Promise<Socket>((resolve, reject) => {
|
|
142
|
+
const onError = (error: Error) => {
|
|
143
|
+
socket.off("connect", onConnect);
|
|
144
|
+
reject(error);
|
|
145
|
+
};
|
|
146
|
+
const onConnect = () => {
|
|
147
|
+
socket.off("error", onError);
|
|
148
|
+
resolve(socket);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
socket.once("error", onError);
|
|
152
|
+
socket.once("connect", onConnect);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isIpcProtocolMessage(message: unknown): message is IpcProtocolMessage {
|
|
157
|
+
if (!isRecord(message)) return false;
|
|
158
|
+
|
|
159
|
+
if (message.type === "ipc-request") {
|
|
160
|
+
return (
|
|
161
|
+
typeof message.id === "string" &&
|
|
162
|
+
typeof message.method === "string" &&
|
|
163
|
+
Array.isArray(message.args)
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (message.type === "ipc-response") {
|
|
168
|
+
return (
|
|
169
|
+
typeof message.id === "string" &&
|
|
170
|
+
typeof message.method === "string" &&
|
|
171
|
+
(message.error === undefined || isSerializedError(message.error))
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isSerializedError(value: unknown): boolean {
|
|
179
|
+
return (
|
|
180
|
+
isRecord(value) &&
|
|
181
|
+
typeof value.name === "string" &&
|
|
182
|
+
typeof value.message === "string" &&
|
|
183
|
+
(value.stack === undefined || typeof value.stack === "string")
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
188
|
+
return typeof value === "object" && value !== null;
|
|
189
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { resolveLibrettoRepoRoot } from "./paths/repo-root.js";
|
|
4
|
+
|
|
5
|
+
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
|
|
6
|
+
|
|
7
|
+
function packageManagerFromUserAgent(
|
|
8
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
9
|
+
): PackageManager | null {
|
|
10
|
+
const userAgent = env.npm_config_user_agent ?? "";
|
|
11
|
+
if (userAgent.startsWith("pnpm")) return "pnpm";
|
|
12
|
+
if (userAgent.startsWith("yarn")) return "yarn";
|
|
13
|
+
if (userAgent.startsWith("bun")) return "bun";
|
|
14
|
+
if (userAgent.startsWith("npm")) return "npm";
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function packageManagerFromLockfile(root: string): PackageManager | null {
|
|
19
|
+
if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
20
|
+
if (existsSync(join(root, "yarn.lock"))) return "yarn";
|
|
21
|
+
if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
|
|
22
|
+
return "bun";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function detectPackageManager(
|
|
27
|
+
root = resolveLibrettoRepoRoot(),
|
|
28
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
29
|
+
): PackageManager {
|
|
30
|
+
const fromUserAgent = packageManagerFromUserAgent(env);
|
|
31
|
+
if (fromUserAgent) return fromUserAgent;
|
|
32
|
+
|
|
33
|
+
return packageManagerFromLockfile(root) ?? "npm";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function detectProjectPackageManager(
|
|
37
|
+
root = resolveLibrettoRepoRoot(),
|
|
38
|
+
): PackageManager {
|
|
39
|
+
return packageManagerFromLockfile(root) ?? "npm";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function installCommand(
|
|
43
|
+
packageManager = detectProjectPackageManager(),
|
|
44
|
+
): string {
|
|
45
|
+
switch (packageManager) {
|
|
46
|
+
case "yarn":
|
|
47
|
+
return "yarn add";
|
|
48
|
+
case "bun":
|
|
49
|
+
return "bun add";
|
|
50
|
+
case "pnpm":
|
|
51
|
+
return "pnpm add";
|
|
52
|
+
default:
|
|
53
|
+
return "npm install";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function librettoRunner(packageManager = detectPackageManager()): string {
|
|
58
|
+
switch (packageManager) {
|
|
59
|
+
case "pnpm":
|
|
60
|
+
return "pnpm exec";
|
|
61
|
+
case "yarn":
|
|
62
|
+
return "yarn";
|
|
63
|
+
case "bun":
|
|
64
|
+
return "bunx";
|
|
65
|
+
default:
|
|
66
|
+
return "npx";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function librettoCommand(
|
|
71
|
+
args = "",
|
|
72
|
+
packageManager = detectPackageManager(),
|
|
73
|
+
): string {
|
|
74
|
+
const suffix = args.trim();
|
|
75
|
+
return `${librettoRunner(packageManager)} libretto${suffix ? ` ${suffix}` : ""}`;
|
|
76
|
+
}
|