@openvcs/sdk 0.2.3 → 0.2.4
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 +30 -1
- package/lib/init.d.ts +2 -0
- package/lib/init.js +6 -3
- package/lib/runtime/contracts.d.ts +45 -0
- package/lib/runtime/contracts.js +4 -0
- package/lib/runtime/dispatcher.d.ts +16 -0
- package/lib/runtime/dispatcher.js +133 -0
- package/lib/runtime/errors.d.ts +5 -0
- package/lib/runtime/errors.js +26 -0
- package/lib/runtime/host.d.ts +10 -0
- package/lib/runtime/host.js +48 -0
- package/lib/runtime/index.d.ts +9 -0
- package/lib/runtime/index.js +166 -0
- package/lib/runtime/transport.d.ts +14 -0
- package/lib/runtime/transport.js +72 -0
- package/lib/types/host.d.ts +57 -0
- package/lib/types/host.js +4 -0
- package/lib/types/index.d.ts +4 -0
- package/lib/types/index.js +22 -0
- package/lib/types/plugin.d.ts +56 -0
- package/lib/types/plugin.js +4 -0
- package/lib/types/protocol.d.ts +77 -0
- package/lib/types/protocol.js +13 -0
- package/lib/types/vcs.d.ts +459 -0
- package/lib/types/vcs.js +4 -0
- package/package.json +14 -1
- package/src/lib/init.ts +6 -3
- package/src/lib/runtime/contracts.ts +52 -0
- package/src/lib/runtime/dispatcher.ts +185 -0
- package/src/lib/runtime/errors.ts +27 -0
- package/src/lib/runtime/host.ts +72 -0
- package/src/lib/runtime/index.ts +201 -0
- package/src/lib/runtime/transport.ts +93 -0
- package/src/lib/types/host.ts +71 -0
- package/src/lib/types/index.ts +7 -0
- package/src/lib/types/plugin.ts +110 -0
- package/src/lib/types/protocol.ts +97 -0
- package/src/lib/types/vcs.ts +579 -0
- package/test/init.test.js +25 -0
- package/test/runtime.test.js +118 -0
package/README.md
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
OpenVCS SDK for npm-based plugin development.
|
|
8
8
|
|
|
9
9
|
Install this package in plugin projects, scaffold a starter plugin, and package
|
|
10
|
-
plugins into `.ovcsp` bundles.
|
|
10
|
+
plugins into `.ovcsp` bundles. The SDK also exports a Node-only JSON-RPC runtime
|
|
11
|
+
layer and shared protocol/types so plugins do not have to hand-roll stdio
|
|
12
|
+
framing or method dispatch.
|
|
11
13
|
|
|
12
14
|
## Install
|
|
13
15
|
|
|
@@ -61,6 +63,33 @@ The generated module template includes TypeScript and Node typings (`@types/node
|
|
|
61
63
|
Plugin IDs entered during scaffold must not be `.`/`..` and must not contain path
|
|
62
64
|
separators (`/` or `\\`).
|
|
63
65
|
|
|
66
|
+
Generated module plugins now start with a working SDK runtime entrypoint:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { createPluginRuntime, startPluginRuntime } from '@openvcs/sdk/runtime';
|
|
70
|
+
|
|
71
|
+
const runtime = createPluginRuntime({
|
|
72
|
+
plugin: {
|
|
73
|
+
async 'plugin.init'(_params, context) {
|
|
74
|
+
context.host.info('OpenVCS plugin started');
|
|
75
|
+
return null;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
startPluginRuntime(runtime);
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Runtime and protocol imports are exposed as npm subpaths:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { createPluginRuntime, pluginError } from '@openvcs/sdk/runtime';
|
|
87
|
+
import type { PluginDelegates, VcsDelegates } from '@openvcs/sdk/types';
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The runtime handles stdio framing, JSON-RPC request dispatch, host notifications,
|
|
91
|
+
default `plugin.*` handlers, and exact-method delegate registration for `vcs.*`.
|
|
92
|
+
|
|
64
93
|
Interactive theme plugin scaffold:
|
|
65
94
|
|
|
66
95
|
```bash
|
package/lib/init.d.ts
CHANGED
|
@@ -25,6 +25,7 @@ declare function defaultPluginIdFromDir(targetDir: string): string;
|
|
|
25
25
|
declare function validatePluginId(pluginId: string): string | undefined;
|
|
26
26
|
declare function createReadlinePromptDriver(output?: NodeJS.WritableStream): PromptDriver;
|
|
27
27
|
declare function collectAnswers({ forceTheme, targetHint }: CollectAnswersOptions, promptDriver?: PromptDriver, output?: NodeJS.WritableStream): Promise<InitAnswers>;
|
|
28
|
+
declare function writeModuleTemplate(answers: InitAnswers): void;
|
|
28
29
|
export declare function runInitCommand(args: string[]): Promise<string>;
|
|
29
30
|
export declare function isUsageError(error: unknown): error is InitCommandError;
|
|
30
31
|
export declare const __private: {
|
|
@@ -33,5 +34,6 @@ export declare const __private: {
|
|
|
33
34
|
defaultPluginIdFromDir: typeof defaultPluginIdFromDir;
|
|
34
35
|
sanitizeIdToken: typeof sanitizeIdToken;
|
|
35
36
|
validatePluginId: typeof validatePluginId;
|
|
37
|
+
writeModuleTemplate: typeof writeModuleTemplate;
|
|
36
38
|
};
|
|
37
39
|
export {};
|
package/lib/init.js
CHANGED
|
@@ -197,8 +197,10 @@ function writeModuleTemplate(answers) {
|
|
|
197
197
|
dist: "openvcs dist --plugin-dir . --out dist",
|
|
198
198
|
test: "openvcs dist --plugin-dir . --out dist --no-build --no-npm-deps",
|
|
199
199
|
},
|
|
200
|
-
|
|
200
|
+
dependencies: {
|
|
201
201
|
"@openvcs/sdk": `^${packageJson.version}`,
|
|
202
|
+
},
|
|
203
|
+
devDependencies: {
|
|
202
204
|
"@types/node": "^22.0.0",
|
|
203
205
|
typescript: "^5.8.2",
|
|
204
206
|
},
|
|
@@ -216,7 +218,7 @@ function writeModuleTemplate(answers) {
|
|
|
216
218
|
},
|
|
217
219
|
include: ["src/**/*.ts"],
|
|
218
220
|
});
|
|
219
|
-
writeText(path.join(answers.targetDir, "src", "plugin.ts"), "
|
|
221
|
+
writeText(path.join(answers.targetDir, "src", "plugin.ts"), "// Copyright © 2025-2026 OpenVCS Contributors\n// SPDX-License-Identifier: GPL-3.0-or-later\n\nimport { createPluginRuntime, startPluginRuntime } from '@openvcs/sdk/runtime';\n\nconst runtime = createPluginRuntime({\n plugin: {\n async 'plugin.init'(_params, context) {\n context.host.info('OpenVCS plugin started');\n return null;\n },\n },\n});\n\nstartPluginRuntime(runtime);\n");
|
|
220
222
|
}
|
|
221
223
|
function writeThemeTemplate(answers) {
|
|
222
224
|
writeCommonFiles(answers);
|
|
@@ -229,7 +231,7 @@ function writeThemeTemplate(answers) {
|
|
|
229
231
|
dist: "openvcs dist --plugin-dir . --out dist",
|
|
230
232
|
test: "openvcs dist --plugin-dir . --out dist --no-build --no-npm-deps",
|
|
231
233
|
},
|
|
232
|
-
|
|
234
|
+
dependencies: {
|
|
233
235
|
"@openvcs/sdk": `^${packageJson.version}`,
|
|
234
236
|
},
|
|
235
237
|
});
|
|
@@ -306,4 +308,5 @@ exports.__private = {
|
|
|
306
308
|
defaultPluginIdFromDir,
|
|
307
309
|
sanitizeIdToken,
|
|
308
310
|
validatePluginId,
|
|
311
|
+
writeModuleTemplate,
|
|
309
312
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { PluginHost, PluginImplements, PluginDelegates, JsonRpcId, JsonRpcRequest, VcsDelegates } from '../types';
|
|
2
|
+
/** Describes the transport endpoints used by the plugin runtime loop. */
|
|
3
|
+
export interface PluginRuntimeTransport {
|
|
4
|
+
/** Stores the readable stdin-like stream receiving framed messages. */
|
|
5
|
+
stdin: NodeJS.ReadStream;
|
|
6
|
+
/** Stores the writable stdout-like stream sending framed messages. */
|
|
7
|
+
stdout: NodeJS.WritableStream;
|
|
8
|
+
}
|
|
9
|
+
/** Describes the context object passed to every SDK delegate handler. */
|
|
10
|
+
export interface PluginRuntimeContext {
|
|
11
|
+
/** Stores the active host notification helper. */
|
|
12
|
+
host: PluginHost;
|
|
13
|
+
/** Stores the request id currently being processed. */
|
|
14
|
+
requestId: JsonRpcId;
|
|
15
|
+
/** Stores the current host method name. */
|
|
16
|
+
method: string;
|
|
17
|
+
}
|
|
18
|
+
/** Describes the options accepted by `createPluginRuntime`. */
|
|
19
|
+
export interface CreatePluginRuntimeOptions {
|
|
20
|
+
/** Stores optional plugin lifecycle and settings delegates. */
|
|
21
|
+
plugin?: PluginDelegates<PluginRuntimeContext>;
|
|
22
|
+
/** Stores optional VCS method delegates. */
|
|
23
|
+
vcs?: VcsDelegates<PluginRuntimeContext>;
|
|
24
|
+
/** Stores optional capability overrides for `plugin.initialize`. */
|
|
25
|
+
implements?: Partial<PluginImplements>;
|
|
26
|
+
/** Stores the `host.log` target emitted by the runtime. */
|
|
27
|
+
logTarget?: string;
|
|
28
|
+
/** Stores the timeout in milliseconds for request handlers. */
|
|
29
|
+
timeout?: number;
|
|
30
|
+
/** Called when runtime starts and begins processing requests. */
|
|
31
|
+
onStart?: () => void | Promise<void>;
|
|
32
|
+
/** Called during stop() after pending operations complete. Called with error if shutdown due to processing error. */
|
|
33
|
+
onShutdown?: (error?: Error) => void | Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
/** Describes one created SDK plugin runtime instance. */
|
|
36
|
+
export interface PluginRuntime {
|
|
37
|
+
/** Starts listening on stdio for framed JSON-RPC requests. */
|
|
38
|
+
start(transport?: PluginRuntimeTransport): void;
|
|
39
|
+
/** Stops the runtime and cleans up pending operations. */
|
|
40
|
+
stop(): void;
|
|
41
|
+
/** Consumes one raw stdio chunk and dispatches complete requests. */
|
|
42
|
+
consumeChunk(chunk: Buffer | string): void;
|
|
43
|
+
/** Dispatches one already-decoded JSON-RPC request. */
|
|
44
|
+
dispatchRequest(request: JsonRpcRequest): Promise<void>;
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { JsonRpcId, PluginDelegates, RequestParams } from '../types';
|
|
2
|
+
import type { PluginHost } from '../types';
|
|
3
|
+
import type { CreatePluginRuntimeOptions } from './contracts';
|
|
4
|
+
/** Describes the response emitter used by the dispatcher. */
|
|
5
|
+
export interface DispatcherResponseWriter {
|
|
6
|
+
/** Emits a JSON-RPC success response. */
|
|
7
|
+
sendResult<TResult>(id: JsonRpcId, result: TResult): void;
|
|
8
|
+
/** Emits a JSON-RPC error response. */
|
|
9
|
+
sendError(id: JsonRpcId, code: number, message: string, data?: unknown): void;
|
|
10
|
+
}
|
|
11
|
+
/** Describes the request handler built by `createRuntimeDispatcher`. */
|
|
12
|
+
export type RuntimeRequestDispatcher = (id: JsonRpcId, method: string, params: RequestParams) => Promise<void>;
|
|
13
|
+
/** Creates the plugin default handlers used when a delegate is omitted. */
|
|
14
|
+
export declare function createDefaultPluginDelegates<TContext>(): Required<Omit<PluginDelegates<TContext>, 'plugin.initialize'>>;
|
|
15
|
+
/** Creates the JSON-RPC dispatcher used by the SDK plugin runtime. */
|
|
16
|
+
export declare function createRuntimeDispatcher(options: CreatePluginRuntimeOptions, host: PluginHost, writer: DispatcherResponseWriter): RuntimeRequestDispatcher;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright © 2025-2026 OpenVCS Contributors
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.createDefaultPluginDelegates = createDefaultPluginDelegates;
|
|
6
|
+
exports.createRuntimeDispatcher = createRuntimeDispatcher;
|
|
7
|
+
const types_1 = require("../types");
|
|
8
|
+
const errors_1 = require("./errors");
|
|
9
|
+
/** Creates the plugin default handlers used when a delegate is omitted. */
|
|
10
|
+
function createDefaultPluginDelegates() {
|
|
11
|
+
return {
|
|
12
|
+
async 'plugin.init'() {
|
|
13
|
+
return null;
|
|
14
|
+
},
|
|
15
|
+
async 'plugin.deinit'() {
|
|
16
|
+
return null;
|
|
17
|
+
},
|
|
18
|
+
async 'plugin.get_menus'() {
|
|
19
|
+
return [];
|
|
20
|
+
},
|
|
21
|
+
async 'plugin.handle_action'() {
|
|
22
|
+
return null;
|
|
23
|
+
},
|
|
24
|
+
async 'plugin.settings.defaults'() {
|
|
25
|
+
return [];
|
|
26
|
+
},
|
|
27
|
+
async 'plugin.settings.on_load'(params) {
|
|
28
|
+
return Array.isArray(params.values) ? params.values : [];
|
|
29
|
+
},
|
|
30
|
+
async 'plugin.settings.on_apply'() {
|
|
31
|
+
return null;
|
|
32
|
+
},
|
|
33
|
+
async 'plugin.settings.on_save'(params) {
|
|
34
|
+
return Array.isArray(params.values) ? params.values : [];
|
|
35
|
+
},
|
|
36
|
+
async 'plugin.settings.on_reset'() {
|
|
37
|
+
return null;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Creates the JSON-RPC dispatcher used by the SDK plugin runtime. */
|
|
42
|
+
function createRuntimeDispatcher(options, host, writer) {
|
|
43
|
+
const defaultPluginDelegates = createDefaultPluginDelegates();
|
|
44
|
+
const pluginDelegates = options.plugin ?? {};
|
|
45
|
+
const vcsDelegates = options.vcs ?? {};
|
|
46
|
+
const runtimeImplements = buildRuntimeImplements(options.implements, options.vcs);
|
|
47
|
+
const timeout = options.timeout;
|
|
48
|
+
const typedPluginDelegates = pluginDelegates;
|
|
49
|
+
const typedDefaultPluginDelegates = defaultPluginDelegates;
|
|
50
|
+
const typedVcsDelegates = vcsDelegates;
|
|
51
|
+
return async (id, method, params) => {
|
|
52
|
+
try {
|
|
53
|
+
if (method === 'plugin.initialize') {
|
|
54
|
+
const expectedVersion = params.expected_protocol_version;
|
|
55
|
+
if (typeof expectedVersion === 'number' && expectedVersion !== types_1.PROTOCOL_VERSION) {
|
|
56
|
+
writer.sendError(id, types_1.PROTOCOL_VERSION_MISMATCH_CODE, 'protocol version mismatch', {
|
|
57
|
+
code: 'protocol-version-mismatch',
|
|
58
|
+
message: `host expects protocol ${expectedVersion}, plugin supports ${types_1.PROTOCOL_VERSION}`,
|
|
59
|
+
});
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const override = pluginDelegates['plugin.initialize']
|
|
63
|
+
? await pluginDelegates['plugin.initialize'](params, {
|
|
64
|
+
host,
|
|
65
|
+
requestId: id,
|
|
66
|
+
method,
|
|
67
|
+
})
|
|
68
|
+
: {};
|
|
69
|
+
writer.sendResult(id, {
|
|
70
|
+
protocol_version: override.protocol_version ?? types_1.PROTOCOL_VERSION,
|
|
71
|
+
implements: {
|
|
72
|
+
...runtimeImplements,
|
|
73
|
+
...override.implements,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const handler = typedPluginDelegates[method] ??
|
|
79
|
+
typedDefaultPluginDelegates[method] ??
|
|
80
|
+
typedVcsDelegates[method];
|
|
81
|
+
if (!handler) {
|
|
82
|
+
throw (0, errors_1.pluginError)('rpc-method-not-found', `method '${method}' is not implemented`);
|
|
83
|
+
}
|
|
84
|
+
let result;
|
|
85
|
+
if (timeout && timeout > 0) {
|
|
86
|
+
const controller = new AbortController();
|
|
87
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
88
|
+
try {
|
|
89
|
+
result = await handler(params, {
|
|
90
|
+
host,
|
|
91
|
+
requestId: id,
|
|
92
|
+
method,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
clearTimeout(timeoutId);
|
|
97
|
+
if (error.name === 'AbortError') {
|
|
98
|
+
throw (0, errors_1.pluginError)('request-timeout', `method '${method}' timed out after ${timeout}ms`);
|
|
99
|
+
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
clearTimeout(timeoutId);
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
result = await handler(params, {
|
|
106
|
+
host,
|
|
107
|
+
requestId: id,
|
|
108
|
+
method,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
writer.sendResult(id, result == null ? null : result);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if ((0, errors_1.isPluginFailure)(error)) {
|
|
115
|
+
writer.sendError(id, error.code, error.message, error.data);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const messageText = error instanceof Error ? error.message : String(error || 'unknown error');
|
|
119
|
+
host.error(messageText);
|
|
120
|
+
writer.sendError(id, types_1.PLUGIN_INTERNAL_ERROR_CODE, messageText, {
|
|
121
|
+
code: 'plugin-internal-error',
|
|
122
|
+
message: messageText,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Builds the handshake capability flags returned from `plugin.initialize`. */
|
|
128
|
+
function buildRuntimeImplements(overrides, vcsDelegates) {
|
|
129
|
+
return {
|
|
130
|
+
plugin: overrides?.plugin ?? true,
|
|
131
|
+
vcs: overrides?.vcs ?? Boolean(vcsDelegates && Object.keys(vcsDelegates).length > 0),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { PluginFailure } from '../types';
|
|
2
|
+
/** Builds the host-facing plugin failure payload used for operational errors. */
|
|
3
|
+
export declare function pluginError(code: string, message: string): PluginFailure;
|
|
4
|
+
/** Returns whether the supplied value is a structured plugin failure. */
|
|
5
|
+
export declare function isPluginFailure(value: unknown): value is PluginFailure;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright © 2025-2026 OpenVCS Contributors
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.pluginError = pluginError;
|
|
6
|
+
exports.isPluginFailure = isPluginFailure;
|
|
7
|
+
const types_1 = require("../types");
|
|
8
|
+
/** Builds the host-facing plugin failure payload used for operational errors. */
|
|
9
|
+
function pluginError(code, message) {
|
|
10
|
+
return {
|
|
11
|
+
code: types_1.PLUGIN_FAILURE_CODE,
|
|
12
|
+
message,
|
|
13
|
+
data: {
|
|
14
|
+
code,
|
|
15
|
+
message,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** Returns whether the supplied value is a structured plugin failure. */
|
|
20
|
+
function isPluginFailure(value) {
|
|
21
|
+
if (value == null || typeof value !== 'object') {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
const candidate = value;
|
|
25
|
+
return candidate.code === types_1.PLUGIN_FAILURE_CODE && typeof candidate.message === 'string';
|
|
26
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { PluginHost } from '../types';
|
|
2
|
+
/** Describes the low-level notification sender used by `createHost`. */
|
|
3
|
+
export type HostNotificationSender = (method: string, params: unknown) => void;
|
|
4
|
+
/** Describes the options accepted by `createHost`. */
|
|
5
|
+
export interface CreateHostOptions {
|
|
6
|
+
/** Stores the `host.log` target name to emit for diagnostic messages. */
|
|
7
|
+
logTarget?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Creates the host notification helper used inside runtime delegates. */
|
|
10
|
+
export declare function createHost(sendNotification: HostNotificationSender, options?: CreateHostOptions): PluginHost;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright © 2025-2026 OpenVCS Contributors
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.createHost = createHost;
|
|
6
|
+
/** Creates the host notification helper used inside runtime delegates. */
|
|
7
|
+
function createHost(sendNotification, options = {}) {
|
|
8
|
+
const logTarget = options.logTarget ?? 'openvcs.plugin';
|
|
9
|
+
return {
|
|
10
|
+
log(level, message) {
|
|
11
|
+
sendNotification('host.log', {
|
|
12
|
+
level,
|
|
13
|
+
target: logTarget,
|
|
14
|
+
message,
|
|
15
|
+
});
|
|
16
|
+
},
|
|
17
|
+
info(message) {
|
|
18
|
+
sendNotification('host.log', {
|
|
19
|
+
level: 'info',
|
|
20
|
+
target: logTarget,
|
|
21
|
+
message,
|
|
22
|
+
});
|
|
23
|
+
},
|
|
24
|
+
error(message) {
|
|
25
|
+
sendNotification('host.log', {
|
|
26
|
+
level: 'error',
|
|
27
|
+
target: logTarget,
|
|
28
|
+
message,
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
uiNotify(params) {
|
|
32
|
+
sendNotification('host.ui_notify', params);
|
|
33
|
+
},
|
|
34
|
+
statusSet(params) {
|
|
35
|
+
sendNotification('host.status_set', params);
|
|
36
|
+
},
|
|
37
|
+
emitEvent(params) {
|
|
38
|
+
sendNotification('host.event_emit', params);
|
|
39
|
+
},
|
|
40
|
+
emitVcsEvent(sessionId, requestId, event) {
|
|
41
|
+
sendNotification('vcs.event', {
|
|
42
|
+
session_id: sessionId,
|
|
43
|
+
request_id: requestId,
|
|
44
|
+
event,
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CreatePluginRuntimeOptions, PluginRuntime, PluginRuntimeTransport } from './contracts';
|
|
2
|
+
export type { CreatePluginRuntimeOptions, PluginRuntime, PluginRuntimeContext, PluginRuntimeTransport, } from './contracts';
|
|
3
|
+
export { createDefaultPluginDelegates } from './dispatcher';
|
|
4
|
+
export { isPluginFailure, pluginError } from './errors';
|
|
5
|
+
export { createHost } from './host';
|
|
6
|
+
/** Creates a reusable stdio JSON-RPC runtime for OpenVCS Node plugins. */
|
|
7
|
+
export declare function createPluginRuntime(options?: CreatePluginRuntimeOptions): PluginRuntime;
|
|
8
|
+
/** Starts a previously created plugin runtime on process stdio. */
|
|
9
|
+
export declare function startPluginRuntime(runtime: PluginRuntime, transport?: PluginRuntimeTransport): void;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright © 2025-2026 OpenVCS Contributors
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.createHost = exports.pluginError = exports.isPluginFailure = exports.createDefaultPluginDelegates = void 0;
|
|
6
|
+
exports.createPluginRuntime = createPluginRuntime;
|
|
7
|
+
exports.startPluginRuntime = startPluginRuntime;
|
|
8
|
+
const dispatcher_1 = require("./dispatcher");
|
|
9
|
+
const host_1 = require("./host");
|
|
10
|
+
const transport_1 = require("./transport");
|
|
11
|
+
var dispatcher_2 = require("./dispatcher");
|
|
12
|
+
Object.defineProperty(exports, "createDefaultPluginDelegates", { enumerable: true, get: function () { return dispatcher_2.createDefaultPluginDelegates; } });
|
|
13
|
+
var errors_1 = require("./errors");
|
|
14
|
+
Object.defineProperty(exports, "isPluginFailure", { enumerable: true, get: function () { return errors_1.isPluginFailure; } });
|
|
15
|
+
Object.defineProperty(exports, "pluginError", { enumerable: true, get: function () { return errors_1.pluginError; } });
|
|
16
|
+
var host_2 = require("./host");
|
|
17
|
+
Object.defineProperty(exports, "createHost", { enumerable: true, get: function () { return host_2.createHost; } });
|
|
18
|
+
/** Creates a reusable stdio JSON-RPC runtime for OpenVCS Node plugins. */
|
|
19
|
+
function createPluginRuntime(options = {}) {
|
|
20
|
+
let buffer = Buffer.alloc(0);
|
|
21
|
+
let processing = Promise.resolve();
|
|
22
|
+
let started = false;
|
|
23
|
+
let currentTransport = null;
|
|
24
|
+
let chunkLock = Promise.resolve();
|
|
25
|
+
const runtime = {
|
|
26
|
+
start(transport = defaultTransport()) {
|
|
27
|
+
if (started) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
started = true;
|
|
31
|
+
currentTransport = transport;
|
|
32
|
+
transport.stdin.on('data', (chunk) => {
|
|
33
|
+
runtime.consumeChunk(chunk);
|
|
34
|
+
});
|
|
35
|
+
transport.stdin.on('error', () => {
|
|
36
|
+
process.exit(1);
|
|
37
|
+
});
|
|
38
|
+
options.onStart?.();
|
|
39
|
+
},
|
|
40
|
+
stop() {
|
|
41
|
+
if (!started) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
started = false;
|
|
45
|
+
processing = processing
|
|
46
|
+
.catch(async (error) => {
|
|
47
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
48
|
+
console.error(`[runtime] shutdown error: ${errorMessage}`);
|
|
49
|
+
const err = error instanceof Error ? error : new Error(errorMessage);
|
|
50
|
+
await options.onShutdown?.(err);
|
|
51
|
+
})
|
|
52
|
+
.then(async () => {
|
|
53
|
+
await options.onShutdown?.();
|
|
54
|
+
});
|
|
55
|
+
currentTransport = null;
|
|
56
|
+
},
|
|
57
|
+
consumeChunk(chunk) {
|
|
58
|
+
if (!started) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const lock = chunkLock.then(() => {
|
|
62
|
+
buffer = Buffer.concat([buffer, normalizeChunk(chunk)]);
|
|
63
|
+
const parsed = (0, transport_1.parseFramedMessages)(buffer);
|
|
64
|
+
buffer = parsed.remainder;
|
|
65
|
+
for (const request of parsed.messages) {
|
|
66
|
+
processing = processing
|
|
67
|
+
.then(async () => {
|
|
68
|
+
await runtime.dispatchRequest(request);
|
|
69
|
+
})
|
|
70
|
+
.catch((error) => {
|
|
71
|
+
const message = error instanceof Error ? error.message : String(error || 'unknown plugin processing error');
|
|
72
|
+
const host = createRuntimeHost(currentTransport, options.logTarget);
|
|
73
|
+
host.error(message);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
chunkLock = lock;
|
|
78
|
+
},
|
|
79
|
+
async dispatchRequest(request) {
|
|
80
|
+
const id = request.id;
|
|
81
|
+
const host = createRuntimeHost(currentTransport, options.logTarget);
|
|
82
|
+
const method = asTrimmedString(request.method);
|
|
83
|
+
const validationErrors = [];
|
|
84
|
+
if (!method)
|
|
85
|
+
validationErrors.push('missing method');
|
|
86
|
+
if (typeof id !== 'number' && typeof id !== 'string')
|
|
87
|
+
validationErrors.push(`invalid id type: ${typeof id}`);
|
|
88
|
+
if (validationErrors.length > 0) {
|
|
89
|
+
host.error(`invalid request: ${validationErrors.join(', ')}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const dispatcher = (0, dispatcher_1.createRuntimeDispatcher)(options, host, {
|
|
93
|
+
async sendResult(requestId, result) {
|
|
94
|
+
await sendMessage(currentTransport, {
|
|
95
|
+
jsonrpc: '2.0',
|
|
96
|
+
id: requestId,
|
|
97
|
+
result,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
async sendError(requestId, code, message, data) {
|
|
101
|
+
await sendMessage(currentTransport, {
|
|
102
|
+
jsonrpc: '2.0',
|
|
103
|
+
id: requestId,
|
|
104
|
+
error: {
|
|
105
|
+
code,
|
|
106
|
+
message,
|
|
107
|
+
...(data == null ? {} : { data }),
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
const methodName = method;
|
|
113
|
+
const params = asRecord(request.params) ?? {};
|
|
114
|
+
const requestId = id;
|
|
115
|
+
await dispatcher(requestId, methodName, params);
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
return runtime;
|
|
119
|
+
}
|
|
120
|
+
/** Starts a previously created plugin runtime on process stdio. */
|
|
121
|
+
function startPluginRuntime(runtime, transport) {
|
|
122
|
+
runtime.start(transport);
|
|
123
|
+
}
|
|
124
|
+
/** Returns the default stdio transport used by the runtime. */
|
|
125
|
+
function defaultTransport() {
|
|
126
|
+
return {
|
|
127
|
+
stdin: process.stdin,
|
|
128
|
+
stdout: process.stdout,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** Sends one JSON-RPC response or notification through the active transport. */
|
|
132
|
+
async function sendMessage(transport, value) {
|
|
133
|
+
await (0, transport_1.writeFramedMessage)((transport ?? defaultTransport()).stdout, value);
|
|
134
|
+
}
|
|
135
|
+
/** Builds the host helper for the currently active transport. */
|
|
136
|
+
function createRuntimeHost(transport, logTarget) {
|
|
137
|
+
return (0, host_1.createHost)(async (method, params) => {
|
|
138
|
+
await sendMessage(transport, {
|
|
139
|
+
jsonrpc: '2.0',
|
|
140
|
+
method,
|
|
141
|
+
params,
|
|
142
|
+
});
|
|
143
|
+
}, { logTarget });
|
|
144
|
+
}
|
|
145
|
+
/** Type guard that returns true if the value is a valid RequestParams object. */
|
|
146
|
+
function isRequestParams(value) {
|
|
147
|
+
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
/** Coerces unknown request method values into trimmed strings. Returns null for non-strings. */
|
|
153
|
+
function asTrimmedString(value) {
|
|
154
|
+
return typeof value === 'string' ? value.trim() : null;
|
|
155
|
+
}
|
|
156
|
+
/** Coerces unknown params to a Record, returning null for invalid input. */
|
|
157
|
+
function asRecord(value) {
|
|
158
|
+
if (isRequestParams(value)) {
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
/** Normalizes incoming data chunks to UTF-8 buffers. */
|
|
164
|
+
function normalizeChunk(chunk) {
|
|
165
|
+
return Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
|
|
166
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { JsonRpcRequest } from '../types';
|
|
2
|
+
/** Describes the parsed result of draining one buffered transport chunk. */
|
|
3
|
+
export interface FramedMessageParseResult {
|
|
4
|
+
/** Stores the decoded requests found in the current buffer. */
|
|
5
|
+
messages: JsonRpcRequest[];
|
|
6
|
+
/** Stores any remaining incomplete bytes. */
|
|
7
|
+
remainder: Buffer;
|
|
8
|
+
}
|
|
9
|
+
/** Serializes one JSON-RPC payload into an LSP-style framed stdio message. */
|
|
10
|
+
export declare function serializeFramedMessage(value: unknown): Buffer;
|
|
11
|
+
/** Writes one JSON-RPC payload to the supplied stdout-like stream. */
|
|
12
|
+
export declare function writeFramedMessage(writer: NodeJS.WritableStream, value: unknown): Promise<void>;
|
|
13
|
+
/** Parses all complete framed JSON-RPC requests currently present in a buffer. */
|
|
14
|
+
export declare function parseFramedMessages(buffer: Buffer): FramedMessageParseResult;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Copyright © 2025-2026 OpenVCS Contributors
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.serializeFramedMessage = serializeFramedMessage;
|
|
6
|
+
exports.writeFramedMessage = writeFramedMessage;
|
|
7
|
+
exports.parseFramedMessages = parseFramedMessages;
|
|
8
|
+
/** Serializes one JSON-RPC payload into an LSP-style framed stdio message. */
|
|
9
|
+
function serializeFramedMessage(value) {
|
|
10
|
+
const payload = Buffer.from(JSON.stringify(value), 'utf8');
|
|
11
|
+
const header = Buffer.from(`Content-Length: ${payload.length}\r\n\r\n`, 'utf8');
|
|
12
|
+
return Buffer.concat([header, payload]);
|
|
13
|
+
}
|
|
14
|
+
/** Writes one JSON-RPC payload to the supplied stdout-like stream. */
|
|
15
|
+
async function writeFramedMessage(writer, value) {
|
|
16
|
+
try {
|
|
17
|
+
const serialized = serializeFramedMessage(value);
|
|
18
|
+
const canContinue = writer.write(serialized);
|
|
19
|
+
if (!canContinue) {
|
|
20
|
+
await new Promise((resolve) => writer.once('drain', resolve));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
console.error(`[transport] failed to write framed message: ${error instanceof Error ? error.message : String(error)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Parses all complete framed JSON-RPC requests currently present in a buffer. */
|
|
28
|
+
function parseFramedMessages(buffer) {
|
|
29
|
+
const messages = [];
|
|
30
|
+
let remainder = buffer;
|
|
31
|
+
while (true) {
|
|
32
|
+
const marker = remainder.indexOf('\r\n\r\n');
|
|
33
|
+
if (marker < 0) {
|
|
34
|
+
return { messages, remainder };
|
|
35
|
+
}
|
|
36
|
+
const header = remainder.subarray(0, marker).toString('utf8');
|
|
37
|
+
const contentLength = readContentLength(header);
|
|
38
|
+
if (contentLength == null) {
|
|
39
|
+
remainder = remainder.subarray(marker + 4);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const totalLength = marker + 4 + contentLength;
|
|
43
|
+
if (remainder.length < totalLength) {
|
|
44
|
+
return { messages, remainder };
|
|
45
|
+
}
|
|
46
|
+
const payload = remainder.subarray(marker + 4, totalLength).toString('utf8');
|
|
47
|
+
remainder = remainder.subarray(totalLength);
|
|
48
|
+
try {
|
|
49
|
+
messages.push(JSON.parse(payload));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Reads one `Content-Length` header value from a framed message header block. */
|
|
57
|
+
function readContentLength(header) {
|
|
58
|
+
const headerLines = header.split(/\r?\n/g);
|
|
59
|
+
for (const line of headerLines) {
|
|
60
|
+
const separatorIndex = line.indexOf(':');
|
|
61
|
+
if (separatorIndex < 0) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const name = line.slice(0, separatorIndex).trim().toLowerCase();
|
|
65
|
+
if (name !== 'content-length') {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const rawValue = Number(line.slice(separatorIndex + 1).trim());
|
|
69
|
+
return Number.isFinite(rawValue) && rawValue >= 0 ? rawValue : null;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|