@openvcs/sdk 0.2.4 → 0.2.5
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 +16 -9
- package/lib/build.d.ts +8 -0
- package/lib/build.js +81 -2
- package/lib/dist.js +1 -0
- package/lib/init.js +2 -2
- package/lib/runtime/factory.d.ts +3 -0
- package/lib/runtime/factory.js +153 -0
- package/lib/runtime/index.d.ts +5 -4
- package/lib/runtime/index.js +11 -154
- package/lib/runtime/registration.d.ts +39 -0
- package/lib/runtime/registration.js +37 -0
- package/package.json +1 -1
- package/src/lib/build.ts +104 -2
- package/src/lib/dist.ts +2 -0
- package/src/lib/init.ts +2 -2
- package/src/lib/runtime/factory.ts +182 -0
- package/src/lib/runtime/index.ts +12 -177
- package/src/lib/runtime/registration.ts +93 -0
- package/test/build.test.js +147 -6
- package/test/cli.test.js +5 -3
- package/test/dist.test.js +27 -18
- package/test/init.test.js +6 -2
- package/test/runtime.test.js +118 -1
package/package.json
CHANGED
package/src/lib/build.ts
CHANGED
|
@@ -32,6 +32,8 @@ interface PackageScripts {
|
|
|
32
32
|
[scriptName: string]: unknown;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
const AUTHORED_PLUGIN_MODULE_BASENAME = "plugin.js";
|
|
36
|
+
|
|
35
37
|
/** Returns the npm executable name for the current platform. */
|
|
36
38
|
export function npmExecutable(): string {
|
|
37
39
|
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
@@ -137,6 +139,14 @@ export function validateDeclaredModuleExec(pluginDir: string, moduleExec: string
|
|
|
137
139
|
return;
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
const targetPath = resolveDeclaredModuleExecPath(pluginDir, moduleExec);
|
|
143
|
+
if (!fs.existsSync(targetPath) || !fs.lstatSync(targetPath).isFile()) {
|
|
144
|
+
throw new Error(`module entrypoint not found at ${targetPath}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Resolves `module.exec` to an absolute path under `bin/`, rejecting invalid targets. */
|
|
149
|
+
function resolveDeclaredModuleExecPath(pluginDir: string, moduleExec: string): string {
|
|
140
150
|
const normalizedExec = moduleExec.trim();
|
|
141
151
|
const lowered = normalizedExec.toLowerCase();
|
|
142
152
|
if (!lowered.endsWith(".js") && !lowered.endsWith(".mjs") && !lowered.endsWith(".cjs")) {
|
|
@@ -151,9 +161,100 @@ export function validateDeclaredModuleExec(pluginDir: string, moduleExec: string
|
|
|
151
161
|
if (!isPathInside(binDir, targetPath) || targetPath === binDir) {
|
|
152
162
|
throw new Error(`manifest module.exec must point to a file under bin/: ${moduleExec}`);
|
|
153
163
|
}
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
|
|
165
|
+
return targetPath;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Returns the compiled plugin module path imported by the generated bootstrap. */
|
|
169
|
+
export function authoredPluginModulePath(pluginDir: string): string {
|
|
170
|
+
return path.resolve(pluginDir, "bin", AUTHORED_PLUGIN_MODULE_BASENAME);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Ensures the plugin's authored module and generated bootstrap paths are compatible. */
|
|
174
|
+
export function validateGeneratedBootstrapTargets(
|
|
175
|
+
pluginDir: string,
|
|
176
|
+
moduleExec: string | undefined,
|
|
177
|
+
): void {
|
|
178
|
+
if (!moduleExec) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
resolveDeclaredModuleExecPath(pluginDir, moduleExec);
|
|
183
|
+
const normalizedExec = moduleExec.trim().toLowerCase();
|
|
184
|
+
if (normalizedExec === AUTHORED_PLUGIN_MODULE_BASENAME.toLowerCase()) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`manifest module.exec must not be ${AUTHORED_PLUGIN_MODULE_BASENAME}; SDK reserves bin/${AUTHORED_PLUGIN_MODULE_BASENAME} for the compiled OnPluginStart module`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const authoredModulePath = authoredPluginModulePath(pluginDir);
|
|
191
|
+
if (!fs.existsSync(authoredModulePath) || !fs.lstatSync(authoredModulePath).isFile()) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`compiled plugin module not found at ${authoredModulePath}; build:plugin must emit bin/${AUTHORED_PLUGIN_MODULE_BASENAME}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Returns a normalized import specifier from one bin file to another. */
|
|
199
|
+
function relativeBinImport(fromExecPath: string, toModulePath: string): string {
|
|
200
|
+
const relativePath = path.relative(path.dirname(fromExecPath), toModulePath).replace(/\\/g, "/");
|
|
201
|
+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Validates that a module import path contains only safe characters for code generation. */
|
|
205
|
+
function validateModuleImportPath(importPath: string): void {
|
|
206
|
+
if (!/^[./a-zA-Z0-9_-]+$/.test(importPath)) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`unsafe module import path: ${importPath}; path must contain only alphanumeric characters, dots, slashes, hyphens, and underscores`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Renders the generated Node bootstrap that owns runtime startup. */
|
|
214
|
+
export function renderGeneratedBootstrap(
|
|
215
|
+
pluginModuleImportPath: string,
|
|
216
|
+
isEsm: boolean,
|
|
217
|
+
): string {
|
|
218
|
+
validateModuleImportPath(pluginModuleImportPath);
|
|
219
|
+
|
|
220
|
+
if (isEsm) {
|
|
221
|
+
return `#!/usr/bin/env node\n// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\nimport { bootstrapPluginModule } from '@openvcs/sdk/runtime';\n\nawait bootstrapPluginModule({\n importPluginModule: async () => import('${pluginModuleImportPath}'),\n modulePath: '${pluginModuleImportPath}',\n});\n`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return `#!/usr/bin/env node\n// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\n(async () => {\n const { bootstrapPluginModule } = require('@openvcs/sdk/runtime');\n await bootstrapPluginModule({\n importPluginModule: async () => require('${pluginModuleImportPath}'),\n modulePath: '${pluginModuleImportPath}',\n });\n})();\n`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Writes the generated SDK-owned module entrypoint under `bin/<module.exec>`. */
|
|
228
|
+
export function generateModuleBootstrap(pluginDir: string, moduleExec: string | undefined): void {
|
|
229
|
+
if (!moduleExec) {
|
|
230
|
+
console.debug(`generateModuleBootstrap: no module.exec defined, skipping bootstrap generation for ${pluginDir}`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
validateGeneratedBootstrapTargets(pluginDir, moduleExec);
|
|
235
|
+
const execPath = resolveDeclaredModuleExecPath(pluginDir, moduleExec);
|
|
236
|
+
const pluginModulePath = authoredPluginModulePath(pluginDir);
|
|
237
|
+
const pluginModuleImportPath = relativeBinImport(execPath, pluginModulePath);
|
|
238
|
+
const isEsm = detectEsmMode(pluginDir, moduleExec);
|
|
239
|
+
|
|
240
|
+
fs.mkdirSync(path.dirname(execPath), { recursive: true });
|
|
241
|
+
fs.writeFileSync(execPath, renderGeneratedBootstrap(pluginModuleImportPath, isEsm), "utf8");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Detects whether the plugin runs in ESM mode based on package.json or file extension. */
|
|
245
|
+
function detectEsmMode(pluginDir: string, moduleExec: string): boolean {
|
|
246
|
+
const packageJsonPath = path.join(pluginDir, "package.json");
|
|
247
|
+
if (fs.existsSync(packageJsonPath) && fs.lstatSync(packageJsonPath).isFile()) {
|
|
248
|
+
try {
|
|
249
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
250
|
+
if (packageJson.type === "module") {
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
// Ignore JSON parse errors
|
|
255
|
+
}
|
|
156
256
|
}
|
|
257
|
+
return moduleExec.trim().endsWith(".mjs");
|
|
157
258
|
}
|
|
158
259
|
|
|
159
260
|
/** Returns whether the plugin repository has a `package.json`. */
|
|
@@ -224,6 +325,7 @@ export function buildPluginAssets(parsedArgs: BuildArgs): ManifestInfo {
|
|
|
224
325
|
}
|
|
225
326
|
|
|
226
327
|
runCommand(npmExecutable(), ["run", "build:plugin"], parsedArgs.pluginDir, parsedArgs.verbose);
|
|
328
|
+
generateModuleBootstrap(parsedArgs.pluginDir, manifest.moduleExec);
|
|
227
329
|
validateDeclaredModuleExec(parsedArgs.pluginDir, manifest.moduleExec);
|
|
228
330
|
return manifest;
|
|
229
331
|
}
|
package/src/lib/dist.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
readManifest,
|
|
10
10
|
runCommand,
|
|
11
11
|
validateDeclaredModuleExec,
|
|
12
|
+
validateGeneratedBootstrapTargets,
|
|
12
13
|
} from "./build";
|
|
13
14
|
import {
|
|
14
15
|
copyDirectoryRecursiveStrict,
|
|
@@ -251,6 +252,7 @@ export async function bundlePlugin(parsedArgs: DistArgs): Promise<string> {
|
|
|
251
252
|
if (!moduleExec && !hasThemes && !entry) {
|
|
252
253
|
throw new Error("manifest has no module.exec, entry, or themes/");
|
|
253
254
|
}
|
|
255
|
+
validateGeneratedBootstrapTargets(pluginDir, moduleExec);
|
|
254
256
|
validateDeclaredModuleExec(pluginDir, moduleExec);
|
|
255
257
|
|
|
256
258
|
ensureDirectory(outDir);
|
package/src/lib/init.ts
CHANGED
|
@@ -223,7 +223,7 @@ function writeCommonFiles(answers: InitAnswers): void {
|
|
|
223
223
|
name: answers.pluginName,
|
|
224
224
|
version: answers.pluginVersion,
|
|
225
225
|
default_enabled: answers.defaultEnabled,
|
|
226
|
-
...(answers.kind === "module" ? { module: { exec: "plugin.js" } } : {}),
|
|
226
|
+
...(answers.kind === "module" ? { module: { exec: "openvcs-plugin.js" } } : {}),
|
|
227
227
|
});
|
|
228
228
|
writeText(path.join(answers.targetDir, ".gitignore"), "node_modules/\ndist/\n");
|
|
229
229
|
}
|
|
@@ -264,7 +264,7 @@ function writeModuleTemplate(answers: InitAnswers): void {
|
|
|
264
264
|
});
|
|
265
265
|
writeText(
|
|
266
266
|
path.join(answers.targetDir, "src", "plugin.ts"),
|
|
267
|
-
"// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\nimport {
|
|
267
|
+
"// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\nimport type { PluginModuleDefinition } from '@openvcs/sdk/runtime';\n\nexport const PluginDefinition: PluginModuleDefinition = {\n plugin: {\n async 'plugin.init'(_params, context) {\n context.host.info('OpenVCS plugin started');\n return null;\n },\n },\n};\n\n/** Runs plugin startup work before the generated runtime begins processing requests. */\nexport function OnPluginStart(): void {}\n"
|
|
268
268
|
);
|
|
269
269
|
}
|
|
270
270
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Copyright © 2025-2026 OpenVCS Contributors
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import type { JsonRpcId, JsonRpcRequest, RequestParams } from '../types';
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
CreatePluginRuntimeOptions,
|
|
8
|
+
PluginRuntime,
|
|
9
|
+
PluginRuntimeTransport,
|
|
10
|
+
} from './contracts';
|
|
11
|
+
import { createRuntimeDispatcher } from './dispatcher';
|
|
12
|
+
import { createHost } from './host';
|
|
13
|
+
import { parseFramedMessages, writeFramedMessage } from './transport';
|
|
14
|
+
|
|
15
|
+
/** Creates a reusable stdio JSON-RPC runtime for OpenVCS Node plugins. */
|
|
16
|
+
export function createPluginRuntime(
|
|
17
|
+
options: CreatePluginRuntimeOptions = {},
|
|
18
|
+
): PluginRuntime {
|
|
19
|
+
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
20
|
+
let processing: Promise<void> = Promise.resolve();
|
|
21
|
+
let started = false;
|
|
22
|
+
let currentTransport: PluginRuntimeTransport | null = null;
|
|
23
|
+
let chunkLock: Promise<void> = Promise.resolve();
|
|
24
|
+
|
|
25
|
+
const runtime: PluginRuntime = {
|
|
26
|
+
start(transport: PluginRuntimeTransport = defaultTransport()): void {
|
|
27
|
+
if (started) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
started = true;
|
|
32
|
+
currentTransport = transport;
|
|
33
|
+
transport.stdin.on('data', (chunk: Buffer | string) => {
|
|
34
|
+
runtime.consumeChunk(chunk);
|
|
35
|
+
});
|
|
36
|
+
transport.stdin.on('error', () => {
|
|
37
|
+
process.exit(1);
|
|
38
|
+
});
|
|
39
|
+
options.onStart?.();
|
|
40
|
+
},
|
|
41
|
+
stop(): void {
|
|
42
|
+
if (!started) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
started = false;
|
|
46
|
+
processing = processing
|
|
47
|
+
.catch(async (error: unknown) => {
|
|
48
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
49
|
+
console.error(`[runtime] shutdown error: ${errorMessage}`);
|
|
50
|
+
const err = error instanceof Error ? error : new Error(errorMessage);
|
|
51
|
+
await options.onShutdown?.(err);
|
|
52
|
+
})
|
|
53
|
+
.then(async () => {
|
|
54
|
+
await options.onShutdown?.();
|
|
55
|
+
});
|
|
56
|
+
currentTransport = null;
|
|
57
|
+
},
|
|
58
|
+
consumeChunk(chunk: Buffer | string): void {
|
|
59
|
+
if (!started) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lock = chunkLock.then(() => {
|
|
64
|
+
buffer = Buffer.concat([buffer, normalizeChunk(chunk)]);
|
|
65
|
+
const parsed = parseFramedMessages(buffer);
|
|
66
|
+
buffer = parsed.remainder;
|
|
67
|
+
|
|
68
|
+
for (const request of parsed.messages) {
|
|
69
|
+
processing = processing
|
|
70
|
+
.then(async () => {
|
|
71
|
+
await runtime.dispatchRequest(request);
|
|
72
|
+
})
|
|
73
|
+
.catch((error: unknown) => {
|
|
74
|
+
const message = error instanceof Error ? error.message : String(error || 'unknown plugin processing error');
|
|
75
|
+
const host = createRuntimeHost(currentTransport, options.logTarget);
|
|
76
|
+
host.error(message);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
chunkLock = lock;
|
|
82
|
+
},
|
|
83
|
+
async dispatchRequest(request: JsonRpcRequest): Promise<void> {
|
|
84
|
+
const id = request.id;
|
|
85
|
+
const host = createRuntimeHost(currentTransport, options.logTarget);
|
|
86
|
+
const method = asTrimmedString(request.method);
|
|
87
|
+
const validationErrors: string[] = [];
|
|
88
|
+
if (!method) validationErrors.push('missing method');
|
|
89
|
+
if (typeof id !== 'number' && typeof id !== 'string') validationErrors.push(`invalid id type: ${typeof id}`);
|
|
90
|
+
if (validationErrors.length > 0) {
|
|
91
|
+
host.error(`invalid request: ${validationErrors.join(', ')}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const dispatcher = createRuntimeDispatcher(options, host, {
|
|
95
|
+
async sendResult<TResult>(requestId: JsonRpcId, result: TResult): Promise<void> {
|
|
96
|
+
await sendMessage(currentTransport, {
|
|
97
|
+
jsonrpc: '2.0',
|
|
98
|
+
id: requestId,
|
|
99
|
+
result,
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
async sendError(requestId: JsonRpcId, code: number, message: string, data?: unknown): Promise<void> {
|
|
103
|
+
await sendMessage(currentTransport, {
|
|
104
|
+
jsonrpc: '2.0',
|
|
105
|
+
id: requestId,
|
|
106
|
+
error: {
|
|
107
|
+
code,
|
|
108
|
+
message,
|
|
109
|
+
...(data == null ? {} : { data }),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const methodName = method as string;
|
|
116
|
+
const params = asRecord(request.params) ?? {};
|
|
117
|
+
const requestId = id as JsonRpcId;
|
|
118
|
+
await dispatcher(requestId, methodName, params);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return runtime;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Returns the default stdio transport used by the runtime. */
|
|
126
|
+
function defaultTransport(): PluginRuntimeTransport {
|
|
127
|
+
return {
|
|
128
|
+
stdin: process.stdin,
|
|
129
|
+
stdout: process.stdout,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Sends one JSON-RPC response or notification through the active transport. */
|
|
134
|
+
async function sendMessage(
|
|
135
|
+
transport: PluginRuntimeTransport | null,
|
|
136
|
+
value: unknown,
|
|
137
|
+
): Promise<void> {
|
|
138
|
+
await writeFramedMessage((transport ?? defaultTransport()).stdout, value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Builds the host helper for the currently active transport. */
|
|
142
|
+
function createRuntimeHost(
|
|
143
|
+
transport: PluginRuntimeTransport | null,
|
|
144
|
+
logTarget: string | undefined,
|
|
145
|
+
) {
|
|
146
|
+
return createHost(
|
|
147
|
+
async (method: string, params: unknown) => {
|
|
148
|
+
await sendMessage(transport, {
|
|
149
|
+
jsonrpc: '2.0',
|
|
150
|
+
method,
|
|
151
|
+
params,
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
{ logTarget },
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Type guard that returns true if the value is a valid RequestParams object. */
|
|
159
|
+
function isRequestParams(value: unknown): value is RequestParams {
|
|
160
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Coerces unknown request method values into trimmed strings. Returns null for non-strings. */
|
|
167
|
+
function asTrimmedString(value: unknown): string | null {
|
|
168
|
+
return typeof value === 'string' ? value.trim() : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Coerces unknown params to a Record, returning null for invalid input. */
|
|
172
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
173
|
+
if (isRequestParams(value)) {
|
|
174
|
+
return value as Record<string, unknown>;
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Normalizes incoming data chunks to UTF-8 buffers. */
|
|
180
|
+
function normalizeChunk(chunk: Buffer | string): Buffer {
|
|
181
|
+
return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
|
|
182
|
+
}
|
package/src/lib/runtime/index.ts
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
// Copyright © 2025-2026 OpenVCS Contributors
|
|
2
2
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
3
|
|
|
4
|
-
import type { JsonRpcId, JsonRpcRequest, RequestParams } from '../types';
|
|
5
|
-
|
|
6
4
|
import type {
|
|
7
|
-
CreatePluginRuntimeOptions,
|
|
8
5
|
PluginRuntime,
|
|
9
|
-
PluginRuntimeContext,
|
|
10
6
|
PluginRuntimeTransport,
|
|
11
7
|
} from './contracts';
|
|
12
|
-
import { createRuntimeDispatcher } from './dispatcher';
|
|
13
|
-
import { createHost } from './host';
|
|
14
|
-
import { parseFramedMessages, writeFramedMessage } from './transport';
|
|
15
8
|
|
|
16
9
|
export type {
|
|
17
10
|
CreatePluginRuntimeOptions,
|
|
@@ -19,119 +12,20 @@ export type {
|
|
|
19
12
|
PluginRuntimeContext,
|
|
20
13
|
PluginRuntimeTransport,
|
|
21
14
|
} from './contracts';
|
|
22
|
-
export {
|
|
15
|
+
export type {
|
|
16
|
+
BootstrapPluginModuleOptions,
|
|
17
|
+
OnPluginStartHandler,
|
|
18
|
+
PluginBootstrapModule,
|
|
19
|
+
PluginModuleDefinition,
|
|
20
|
+
} from './registration';
|
|
21
|
+
export { createDefaultPluginDelegates, createRuntimeDispatcher } from './dispatcher';
|
|
23
22
|
export { isPluginFailure, pluginError } from './errors';
|
|
23
|
+
export { createPluginRuntime } from './factory';
|
|
24
24
|
export { createHost } from './host';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
): PluginRuntime {
|
|
30
|
-
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
|
31
|
-
let processing: Promise<void> = Promise.resolve();
|
|
32
|
-
let started = false;
|
|
33
|
-
let currentTransport: PluginRuntimeTransport | null = null;
|
|
34
|
-
let chunkLock: Promise<void> = Promise.resolve();
|
|
35
|
-
|
|
36
|
-
const runtime: PluginRuntime = {
|
|
37
|
-
start(transport: PluginRuntimeTransport = defaultTransport()): void {
|
|
38
|
-
if (started) {
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
started = true;
|
|
43
|
-
currentTransport = transport;
|
|
44
|
-
transport.stdin.on('data', (chunk: Buffer | string) => {
|
|
45
|
-
runtime.consumeChunk(chunk);
|
|
46
|
-
});
|
|
47
|
-
transport.stdin.on('error', () => {
|
|
48
|
-
process.exit(1);
|
|
49
|
-
});
|
|
50
|
-
options.onStart?.();
|
|
51
|
-
},
|
|
52
|
-
stop(): void {
|
|
53
|
-
if (!started) {
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
started = false;
|
|
57
|
-
processing = processing
|
|
58
|
-
.catch(async (error: unknown) => {
|
|
59
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
60
|
-
console.error(`[runtime] shutdown error: ${errorMessage}`);
|
|
61
|
-
const err = error instanceof Error ? error : new Error(errorMessage);
|
|
62
|
-
await options.onShutdown?.(err);
|
|
63
|
-
})
|
|
64
|
-
.then(async () => {
|
|
65
|
-
await options.onShutdown?.();
|
|
66
|
-
});
|
|
67
|
-
currentTransport = null;
|
|
68
|
-
},
|
|
69
|
-
consumeChunk(chunk: Buffer | string): void {
|
|
70
|
-
if (!started) {
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const lock = chunkLock.then(() => {
|
|
75
|
-
buffer = Buffer.concat([buffer, normalizeChunk(chunk)]);
|
|
76
|
-
const parsed = parseFramedMessages(buffer);
|
|
77
|
-
buffer = parsed.remainder;
|
|
78
|
-
|
|
79
|
-
for (const request of parsed.messages) {
|
|
80
|
-
processing = processing
|
|
81
|
-
.then(async () => {
|
|
82
|
-
await runtime.dispatchRequest(request);
|
|
83
|
-
})
|
|
84
|
-
.catch((error: unknown) => {
|
|
85
|
-
const message = error instanceof Error ? error.message : String(error || 'unknown plugin processing error');
|
|
86
|
-
const host = createRuntimeHost(currentTransport, options.logTarget);
|
|
87
|
-
host.error(message);
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
chunkLock = lock;
|
|
93
|
-
},
|
|
94
|
-
async dispatchRequest(request: JsonRpcRequest): Promise<void> {
|
|
95
|
-
const id = request.id;
|
|
96
|
-
const host = createRuntimeHost(currentTransport, options.logTarget);
|
|
97
|
-
const method = asTrimmedString(request.method);
|
|
98
|
-
const validationErrors: string[] = [];
|
|
99
|
-
if (!method) validationErrors.push('missing method');
|
|
100
|
-
if (typeof id !== 'number' && typeof id !== 'string') validationErrors.push(`invalid id type: ${typeof id}`);
|
|
101
|
-
if (validationErrors.length > 0) {
|
|
102
|
-
host.error(`invalid request: ${validationErrors.join(', ')}`);
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const dispatcher = createRuntimeDispatcher(options, host, {
|
|
106
|
-
async sendResult<TResult>(requestId: JsonRpcId, result: TResult): Promise<void> {
|
|
107
|
-
await sendMessage(currentTransport, {
|
|
108
|
-
jsonrpc: '2.0',
|
|
109
|
-
id: requestId,
|
|
110
|
-
result,
|
|
111
|
-
});
|
|
112
|
-
},
|
|
113
|
-
async sendError(requestId: JsonRpcId, code: number, message: string, data?: unknown): Promise<void> {
|
|
114
|
-
await sendMessage(currentTransport, {
|
|
115
|
-
jsonrpc: '2.0',
|
|
116
|
-
id: requestId,
|
|
117
|
-
error: {
|
|
118
|
-
code,
|
|
119
|
-
message,
|
|
120
|
-
...(data == null ? {} : { data }),
|
|
121
|
-
},
|
|
122
|
-
});
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
const methodName = method as string;
|
|
127
|
-
const params = asRecord(request.params) ?? {};
|
|
128
|
-
const requestId = id as JsonRpcId;
|
|
129
|
-
await dispatcher(requestId, methodName, params);
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
return runtime;
|
|
134
|
-
}
|
|
25
|
+
export {
|
|
26
|
+
bootstrapPluginModule,
|
|
27
|
+
createRegisteredPluginRuntime,
|
|
28
|
+
} from './registration';
|
|
135
29
|
|
|
136
30
|
/** Starts a previously created plugin runtime on process stdio. */
|
|
137
31
|
export function startPluginRuntime(
|
|
@@ -140,62 +34,3 @@ export function startPluginRuntime(
|
|
|
140
34
|
): void {
|
|
141
35
|
runtime.start(transport);
|
|
142
36
|
}
|
|
143
|
-
|
|
144
|
-
/** Returns the default stdio transport used by the runtime. */
|
|
145
|
-
function defaultTransport(): PluginRuntimeTransport {
|
|
146
|
-
return {
|
|
147
|
-
stdin: process.stdin,
|
|
148
|
-
stdout: process.stdout,
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Sends one JSON-RPC response or notification through the active transport. */
|
|
153
|
-
async function sendMessage(
|
|
154
|
-
transport: PluginRuntimeTransport | null,
|
|
155
|
-
value: unknown,
|
|
156
|
-
): Promise<void> {
|
|
157
|
-
await writeFramedMessage((transport ?? defaultTransport()).stdout, value);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Builds the host helper for the currently active transport. */
|
|
161
|
-
function createRuntimeHost(
|
|
162
|
-
transport: PluginRuntimeTransport | null,
|
|
163
|
-
logTarget: string | undefined,
|
|
164
|
-
) {
|
|
165
|
-
return createHost(
|
|
166
|
-
async (method: string, params: unknown) => {
|
|
167
|
-
await sendMessage(transport, {
|
|
168
|
-
jsonrpc: '2.0',
|
|
169
|
-
method,
|
|
170
|
-
params,
|
|
171
|
-
});
|
|
172
|
-
},
|
|
173
|
-
{ logTarget },
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** Type guard that returns true if the value is a valid RequestParams object. */
|
|
178
|
-
function isRequestParams(value: unknown): value is RequestParams {
|
|
179
|
-
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
180
|
-
return false;
|
|
181
|
-
}
|
|
182
|
-
return true;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Coerces unknown request method values into trimmed strings. Returns null for non-strings. */
|
|
186
|
-
function asTrimmedString(value: unknown): string | null {
|
|
187
|
-
return typeof value === 'string' ? value.trim() : null;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** Coerces unknown params to a Record, returning null for invalid input. */
|
|
191
|
-
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
192
|
-
if (isRequestParams(value)) {
|
|
193
|
-
return value as Record<string, unknown>;
|
|
194
|
-
}
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Normalizes incoming data chunks to UTF-8 buffers. */
|
|
199
|
-
function normalizeChunk(chunk: Buffer | string): Buffer {
|
|
200
|
-
return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
|
|
201
|
-
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Copyright © 2025-2026 OpenVCS Contributors
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
PluginDelegates,
|
|
6
|
+
PluginImplements,
|
|
7
|
+
VcsDelegates,
|
|
8
|
+
} from '../types';
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
CreatePluginRuntimeOptions,
|
|
12
|
+
PluginRuntime,
|
|
13
|
+
PluginRuntimeContext,
|
|
14
|
+
PluginRuntimeTransport,
|
|
15
|
+
} from './contracts';
|
|
16
|
+
import { createPluginRuntime } from './factory';
|
|
17
|
+
|
|
18
|
+
/** Describes the plugin module startup hook invoked by the generated bootstrap. */
|
|
19
|
+
export type OnPluginStartHandler = () => void | Promise<void>;
|
|
20
|
+
|
|
21
|
+
/** Describes runtime-wide options applied by the generated bootstrap. */
|
|
22
|
+
export interface PluginModuleDefinition {
|
|
23
|
+
/** Stores optional `plugin.*` delegates contributed by the plugin module. */
|
|
24
|
+
plugin?: PluginDelegates<PluginRuntimeContext>;
|
|
25
|
+
/** Stores optional `vcs.*` delegates contributed by the plugin module. */
|
|
26
|
+
vcs?: VcsDelegates<PluginRuntimeContext>;
|
|
27
|
+
/** Stores optional capability overrides for `plugin.initialize`. */
|
|
28
|
+
implements?: Partial<PluginImplements>;
|
|
29
|
+
/** Stores the `host.log` target emitted by the runtime. */
|
|
30
|
+
logTarget?: string;
|
|
31
|
+
/** Stores the timeout in milliseconds for request handlers. */
|
|
32
|
+
timeout?: number;
|
|
33
|
+
/** Called during stop() after pending operations complete. */
|
|
34
|
+
onShutdown?: (error?: Error) => void | Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Describes one imported plugin module consumed by the generated bootstrap. */
|
|
38
|
+
export interface PluginBootstrapModule {
|
|
39
|
+
/** Stores the plugin author's declarative runtime definition. */
|
|
40
|
+
PluginDefinition?: PluginModuleDefinition;
|
|
41
|
+
/** Stores the plugin author's startup hook. */
|
|
42
|
+
OnPluginStart?: OnPluginStartHandler;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Describes the generated bootstrap inputs used to import and start a plugin. */
|
|
46
|
+
export interface BootstrapPluginModuleOptions {
|
|
47
|
+
/** Imports the plugin author's compiled runtime module. */
|
|
48
|
+
importPluginModule: () => Promise<PluginBootstrapModule>;
|
|
49
|
+
/** Stores the plugin module path for error messages. */
|
|
50
|
+
modulePath: string;
|
|
51
|
+
/** Overrides the transport used by the runtime loop. */
|
|
52
|
+
transport?: PluginRuntimeTransport;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Creates a runtime from one declarative plugin module definition. */
|
|
56
|
+
export function createRegisteredPluginRuntime(
|
|
57
|
+
definition: PluginModuleDefinition = {},
|
|
58
|
+
): PluginRuntime {
|
|
59
|
+
const options: CreatePluginRuntimeOptions = {
|
|
60
|
+
plugin: definition.plugin,
|
|
61
|
+
vcs: definition.vcs,
|
|
62
|
+
implements: definition.implements,
|
|
63
|
+
logTarget: definition.logTarget,
|
|
64
|
+
timeout: definition.timeout,
|
|
65
|
+
onShutdown: definition.onShutdown,
|
|
66
|
+
};
|
|
67
|
+
return createPluginRuntime(options);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Imports the author module, applies its definition, runs `OnPluginStart`, and starts the runtime loop. */
|
|
71
|
+
export async function bootstrapPluginModule(
|
|
72
|
+
options: BootstrapPluginModuleOptions,
|
|
73
|
+
): Promise<PluginRuntime> {
|
|
74
|
+
const pluginModule = await options.importPluginModule();
|
|
75
|
+
const onPluginStart = pluginModule.OnPluginStart;
|
|
76
|
+
|
|
77
|
+
if (typeof onPluginStart !== 'function') {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`plugin module '${options.modulePath}' must export OnPluginStart()`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await onPluginStart();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
+
throw new Error(`plugin startup failed: ${message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const runtime = createRegisteredPluginRuntime(pluginModule.PluginDefinition);
|
|
91
|
+
runtime.start(options.transport);
|
|
92
|
+
return runtime;
|
|
93
|
+
}
|