@vellumai/credential-executor 0.11.2 → 0.11.3-staging.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.
- package/Dockerfile +1 -0
- package/knip.json +2 -1
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +4 -2
- package/src/__tests__/local-secure-key-backend.test.ts +149 -9
- package/src/main.ts +42 -26
- package/src/materializers/local-secure-key-backend.ts +37 -5
package/Dockerfile
CHANGED
|
@@ -56,6 +56,7 @@ COPY --from=builder --chown=ces:ces /app /app
|
|
|
56
56
|
COPY --chown=ces:ces packages/service-contracts /app/packages/service-contracts
|
|
57
57
|
COPY --chown=ces:ces packages/credential-storage /app/packages/credential-storage
|
|
58
58
|
COPY --chown=ces:ces packages/egress-proxy /app/packages/egress-proxy
|
|
59
|
+
COPY --chown=ces:ces packages/ipc-server-utils /app/packages/ipc-server-utils
|
|
59
60
|
COPY --chown=ces:ces credential-executor ./
|
|
60
61
|
|
|
61
62
|
# Pre-create /ces-data so the non-root ces user can write to it
|
package/knip.json
CHANGED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vellumai/ipc-server-utils",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "bunx tsc --noEmit",
|
|
12
|
+
"test": "bun test src/"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/bun": "1.3.10",
|
|
16
|
+
"typescript": "5.9.3"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { removeIpcEndpointFile, resolveIpcEndpoint } from "./endpoint.js";
|
|
6
|
+
const windowsEndpoint = (name: string, workspaceDir: string) => resolveIpcEndpoint(name, { workspaceDir, platform: "win32" });
|
|
7
|
+
describe("resolveIpcEndpoint", () => {
|
|
8
|
+
test("keeps long POSIX fallbacks deterministic and bounded", () => {
|
|
9
|
+
const options = {
|
|
10
|
+
workspaceDir: `/tmp/${"a".repeat(120)}`,
|
|
11
|
+
platform: "darwin" as const,
|
|
12
|
+
};
|
|
13
|
+
const endpoint = resolveIpcEndpoint("assistant", options);
|
|
14
|
+
expect(resolveIpcEndpoint("assistant", options)).toEqual(endpoint);
|
|
15
|
+
expect(Buffer.byteLength(endpoint.path)).toBeLessThanOrEqual(103);
|
|
16
|
+
});
|
|
17
|
+
test("returns normalized, isolated, bounded Windows pipes", () => {
|
|
18
|
+
const first = windowsEndpoint("assistant", "C:\\one");
|
|
19
|
+
expect(resolveIpcEndpoint("assistant", { workspaceDir: "c:\\ONE\\", platform: "win32", env: { ASSISTANT_IPC_SOCKET_DIR: "C:\\ignored" } })).toEqual(first);
|
|
20
|
+
expect(first.path.length).toBeLessThanOrEqual(256);
|
|
21
|
+
expect(windowsEndpoint("assistant", "C:\\two").path).not.toBe(first.path);
|
|
22
|
+
expect(windowsEndpoint("gateway", "C:\\one").path).not.toBe(first.path);
|
|
23
|
+
});
|
|
24
|
+
test("cleans socket files but skips named pipes", () => {
|
|
25
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-ipc-cleanup-"));
|
|
26
|
+
const socketPath = join(dir, "assistant.sock");
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(socketPath, "stale");
|
|
29
|
+
removeIpcEndpointFile(socketPath);
|
|
30
|
+
expect(existsSync(socketPath)).toBe(false);
|
|
31
|
+
removeIpcEndpointFile("\\\\.\\pipe\\vellum-assistant-test");
|
|
32
|
+
} finally {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { unlinkSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { posix, win32 } from "node:path";
|
|
5
|
+
|
|
6
|
+
const DARWIN_UNIX_SOCKET_MAX_PATH_BYTES = 103;
|
|
7
|
+
const DEFAULT_UNIX_SOCKET_MAX_PATH_BYTES = 107;
|
|
8
|
+
const IPC_TMP_DIR_NAME = "vellum-ipc";
|
|
9
|
+
const join = posix.join;
|
|
10
|
+
|
|
11
|
+
export type IpcSocketPathSource =
|
|
12
|
+
| "env-override"
|
|
13
|
+
| "workspace"
|
|
14
|
+
| "tmp-hash"
|
|
15
|
+
| "tmp-short-hash"
|
|
16
|
+
| "windows-named-pipe";
|
|
17
|
+
|
|
18
|
+
export interface IpcSocketPathResolution {
|
|
19
|
+
path: string;
|
|
20
|
+
source: IpcSocketPathSource;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IpcEndpointOptions {
|
|
24
|
+
workspaceDir: string;
|
|
25
|
+
env?: Record<string, string | undefined>;
|
|
26
|
+
platform?: NodeJS.Platform;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getUnixSocketMaxPathBytes(platform: NodeJS.Platform): number {
|
|
30
|
+
return platform === "darwin"
|
|
31
|
+
? DARWIN_UNIX_SOCKET_MAX_PATH_BYTES
|
|
32
|
+
: DEFAULT_UNIX_SOCKET_MAX_PATH_BYTES;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isPathWithinSocketLimit(path: string, maxPathBytes: number): boolean {
|
|
36
|
+
return Buffer.byteLength(path, "utf8") <= maxPathBytes;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Derive the env var name and socket filename from a socket name.
|
|
41
|
+
*
|
|
42
|
+
* Examples (hyphens in the name become underscores in the env var):
|
|
43
|
+
*/
|
|
44
|
+
function deriveSocketNames(socketName: string): {
|
|
45
|
+
envVar: string;
|
|
46
|
+
fileName: string;
|
|
47
|
+
} {
|
|
48
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(socketName)) {
|
|
49
|
+
throw new Error(`Invalid IPC endpoint name: ${socketName}`);
|
|
50
|
+
}
|
|
51
|
+
const envVar = `${socketName.toUpperCase().replace(/-/g, "_")}_IPC_SOCKET_DIR`;
|
|
52
|
+
const fileName = `${socketName}.sock`;
|
|
53
|
+
return { envVar, fileName };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resolveWindowsNamedPipe(
|
|
57
|
+
socketName: string,
|
|
58
|
+
workspaceDir: string,
|
|
59
|
+
): IpcSocketPathResolution {
|
|
60
|
+
const workspaceIdentity = win32
|
|
61
|
+
.normalize(workspaceDir)
|
|
62
|
+
.replace(/\\+$/, "")
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
const hash = createHash("sha256")
|
|
65
|
+
.update(`${workspaceIdentity}\0${socketName}`)
|
|
66
|
+
.digest("hex")
|
|
67
|
+
.slice(0, 24);
|
|
68
|
+
return {
|
|
69
|
+
path: `\\\\.\\pipe\\vellum-${socketName}-${hash}`,
|
|
70
|
+
source: "windows-named-pipe",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isNamedPipePath(endpointPath: string): boolean {
|
|
75
|
+
return /^\\\\[.?]\\pipe\\/.test(endpointPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function removeIpcEndpointFile(endpointPath: string): void {
|
|
79
|
+
if (isNamedPipePath(endpointPath)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(endpointPath);
|
|
84
|
+
} catch {
|
|
85
|
+
// Already absent.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Resolve the path to an IPC socket file.
|
|
91
|
+
*
|
|
92
|
+
* Resolution order:
|
|
93
|
+
* POSIX uses overrides, workspace paths, then bounded temporary paths.
|
|
94
|
+
* Windows uses deterministic named pipes.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveIpcEndpoint(
|
|
97
|
+
socketName: string,
|
|
98
|
+
options: IpcEndpointOptions,
|
|
99
|
+
): IpcSocketPathResolution {
|
|
100
|
+
const { envVar, fileName } = deriveSocketNames(socketName);
|
|
101
|
+
const platform = options.platform ?? process.platform;
|
|
102
|
+
if (platform === "win32") {
|
|
103
|
+
return resolveWindowsNamedPipe(socketName, options.workspaceDir);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Explicit override via env var.
|
|
107
|
+
const envSocketDir = (options.env ?? process.env)[envVar]?.trim();
|
|
108
|
+
if (envSocketDir) {
|
|
109
|
+
return {
|
|
110
|
+
path: join(envSocketDir, fileName),
|
|
111
|
+
source: "env-override",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const maxPathBytes = getUnixSocketMaxPathBytes(platform);
|
|
116
|
+
const workspacePath = join(options.workspaceDir, fileName);
|
|
117
|
+
|
|
118
|
+
if (isPathWithinSocketLimit(workspacePath, maxPathBytes)) {
|
|
119
|
+
return {
|
|
120
|
+
path: workspacePath,
|
|
121
|
+
source: "workspace",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Workspace path exceeds AF_UNIX limit - fall back to tmpdir.
|
|
126
|
+
const hash = createHash("sha256")
|
|
127
|
+
.update(workspacePath)
|
|
128
|
+
.digest("hex")
|
|
129
|
+
.slice(0, 12);
|
|
130
|
+
const hashedPath = join(tmpdir(), IPC_TMP_DIR_NAME, `${hash}-${fileName}`);
|
|
131
|
+
if (isPathWithinSocketLimit(hashedPath, maxPathBytes)) {
|
|
132
|
+
return {
|
|
133
|
+
path: hashedPath,
|
|
134
|
+
source: "tmp-hash",
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
path: join(tmpdir(), `v-${hash}.sock`),
|
|
140
|
+
source: "tmp-short-hash",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
SocketWatchdog,
|
|
3
|
+
ensureSocketDir,
|
|
4
|
+
type SocketWatchdogOptions,
|
|
5
|
+
type SocketWatchdogLogger,
|
|
6
|
+
} from "./socket-watchdog.js";
|
|
7
|
+
export { isNamedPipePath, removeIpcEndpointFile, resolveIpcEndpoint } from "./endpoint.js";
|
|
8
|
+
export { ipcListenOptions } from "./listen-options.js";
|
|
9
|
+
export {
|
|
10
|
+
IpcFrameReader,
|
|
11
|
+
writeLegacyMessage,
|
|
12
|
+
writeMessage,
|
|
13
|
+
writeStreamChunk,
|
|
14
|
+
writeStreamEnd,
|
|
15
|
+
type IpcEnvelope,
|
|
16
|
+
type OnMessageCallback,
|
|
17
|
+
type StreamCallbacks,
|
|
18
|
+
} from "./ipc-framing.js";
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Length-prefixed binary framing for the IPC protocol.
|
|
3
|
+
*
|
|
4
|
+
* Wire format: [4-byte big-endian length][payload bytes]
|
|
5
|
+
*
|
|
6
|
+
* Messages use a JSON envelope. When the envelope's `headers` map contains
|
|
7
|
+
* a `content-length` key, a single binary data frame immediately follows
|
|
8
|
+
* the JSON frame.
|
|
9
|
+
*
|
|
10
|
+
* Chunked streaming: when `headers["transfer-encoding"]` is `"chunked"`,
|
|
11
|
+
* multiple binary data frames follow the JSON envelope. A zero-length
|
|
12
|
+
* frame (4 bytes of 0x00) terminates the stream. This enables streaming
|
|
13
|
+
* responses (e.g. audio, SSE) over IPC without buffering the full payload.
|
|
14
|
+
*
|
|
15
|
+
* Backward compatibility: the reader detects legacy newline-delimited JSON
|
|
16
|
+
* by checking if the first byte is `{` (0x7B). New-format frames always
|
|
17
|
+
* start with a 4-byte length prefix whose first byte is < 0x7B for any
|
|
18
|
+
* realistic message size (< 2 GB).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Socket } from "node:net";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface IpcEnvelope {
|
|
28
|
+
id: string;
|
|
29
|
+
// Request fields
|
|
30
|
+
method?: string;
|
|
31
|
+
params?: Record<string, unknown>;
|
|
32
|
+
// Response fields
|
|
33
|
+
result?: unknown;
|
|
34
|
+
error?: string;
|
|
35
|
+
// Shared — when headers["content-length"] is present, a binary frame follows.
|
|
36
|
+
// When headers["transfer-encoding"] is "chunked", multiple binary frames
|
|
37
|
+
// follow until a zero-length terminator.
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Writing
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Write a length-prefixed frame to a socket. */
|
|
46
|
+
function writeFrame(socket: Socket, data: Buffer | Uint8Array): void {
|
|
47
|
+
const header = Buffer.alloc(4);
|
|
48
|
+
header.writeUInt32BE(data.length, 0);
|
|
49
|
+
socket.write(header);
|
|
50
|
+
socket.write(data);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write an IPC envelope, optionally followed by a binary data frame.
|
|
55
|
+
* If `binary` is provided, the envelope's headers must include content-length.
|
|
56
|
+
*/
|
|
57
|
+
export function writeMessage(
|
|
58
|
+
socket: Socket,
|
|
59
|
+
envelope: IpcEnvelope,
|
|
60
|
+
binary?: Uint8Array,
|
|
61
|
+
): void {
|
|
62
|
+
const json = Buffer.from(JSON.stringify(envelope), "utf-8");
|
|
63
|
+
writeFrame(socket, json);
|
|
64
|
+
if (binary) {
|
|
65
|
+
writeFrame(socket, binary);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Write a legacy newline-delimited JSON message.
|
|
71
|
+
* Used when the client connected with the legacy protocol.
|
|
72
|
+
*/
|
|
73
|
+
export function writeLegacyMessage(
|
|
74
|
+
socket: Socket,
|
|
75
|
+
envelope: IpcEnvelope,
|
|
76
|
+
): void {
|
|
77
|
+
socket.write(JSON.stringify(envelope) + "\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write a single chunk in a chunked transfer stream.
|
|
82
|
+
* The envelope must have already been sent with transfer-encoding: chunked.
|
|
83
|
+
*/
|
|
84
|
+
export function writeStreamChunk(socket: Socket, chunk: Uint8Array): void {
|
|
85
|
+
writeFrame(socket, chunk);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Write a zero-length frame to signal the end of a chunked transfer stream.
|
|
90
|
+
*/
|
|
91
|
+
export function writeStreamEnd(socket: Socket): void {
|
|
92
|
+
const terminator = Buffer.alloc(4); // 4 bytes of 0x00 = length 0
|
|
93
|
+
socket.write(terminator);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Reading
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** Callback for complete messages (non-streaming). */
|
|
101
|
+
export type OnMessageCallback = (
|
|
102
|
+
envelope: IpcEnvelope,
|
|
103
|
+
binary: Uint8Array | undefined,
|
|
104
|
+
) => void;
|
|
105
|
+
|
|
106
|
+
/** Callbacks for chunked streaming responses. */
|
|
107
|
+
export interface StreamCallbacks {
|
|
108
|
+
onStreamStart: (envelope: IpcEnvelope) => void;
|
|
109
|
+
onStreamChunk: (chunk: Uint8Array) => void;
|
|
110
|
+
onStreamEnd: () => void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Streaming reader that accumulates socket data and emits parsed messages.
|
|
115
|
+
* Handles both legacy newline-delimited JSON and new length-prefixed frames.
|
|
116
|
+
*
|
|
117
|
+
* Supports three response modes:
|
|
118
|
+
* 1. JSON-only: envelope with no binary follow-up
|
|
119
|
+
* 2. Binary: envelope with content-length → single binary frame
|
|
120
|
+
* 3. Chunked: envelope with transfer-encoding: chunked → multiple binary
|
|
121
|
+
* frames terminated by a zero-length frame
|
|
122
|
+
*/
|
|
123
|
+
export class IpcFrameReader {
|
|
124
|
+
private buffer = Buffer.alloc(0);
|
|
125
|
+
private onMessage: OnMessageCallback;
|
|
126
|
+
private onError: (err: Error) => void;
|
|
127
|
+
private streamCallbacks: StreamCallbacks | undefined;
|
|
128
|
+
|
|
129
|
+
// State machine for length-prefixed reading
|
|
130
|
+
private state:
|
|
131
|
+
| "detect"
|
|
132
|
+
| "read-length"
|
|
133
|
+
| "read-payload"
|
|
134
|
+
| "read-binary"
|
|
135
|
+
| "read-stream-chunk-length"
|
|
136
|
+
| "read-stream-chunk" = "detect";
|
|
137
|
+
private pendingLength = 0;
|
|
138
|
+
private pendingEnvelope: IpcEnvelope | null = null;
|
|
139
|
+
private expectBinary = false;
|
|
140
|
+
|
|
141
|
+
/** Whether this connection uses the legacy newline-delimited protocol. */
|
|
142
|
+
isLegacy = false;
|
|
143
|
+
|
|
144
|
+
constructor(
|
|
145
|
+
onMessage: OnMessageCallback,
|
|
146
|
+
onError?: (err: Error) => void,
|
|
147
|
+
streamCallbacks?: StreamCallbacks,
|
|
148
|
+
) {
|
|
149
|
+
this.onMessage = onMessage;
|
|
150
|
+
this.onError = onError ?? (() => {});
|
|
151
|
+
this.streamCallbacks = streamCallbacks;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Feed incoming socket data into the reader. */
|
|
155
|
+
push(chunk: Buffer): void {
|
|
156
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
157
|
+
this.drain();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private drain(): void {
|
|
161
|
+
while (true) {
|
|
162
|
+
if (this.state === "detect") {
|
|
163
|
+
if (this.buffer.length === 0) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Legacy detection: first byte is '{' (0x7B)
|
|
167
|
+
if (this.buffer[0] === 0x7b) {
|
|
168
|
+
this.isLegacy = true;
|
|
169
|
+
this.drainLegacy();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// New format — fall through to read-length
|
|
173
|
+
this.state = "read-length";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (this.state === "read-length") {
|
|
177
|
+
if (this.buffer.length < 4) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this.pendingLength = this.buffer.readUInt32BE(0);
|
|
181
|
+
this.buffer = this.buffer.subarray(4);
|
|
182
|
+
this.state = this.expectBinary ? "read-binary" : "read-payload";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (this.state === "read-payload") {
|
|
186
|
+
if (this.buffer.length < this.pendingLength) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const payload = this.buffer.subarray(0, this.pendingLength);
|
|
190
|
+
this.buffer = this.buffer.subarray(this.pendingLength);
|
|
191
|
+
|
|
192
|
+
let envelope: IpcEnvelope;
|
|
193
|
+
try {
|
|
194
|
+
envelope = JSON.parse(payload.toString("utf-8")) as IpcEnvelope;
|
|
195
|
+
} catch {
|
|
196
|
+
this.onError(new Error("Invalid JSON in IPC frame"));
|
|
197
|
+
this.state = "detect";
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const transferEncoding = envelope.headers?.["transfer-encoding"];
|
|
202
|
+
if (transferEncoding === "chunked") {
|
|
203
|
+
// Chunked streaming — emit start, then read chunks until terminator
|
|
204
|
+
this.pendingEnvelope = envelope;
|
|
205
|
+
this.streamCallbacks?.onStreamStart(envelope);
|
|
206
|
+
this.state = "read-stream-chunk-length";
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const contentLength = envelope.headers?.["content-length"];
|
|
211
|
+
if (contentLength != null) {
|
|
212
|
+
// Binary frame follows
|
|
213
|
+
this.pendingEnvelope = envelope;
|
|
214
|
+
this.expectBinary = true;
|
|
215
|
+
this.state = "read-length";
|
|
216
|
+
} else {
|
|
217
|
+
this.onMessage(envelope, undefined);
|
|
218
|
+
this.expectBinary = false;
|
|
219
|
+
this.state = "detect";
|
|
220
|
+
}
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (this.state === "read-binary") {
|
|
225
|
+
if (this.buffer.length < this.pendingLength) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const binary = new Uint8Array(
|
|
229
|
+
this.buffer.subarray(0, this.pendingLength),
|
|
230
|
+
);
|
|
231
|
+
this.buffer = this.buffer.subarray(this.pendingLength);
|
|
232
|
+
|
|
233
|
+
this.onMessage(this.pendingEnvelope!, binary);
|
|
234
|
+
this.pendingEnvelope = null;
|
|
235
|
+
this.expectBinary = false;
|
|
236
|
+
this.state = "detect";
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Chunked streaming states
|
|
241
|
+
if (this.state === "read-stream-chunk-length") {
|
|
242
|
+
if (this.buffer.length < 4) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.pendingLength = this.buffer.readUInt32BE(0);
|
|
246
|
+
this.buffer = this.buffer.subarray(4);
|
|
247
|
+
|
|
248
|
+
if (this.pendingLength === 0) {
|
|
249
|
+
// Zero-length frame = end of stream
|
|
250
|
+
this.streamCallbacks?.onStreamEnd();
|
|
251
|
+
this.pendingEnvelope = null;
|
|
252
|
+
this.state = "detect";
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
this.state = "read-stream-chunk";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (this.state === "read-stream-chunk") {
|
|
260
|
+
if (this.buffer.length < this.pendingLength) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const chunk = new Uint8Array(
|
|
264
|
+
this.buffer.subarray(0, this.pendingLength),
|
|
265
|
+
);
|
|
266
|
+
this.buffer = this.buffer.subarray(this.pendingLength);
|
|
267
|
+
|
|
268
|
+
this.streamCallbacks?.onStreamChunk(chunk);
|
|
269
|
+
this.state = "read-stream-chunk-length";
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Legacy mode: parse newline-delimited JSON lines.
|
|
277
|
+
* Once we enter legacy mode, we stay in it for the lifetime of the connection.
|
|
278
|
+
*/
|
|
279
|
+
private drainLegacy(): void {
|
|
280
|
+
let newlineIdx: number;
|
|
281
|
+
while ((newlineIdx = this.buffer.indexOf(0x0a)) !== -1) {
|
|
282
|
+
const line = this.buffer.subarray(0, newlineIdx).toString("utf-8").trim();
|
|
283
|
+
this.buffer = this.buffer.subarray(newlineIdx + 1);
|
|
284
|
+
if (!line) {
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
const envelope = JSON.parse(line) as IpcEnvelope;
|
|
289
|
+
this.onMessage(envelope, undefined);
|
|
290
|
+
} catch {
|
|
291
|
+
this.onError(new Error("Invalid JSON in legacy IPC line"));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|