@pontia/pi-client-plugin 0.2.0 → 0.2.1

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.
@@ -0,0 +1,456 @@
1
+ import { createConnection, type Socket } from "node:net";
2
+ import { isAbsolute, join } from "node:path";
3
+
4
+ export const CONTROL_VERSION = 6;
5
+ export const MAX_CONTROL_FRAME_BYTES = 64 * 1024;
6
+ // Match the former HTTP event body limit, including the RPC envelope.
7
+ export const MAX_RPC_FRAME_BYTES = 2 * 1024 * 1024 + 1024;
8
+ const REQUEST_TIMEOUT_MS = 5_000;
9
+
10
+ export interface ControlIdentity {
11
+ sessionId: string;
12
+ runtimeInstanceId: string;
13
+ clientSessionKey?: string;
14
+ }
15
+ export interface ControlInput {
16
+ input: string;
17
+ inboxMessageId?: string;
18
+ }
19
+ export interface PiModel {
20
+ id: string;
21
+ name: string;
22
+ description: string;
23
+ }
24
+ export interface ModelControl {
25
+ listModels(): PiModel[];
26
+ setModel(model: string): Promise<void>;
27
+ onReconnect(): Promise<void>;
28
+ }
29
+ export interface LifecycleControl {
30
+ interrupt(): void;
31
+ shutdown(): void;
32
+ }
33
+ export interface PiConnection {
34
+ request(method: string, params: object): Promise<unknown>;
35
+ registered(identity: ControlIdentity): void;
36
+ close(): Promise<void>;
37
+ }
38
+ export class RpcError extends Error {
39
+ readonly code: number;
40
+ constructor(code: number, message: string) {
41
+ super(message);
42
+ this.code = code;
43
+ }
44
+ }
45
+
46
+ export function piSocketPath(pontiaHome: string): string {
47
+ const path = join(pontiaHome, "state", "pi", "rpc.sock");
48
+ if (!isAbsolute(path) || path.includes("\0") || Buffer.byteLength(path) > 103) {
49
+ throw new Error("Pi Unix socket requires an absolute path of at most 103 bytes without NUL");
50
+ }
51
+ return path;
52
+ }
53
+
54
+ class RpcSocket {
55
+ private sequence = 0;
56
+ private pending = new Map<
57
+ string,
58
+ {
59
+ resolve(value: unknown): void;
60
+ reject(error: Error): void;
61
+ timer: ReturnType<typeof setTimeout>;
62
+ }
63
+ >();
64
+ private buffered = Buffer.alloc(0);
65
+ private failed = false;
66
+
67
+ private socket: Socket;
68
+ private onSubmit: (input: ControlInput) => void;
69
+ private models?: ModelControl;
70
+ private lifecycle?: LifecycleControl;
71
+ private onReplay?: (inboxMessageId: string) => void;
72
+
73
+ constructor(
74
+ socket: Socket,
75
+ onSubmit: (input: ControlInput) => void,
76
+ models?: ModelControl,
77
+ onReplay?: (inboxMessageId: string) => void,
78
+ lifecycle?: LifecycleControl,
79
+ ) {
80
+ this.socket = socket;
81
+ this.onSubmit = onSubmit;
82
+ this.models = models;
83
+ this.onReplay = onReplay;
84
+ this.lifecycle = lifecycle;
85
+ socket.on("data", (chunk: Buffer) => this.receive(chunk));
86
+ socket.on("error", (error) => this.fail(error));
87
+ socket.on("close", () =>
88
+ this.fail(new Error("Pi RPC connection closed; requests were not replayed")),
89
+ );
90
+ }
91
+
92
+ close(): void {
93
+ this.fail(new Error("Pi RPC connection closed"));
94
+ }
95
+
96
+ request(method: string, params: object): Promise<unknown> {
97
+ if (this.failed) return Promise.reject(new Error("Pi RPC connection is closed"));
98
+ const id = `pi:${this.sequence++}`;
99
+ const encoded = this.encode({ jsonrpc: "2.0", id, method, params });
100
+ return new Promise((resolve, reject) => {
101
+ const timer = setTimeout(
102
+ () => this.fail(new Error("Pi RPC timed out; request was not replayed")),
103
+ REQUEST_TIMEOUT_MS,
104
+ );
105
+ timer.unref();
106
+ this.pending.set(id, { resolve, reject, timer });
107
+ this.write(encoded);
108
+ });
109
+ }
110
+
111
+ private encode(value: object): string {
112
+ const encoded = JSON.stringify(value);
113
+ if (Buffer.byteLength(encoded) > MAX_RPC_FRAME_BYTES)
114
+ throw new RpcError(-32602, "Pi RPC frame exceeds size limit");
115
+ return `${encoded}\n`;
116
+ }
117
+
118
+ private write(encoded: string): void {
119
+ if (this.socket.writableLength + Buffer.byteLength(encoded) > 2 * MAX_RPC_FRAME_BYTES) {
120
+ this.fail(new Error("Pi RPC backpressure limit reached"));
121
+ return;
122
+ }
123
+ this.socket.write(encoded);
124
+ }
125
+
126
+ private fail(error: Error): void {
127
+ if (this.failed) return;
128
+ this.failed = true;
129
+ for (const pending of this.pending.values()) {
130
+ clearTimeout(pending.timer);
131
+ pending.reject(error);
132
+ }
133
+ this.pending.clear();
134
+ this.socket.destroy();
135
+ }
136
+
137
+ private receive(chunk: Buffer): void {
138
+ let offset = 0;
139
+ try {
140
+ while (offset < chunk.length && !this.failed) {
141
+ const newline = chunk.indexOf(10, offset);
142
+ const end = newline === -1 ? chunk.length : newline;
143
+ const part = chunk.subarray(offset, end);
144
+ if (this.buffered.length + part.length > MAX_RPC_FRAME_BYTES)
145
+ throw new Error("Pi RPC frame exceeds size limit");
146
+ this.buffered = Buffer.concat([this.buffered, part]);
147
+ if (newline === -1) return;
148
+ const frame = this.buffered;
149
+ this.buffered = Buffer.alloc(0);
150
+ this.handle(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(frame)));
151
+ offset = newline + 1;
152
+ }
153
+ } catch (error) {
154
+ this.fail(error instanceof Error ? error : new Error(String(error)));
155
+ }
156
+ }
157
+
158
+ private handle(value: unknown): void {
159
+ if (!value || typeof value !== "object" || Array.isArray(value))
160
+ throw new Error("Invalid Pi RPC frame");
161
+ const message = value as Record<string, any>;
162
+ if (message.jsonrpc !== "2.0") throw new Error("Invalid Pi RPC version");
163
+ if (Object.hasOwn(message, "method")) {
164
+ if (
165
+ typeof message.method !== "string" ||
166
+ Object.hasOwn(message, "result") ||
167
+ Object.hasOwn(message, "error") ||
168
+ (message.id !== undefined &&
169
+ typeof message.id !== "string" &&
170
+ typeof message.id !== "number")
171
+ ) {
172
+ throw new Error("Invalid Pi RPC request");
173
+ }
174
+ const respond = (body: object) => {
175
+ if (message.id !== undefined)
176
+ this.write(this.encode({ jsonrpc: "2.0", id: message.id, ...body }));
177
+ };
178
+ const error = (code: number, message: string) => respond({ error: { code, message } });
179
+ const params = message.params === undefined ? {} : message.params;
180
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
181
+ error(-32602, "Expected named parameters");
182
+ return;
183
+ }
184
+ if (message.method === "ping") {
185
+ respond({ result: { pong: true } });
186
+ return;
187
+ }
188
+ if (this.lifecycle && (message.method === "interrupt" || message.method === "shutdown")) {
189
+ try {
190
+ this.lifecycle[message.method as "interrupt" | "shutdown"]();
191
+ respond({ result: { accepted: true } });
192
+ } catch (failure) {
193
+ error(
194
+ failure instanceof RpcError ? failure.code : -32006,
195
+ failure instanceof Error ? failure.message : String(failure),
196
+ );
197
+ }
198
+ return;
199
+ }
200
+ if (message.method === "branch.replay" && this.onReplay) {
201
+ if (
202
+ typeof params.inbox_message_id !== "string" ||
203
+ !/^msg_[^\s]+$/.test(params.inbox_message_id)
204
+ ) {
205
+ error(-32602, "branch.replay requires an Inbox Message identifier");
206
+ return;
207
+ }
208
+ try {
209
+ this.onReplay(params.inbox_message_id);
210
+ respond({ result: { accepted: true } });
211
+ } catch (failure) {
212
+ error(
213
+ failure instanceof RpcError ? failure.code : -32006,
214
+ failure instanceof Error ? failure.message : String(failure),
215
+ );
216
+ }
217
+ return;
218
+ }
219
+ if (this.models && (message.method === "models.list" || message.method === "model.set")) {
220
+ const models = this.models;
221
+ if (
222
+ message.method === "model.set" &&
223
+ (typeof params.model !== "string" || !params.model.trim())
224
+ ) {
225
+ error(-32602, "model.set requires a non-empty model");
226
+ return;
227
+ }
228
+ // Keep reading responses while setModel emits an acknowledged model fact.
229
+ void (async () => {
230
+ try {
231
+ if (message.method === "models.list")
232
+ respond({ result: { models: models.listModels() } });
233
+ else {
234
+ await models.setModel(params.model);
235
+ respond({ result: { accepted: true } });
236
+ }
237
+ } catch (failure) {
238
+ error(
239
+ failure instanceof RpcError ? failure.code : -32006,
240
+ failure instanceof Error ? failure.message : String(failure),
241
+ );
242
+ }
243
+ })().catch((failure) => this.fail(failure));
244
+ return;
245
+ }
246
+ if (message.method !== "submit") {
247
+ error(-32601, "Unknown Pi control method");
248
+ return;
249
+ }
250
+ if (Buffer.byteLength(JSON.stringify(message)) > MAX_CONTROL_FRAME_BYTES) {
251
+ error(-32602, "Pi submit frame exceeds 64 KiB");
252
+ return;
253
+ }
254
+ if (
255
+ typeof params.input !== "string" ||
256
+ !params.input.trim() ||
257
+ (params.inbox_message_id != null &&
258
+ (typeof params.inbox_message_id !== "string" || !params.inbox_message_id))
259
+ ) {
260
+ error(-32602, "submit requires non-empty input and optional inbox_message_id");
261
+ return;
262
+ }
263
+ try {
264
+ this.onSubmit({
265
+ input: params.input,
266
+ inboxMessageId: params.inbox_message_id ?? undefined,
267
+ });
268
+ respond({ result: { accepted: true } });
269
+ } catch (failure) {
270
+ error(
271
+ failure instanceof RpcError ? failure.code : -32006,
272
+ failure instanceof Error ? failure.message : String(failure),
273
+ );
274
+ }
275
+ return;
276
+ }
277
+ if (
278
+ Object.hasOwn(message, "result") === Object.hasOwn(message, "error") ||
279
+ (message.error !== undefined &&
280
+ (!message.error ||
281
+ !Number.isInteger(message.error.code) ||
282
+ typeof message.error.message !== "string"))
283
+ ) {
284
+ throw new Error("Invalid Pi RPC response");
285
+ }
286
+ const pending = this.pending.get(message.id);
287
+ if (!pending) throw new Error("Unexpected Pi RPC response ID");
288
+ this.pending.delete(message.id);
289
+ clearTimeout(pending.timer);
290
+ if (message.error) pending.reject(new RpcError(message.error.code, message.error.message));
291
+ else pending.resolve(message.result);
292
+ }
293
+ }
294
+
295
+ export async function connectPi(
296
+ pontiaHome: string,
297
+ onError: (error: Error) => void,
298
+ onSubmit: (input: ControlInput) => void,
299
+ models?: ModelControl,
300
+ onReplay?: (inboxMessageId: string) => void,
301
+ lifecycle?: LifecycleControl,
302
+ ): Promise<PiConnection> {
303
+ const path = piSocketPath(pontiaHome);
304
+ let identity: ControlIdentity | undefined;
305
+ let stopped = false;
306
+ let retry: ReturnType<typeof setTimeout> | undefined;
307
+ let current: RpcSocket | undefined;
308
+ const connecting = new Set<Socket>();
309
+ const peers = new Set<RpcSocket>();
310
+ let reconnectRejected = false;
311
+ let attached = false;
312
+ const waiting = new Set<() => void>();
313
+
314
+ async function ready(): Promise<void> {
315
+ if (!identity || attached || stopped || reconnectRejected) return;
316
+ await new Promise<void>((resolve, reject) => {
317
+ const wake = () => {
318
+ clearTimeout(timer);
319
+ waiting.delete(wake);
320
+ resolve();
321
+ };
322
+ const timer = setTimeout(() => {
323
+ waiting.delete(wake);
324
+ reject(new Error("Pi reconnect timed out"));
325
+ }, REQUEST_TIMEOUT_MS);
326
+ waiting.add(wake);
327
+ });
328
+ }
329
+
330
+ function wakeRequests(): void {
331
+ for (const wake of waiting) wake();
332
+ }
333
+
334
+ async function open(reportingOnly = false): Promise<RpcSocket> {
335
+ const socket = createConnection(path);
336
+ connecting.add(socket);
337
+ socket.unref();
338
+ try {
339
+ await new Promise<void>((resolve, reject) => {
340
+ const timer = setTimeout(
341
+ () => socket.destroy(new Error("Pi socket connection timed out")),
342
+ REQUEST_TIMEOUT_MS,
343
+ );
344
+ socket.once("error", reject);
345
+ socket.once("connect", () => {
346
+ clearTimeout(timer);
347
+ socket.off("error", reject);
348
+ resolve();
349
+ });
350
+ socket.once("close", () => {
351
+ clearTimeout(timer);
352
+ reject(new Error("Pi socket closed during connect"));
353
+ });
354
+ });
355
+ } finally {
356
+ connecting.delete(socket);
357
+ }
358
+ if (stopped) {
359
+ socket.destroy();
360
+ throw new Error("Pi connection is closed");
361
+ }
362
+ const peer = new RpcSocket(
363
+ socket,
364
+ reportingOnly
365
+ ? () => {
366
+ throw new Error("Pi reporting connection cannot accept input");
367
+ }
368
+ : onSubmit,
369
+ reportingOnly ? undefined : models,
370
+ reportingOnly ? undefined : onReplay,
371
+ reportingOnly ? undefined : lifecycle,
372
+ );
373
+ peers.add(peer);
374
+ socket.once("close", () => {
375
+ peers.delete(peer);
376
+ if (current === peer) {
377
+ current = undefined;
378
+ attached = false;
379
+ reconnect();
380
+ }
381
+ });
382
+ return peer;
383
+ }
384
+
385
+ function reconnect(): void {
386
+ if (stopped || reconnectRejected || !identity || retry) return;
387
+ retry = setTimeout(async () => {
388
+ retry = undefined;
389
+ try {
390
+ const peer = await open();
391
+ current = peer;
392
+ const result = (await peer.request("runtime.attach", {
393
+ version: CONTROL_VERSION,
394
+ session_id: identity!.sessionId,
395
+ runtime_instance_id: identity!.runtimeInstanceId,
396
+ client_session_key: identity!.clientSessionKey,
397
+ })) as Record<string, unknown>;
398
+ if (
399
+ result?.session_id !== identity!.sessionId ||
400
+ result?.runtime_instance_id !== identity!.runtimeInstanceId
401
+ ) {
402
+ throw new RpcError(-32009, "Pi reconnect identity mismatch");
403
+ }
404
+ attached = true;
405
+ wakeRequests();
406
+ await models?.onReconnect();
407
+ } catch (error) {
408
+ onError(error instanceof Error ? error : new Error(String(error)));
409
+ // A rejected identity cannot be repaired by repeating registration.
410
+ if (error instanceof RpcError && error.code !== -32603) {
411
+ reconnectRejected = true;
412
+ wakeRequests();
413
+ }
414
+ current?.close();
415
+ current = undefined;
416
+ reconnect();
417
+ }
418
+ }, 250);
419
+ retry.unref();
420
+ }
421
+
422
+ current = await open();
423
+ return {
424
+ async request(method, params) {
425
+ if (method === "turn.startFailure") {
426
+ if (!identity?.clientSessionKey || stopped)
427
+ throw new Error("Pi reporting identity is unavailable");
428
+ const peer = await open(true);
429
+ try {
430
+ return await peer.request(method, {
431
+ ...params,
432
+ client_session_key: identity.clientSessionKey,
433
+ });
434
+ } finally {
435
+ peer.close();
436
+ }
437
+ }
438
+ await ready();
439
+ if (!current || stopped) return Promise.reject(new Error("Pi connection is unavailable"));
440
+ return current.request(method, params);
441
+ },
442
+ registered(value) {
443
+ identity = value;
444
+ attached = !!current;
445
+ if (!current) reconnect();
446
+ },
447
+ async close() {
448
+ stopped = true;
449
+ wakeRequests();
450
+ if (retry) clearTimeout(retry);
451
+ for (const socket of connecting) socket.destroy();
452
+ for (const peer of peers) peer.close();
453
+ current = undefined;
454
+ },
455
+ };
456
+ }
package/src/discovery.ts CHANGED
@@ -1,23 +1,6 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { isAbsolute, join, parse, sep } from "node:path";
3
2
  import type { EnvLike } from "./context.js";
4
3
 
5
- const DEFAULT_BIND_ADDR = "127.0.0.1:8080";
6
-
7
- export interface PontiaConnection {
8
- baseUrl: string;
9
- internalEventUrl: string;
10
- bindingUpsertUrl: string;
11
- externalApiUrl: string;
12
- externalApiToken?: string;
13
- }
14
-
15
- export interface PontiaDiscoveryOptions {
16
- pontiaHome?: string;
17
- env?: EnvLike;
18
- fetch?: typeof fetch;
19
- }
20
-
21
4
  function optionalString(value: unknown): string | undefined {
22
5
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
23
6
  }
@@ -30,53 +13,7 @@ export function pontiaHomeFromEnv(env: EnvLike = process.env): string | undefine
30
13
 
31
14
  function validRoot(value: unknown): string | undefined {
32
15
  const path = optionalString(value);
33
- if (!path || !isAbsolute(path) || parse(path).root === path || path.split(sep).includes("..")) return undefined;
34
- return path;
35
- }
36
-
37
- function parseTomlString(raw: string, key: string): string | undefined {
38
- const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
- const match = raw.match(new RegExp(`^\\s*${escaped}\\s*=\\s*"([^"]*)"`, "m"));
40
- return optionalString(match?.[1]);
41
- }
42
-
43
- function baseUrlFromBindAddr(bindAddr: string): string | undefined {
44
- const value = bindAddr.trim();
45
- const bracketMatch = value.match(/^\[([^\]]+)\]:(\d+)$/);
46
- const plainMatch = value.match(/^([^:]+):(\d+)$/);
47
- const host = bracketMatch?.[1] ?? plainMatch?.[1];
48
- const port = bracketMatch?.[2] ?? plainMatch?.[2];
49
- if (!host || !port) return undefined;
50
- const localHost = host === "0.0.0.0" || host === "::" || host === "[::]" ? "127.0.0.1" : host;
51
- return port === "80" ? `http://${localHost}` : `http://${localHost}:${port}`;
52
- }
53
-
54
- function connectionFromBaseUrl(baseUrl: string, externalApiToken?: string): PontiaConnection {
55
- const normalized = baseUrl.replace(/\/+$/, "");
56
- return {
57
- baseUrl: normalized,
58
- internalEventUrl: `${normalized}/internal/v1/events`,
59
- bindingUpsertUrl: `${normalized}/internal/v1/runtime-bindings/upsert`,
60
- externalApiUrl: `${normalized}/external/v1`,
61
- ...(externalApiToken ? { externalApiToken } : {}),
62
- };
63
- }
64
-
65
- export async function resolvePontiaConnection(options: PontiaDiscoveryOptions = {}): Promise<PontiaConnection | undefined> {
66
- const pontiaHome = options.pontiaHome !== undefined ? validRoot(options.pontiaHome) : pontiaHomeFromEnv(options.env);
67
- if (!pontiaHome) return undefined;
68
- const configPath = join(pontiaHome, "config.toml");
69
-
70
- let raw: string;
71
- try {
72
- raw = await readFile(configPath, "utf8");
73
- } catch {
16
+ if (!path || !isAbsolute(path) || parse(path).root === path || path.split(sep).includes(".."))
74
17
  return undefined;
75
- }
76
-
77
- const configuredBindAddr = parseTomlString(raw, "bind_addr");
78
- if (!configuredBindAddr && /^\s*bind_addr\s*=/m.test(raw)) return undefined;
79
- const baseUrl = baseUrlFromBindAddr(configuredBindAddr ?? DEFAULT_BIND_ADDR);
80
- if (!baseUrl) return undefined;
81
- return connectionFromBaseUrl(baseUrl, parseTomlString(raw, "external_api_token"));
18
+ return path;
82
19
  }