@monosmith/plugin-sdk 0.0.0-stage → 0.2.0
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 +90 -2
- package/build-dir/build-dir.algorithm.d.ts +16 -0
- package/build-dir/build-dir.algorithm.js +26 -0
- package/build-dir/index.d.ts +1 -0
- package/build-dir/index.js +1 -0
- package/ci/ci-contribution.algorithm.d.ts +28 -0
- package/ci/ci-contribution.algorithm.js +19 -0
- package/ci/index.d.ts +2 -0
- package/ci/index.js +1 -0
- package/codec/encode-by-schema.algorithm.d.ts +12 -0
- package/codec/encode-by-schema.algorithm.js +105 -0
- package/codec/encode-error.error.d.ts +4 -0
- package/codec/encode-error.error.js +7 -0
- package/codec/encode-json-value.algorithm.d.ts +13 -0
- package/codec/encode-json-value.algorithm.js +51 -0
- package/codec/index.d.ts +6 -0
- package/codec/index.js +5 -0
- package/codec/message-parse.error.d.ts +4 -0
- package/codec/message-parse.error.js +7 -0
- package/codec/method-codec.algorithm.d.ts +8 -0
- package/codec/method-codec.algorithm.js +31 -0
- package/codec/normalize-by-schema.algorithm.d.ts +14 -0
- package/codec/normalize-by-schema.algorithm.js +103 -0
- package/codec/params-error.error.d.ts +4 -0
- package/codec/params-error.error.js +7 -0
- package/codec/schema-lookup.algorithm.d.ts +13 -0
- package/codec/schema-lookup.algorithm.js +56 -0
- package/codec/wire-message.mapper.d.ts +27 -0
- package/codec/wire-message.mapper.js +62 -0
- package/concurrency/concurrency.policy.d.ts +21 -0
- package/concurrency/concurrency.policy.js +30 -0
- package/concurrency/index.d.ts +1 -0
- package/concurrency/index.js +1 -0
- package/conformance/check-generation.use-case.d.ts +12 -0
- package/conformance/check-generation.use-case.js +91 -0
- package/conformance/check-plugin.use-case.d.ts +25 -0
- package/conformance/check-plugin.use-case.js +81 -0
- package/conformance/check-scheduling.use-case.d.ts +11 -0
- package/conformance/check-scheduling.use-case.js +98 -0
- package/conformance/conformance-report.model.d.ts +22 -0
- package/conformance/conformance-report.model.js +23 -0
- package/conformance/find-manifests.algorithm.d.ts +7 -0
- package/conformance/find-manifests.algorithm.js +34 -0
- package/conformance/go-list.mapper.d.ts +4 -0
- package/conformance/go-list.mapper.js +8 -0
- package/conformance/index.d.ts +5 -0
- package/conformance/index.js +3 -0
- package/framing/frame-message.algorithm.d.ts +5 -0
- package/framing/frame-message.algorithm.js +8 -0
- package/framing/frame-reader.store.d.ts +15 -0
- package/framing/frame-reader.store.js +59 -0
- package/framing/framing.error.d.ts +4 -0
- package/framing/framing.error.js +7 -0
- package/framing/index.d.ts +3 -0
- package/framing/index.js +3 -0
- package/host/call-interrupted.error.d.ts +16 -0
- package/host/call-interrupted.error.js +20 -0
- package/host/connect-plugin.use-case.d.ts +26 -0
- package/host/connect-plugin.use-case.js +44 -0
- package/host/host-options.contract.d.ts +34 -0
- package/host/host-options.contract.js +4 -0
- package/host/index.d.ts +9 -0
- package/host/index.js +5 -0
- package/host/plugin-client.client.d.ts +61 -0
- package/host/plugin-client.client.js +297 -0
- package/host/start-plugin.use-case.d.ts +10 -0
- package/host/start-plugin.use-case.js +57 -0
- package/index.d.ts +16 -0
- package/index.js +21 -0
- package/jsondoc/indent-raw-json.algorithm.d.ts +8 -0
- package/jsondoc/indent-raw-json.algorithm.js +60 -0
- package/jsondoc/index.d.ts +2 -0
- package/jsondoc/index.js +2 -0
- package/jsondoc/json-document.error.d.ts +4 -0
- package/jsondoc/json-document.error.js +7 -0
- package/jsondoc/json-document.model.d.ts +38 -0
- package/jsondoc/json-document.model.js +113 -0
- package/jsondoc/scan-json-object.algorithm.d.ts +17 -0
- package/jsondoc/scan-json-object.algorithm.js +132 -0
- package/package.json +34 -4
- package/plugin-refs/index.d.ts +1 -0
- package/plugin-refs/index.js +1 -0
- package/plugin-refs/plugin-ref.policy.d.ts +9 -0
- package/plugin-refs/plugin-ref.policy.js +22 -0
- package/plugin-roles/index.d.ts +1 -0
- package/plugin-roles/index.js +1 -0
- package/plugin-roles/plugin-role.policy.d.ts +16 -0
- package/plugin-roles/plugin-role.policy.js +30 -0
- package/projects/audit-settings.policy.d.ts +5 -0
- package/projects/audit-settings.policy.js +8 -0
- package/projects/index.d.ts +6 -0
- package/projects/index.js +6 -0
- package/projects/manifest-match.algorithm.d.ts +7 -0
- package/projects/manifest-match.algorithm.js +38 -0
- package/projects/project-kind.policy.d.ts +9 -0
- package/projects/project-kind.policy.js +28 -0
- package/projects/publishes.policy.d.ts +7 -0
- package/projects/publishes.policy.js +8 -0
- package/projects/set-toolchain.algorithm.d.ts +7 -0
- package/projects/set-toolchain.algorithm.js +13 -0
- package/projects/stack-plugins.algorithm.d.ts +3 -0
- package/projects/stack-plugins.algorithm.js +4 -0
- package/protocol/index.d.ts +4 -0
- package/protocol/index.js +3 -0
- package/protocol/protocol-schema.contract.d.ts +3 -0
- package/protocol/protocol-schema.contract.js +1976 -0
- package/protocol/protocol-types.contract.d.ts +1057 -0
- package/protocol/protocol-types.contract.js +43 -0
- package/protocol/rpc-error.error.d.ts +9 -0
- package/protocol/rpc-error.error.js +13 -0
- package/protocol/schema-document.contract.d.ts +58 -0
- package/protocol/schema-document.contract.js +1 -0
- package/scaffold/index.d.ts +3 -0
- package/scaffold/index.js +2 -0
- package/scaffold/scaffold-data.mapper.d.ts +15 -0
- package/scaffold/scaffold-data.mapper.js +24 -0
- package/scaffold/scaffold-project.use-case.d.ts +20 -0
- package/scaffold/scaffold-project.use-case.js +13 -0
- package/serve/capabilities-of.algorithm.d.ts +14 -0
- package/serve/capabilities-of.algorithm.js +31 -0
- package/serve/claim-stdout.use-case.d.ts +10 -0
- package/serve/claim-stdout.use-case.js +14 -0
- package/serve/dispatch-request.use-case.d.ts +15 -0
- package/serve/dispatch-request.use-case.js +57 -0
- package/serve/index.d.ts +4 -0
- package/serve/index.js +2 -0
- package/serve/initialize-plugin.use-case.d.ts +9 -0
- package/serve/initialize-plugin.use-case.js +16 -0
- package/serve/plugin-members.config.d.ts +16 -0
- package/serve/plugin-members.config.js +25 -0
- package/serve/plugin.contract.d.ts +80 -0
- package/serve/plugin.contract.js +1 -0
- package/serve/protocol-streams.contract.d.ts +13 -0
- package/serve/protocol-streams.contract.js +1 -0
- package/serve/request-outcome.contract.d.ts +9 -0
- package/serve/request-outcome.contract.js +1 -0
- package/serve/request-pool.store.d.ts +49 -0
- package/serve/request-pool.store.js +110 -0
- package/serve/serve.use-case.d.ts +31 -0
- package/serve/serve.use-case.js +180 -0
- package/tasks/contribute-tasks.algorithm.d.ts +15 -0
- package/tasks/contribute-tasks.algorithm.js +22 -0
- package/tasks/index.d.ts +2 -0
- package/tasks/index.js +2 -0
- package/tasks/task.algorithm.d.ts +18 -0
- package/tasks/task.algorithm.js +23 -0
- package/templating/file-exists.error.d.ts +6 -0
- package/templating/file-exists.error.js +10 -0
- package/templating/index.d.ts +4 -0
- package/templating/index.js +4 -0
- package/templating/parse-template.algorithm.d.ts +11 -0
- package/templating/parse-template.algorithm.js +197 -0
- package/templating/render-template.algorithm.d.ts +7 -0
- package/templating/render-template.algorithm.js +87 -0
- package/templating/template-functions.algorithm.d.ts +10 -0
- package/templating/template-functions.algorithm.js +104 -0
- package/templating/template-syntax.contract.d.ts +33 -0
- package/templating/template-syntax.contract.js +1 -0
- package/templating/template.error.d.ts +4 -0
- package/templating/template.error.js +7 -0
- package/templating/tokenize-action.algorithm.d.ts +20 -0
- package/templating/tokenize-action.algorithm.js +57 -0
- package/templating/write-templates.use-case.d.ts +11 -0
- package/templating/write-templates.use-case.js +44 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One message as the protocol frames it: `Content-Length: <bytes>`, a blank
|
|
3
|
+
* line (CRLF CRLF), then the body. The length counts bytes of UTF-8, not characters.
|
|
4
|
+
*/
|
|
5
|
+
export function frameMessage(body) {
|
|
6
|
+
const bytes = Buffer.from(body, 'utf8');
|
|
7
|
+
return Buffer.concat([Buffer.from(`Content-Length: ${bytes.length}\r\n\r\n`, 'ascii'), bytes]);
|
|
8
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cuts a byte stream into the bodies of its frames. Bytes arrive in chunks of
|
|
3
|
+
* any size; {@link FrameReader.push} returns every body a chunk completed.
|
|
4
|
+
* Headers end at a blank line (CRLF or LF alone, as the Go SDK reads them),
|
|
5
|
+
* `Content-Length` is matched without regard to case, and a header that is not
|
|
6
|
+
* `Name: value`, a length that is not a number from 0 to 64 MiB, or a header
|
|
7
|
+
* block without a length is a {@link FramingError}.
|
|
8
|
+
*/
|
|
9
|
+
export declare class FrameReader {
|
|
10
|
+
#private;
|
|
11
|
+
/** Bytes received that are not part of a complete frame yet. */
|
|
12
|
+
get pending(): number;
|
|
13
|
+
/** Adds the bytes that arrived and returns the body of every frame they completed, in order. */
|
|
14
|
+
push(chunk: Uint8Array): string[];
|
|
15
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { MAX_MESSAGE_BYTES } from '../protocol/index.js';
|
|
2
|
+
import { FramingError } from './framing.error.js';
|
|
3
|
+
/**
|
|
4
|
+
* Cuts a byte stream into the bodies of its frames. Bytes arrive in chunks of
|
|
5
|
+
* any size; {@link FrameReader.push} returns every body a chunk completed.
|
|
6
|
+
* Headers end at a blank line (CRLF or LF alone, as the Go SDK reads them),
|
|
7
|
+
* `Content-Length` is matched without regard to case, and a header that is not
|
|
8
|
+
* `Name: value`, a length that is not a number from 0 to 64 MiB, or a header
|
|
9
|
+
* block without a length is a {@link FramingError}.
|
|
10
|
+
*/
|
|
11
|
+
export class FrameReader {
|
|
12
|
+
#buffer = Buffer.alloc(0);
|
|
13
|
+
/** The length and where the body starts, or null while the header is incomplete. */
|
|
14
|
+
#readHeader() {
|
|
15
|
+
let length = -1;
|
|
16
|
+
let position = 0;
|
|
17
|
+
for (;;) {
|
|
18
|
+
const newline = this.#buffer.indexOf(0x0A, position);
|
|
19
|
+
if (newline === -1)
|
|
20
|
+
return null;
|
|
21
|
+
const line = this.#buffer.subarray(position, newline).toString('ascii').replace(/[\r\n]+$/, '');
|
|
22
|
+
position = newline + 1;
|
|
23
|
+
if (line === '')
|
|
24
|
+
break;
|
|
25
|
+
const colon = line.indexOf(':');
|
|
26
|
+
if (colon === -1)
|
|
27
|
+
throw new FramingError(`malformed header "${line}"`);
|
|
28
|
+
if (line.slice(0, colon).trim().toLowerCase() !== 'content-length')
|
|
29
|
+
continue;
|
|
30
|
+
const text = line.slice(colon + 1).trim();
|
|
31
|
+
const parsed = /^\d+$/.test(text) ? Number(text) : NaN;
|
|
32
|
+
if (!Number.isSafeInteger(parsed) || parsed > MAX_MESSAGE_BYTES)
|
|
33
|
+
throw new FramingError(`invalid Content-Length "${line.slice(colon + 1)}"`);
|
|
34
|
+
length = parsed;
|
|
35
|
+
}
|
|
36
|
+
if (length < 0)
|
|
37
|
+
throw new FramingError('message without Content-Length');
|
|
38
|
+
return { length, bodyStart: position };
|
|
39
|
+
}
|
|
40
|
+
/** Bytes received that are not part of a complete frame yet. */
|
|
41
|
+
get pending() {
|
|
42
|
+
return this.#buffer.length;
|
|
43
|
+
}
|
|
44
|
+
/** Adds the bytes that arrived and returns the body of every frame they completed, in order. */
|
|
45
|
+
push(chunk) {
|
|
46
|
+
this.#buffer = this.#buffer.length === 0 ? Buffer.from(chunk) : Buffer.concat([this.#buffer, chunk]);
|
|
47
|
+
const bodies = [];
|
|
48
|
+
for (;;) {
|
|
49
|
+
const header = this.#readHeader();
|
|
50
|
+
if (header === null)
|
|
51
|
+
return bodies;
|
|
52
|
+
const end = header.bodyStart + header.length;
|
|
53
|
+
if (this.#buffer.length < end)
|
|
54
|
+
return bodies;
|
|
55
|
+
bodies.push(this.#buffer.subarray(header.bodyStart, end).toString('utf8'));
|
|
56
|
+
this.#buffer = this.#buffer.subarray(end);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** The bytes on the stream are not a sequence of frames: a malformed header, a missing or invalid Content-Length. The stream cannot be trusted after it. */
|
|
2
|
+
export class FramingError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'FramingError';
|
|
6
|
+
}
|
|
7
|
+
}
|
package/framing/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { RpcError } from '../protocol/index.ts';
|
|
2
|
+
/** Why the host stopped waiting for a call. */
|
|
3
|
+
export type InterruptionReason = 'cancelled' | 'timeout';
|
|
4
|
+
/**
|
|
5
|
+
* A call the host gave up on, because its `signal` aborted (`cancelled`) or its
|
|
6
|
+
* time ran out (`timeout`). `killed` says the plugin did not stop in time
|
|
7
|
+
* and was killed, which also fails every other call in flight to it. When the
|
|
8
|
+
* plugin answered while being stopped, `response` is its error (the
|
|
9
|
+
* request-cancelled code, usually).
|
|
10
|
+
*/
|
|
11
|
+
export declare class CallInterruptedError extends Error {
|
|
12
|
+
readonly reason: InterruptionReason;
|
|
13
|
+
readonly killed: boolean;
|
|
14
|
+
readonly response?: RpcError;
|
|
15
|
+
constructor(message: string, reason: InterruptionReason, killed: boolean, response?: RpcError);
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A call the host gave up on, because its `signal` aborted (`cancelled`) or its
|
|
3
|
+
* time ran out (`timeout`). `killed` says the plugin did not stop in time
|
|
4
|
+
* and was killed, which also fails every other call in flight to it. When the
|
|
5
|
+
* plugin answered while being stopped, `response` is its error (the
|
|
6
|
+
* request-cancelled code, usually).
|
|
7
|
+
*/
|
|
8
|
+
export class CallInterruptedError extends Error {
|
|
9
|
+
reason;
|
|
10
|
+
killed;
|
|
11
|
+
response;
|
|
12
|
+
constructor(message, reason, killed, response) {
|
|
13
|
+
super(message, response === undefined ? undefined : { cause: response });
|
|
14
|
+
this.name = 'CallInterruptedError';
|
|
15
|
+
this.reason = reason;
|
|
16
|
+
this.killed = killed;
|
|
17
|
+
if (response !== undefined)
|
|
18
|
+
this.response = response;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SmithPlugin } from '../serve/index.ts';
|
|
2
|
+
import type { HostOptions } from './host-options.contract.ts';
|
|
3
|
+
import { PluginClient } from './plugin-client.client.ts';
|
|
4
|
+
/** The streams of a plugin that is somewhere else: its responses to read, its requests to write. */
|
|
5
|
+
export interface PluginStreams {
|
|
6
|
+
/** What the plugin writes (its standard output). */
|
|
7
|
+
input: AsyncIterable<Uint8Array>;
|
|
8
|
+
/** What the plugin reads (its standard input). */
|
|
9
|
+
output: {
|
|
10
|
+
write: (chunk: Buffer, callback: (error?: Error | null) => void) => unknown;
|
|
11
|
+
end: () => unknown;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Performs the handshake with a plugin reachable through a pair of streams.
|
|
16
|
+
* Closing the client shuts the plugin down and ends the streams; there is no
|
|
17
|
+
* process to kill.
|
|
18
|
+
*/
|
|
19
|
+
export declare function connectPlugin(streams: PluginStreams, options: HostOptions): Promise<PluginClient>;
|
|
20
|
+
/**
|
|
21
|
+
* Serves a plugin object in this process over pipes and connects to it, the way
|
|
22
|
+
* the Go SDK's tests serve a plugin with `serve.Serve` over `io.Pipe`: the
|
|
23
|
+
* real framing, handshake and dispatch, no process. Closing the client waits
|
|
24
|
+
* for the serve loop to end.
|
|
25
|
+
*/
|
|
26
|
+
export declare function connectInProcess(plugin: SmithPlugin, options: HostOptions): Promise<PluginClient>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { PassThrough } from 'node:stream';
|
|
2
|
+
import { serveStreams } from '../serve/index.js';
|
|
3
|
+
import { PluginClient } from './plugin-client.client.js';
|
|
4
|
+
/**
|
|
5
|
+
* Performs the handshake with a plugin reachable through a pair of streams.
|
|
6
|
+
* Closing the client shuts the plugin down and ends the streams; there is no
|
|
7
|
+
* process to kill.
|
|
8
|
+
*/
|
|
9
|
+
export function connectPlugin(streams, options) {
|
|
10
|
+
return PluginClient.open({
|
|
11
|
+
input: streams.input,
|
|
12
|
+
output: streams.output,
|
|
13
|
+
kill: () => { streams.output.end(); },
|
|
14
|
+
finish: () => Promise.resolve(null),
|
|
15
|
+
}, options);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Serves a plugin object in this process over pipes and connects to it, the way
|
|
19
|
+
* the Go SDK's tests serve a plugin with `serve.Serve` over `io.Pipe`: the
|
|
20
|
+
* real framing, handshake and dispatch, no process. Closing the client waits
|
|
21
|
+
* for the serve loop to end.
|
|
22
|
+
*/
|
|
23
|
+
export async function connectInProcess(plugin, options) {
|
|
24
|
+
const toPlugin = new PassThrough();
|
|
25
|
+
const fromPlugin = new PassThrough();
|
|
26
|
+
const served = (async () => {
|
|
27
|
+
try {
|
|
28
|
+
await serveStreams(plugin, { input: toPlugin, output: fromPlugin, log: () => { } });
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return error instanceof Error ? error.message : String(error);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
fromPlugin.end();
|
|
36
|
+
}
|
|
37
|
+
})();
|
|
38
|
+
return await PluginClient.open({
|
|
39
|
+
input: fromPlugin,
|
|
40
|
+
output: toPlugin,
|
|
41
|
+
kill: () => { toPlugin.end(); },
|
|
42
|
+
finish: () => served,
|
|
43
|
+
}, options);
|
|
44
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Peer } from '../protocol/index.ts';
|
|
2
|
+
/** What the host tells the plugin, and how long it waits. */
|
|
3
|
+
export interface HostOptions {
|
|
4
|
+
/** The absolute workspace root sent in `initialize`. A plugin that reads manifests resolves them against it. */
|
|
5
|
+
workspaceRoot: string;
|
|
6
|
+
/** The host's name and version. Default: `smith`, `dev`. */
|
|
7
|
+
host?: Peer;
|
|
8
|
+
/** How long `initialize` may take. Default: 30 seconds. */
|
|
9
|
+
initializeTimeoutMs?: number;
|
|
10
|
+
/** How long a plugin has to answer after it was asked to stop (or, for one that cannot be asked, after the caller cancelled) before it is killed. Default: 5 seconds. */
|
|
11
|
+
cancelGraceMs?: number;
|
|
12
|
+
}
|
|
13
|
+
/** Options of {@link startPlugin}: a plugin process to launch. */
|
|
14
|
+
export interface StartOptions extends HostOptions {
|
|
15
|
+
/** The plugin's argv: the program and its arguments. */
|
|
16
|
+
command: string[];
|
|
17
|
+
/** The environment. Default: the current one. `SMITH_PLUGIN=1` is set in it unless it already has a value, as smith does. */
|
|
18
|
+
env?: NodeJS.ProcessEnv;
|
|
19
|
+
/** The plugin's working directory. Default: the current one. */
|
|
20
|
+
cwd?: string;
|
|
21
|
+
/** What happens to the plugin's standard error, where it logs. Default: it goes to this process's standard error. */
|
|
22
|
+
stderr?: 'inherit' | 'ignore';
|
|
23
|
+
}
|
|
24
|
+
/** How long a plugin has to answer after it was asked to stop, before the host kills it. */
|
|
25
|
+
export declare const DEFAULT_CANCEL_GRACE_MS = 5000;
|
|
26
|
+
/** The longest a call may take unless the caller says otherwise: generate and publish run real toolchains. */
|
|
27
|
+
export declare const DEFAULT_CALL_TIMEOUT_MS: number;
|
|
28
|
+
/** Options of one {@link PluginClient.call}. */
|
|
29
|
+
export interface CallOptions {
|
|
30
|
+
/** How long the plugin may take. On expiry the plugin process is killed: its stream can no longer be trusted. Default: 2 minutes. */
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
/** Aborting it cancels the call: the plugin is sent `cancel` if it advertised it, and the call ends within the grace period. */
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** How long a plugin has to answer after it was asked to stop, before the host kills it. */
|
|
2
|
+
export const DEFAULT_CANCEL_GRACE_MS = 5000;
|
|
3
|
+
/** The longest a call may take unless the caller says otherwise: generate and publish run real toolchains. */
|
|
4
|
+
export const DEFAULT_CALL_TIMEOUT_MS = 2 * 60 * 1000;
|
package/host/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { PluginClient } from './plugin-client.client.ts';
|
|
2
|
+
export type { PluginConnection } from './plugin-client.client.ts';
|
|
3
|
+
export { startPlugin } from './start-plugin.use-case.ts';
|
|
4
|
+
export { connectInProcess, connectPlugin } from './connect-plugin.use-case.ts';
|
|
5
|
+
export type { PluginStreams } from './connect-plugin.use-case.ts';
|
|
6
|
+
export { CallInterruptedError } from './call-interrupted.error.ts';
|
|
7
|
+
export type { InterruptionReason } from './call-interrupted.error.ts';
|
|
8
|
+
export { DEFAULT_CALL_TIMEOUT_MS, DEFAULT_CANCEL_GRACE_MS } from './host-options.contract.ts';
|
|
9
|
+
export type { CallOptions, HostOptions, StartOptions } from './host-options.contract.ts';
|
package/host/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PluginClient } from './plugin-client.client.js';
|
|
2
|
+
export { startPlugin } from './start-plugin.use-case.js';
|
|
3
|
+
export { connectInProcess, connectPlugin } from './connect-plugin.use-case.js';
|
|
4
|
+
export { CallInterruptedError } from './call-interrupted.error.js';
|
|
5
|
+
export { DEFAULT_CALL_TIMEOUT_MS, DEFAULT_CANCEL_GRACE_MS } from './host-options.contract.js';
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { InitializeResult, MethodName, ParamsOf, ResultOf } from '../protocol/index.ts';
|
|
2
|
+
import type { CallOptions, HostOptions } from './host-options.contract.ts';
|
|
3
|
+
/** The two ends of the pipe to a plugin, and what to do with the plugin when the stream cannot be trusted. */
|
|
4
|
+
export interface PluginConnection {
|
|
5
|
+
/** The plugin's responses. */
|
|
6
|
+
input: AsyncIterable<Uint8Array>;
|
|
7
|
+
/** The plugin's requests. */
|
|
8
|
+
output: {
|
|
9
|
+
write: (chunk: Buffer, callback: (error?: Error | null) => void) => unknown;
|
|
10
|
+
end: () => unknown;
|
|
11
|
+
};
|
|
12
|
+
/** Called when the plugin did not answer in time: ends the plugin, if it is a process. */
|
|
13
|
+
kill: () => void;
|
|
14
|
+
/** Resolves once the plugin has gone, with the reason it did not end well (or `null`). Called by {@link PluginClient.close}. */
|
|
15
|
+
finish: (graceMs: number) => Promise<string | null>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The host's side of a connection to one initialized plugin, for a plugin
|
|
19
|
+
* author's tests (smith itself is the host in real use). It never has more
|
|
20
|
+
* requests in flight than the plugin declared in `concurrency`, as smith does:
|
|
21
|
+
* with the default of one, calls are serialised, each waiting for the one
|
|
22
|
+
* before it to be answered. Each call has a timeout and may be cancelled with
|
|
23
|
+
* an `AbortSignal`. Open one with {@link startPlugin} (a process),
|
|
24
|
+
* {@link connectPlugin} (streams) or {@link connectInProcess} (a plugin object
|
|
25
|
+
* served in this process).
|
|
26
|
+
*/
|
|
27
|
+
export declare class PluginClient {
|
|
28
|
+
#private;
|
|
29
|
+
/** Performs the handshake on a connection. */
|
|
30
|
+
static open(connection: PluginConnection, options: HostOptions): Promise<PluginClient>;
|
|
31
|
+
/** What the plugin answered to `initialize`: identity, kind, roles, capabilities. */
|
|
32
|
+
info: InitializeResult;
|
|
33
|
+
private constructor();
|
|
34
|
+
/**
|
|
35
|
+
* Sends one request and resolves with its decoded result. An error answer
|
|
36
|
+
* rejects with an {@link RpcError} carrying its code. The call waits for a
|
|
37
|
+
* place first, so the plugin never has more requests in flight than it
|
|
38
|
+
* declared; the timeout counts from the moment the request is sent.
|
|
39
|
+
*
|
|
40
|
+
* When `options.signal` aborts or the time runs out the host stops waiting.
|
|
41
|
+
* A plugin that advertised `cancel` is sent the notification and has the
|
|
42
|
+
* grace period to answer: with the result it had already (the call resolves),
|
|
43
|
+
* or an error (the call rejects with a {@link CallInterruptedError} carrying
|
|
44
|
+
* it). A plugin that does not answer in time is killed, because its stream can
|
|
45
|
+
* no longer be trusted, and the call rejects; a plugin that cannot be asked
|
|
46
|
+
* to stop is killed at once on a timeout, and after the grace period when
|
|
47
|
+
* the caller cancelled.
|
|
48
|
+
*/
|
|
49
|
+
call<M extends MethodName>(method: M, params: ParamsOf<M>, options?: CallOptions): Promise<ResultOf<M>>;
|
|
50
|
+
call(method: string, params?: unknown, options?: CallOptions): Promise<unknown>;
|
|
51
|
+
/** How long the plugin has to answer after it was asked to stop. */
|
|
52
|
+
get cancelGraceMs(): number;
|
|
53
|
+
/** The most requests this client has had in flight at once. */
|
|
54
|
+
get peakInFlight(): number;
|
|
55
|
+
/** Sends a message that gets no answer, such as `cancel`. */
|
|
56
|
+
notify(method: string, params: unknown): Promise<void>;
|
|
57
|
+
/** Whether the plugin advertised a method: `initialize` and `shutdown` always, the others by their capability. */
|
|
58
|
+
supports(method: string): boolean;
|
|
59
|
+
/** Asks the plugin to shut down and waits briefly for it to go. Rejects if it did not go quietly. */
|
|
60
|
+
close(): Promise<void>;
|
|
61
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { decodeResult, encodeParams, parseResponse, writeNotification, writeRequest } from '../codec/index.js';
|
|
2
|
+
import { inFlightLimit } from '../concurrency/index.js';
|
|
3
|
+
import { FrameReader, frameMessage } from '../framing/index.js';
|
|
4
|
+
import { PROTOCOL_VERSION, RpcError, protocolSchema } from '../protocol/index.js';
|
|
5
|
+
import { CallInterruptedError } from './call-interrupted.error.js';
|
|
6
|
+
import { DEFAULT_CALL_TIMEOUT_MS, DEFAULT_CANCEL_GRACE_MS } from './host-options.contract.js';
|
|
7
|
+
function writeFrame(output, frame) {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
output.write(frame, (error) => {
|
|
10
|
+
if (error === null || error === undefined)
|
|
11
|
+
resolve();
|
|
12
|
+
else
|
|
13
|
+
reject(error);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
function messageOf(thrown) {
|
|
18
|
+
return thrown instanceof Error ? thrown.message : String(thrown);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The host's side of a connection to one initialized plugin, for a plugin
|
|
22
|
+
* author's tests (smith itself is the host in real use). It never has more
|
|
23
|
+
* requests in flight than the plugin declared in `concurrency`, as smith does:
|
|
24
|
+
* with the default of one, calls are serialised, each waiting for the one
|
|
25
|
+
* before it to be answered. Each call has a timeout and may be cancelled with
|
|
26
|
+
* an `AbortSignal`. Open one with {@link startPlugin} (a process),
|
|
27
|
+
* {@link connectPlugin} (streams) or {@link connectInProcess} (a plugin object
|
|
28
|
+
* served in this process).
|
|
29
|
+
*/
|
|
30
|
+
export class PluginClient {
|
|
31
|
+
/** Performs the handshake on a connection. */
|
|
32
|
+
static async open(connection, options) {
|
|
33
|
+
const client = new PluginClient(connection, options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS);
|
|
34
|
+
try {
|
|
35
|
+
client.info = await client.call('initialize', {
|
|
36
|
+
protocolVersions: [PROTOCOL_VERSION],
|
|
37
|
+
host: options.host ?? { name: 'smith', version: 'dev' },
|
|
38
|
+
workspaceRoot: options.workspaceRoot,
|
|
39
|
+
}, { timeoutMs: options.initializeTimeoutMs ?? 30_000 });
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
connection.kill();
|
|
43
|
+
throw new Error(`initializing plugin: ${messageOf(error)}`, { cause: error });
|
|
44
|
+
}
|
|
45
|
+
if (client.info.protocolVersion !== PROTOCOL_VERSION) {
|
|
46
|
+
connection.kill();
|
|
47
|
+
throw new Error(`plugin ${client.info.plugin.name} chose protocol version ${client.info.protocolVersion}, host speaks ${PROTOCOL_VERSION}`);
|
|
48
|
+
}
|
|
49
|
+
client.#limit = inFlightLimit(client.info.concurrency);
|
|
50
|
+
return client;
|
|
51
|
+
}
|
|
52
|
+
#connection;
|
|
53
|
+
#waiters = new Map();
|
|
54
|
+
#queue = [];
|
|
55
|
+
#graceMs;
|
|
56
|
+
#lost;
|
|
57
|
+
#nextId = 0;
|
|
58
|
+
#limit = 1;
|
|
59
|
+
#inFlight = 0;
|
|
60
|
+
#peak = 0;
|
|
61
|
+
/** What the plugin answered to `initialize`: identity, kind, roles, capabilities. */
|
|
62
|
+
info;
|
|
63
|
+
constructor(connection, graceMs) {
|
|
64
|
+
this.#connection = connection;
|
|
65
|
+
this.#graceMs = graceMs;
|
|
66
|
+
void this.#readResponses();
|
|
67
|
+
}
|
|
68
|
+
/** Waits for a place among the requests that may be in flight; rejects if `signal` aborts first. */
|
|
69
|
+
#acquire(signal) {
|
|
70
|
+
if (signal?.aborted === true)
|
|
71
|
+
return Promise.reject(new CallInterruptedError('cancelled before it was sent', 'cancelled', false));
|
|
72
|
+
if (this.#inFlight < this.#limit) {
|
|
73
|
+
this.#take();
|
|
74
|
+
return Promise.resolve();
|
|
75
|
+
}
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const entry = {
|
|
78
|
+
grant: () => {
|
|
79
|
+
signal?.removeEventListener('abort', onAbort);
|
|
80
|
+
this.#take();
|
|
81
|
+
resolve();
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
const onAbort = () => {
|
|
85
|
+
const at = this.#queue.indexOf(entry);
|
|
86
|
+
if (at !== -1)
|
|
87
|
+
this.#queue.splice(at, 1);
|
|
88
|
+
reject(new CallInterruptedError('cancelled before it was sent', 'cancelled', false));
|
|
89
|
+
};
|
|
90
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
91
|
+
this.#queue.push(entry);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
#take() {
|
|
95
|
+
this.#inFlight++;
|
|
96
|
+
this.#peak = Math.max(this.#peak, this.#inFlight);
|
|
97
|
+
}
|
|
98
|
+
#release() {
|
|
99
|
+
this.#inFlight--;
|
|
100
|
+
this.#queue.shift()?.grant();
|
|
101
|
+
}
|
|
102
|
+
async #send(method, params, timeoutMs, signal) {
|
|
103
|
+
if (this.#lost !== undefined)
|
|
104
|
+
throw new Error(`${method}: plugin stopped answering: ${this.#lost.message}`, { cause: this.#lost });
|
|
105
|
+
const id = ++this.#nextId;
|
|
106
|
+
const body = writeRequest(id, method, params === undefined ? undefined : encodeParams(method, params));
|
|
107
|
+
const answer = new Promise((resolve) => { this.#waiters.set(id, { settle: resolve }); });
|
|
108
|
+
try {
|
|
109
|
+
await writeFrame(this.#connection.output, frameMessage(body));
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
this.#waiters.delete(id);
|
|
113
|
+
throw new Error(`${method}: plugin is not accepting requests: ${messageOf(error)}`, { cause: error });
|
|
114
|
+
}
|
|
115
|
+
let timer;
|
|
116
|
+
let onAbort;
|
|
117
|
+
const interrupted = new Promise((resolve) => {
|
|
118
|
+
timer = setTimeout(() => { resolve('timeout'); }, timeoutMs);
|
|
119
|
+
onAbort = () => { resolve('cancelled'); };
|
|
120
|
+
if (signal?.aborted === true)
|
|
121
|
+
resolve('cancelled');
|
|
122
|
+
else
|
|
123
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
124
|
+
});
|
|
125
|
+
try {
|
|
126
|
+
const winner = await Promise.race([answer, interrupted]);
|
|
127
|
+
if (typeof winner !== 'string') {
|
|
128
|
+
this.#waiters.delete(id);
|
|
129
|
+
return this.#decode(method, winner);
|
|
130
|
+
}
|
|
131
|
+
return await this.#giveUp(method, id, answer, winner, timeoutMs);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
if (onAbort !== undefined)
|
|
136
|
+
signal?.removeEventListener('abort', onAbort);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
#decode(method, outcome) {
|
|
140
|
+
if ('error' in outcome)
|
|
141
|
+
throw new Error(`${method}: plugin stopped answering: ${outcome.error.message}`, { cause: outcome.error });
|
|
142
|
+
try {
|
|
143
|
+
const response = parseResponse(outcome.body);
|
|
144
|
+
if (response.error !== null)
|
|
145
|
+
throw new RpcError(response.error.code, response.error.message);
|
|
146
|
+
return decodeResult(method, response.result);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error instanceof RpcError)
|
|
150
|
+
throw error;
|
|
151
|
+
throw new Error(`${method}: plugin stopped answering: ${messageOf(error)}`, { cause: error });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The end of a call whose signal aborted or whose time ran out. A plugin that
|
|
156
|
+
* advertised cancel is sent the notification and has the grace period to
|
|
157
|
+
* answer; one that cannot be asked is killed at once when the time ran out,
|
|
158
|
+
* and after the grace period when the caller cancelled. A plugin that does not
|
|
159
|
+
* answer in time is killed, and every other call in flight to it fails.
|
|
160
|
+
*/
|
|
161
|
+
async #giveUp(method, id, answer, reason, timeoutMs) {
|
|
162
|
+
const asked = this.supports('cancel');
|
|
163
|
+
if (asked) {
|
|
164
|
+
try {
|
|
165
|
+
await this.notify('cancel', { id });
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
// The plugin is gone; waiting for the answer below reports how it went.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (asked || reason === 'cancelled') {
|
|
172
|
+
let timer;
|
|
173
|
+
const grace = new Promise((resolve) => {
|
|
174
|
+
timer = setTimeout(() => { resolve('expired'); }, this.#graceMs);
|
|
175
|
+
});
|
|
176
|
+
const winner = await Promise.race([answer, grace]);
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
if (winner !== 'expired') {
|
|
179
|
+
this.#waiters.delete(id);
|
|
180
|
+
return this.#answeredAfterStopping(method, winner, reason, timeoutMs);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
this.#waiters.delete(id);
|
|
184
|
+
this.#connection.kill();
|
|
185
|
+
if (reason === 'timeout')
|
|
186
|
+
throw new CallInterruptedError(`${method}: plugin did not answer within ${timeoutMs}ms`, reason, true);
|
|
187
|
+
throw new CallInterruptedError(`${method}: cancelled; the plugin did not stop within ${this.#graceMs}ms and was killed`, reason, true);
|
|
188
|
+
}
|
|
189
|
+
/** The plugin answered while the host was giving up: a result is the work finished in time, and is the call's result. */
|
|
190
|
+
#answeredAfterStopping(method, outcome, reason, timeoutMs) {
|
|
191
|
+
try {
|
|
192
|
+
return this.#decode(method, outcome);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
const what = reason === 'timeout' ? `did not answer within ${timeoutMs}ms` : 'cancelled';
|
|
196
|
+
throw new CallInterruptedError(`${method}: ${what}: ${messageOf(error)}`, reason, false, error instanceof RpcError ? error : undefined);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async #readResponses() {
|
|
200
|
+
const reader = new FrameReader();
|
|
201
|
+
try {
|
|
202
|
+
for await (const chunk of this.#connection.input) {
|
|
203
|
+
for (const body of reader.push(chunk))
|
|
204
|
+
this.#deliver(body);
|
|
205
|
+
}
|
|
206
|
+
this.#lose(new Error('EOF'));
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
this.#lose(error instanceof Error ? error : new Error(String(error)));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
#deliver(body) {
|
|
213
|
+
let id;
|
|
214
|
+
try {
|
|
215
|
+
id = parseResponse(body).id;
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
// A malformed response cannot be attributed to a call: the stream is lost.
|
|
219
|
+
this.#lose(new Error(`malformed response: ${messageOf(error)}`, { cause: error }));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (id === null)
|
|
223
|
+
return;
|
|
224
|
+
const waiter = this.#waiters.get(id);
|
|
225
|
+
if (waiter === undefined)
|
|
226
|
+
return;
|
|
227
|
+
this.#waiters.delete(id);
|
|
228
|
+
waiter.settle({ body });
|
|
229
|
+
}
|
|
230
|
+
#lose(error) {
|
|
231
|
+
this.#lost = error;
|
|
232
|
+
const waiting = this.#waiters;
|
|
233
|
+
this.#waiters = new Map();
|
|
234
|
+
for (const waiter of waiting.values())
|
|
235
|
+
waiter.settle({ error });
|
|
236
|
+
}
|
|
237
|
+
async call(method, params, options = {}) {
|
|
238
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
239
|
+
if (method === 'shutdown')
|
|
240
|
+
return await this.#send(method, params, timeoutMs, options.signal); // it must reach a plugin that is busy
|
|
241
|
+
try {
|
|
242
|
+
await this.#acquire(options.signal);
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error instanceof CallInterruptedError)
|
|
246
|
+
throw new CallInterruptedError(`${method}: ${error.message}`, error.reason, false);
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
return await this.#send(method, params, timeoutMs, options.signal);
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
this.#release();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/** How long the plugin has to answer after it was asked to stop. */
|
|
257
|
+
get cancelGraceMs() {
|
|
258
|
+
return this.#graceMs;
|
|
259
|
+
}
|
|
260
|
+
/** The most requests this client has had in flight at once. */
|
|
261
|
+
get peakInFlight() {
|
|
262
|
+
return this.#peak;
|
|
263
|
+
}
|
|
264
|
+
/** Sends a message that gets no answer, such as `cancel`. */
|
|
265
|
+
async notify(method, params) {
|
|
266
|
+
if (this.#lost !== undefined)
|
|
267
|
+
throw new Error(`${method}: plugin stopped answering: ${this.#lost.message}`, { cause: this.#lost });
|
|
268
|
+
try {
|
|
269
|
+
const body = writeNotification(method, encodeParams(method, params));
|
|
270
|
+
await writeFrame(this.#connection.output, frameMessage(body));
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
throw new Error(`${method}: plugin is not accepting requests: ${messageOf(error)}`, { cause: error });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Whether the plugin advertised a method: `initialize` and `shutdown` always, the others by their capability. */
|
|
277
|
+
supports(method) {
|
|
278
|
+
if (method === 'initialize' || method === 'shutdown')
|
|
279
|
+
return true;
|
|
280
|
+
const capability = Object.hasOwn(protocolSchema['x-methods'], method) ? protocolSchema['x-methods'][method].capability : null;
|
|
281
|
+
const advertised = this.info?.capabilities;
|
|
282
|
+
return capability !== null && advertised?.[capability] === true;
|
|
283
|
+
}
|
|
284
|
+
/** Asks the plugin to shut down and waits briefly for it to go. Rejects if it did not go quietly. */
|
|
285
|
+
async close() {
|
|
286
|
+
try {
|
|
287
|
+
await this.call('shutdown', {}, { timeoutMs: 5000 });
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// A plugin that is already gone cannot be asked; waiting for it below reports how it went.
|
|
291
|
+
}
|
|
292
|
+
this.#connection.output.end();
|
|
293
|
+
const trouble = await this.#connection.finish(5000);
|
|
294
|
+
if (trouble !== null)
|
|
295
|
+
throw new Error(trouble);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { StartOptions } from './host-options.contract.ts';
|
|
2
|
+
import { PluginClient } from './plugin-client.client.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Launches a plugin process and performs the handshake, as smith does: the
|
|
5
|
+
* plugin's argv, `SMITH_PLUGIN=1` in its environment, its standard input and
|
|
6
|
+
* output as the protocol's channel, its standard error forwarded to this
|
|
7
|
+
* process's. Close the client when done: it asks the plugin to shut down and
|
|
8
|
+
* kills it if it does not go.
|
|
9
|
+
*/
|
|
10
|
+
export declare function startPlugin(options: StartOptions): Promise<PluginClient>;
|