@superdoc-dev/sdk 1.0.0-alpha.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/LICENSE +661 -0
- package/package.json +29 -0
- package/skills/.gitkeep +0 -0
- package/skills/editing-docx.md +113 -0
- package/src/generated/client.ts +857 -0
- package/src/generated/contract.ts +3467 -0
- package/src/index.ts +33 -0
- package/src/runtime/embedded-cli.ts +99 -0
- package/src/runtime/errors.ts +13 -0
- package/src/runtime/host.ts +463 -0
- package/src/runtime/process.ts +69 -0
- package/src/runtime/spawn.ts +190 -0
- package/src/runtime/transport-common.ts +134 -0
- package/src/skills.ts +76 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createDocApi } from './generated/client';
|
|
2
|
+
import { SuperDocRuntime, type SuperDocClientOptions } from './runtime/process';
|
|
3
|
+
|
|
4
|
+
export class SuperDocClient {
|
|
5
|
+
private readonly runtime: SuperDocRuntime;
|
|
6
|
+
readonly doc: ReturnType<typeof createDocApi>;
|
|
7
|
+
|
|
8
|
+
constructor(options: SuperDocClientOptions = {}) {
|
|
9
|
+
this.runtime = new SuperDocRuntime(options);
|
|
10
|
+
this.doc = createDocApi(this.runtime);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async connect(): Promise<void> {
|
|
14
|
+
await this.runtime.connect();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async dispose(): Promise<void> {
|
|
18
|
+
await this.runtime.dispose();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSuperDocClient(options: SuperDocClientOptions = {}): SuperDocClient {
|
|
23
|
+
return new SuperDocClient(options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { getSkill, listSkills } from './skills';
|
|
27
|
+
export { SuperDocCliError } from './runtime/errors';
|
|
28
|
+
export type {
|
|
29
|
+
InvokeOptions,
|
|
30
|
+
OperationSpec,
|
|
31
|
+
OperationParamSpec,
|
|
32
|
+
SuperDocClientOptions,
|
|
33
|
+
} from './runtime/process';
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { chmodSync, existsSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { SuperDocCliError } from './errors';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
|
|
9
|
+
type SupportedTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64' | 'windows-x64';
|
|
10
|
+
|
|
11
|
+
const TARGET_TO_PACKAGE: Record<SupportedTarget, string> = {
|
|
12
|
+
'darwin-arm64': '@superdoc-dev/sdk-darwin-arm64',
|
|
13
|
+
'darwin-x64': '@superdoc-dev/sdk-darwin-x64',
|
|
14
|
+
'linux-x64': '@superdoc-dev/sdk-linux-x64',
|
|
15
|
+
'linux-arm64': '@superdoc-dev/sdk-linux-arm64',
|
|
16
|
+
'windows-x64': '@superdoc-dev/sdk-windows-x64',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const TARGET_TO_DIR: Record<SupportedTarget, string> = {
|
|
20
|
+
'darwin-arm64': 'sdk-darwin-arm64',
|
|
21
|
+
'darwin-x64': 'sdk-darwin-x64',
|
|
22
|
+
'linux-x64': 'sdk-linux-x64',
|
|
23
|
+
'linux-arm64': 'sdk-linux-arm64',
|
|
24
|
+
'windows-x64': 'sdk-windows-x64',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function resolveTarget(): SupportedTarget | null {
|
|
28
|
+
const platform = process.platform;
|
|
29
|
+
const arch = process.arch;
|
|
30
|
+
|
|
31
|
+
if (platform === 'darwin' && arch === 'arm64') return 'darwin-arm64';
|
|
32
|
+
if (platform === 'darwin' && arch === 'x64') return 'darwin-x64';
|
|
33
|
+
if (platform === 'linux' && arch === 'x64') return 'linux-x64';
|
|
34
|
+
if (platform === 'linux' && arch === 'arm64') return 'linux-arm64';
|
|
35
|
+
if (platform === 'win32' && arch === 'x64') return 'windows-x64';
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function binaryNameForTarget(target: SupportedTarget): string {
|
|
41
|
+
return target === 'windows-x64' ? 'superdoc.exe' : 'superdoc';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ensureExecutable(binaryPath: string): void {
|
|
45
|
+
if (process.platform === 'win32') return;
|
|
46
|
+
try {
|
|
47
|
+
chmodSync(binaryPath, 0o755);
|
|
48
|
+
} catch {
|
|
49
|
+
// Non-fatal: if chmod fails, spawn() will surface the real execution error.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolveFromPlatformPackage(target: SupportedTarget): string | null {
|
|
54
|
+
const pkg = TARGET_TO_PACKAGE[target];
|
|
55
|
+
const binaryName = binaryNameForTarget(target);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
return require.resolve(`${pkg}/bin/${binaryName}`);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveFromWorkspaceFallback(target: SupportedTarget): string | null {
|
|
65
|
+
const binaryName = binaryNameForTarget(target);
|
|
66
|
+
const dirName = TARGET_TO_DIR[target];
|
|
67
|
+
const filePath = path.resolve(fileURLToPath(new URL('../../platforms', import.meta.url)), dirName, 'bin', binaryName);
|
|
68
|
+
if (!existsSync(filePath)) return null;
|
|
69
|
+
return filePath;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function resolveEmbeddedCliBinary(): string {
|
|
73
|
+
const target = resolveTarget();
|
|
74
|
+
if (!target) {
|
|
75
|
+
throw new SuperDocCliError('No embedded SuperDoc CLI binary is available for this platform.', {
|
|
76
|
+
code: 'UNSUPPORTED_PLATFORM',
|
|
77
|
+
details: {
|
|
78
|
+
platform: process.platform,
|
|
79
|
+
arch: process.arch,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const platformPackagePath = resolveFromPlatformPackage(target);
|
|
85
|
+
const resolvedPath = platformPackagePath ?? resolveFromWorkspaceFallback(target);
|
|
86
|
+
|
|
87
|
+
if (!resolvedPath) {
|
|
88
|
+
throw new SuperDocCliError('Embedded SuperDoc CLI binary is missing for this platform.', {
|
|
89
|
+
code: 'CLI_BINARY_MISSING',
|
|
90
|
+
details: {
|
|
91
|
+
target,
|
|
92
|
+
packageName: TARGET_TO_PACKAGE[target],
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
ensureExecutable(resolvedPath);
|
|
98
|
+
return resolvedPath;
|
|
99
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export class SuperDocCliError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
readonly details?: unknown;
|
|
4
|
+
readonly exitCode?: number;
|
|
5
|
+
|
|
6
|
+
constructor(message: string, options: { code: string; details?: unknown; exitCode?: number }) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'SuperDocCliError';
|
|
9
|
+
this.code = options.code;
|
|
10
|
+
this.details = options.details;
|
|
11
|
+
this.exitCode = options.exitCode;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
|
|
3
|
+
import { buildOperationArgv, resolveInvocation, type InvokeOptions, type OperationSpec, type SuperDocHostOptions } from './transport-common';
|
|
4
|
+
import { SuperDocCliError } from './errors';
|
|
5
|
+
|
|
6
|
+
type PendingRequest = {
|
|
7
|
+
resolve: (value: unknown) => void;
|
|
8
|
+
reject: (error: SuperDocCliError) => void;
|
|
9
|
+
timer: NodeJS.Timeout;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type JsonRpcErrorData = {
|
|
13
|
+
cliCode?: unknown;
|
|
14
|
+
message?: unknown;
|
|
15
|
+
details?: unknown;
|
|
16
|
+
exitCode?: unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type JsonRpcError = {
|
|
20
|
+
code: number;
|
|
21
|
+
message: string;
|
|
22
|
+
data?: JsonRpcErrorData;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const HOST_PROTOCOL_VERSION = '1.0';
|
|
26
|
+
const REQUIRED_FEATURES = ['cli.invoke', 'host.shutdown'];
|
|
27
|
+
|
|
28
|
+
const JSON_RPC_TIMEOUT_CODE = -32011;
|
|
29
|
+
|
|
30
|
+
export class HostTransport {
|
|
31
|
+
private readonly cliBin: string;
|
|
32
|
+
private readonly env?: Record<string, string | undefined>;
|
|
33
|
+
private readonly startupTimeoutMs: number;
|
|
34
|
+
private readonly shutdownTimeoutMs: number;
|
|
35
|
+
private readonly requestTimeoutMs?: number;
|
|
36
|
+
private readonly watchdogTimeoutMs: number;
|
|
37
|
+
private readonly maxQueueDepth: number;
|
|
38
|
+
|
|
39
|
+
private child: ChildProcessWithoutNullStreams | null = null;
|
|
40
|
+
private stdoutReader: ReadlineInterface | null = null;
|
|
41
|
+
private readonly pending = new Map<number, PendingRequest>();
|
|
42
|
+
private nextRequestId = 1;
|
|
43
|
+
private connecting: Promise<void> | null = null;
|
|
44
|
+
private stopping = false;
|
|
45
|
+
|
|
46
|
+
constructor(options: {
|
|
47
|
+
cliBin: string;
|
|
48
|
+
env?: Record<string, string | undefined>;
|
|
49
|
+
host?: SuperDocHostOptions;
|
|
50
|
+
}) {
|
|
51
|
+
this.cliBin = options.cliBin;
|
|
52
|
+
this.env = options.env;
|
|
53
|
+
|
|
54
|
+
this.startupTimeoutMs = options.host?.startupTimeoutMs ?? 5_000;
|
|
55
|
+
this.shutdownTimeoutMs = options.host?.shutdownTimeoutMs ?? 5_000;
|
|
56
|
+
this.requestTimeoutMs = options.host?.requestTimeoutMs;
|
|
57
|
+
this.watchdogTimeoutMs = options.host?.watchdogTimeoutMs ?? 30_000;
|
|
58
|
+
this.maxQueueDepth = options.host?.maxQueueDepth ?? 100;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async connect(): Promise<void> {
|
|
62
|
+
await this.ensureConnected();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async dispose(): Promise<void> {
|
|
66
|
+
if (!this.child) return;
|
|
67
|
+
|
|
68
|
+
this.stopping = true;
|
|
69
|
+
|
|
70
|
+
const child = this.child;
|
|
71
|
+
try {
|
|
72
|
+
await this.sendJsonRpcRequest('host.shutdown', {}, this.shutdownTimeoutMs);
|
|
73
|
+
} catch {
|
|
74
|
+
// ignore and force shutdown below
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await new Promise<void>((resolve) => {
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
child.kill('SIGKILL');
|
|
80
|
+
resolve();
|
|
81
|
+
}, this.shutdownTimeoutMs);
|
|
82
|
+
|
|
83
|
+
child.once('close', () => {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
resolve();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
this.cleanupProcess(null);
|
|
90
|
+
this.stopping = false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async invoke<TData extends Record<string, unknown>>(
|
|
94
|
+
operation: OperationSpec,
|
|
95
|
+
params: Record<string, unknown> = {},
|
|
96
|
+
options: InvokeOptions = {},
|
|
97
|
+
): Promise<TData> {
|
|
98
|
+
await this.ensureConnected();
|
|
99
|
+
|
|
100
|
+
const argv = buildOperationArgv(operation, params, options, this.requestTimeoutMs, false);
|
|
101
|
+
const stdinBase64 = options.stdinBytes ? Buffer.from(options.stdinBytes).toString('base64') : '';
|
|
102
|
+
const watchdogTimeout = this.resolveWatchdogTimeout(options.timeoutMs);
|
|
103
|
+
|
|
104
|
+
const response = await this.sendJsonRpcRequest(
|
|
105
|
+
'cli.invoke',
|
|
106
|
+
{
|
|
107
|
+
argv,
|
|
108
|
+
stdinBase64,
|
|
109
|
+
},
|
|
110
|
+
watchdogTimeout,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (typeof response !== 'object' || response == null || Array.isArray(response)) {
|
|
114
|
+
throw new SuperDocCliError('Host returned invalid cli.invoke result.', {
|
|
115
|
+
code: 'HOST_PROTOCOL_ERROR',
|
|
116
|
+
details: { result: response },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const resultRecord = response as Record<string, unknown>;
|
|
121
|
+
return resultRecord.data as TData;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async ensureConnected(): Promise<void> {
|
|
125
|
+
if (this.child && !this.child.killed) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (this.connecting) {
|
|
130
|
+
await this.connecting;
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.connecting = this.startHostProcess();
|
|
135
|
+
try {
|
|
136
|
+
await this.connecting;
|
|
137
|
+
} finally {
|
|
138
|
+
this.connecting = null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private async startHostProcess(): Promise<void> {
|
|
143
|
+
const { command, prefixArgs } = resolveInvocation(this.cliBin);
|
|
144
|
+
const args = [...prefixArgs, 'host', '--stdio'];
|
|
145
|
+
|
|
146
|
+
const child = spawn(command, args, {
|
|
147
|
+
env: {
|
|
148
|
+
...process.env,
|
|
149
|
+
...(this.env ?? {}),
|
|
150
|
+
},
|
|
151
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
this.child = child;
|
|
155
|
+
|
|
156
|
+
const stdoutReader = createInterface({
|
|
157
|
+
input: child.stdout,
|
|
158
|
+
crlfDelay: Number.POSITIVE_INFINITY,
|
|
159
|
+
});
|
|
160
|
+
this.stdoutReader = stdoutReader;
|
|
161
|
+
|
|
162
|
+
stdoutReader.on('line', (line) => {
|
|
163
|
+
this.onStdoutLine(line);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
child.stderr.on('data', () => {
|
|
167
|
+
// stderr is intentionally ignored in host mode unless process exits unexpectedly
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
child.on('error', (error) => {
|
|
171
|
+
this.handleDisconnect(
|
|
172
|
+
new SuperDocCliError('Host process failed.', {
|
|
173
|
+
code: 'HOST_DISCONNECTED',
|
|
174
|
+
details: {
|
|
175
|
+
message: error instanceof Error ? error.message : String(error),
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
child.on('close', (code, signal) => {
|
|
182
|
+
const isExpectedClose = this.stopping;
|
|
183
|
+
if (isExpectedClose) {
|
|
184
|
+
this.cleanupProcess(null);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.handleDisconnect(
|
|
189
|
+
new SuperDocCliError('Host process disconnected.', {
|
|
190
|
+
code: 'HOST_DISCONNECTED',
|
|
191
|
+
details: {
|
|
192
|
+
exitCode: code,
|
|
193
|
+
signal,
|
|
194
|
+
},
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const capabilities = await this.sendJsonRpcRequest('host.capabilities', {}, this.startupTimeoutMs);
|
|
201
|
+
this.assertCapabilities(capabilities);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const normalized =
|
|
204
|
+
error instanceof SuperDocCliError
|
|
205
|
+
? error
|
|
206
|
+
: new SuperDocCliError('Host handshake failed.', {
|
|
207
|
+
code: 'HOST_HANDSHAKE_FAILED',
|
|
208
|
+
details: {
|
|
209
|
+
message: error instanceof Error ? error.message : String(error),
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
this.handleDisconnect(normalized);
|
|
213
|
+
throw normalized;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private assertCapabilities(response: unknown): void {
|
|
218
|
+
if (typeof response !== 'object' || response == null || Array.isArray(response)) {
|
|
219
|
+
throw new SuperDocCliError('Host capabilities response is invalid.', {
|
|
220
|
+
code: 'HOST_HANDSHAKE_FAILED',
|
|
221
|
+
details: { response },
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const record = response as Record<string, unknown>;
|
|
226
|
+
const protocolVersion = record.protocolVersion;
|
|
227
|
+
const features = record.features;
|
|
228
|
+
|
|
229
|
+
if (protocolVersion !== HOST_PROTOCOL_VERSION) {
|
|
230
|
+
throw new SuperDocCliError('Host protocol version is unsupported.', {
|
|
231
|
+
code: 'HOST_HANDSHAKE_FAILED',
|
|
232
|
+
details: {
|
|
233
|
+
expected: HOST_PROTOCOL_VERSION,
|
|
234
|
+
actual: protocolVersion,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!Array.isArray(features) || features.some((feature) => typeof feature !== 'string')) {
|
|
240
|
+
throw new SuperDocCliError('Host capabilities.features must be a string array.', {
|
|
241
|
+
code: 'HOST_HANDSHAKE_FAILED',
|
|
242
|
+
details: { features },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
for (const requiredFeature of REQUIRED_FEATURES) {
|
|
247
|
+
if (!features.includes(requiredFeature)) {
|
|
248
|
+
throw new SuperDocCliError(`Host does not support required feature: ${requiredFeature}`, {
|
|
249
|
+
code: 'HOST_HANDSHAKE_FAILED',
|
|
250
|
+
details: { features },
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private resolveWatchdogTimeout(timeoutMsOverride: number | undefined): number {
|
|
257
|
+
if (timeoutMsOverride != null) {
|
|
258
|
+
return Math.max(this.watchdogTimeoutMs, timeoutMsOverride + 1_000);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (this.requestTimeoutMs != null) {
|
|
262
|
+
return Math.max(this.watchdogTimeoutMs, this.requestTimeoutMs + 1_000);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return this.watchdogTimeoutMs;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private async sendJsonRpcRequest(method: string, params: unknown, watchdogTimeoutMs: number): Promise<unknown> {
|
|
269
|
+
const child = this.child;
|
|
270
|
+
if (!child || !child.stdin.writable) {
|
|
271
|
+
throw new SuperDocCliError('Host process is not available.', {
|
|
272
|
+
code: 'HOST_DISCONNECTED',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (this.pending.size >= this.maxQueueDepth) {
|
|
277
|
+
throw new SuperDocCliError('Host request queue is full.', {
|
|
278
|
+
code: 'HOST_QUEUE_FULL',
|
|
279
|
+
details: {
|
|
280
|
+
maxQueueDepth: this.maxQueueDepth,
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const id = this.nextRequestId;
|
|
286
|
+
this.nextRequestId += 1;
|
|
287
|
+
|
|
288
|
+
const payload = JSON.stringify({
|
|
289
|
+
jsonrpc: '2.0',
|
|
290
|
+
id,
|
|
291
|
+
method,
|
|
292
|
+
params,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const promise = new Promise<unknown>((resolve, reject) => {
|
|
296
|
+
const timer = setTimeout(() => {
|
|
297
|
+
this.pending.delete(id);
|
|
298
|
+
|
|
299
|
+
const timeoutError = new SuperDocCliError(`Host watchdog timed out waiting for ${method}.`, {
|
|
300
|
+
code: 'HOST_TIMEOUT',
|
|
301
|
+
details: {
|
|
302
|
+
method,
|
|
303
|
+
timeoutMs: watchdogTimeoutMs,
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
reject(timeoutError);
|
|
308
|
+
|
|
309
|
+
this.handleDisconnect(
|
|
310
|
+
new SuperDocCliError('Host watchdog timeout; host process will be restarted on next request.', {
|
|
311
|
+
code: 'HOST_DISCONNECTED',
|
|
312
|
+
details: {
|
|
313
|
+
method,
|
|
314
|
+
timeoutMs: watchdogTimeoutMs,
|
|
315
|
+
},
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
}, watchdogTimeoutMs);
|
|
319
|
+
|
|
320
|
+
this.pending.set(id, {
|
|
321
|
+
resolve,
|
|
322
|
+
reject,
|
|
323
|
+
timer,
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
child.stdin.write(`${payload}\n`, (error) => {
|
|
327
|
+
if (!error) return;
|
|
328
|
+
|
|
329
|
+
const pending = this.pending.get(id);
|
|
330
|
+
if (!pending) return;
|
|
331
|
+
|
|
332
|
+
clearTimeout(pending.timer);
|
|
333
|
+
this.pending.delete(id);
|
|
334
|
+
reject(
|
|
335
|
+
new SuperDocCliError('Failed to write request to host process.', {
|
|
336
|
+
code: 'HOST_DISCONNECTED',
|
|
337
|
+
details: {
|
|
338
|
+
method,
|
|
339
|
+
message: error.message,
|
|
340
|
+
},
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
return promise;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private onStdoutLine(line: string): void {
|
|
350
|
+
let parsed: unknown;
|
|
351
|
+
try {
|
|
352
|
+
parsed = JSON.parse(line);
|
|
353
|
+
} catch {
|
|
354
|
+
// Ignore non-protocol stdout noise (for example telemetry/logging lines).
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (typeof parsed !== 'object' || parsed == null || Array.isArray(parsed)) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const record = parsed as Record<string, unknown>;
|
|
363
|
+
if (record.jsonrpc !== '2.0') {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if ('method' in record && !('id' in record)) {
|
|
368
|
+
// Notification; reserved for future eventing.
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const idRaw = record.id;
|
|
373
|
+
if (typeof idRaw !== 'number') {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const pending = this.pending.get(idRaw);
|
|
378
|
+
if (!pending) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
clearTimeout(pending.timer);
|
|
383
|
+
this.pending.delete(idRaw);
|
|
384
|
+
|
|
385
|
+
if ('error' in record) {
|
|
386
|
+
pending.reject(this.mapJsonRpcError(record.error));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
pending.resolve(record.result);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
private mapJsonRpcError(rawError: unknown): SuperDocCliError {
|
|
394
|
+
if (typeof rawError !== 'object' || rawError == null || Array.isArray(rawError)) {
|
|
395
|
+
return new SuperDocCliError('Host returned an unknown JSON-RPC error.', {
|
|
396
|
+
code: 'HOST_PROTOCOL_ERROR',
|
|
397
|
+
details: { error: rawError },
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const error = rawError as JsonRpcError;
|
|
402
|
+
|
|
403
|
+
const data = error.data as JsonRpcErrorData | undefined;
|
|
404
|
+
const cliCode = typeof data?.cliCode === 'string' ? data.cliCode : undefined;
|
|
405
|
+
const cliMessage = typeof data?.message === 'string' ? data.message : undefined;
|
|
406
|
+
const exitCode = typeof data?.exitCode === 'number' ? data.exitCode : undefined;
|
|
407
|
+
|
|
408
|
+
if (cliCode) {
|
|
409
|
+
return new SuperDocCliError(cliMessage ?? error.message ?? 'Command failed.', {
|
|
410
|
+
code: cliCode,
|
|
411
|
+
details: data?.details,
|
|
412
|
+
exitCode,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (error.code === JSON_RPC_TIMEOUT_CODE) {
|
|
417
|
+
return new SuperDocCliError(error.message, {
|
|
418
|
+
code: 'TIMEOUT',
|
|
419
|
+
details: data,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return new SuperDocCliError(error.message, {
|
|
424
|
+
code: 'COMMAND_FAILED',
|
|
425
|
+
details: data,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private handleDisconnect(error: SuperDocCliError): void {
|
|
430
|
+
this.cleanupProcess(error);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private cleanupProcess(error: SuperDocCliError | null): void {
|
|
434
|
+
const child = this.child;
|
|
435
|
+
if (child) {
|
|
436
|
+
child.removeAllListeners();
|
|
437
|
+
child.kill('SIGKILL');
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
this.child = null;
|
|
441
|
+
|
|
442
|
+
if (this.stdoutReader) {
|
|
443
|
+
this.stdoutReader.removeAllListeners();
|
|
444
|
+
this.stdoutReader.close();
|
|
445
|
+
this.stdoutReader = null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const pendingEntries = Array.from(this.pending.values());
|
|
449
|
+
this.pending.clear();
|
|
450
|
+
|
|
451
|
+
if (!error) {
|
|
452
|
+
for (const pending of pendingEntries) {
|
|
453
|
+
clearTimeout(pending.timer);
|
|
454
|
+
}
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const pending of pendingEntries) {
|
|
459
|
+
clearTimeout(pending.timer);
|
|
460
|
+
pending.reject(error);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { HostTransport } from './host';
|
|
2
|
+
import { SpawnTransport } from './spawn';
|
|
3
|
+
import { resolveEmbeddedCliBinary } from './embedded-cli';
|
|
4
|
+
import type {
|
|
5
|
+
InvokeOptions,
|
|
6
|
+
OperationParamSpec,
|
|
7
|
+
OperationSpec,
|
|
8
|
+
SuperDocClientOptions,
|
|
9
|
+
SuperDocHostOptions,
|
|
10
|
+
SuperDocTransport,
|
|
11
|
+
} from './transport-common';
|
|
12
|
+
|
|
13
|
+
type RuntimeTransport = {
|
|
14
|
+
invoke<TData extends Record<string, unknown>>(
|
|
15
|
+
operation: OperationSpec,
|
|
16
|
+
params?: Record<string, unknown>,
|
|
17
|
+
options?: InvokeOptions,
|
|
18
|
+
): Promise<TData>;
|
|
19
|
+
connect(): Promise<void>;
|
|
20
|
+
dispose(): Promise<void>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class SuperDocRuntime {
|
|
24
|
+
private readonly transport: RuntimeTransport;
|
|
25
|
+
|
|
26
|
+
constructor(options: SuperDocClientOptions = {}) {
|
|
27
|
+
const cliBin = options.cliBin ?? process.env.SUPERDOC_CLI_BIN ?? resolveEmbeddedCliBinary();
|
|
28
|
+
const transportMode = options.transport ?? 'spawn';
|
|
29
|
+
|
|
30
|
+
this.transport = this.createTransport(transportMode, {
|
|
31
|
+
cliBin,
|
|
32
|
+
env: options.env,
|
|
33
|
+
host: options.host,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async connect(): Promise<void> {
|
|
38
|
+
await this.transport.connect();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async dispose(): Promise<void> {
|
|
42
|
+
await this.transport.dispose();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async invoke<TData extends Record<string, unknown>>(
|
|
46
|
+
operation: OperationSpec,
|
|
47
|
+
params: Record<string, unknown> = {},
|
|
48
|
+
options: InvokeOptions = {},
|
|
49
|
+
): Promise<TData> {
|
|
50
|
+
return this.transport.invoke<TData>(operation, params, options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
private createTransport(
|
|
54
|
+
mode: SuperDocTransport,
|
|
55
|
+
options: {
|
|
56
|
+
cliBin: string;
|
|
57
|
+
env?: Record<string, string | undefined>;
|
|
58
|
+
host?: SuperDocHostOptions;
|
|
59
|
+
},
|
|
60
|
+
): RuntimeTransport {
|
|
61
|
+
if (mode === 'host') {
|
|
62
|
+
return new HostTransport(options);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new SpawnTransport(options);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type { InvokeOptions, OperationParamSpec, OperationSpec, SuperDocClientOptions, SuperDocHostOptions, SuperDocTransport };
|