@monosmith/plugin-sdk 0.0.0-stage → 0.1.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 +89 -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 +25 -0
- package/codec/wire-message.mapper.js +58 -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 +24 -0
- package/conformance/check-plugin.use-case.js +78 -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/connect-plugin.use-case.d.ts +26 -0
- package/host/connect-plugin.use-case.js +44 -0
- package/host/host-options.contract.d.ts +28 -0
- package/host/host-options.contract.js +2 -0
- package/host/index.d.ts +7 -0
- package/host/index.js +4 -0
- package/host/plugin-client.client.d.ts +42 -0
- package/host/plugin-client.client.js +166 -0
- package/host/start-plugin.use-case.d.ts +10 -0
- package/host/start-plugin.use-case.js +57 -0
- package/index.d.ts +15 -0
- package/index.js +20 -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 +1925 -0
- package/protocol/protocol-types.contract.d.ts +1017 -0
- package/protocol/protocol-types.contract.js +36 -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 +55 -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 +13 -0
- package/serve/capabilities-of.algorithm.js +28 -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 +14 -0
- package/serve/dispatch-request.use-case.js +56 -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 +65 -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/serve.use-case.d.ts +27 -0
- package/serve/serve.use-case.js +93 -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
package/README.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @monosmith/plugin-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The TypeScript SDK for writing [smith](https://github.com/monosmith-app/monosmith) plugins: the
|
|
4
|
+
protocol's types, the serve loop, a host to test a plugin with, the conformance checks of
|
|
5
|
+
`smith plugin check`, `[[ ]]` templating and JSON editing that keeps key order. No runtime
|
|
6
|
+
dependencies; Node.js 22 or later (24 to run a plugin written in TypeScript as it is).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @monosmith/plugin-sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
A plugin is an object with `describe` and one method for each thing it does. The SDK frames the
|
|
13
|
+
JSON-RPC messages, answers `initialize`, derives the capabilities from the methods the object has and
|
|
14
|
+
keeps standard output for the protocol:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
#!/usr/bin/env node
|
|
18
|
+
import { serve, task, contributeTo } from '@monosmith/plugin-sdk'
|
|
19
|
+
|
|
20
|
+
await serve({
|
|
21
|
+
describe: () => ({ plugin: { name: 'widget-lint', version: '0.1.0' }, kind: 'convention', roles: ['structure'], languages: ['widget'] }),
|
|
22
|
+
contribute: ({ projects }) => contributeTo(projects, () => ({
|
|
23
|
+
tasks: { 'layout-check': task(['node', 'check.js'], { cache: true }) },
|
|
24
|
+
dependsOn: { lint: ['layout-check'] },
|
|
25
|
+
})),
|
|
26
|
+
})
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Test it in-process, with the real handshake and framing, and run the same checks as
|
|
30
|
+
`smith plugin check`:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { checkPlugin, connectInProcess } from '@monosmith/plugin-sdk'
|
|
34
|
+
|
|
35
|
+
const client = await connectInProcess(plugin, { workspaceRoot: dir })
|
|
36
|
+
const report = await checkPlugin(client)
|
|
37
|
+
if (!report.ok) throw new Error(report.failures())
|
|
38
|
+
await client.close()
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The full guide and API reference: [The TypeScript SDK](../../docs/plugins/creating/typescript-sdk.md).
|
|
42
|
+
Examples that use it: [`structure-layout-node`](../../docs/examples/plugins/structure-layout-node) and
|
|
43
|
+
[`language-widget-ts`](../../docs/examples/plugins/language-widget-ts).
|
|
44
|
+
|
|
45
|
+
## The protocol schema is the source of truth
|
|
46
|
+
|
|
47
|
+
The types in `src/protocol` are not written by hand. The Go types of the protocol
|
|
48
|
+
(`packages/plugin-sdk/protocol`) are the source of truth; their JSON Schema is committed as
|
|
49
|
+
[`packages/plugin-sdk/schema/protocol.schema.json`](../plugin-sdk/schema/protocol.schema.json), and
|
|
50
|
+
`tools/generate-protocol` turns that into `protocol-types.contract.ts` (the interfaces, the methods
|
|
51
|
+
table, the error codes) and `protocol-schema.contract.ts` (the schema itself, which the wire codec and the
|
|
52
|
+
host's capability check read at run time). After a change to the protocol:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
smith run schema -p plugin-sdk # the Go side writes the schema
|
|
56
|
+
smith run generate -p plugin-sdk-ts # this side writes the types
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Two tests fail when either file is stale: the Go test `TestCommittedSchemaIsCurrent` and
|
|
60
|
+
`tools/generate-protocol/generate-protocol.use-case.spec.ts` (`smith run generate-check -p plugin-sdk-ts`
|
|
61
|
+
checks without writing). A test also fails when the schema has a method this SDK does not know how to
|
|
62
|
+
serve (`PLUGIN_MEMBERS`), so a new method has to be given its place in the plugin object.
|
|
63
|
+
|
|
64
|
+
## What is where
|
|
65
|
+
|
|
66
|
+
| Folder of `src` | What it holds |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `protocol` | the generated types, the schema as data, `RpcError` |
|
|
69
|
+
| `codec` | the wire form of the messages: key order, `omitempty`, sorted maps and escaping as the Go SDK writes them, so the bytes are equal; params shaped as the types promise |
|
|
70
|
+
| `framing` | `Content-Length` framing over a byte stream |
|
|
71
|
+
| `serve` | `serve`, `serveStreams`, the plugin interfaces, capability derivation |
|
|
72
|
+
| `host` | `startPlugin`, `connectPlugin`, `connectInProcess`, `PluginClient`: the host side, for tests |
|
|
73
|
+
| `conformance` | `checkPlugin`: the checks of `smith plugin check` |
|
|
74
|
+
| `templating`, `jsondoc` | ports of the Go SDK's `templating` and `jsondoc` |
|
|
75
|
+
| `tasks`, `scaffold`, `ci` | helpers to build the answers of contribute, generate and ci |
|
|
76
|
+
| `build-dir`, `plugin-roles`, `plugin-refs`, `projects` | the protocol package's small functions: build directories, roles, plugin references, project kinds, manifests |
|
|
77
|
+
|
|
78
|
+
The source is organised by vertical slices (`smith` lints it); `integration` holds the tests that start
|
|
79
|
+
real processes: the examples under `smith plugin check` and in a throwaway workspace, and the
|
|
80
|
+
compatibility test that a TypeScript plugin and a Go plugin with the same logic answer with the same
|
|
81
|
+
bytes.
|
|
82
|
+
|
|
83
|
+
## Develop
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
smith run lint typecheck test build -p plugin-sdk-ts
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The integration tests need `smith` and `go` on the path and skip themselves without them. The package
|
|
90
|
+
is released by smith from conventional commits, like the other packages of this repository.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directory at the workspace root every build writes into: smith's opinion,
|
|
3
|
+
* so all output is in one place, outside every source tree, and cleaning is
|
|
4
|
+
* deleting one folder. A plugin builds a project into {@link projectBuildDir};
|
|
5
|
+
* `smith doctor` fails a task whose outputs fall outside it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const BUILD_DIR = "dist";
|
|
8
|
+
/** Where a project's build goes, workspace-relative: `dist/<project dir>`, mirroring the workspace (`dist/apps/web`). */
|
|
9
|
+
export declare function projectBuildDir(root: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* The same directory relative to the project (`../../dist/apps/web`): what a
|
|
12
|
+
* tool configured from inside the project, or a task's `outputs`, name.
|
|
13
|
+
*/
|
|
14
|
+
export declare function buildDirFromProject(root: string): string;
|
|
15
|
+
/** Whether a workspace-relative path is inside the build directory. */
|
|
16
|
+
export declare function inBuildDir(path: string): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The directory at the workspace root every build writes into: smith's opinion,
|
|
3
|
+
* so all output is in one place, outside every source tree, and cleaning is
|
|
4
|
+
* deleting one folder. A plugin builds a project into {@link projectBuildDir};
|
|
5
|
+
* `smith doctor` fails a task whose outputs fall outside it.
|
|
6
|
+
*/
|
|
7
|
+
export const BUILD_DIR = 'dist';
|
|
8
|
+
function trimSlashes(root) {
|
|
9
|
+
return root.replaceAll(/^\/+|\/+$/g, '');
|
|
10
|
+
}
|
|
11
|
+
/** Where a project's build goes, workspace-relative: `dist/<project dir>`, mirroring the workspace (`dist/apps/web`). */
|
|
12
|
+
export function projectBuildDir(root) {
|
|
13
|
+
return `${BUILD_DIR}/${trimSlashes(root)}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The same directory relative to the project (`../../dist/apps/web`): what a
|
|
17
|
+
* tool configured from inside the project, or a task's `outputs`, name.
|
|
18
|
+
*/
|
|
19
|
+
export function buildDirFromProject(root) {
|
|
20
|
+
const trimmed = trimSlashes(root);
|
|
21
|
+
return '../'.repeat(trimmed.split('/').length) + projectBuildDir(trimmed);
|
|
22
|
+
}
|
|
23
|
+
/** Whether a workspace-relative path is inside the build directory. */
|
|
24
|
+
export function inBuildDir(path) {
|
|
25
|
+
return path === BUILD_DIR || path.startsWith(`${BUILD_DIR}/`);
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BUILD_DIR, buildDirFromProject, inBuildDir, projectBuildDir } from './build-dir.algorithm.ts';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BUILD_DIR, buildDirFromProject, inBuildDir, projectBuildDir } from './build-dir.algorithm.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ActionStep, Credential, Step } from '../protocol/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* A provider-native step: a GitHub action (`'actions/setup-node@v6'`) or an
|
|
4
|
+
* Azure task (`'NodeTool@0'`) with its inputs, written in the order given. The
|
|
5
|
+
* values of a pipeline are written as quoted YAML strings, except `true` and
|
|
6
|
+
* `false`, so pass strings.
|
|
7
|
+
*/
|
|
8
|
+
export declare function actionStep(uses: string, inputs?: Record<string, string> | Array<[string, string]>): ActionStep;
|
|
9
|
+
export interface StepOptions {
|
|
10
|
+
/** The workspace-relative directory to run in. Default: the workspace root. */
|
|
11
|
+
dir?: string;
|
|
12
|
+
/** Whether a failure fails the phase (the default) or only warns. */
|
|
13
|
+
blocking?: boolean;
|
|
14
|
+
/** Workspace-relative files whose content decides what an audit step finds (a lockfile): smith skips a passing step while they are unchanged. */
|
|
15
|
+
cacheFiles?: string[];
|
|
16
|
+
}
|
|
17
|
+
/** One command a CI phase (`setup`, `audit`, `postRelease`) runs. It blocks unless `blocking: false`. */
|
|
18
|
+
export declare function ciStep(name: string, command: string[], options?: StepOptions): Step;
|
|
19
|
+
export interface CredentialOptions {
|
|
20
|
+
/** Release works without it. Default: it is required. */
|
|
21
|
+
required?: boolean;
|
|
22
|
+
/** Also give it to `smith ci setup`: installs from a private registry need it. */
|
|
23
|
+
setup?: boolean;
|
|
24
|
+
/** Also give it to every step that runs tasks. */
|
|
25
|
+
tasks?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** A secret the release phase needs, read from the environment variable `env`. */
|
|
28
|
+
export declare function credential(env: string, description: string, options?: CredentialOptions): Credential;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A provider-native step: a GitHub action (`'actions/setup-node@v6'`) or an
|
|
3
|
+
* Azure task (`'NodeTool@0'`) with its inputs, written in the order given. The
|
|
4
|
+
* values of a pipeline are written as quoted YAML strings, except `true` and
|
|
5
|
+
* `false`, so pass strings.
|
|
6
|
+
*/
|
|
7
|
+
export function actionStep(uses, inputs = {}) {
|
|
8
|
+
const pairs = Array.isArray(inputs) ? inputs : Object.entries(inputs);
|
|
9
|
+
return pairs.length === 0 ? { uses } : { uses, inputs: pairs.map(([name, value]) => ({ name, value })) };
|
|
10
|
+
}
|
|
11
|
+
/** One command a CI phase (`setup`, `audit`, `postRelease`) runs. It blocks unless `blocking: false`. */
|
|
12
|
+
export function ciStep(name, command, options = {}) {
|
|
13
|
+
const { dir, cacheFiles } = options;
|
|
14
|
+
return { name, command, blocking: options.blocking ?? true, ...(dir !== undefined && { dir }), ...(cacheFiles !== undefined && { cacheFiles }) };
|
|
15
|
+
}
|
|
16
|
+
/** A secret the release phase needs, read from the environment variable `env`. */
|
|
17
|
+
export function credential(env, description, options = {}) {
|
|
18
|
+
return { env, description, required: options.required ?? true, ...((options.setup === true) && { setup: true }), ...((options.tasks === true) && { tasks: true }) };
|
|
19
|
+
}
|
package/ci/index.d.ts
ADDED
package/ci/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { actionStep, ciStep, credential } from './ci-contribution.algorithm.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SchemaDocument, SchemaNode } from '../protocol/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Writes a value as the JSON the Go SDK would write for the same message:
|
|
4
|
+
* the members of a message in the order the schema lists them (the order of the
|
|
5
|
+
* Go fields), an optional member left out when it is absent or holds the
|
|
6
|
+
* zero value Go's `omitempty` drops (`""`, `0`, `false`, an empty array or
|
|
7
|
+
* map), the keys of a map sorted, HTML characters escaped. So the bytes a TypeScript
|
|
8
|
+
* plugin writes equal the bytes a Go plugin writes for the same answer.
|
|
9
|
+
*
|
|
10
|
+
* It throws {@link EncodeError} for a required member that is missing or a value of the wrong type.
|
|
11
|
+
*/
|
|
12
|
+
export declare function encodeBySchema(schema: SchemaDocument, node: SchemaNode, value: unknown, path: string): string;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { EncodeError } from './encode-error.error.js';
|
|
2
|
+
import { compareKeys, encodeJsonString, encodeJsonValue } from './encode-json-value.algorithm.js';
|
|
3
|
+
import { baseNode, isNullable, kindOf } from './schema-lookup.algorithm.js';
|
|
4
|
+
/**
|
|
5
|
+
* Writes a value as the JSON the Go SDK would write for the same message:
|
|
6
|
+
* the members of a message in the order the schema lists them (the order of the
|
|
7
|
+
* Go fields), an optional member left out when it is absent or holds the
|
|
8
|
+
* zero value Go's `omitempty` drops (`""`, `0`, `false`, an empty array or
|
|
9
|
+
* map), the keys of a map sorted, HTML characters escaped. So the bytes a TypeScript
|
|
10
|
+
* plugin writes equal the bytes a Go plugin writes for the same answer.
|
|
11
|
+
*
|
|
12
|
+
* It throws {@link EncodeError} for a required member that is missing or a value of the wrong type.
|
|
13
|
+
*/
|
|
14
|
+
export function encodeBySchema(schema, node, value, path) {
|
|
15
|
+
const kind = kindOf(schema, node);
|
|
16
|
+
if (value === null || value === undefined) {
|
|
17
|
+
if (isNullable(node) || (kind === 'any' && value === null))
|
|
18
|
+
return 'null';
|
|
19
|
+
throw new EncodeError(`${path} is required${value === null ? ' and cannot be null' : ''}`);
|
|
20
|
+
}
|
|
21
|
+
const base = baseNode(schema, node);
|
|
22
|
+
switch (kind) {
|
|
23
|
+
case 'string': {
|
|
24
|
+
if (typeof value !== 'string')
|
|
25
|
+
throw wrongType(path, 'a string', value);
|
|
26
|
+
return encodeJsonString(value);
|
|
27
|
+
}
|
|
28
|
+
case 'integer': {
|
|
29
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value))
|
|
30
|
+
throw wrongType(path, 'an integer', value);
|
|
31
|
+
return String(value);
|
|
32
|
+
}
|
|
33
|
+
case 'number': {
|
|
34
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
35
|
+
throw wrongType(path, 'a number', value);
|
|
36
|
+
return String(value);
|
|
37
|
+
}
|
|
38
|
+
case 'boolean': {
|
|
39
|
+
if (typeof value !== 'boolean')
|
|
40
|
+
throw wrongType(path, 'a boolean', value);
|
|
41
|
+
return value ? 'true' : 'false';
|
|
42
|
+
}
|
|
43
|
+
case 'array': {
|
|
44
|
+
if (!Array.isArray(value))
|
|
45
|
+
throw wrongType(path, 'an array', value);
|
|
46
|
+
const items = base.items ?? {};
|
|
47
|
+
return `[${value.map((item, i) => encodeBySchema(schema, items, item, `${path}[${i}]`)).join(',')}]`;
|
|
48
|
+
}
|
|
49
|
+
case 'map': {
|
|
50
|
+
if (!isPlainObject(value))
|
|
51
|
+
throw wrongType(path, 'an object', value);
|
|
52
|
+
const values = typeof base.additionalProperties === 'object' ? base.additionalProperties : {};
|
|
53
|
+
const members = Object.entries(value)
|
|
54
|
+
.filter(([, member]) => member !== undefined)
|
|
55
|
+
.sort(([a], [b]) => compareKeys(a, b))
|
|
56
|
+
.map(([name, member]) => `${encodeJsonString(name)}:${encodeBySchema(schema, values, member, `${path}.${name}`)}`);
|
|
57
|
+
return `{${members.join(',')}}`;
|
|
58
|
+
}
|
|
59
|
+
case 'struct': {
|
|
60
|
+
if (!isPlainObject(value))
|
|
61
|
+
throw wrongType(path, 'an object', value);
|
|
62
|
+
return encodeStruct(schema, base, value, path);
|
|
63
|
+
}
|
|
64
|
+
default: {
|
|
65
|
+
return encodeJsonValue(value, path);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function encodeStruct(schema, base, value, path) {
|
|
70
|
+
const properties = base.properties ?? {};
|
|
71
|
+
const required = new Set(base.required);
|
|
72
|
+
const members = [];
|
|
73
|
+
for (const [name, property] of Object.entries(properties)) {
|
|
74
|
+
const member = value[name];
|
|
75
|
+
if (!required.has(name) && isOmitted(schema, property, member))
|
|
76
|
+
continue;
|
|
77
|
+
members.push(`${encodeJsonString(name)}:${encodeBySchema(schema, property, member, `${path}.${name}`)}`);
|
|
78
|
+
}
|
|
79
|
+
// Members the schema does not know (a protocol that grew past this SDK) are kept, after the known ones.
|
|
80
|
+
const known = new Set(Object.keys(properties));
|
|
81
|
+
const unknown = Object.keys(value).filter(name => !known.has(name) && value[name] !== undefined).sort(compareKeys);
|
|
82
|
+
for (const name of unknown)
|
|
83
|
+
members.push(`${encodeJsonString(name)}:${encodeJsonValue(value[name], `${path}.${name}`)}`);
|
|
84
|
+
return `{${members.join(',')}}`;
|
|
85
|
+
}
|
|
86
|
+
/** Go's `omitempty`: absent, null, or the zero value of a scalar, array or map. A struct is never empty. */
|
|
87
|
+
function isOmitted(schema, property, member) {
|
|
88
|
+
if (member === undefined || member === null)
|
|
89
|
+
return true;
|
|
90
|
+
const kind = kindOf(schema, property);
|
|
91
|
+
if (kind === 'string')
|
|
92
|
+
return member === '';
|
|
93
|
+
if (kind === 'integer' || kind === 'number')
|
|
94
|
+
return member === 0;
|
|
95
|
+
if (kind === 'boolean')
|
|
96
|
+
return member === false;
|
|
97
|
+
return kind === 'array' ? Array.isArray(member) && member.length === 0 : kind === 'map' && isPlainObject(member) && Object.values(member).every(v => v === undefined);
|
|
98
|
+
}
|
|
99
|
+
function isPlainObject(value) {
|
|
100
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
function wrongType(path, expected, value) {
|
|
103
|
+
const got = Array.isArray(value) ? 'an array' : (typeof value === 'object' ? 'an object' : `a ${typeof value}`);
|
|
104
|
+
return new EncodeError(`${path} must be ${expected}, not ${got}`);
|
|
105
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A string as the Go SDK writes it: JSON escaping, and `<`, `>`, `&`, U+2028
|
|
3
|
+
* and U+2029 as `\u` escapes (Go's `json.Marshal` escapes HTML by default). Both
|
|
4
|
+
* forms decode to the same text; writing Go's makes the two SDKs' bytes equal.
|
|
5
|
+
*/
|
|
6
|
+
export declare function encodeJsonString(text: string): string;
|
|
7
|
+
/** Go sorts the keys of a map by their UTF-8 bytes. */
|
|
8
|
+
export declare function compareKeys(a: string, b: string): number;
|
|
9
|
+
/**
|
|
10
|
+
* Any JSON value as the Go SDK writes a `map[string]any`: object keys sorted,
|
|
11
|
+
* members whose value is `undefined` left out.
|
|
12
|
+
*/
|
|
13
|
+
export declare function encodeJsonValue(value: unknown, path: string): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { EncodeError } from './encode-error.error.js';
|
|
2
|
+
/**
|
|
3
|
+
* A string as the Go SDK writes it: JSON escaping, and `<`, `>`, `&`, U+2028
|
|
4
|
+
* and U+2029 as `\u` escapes (Go's `json.Marshal` escapes HTML by default). Both
|
|
5
|
+
* forms decode to the same text; writing Go's makes the two SDKs' bytes equal.
|
|
6
|
+
*/
|
|
7
|
+
export function encodeJsonString(text) {
|
|
8
|
+
return JSON.stringify(text)
|
|
9
|
+
.replaceAll('<', String.raw `\u003c`)
|
|
10
|
+
.replaceAll('>', String.raw `\u003e`)
|
|
11
|
+
.replaceAll('&', String.raw `\u0026`)
|
|
12
|
+
.replaceAll('\u{2028}', String.raw `\u2028`)
|
|
13
|
+
.replaceAll('\u{2029}', String.raw `\u2029`);
|
|
14
|
+
}
|
|
15
|
+
/** Go sorts the keys of a map by their UTF-8 bytes. */
|
|
16
|
+
export function compareKeys(a, b) {
|
|
17
|
+
return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Any JSON value as the Go SDK writes a `map[string]any`: object keys sorted,
|
|
21
|
+
* members whose value is `undefined` left out.
|
|
22
|
+
*/
|
|
23
|
+
export function encodeJsonValue(value, path) {
|
|
24
|
+
if (value === null)
|
|
25
|
+
return 'null';
|
|
26
|
+
switch (typeof value) {
|
|
27
|
+
case 'string': {
|
|
28
|
+
return encodeJsonString(value);
|
|
29
|
+
}
|
|
30
|
+
case 'boolean': {
|
|
31
|
+
return value ? 'true' : 'false';
|
|
32
|
+
}
|
|
33
|
+
case 'number': {
|
|
34
|
+
if (!Number.isFinite(value))
|
|
35
|
+
throw new EncodeError(`${path}: ${value} cannot be written as JSON`);
|
|
36
|
+
return String(value);
|
|
37
|
+
}
|
|
38
|
+
case 'object': {
|
|
39
|
+
if (Array.isArray(value))
|
|
40
|
+
return `[${value.map((item, i) => encodeJsonValue(item === undefined ? null : item, `${path}[${i}]`)).join(',')}]`;
|
|
41
|
+
const members = Object.entries(value)
|
|
42
|
+
.filter(([, member]) => member !== undefined)
|
|
43
|
+
.sort(([a], [b]) => compareKeys(a, b))
|
|
44
|
+
.map(([name, member]) => `${encodeJsonString(name)}:${encodeJsonValue(member, `${path}.${name}`)}`);
|
|
45
|
+
return `{${members.join(',')}}`;
|
|
46
|
+
}
|
|
47
|
+
default: {
|
|
48
|
+
throw new EncodeError(`${path}: a ${typeof value} cannot be written as JSON`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
package/codec/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { decodeParams, decodeResult, encodeParams, encodeResult } from './method-codec.algorithm.ts';
|
|
2
|
+
export { EncodeError } from './encode-error.error.ts';
|
|
3
|
+
export { ParamsError } from './params-error.error.ts';
|
|
4
|
+
export { MessageParseError } from './message-parse.error.ts';
|
|
5
|
+
export { parseRequest, parseResponse, writeFailure, writeRequest, writeResult } from './wire-message.mapper.ts';
|
|
6
|
+
export type { ReceivedRequest, ReceivedResponse } from './wire-message.mapper.ts';
|
package/codec/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { decodeParams, decodeResult, encodeParams, encodeResult } from './method-codec.algorithm.js';
|
|
2
|
+
export { EncodeError } from './encode-error.error.js';
|
|
3
|
+
export { ParamsError } from './params-error.error.js';
|
|
4
|
+
export { MessageParseError } from './message-parse.error.js';
|
|
5
|
+
export { parseRequest, parseResponse, writeFailure, writeRequest, writeResult } from './wire-message.mapper.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A frame whose body is not a JSON-RPC 2.0 message of the shape the protocol uses. The serve loop answers it with the code ParseError. */
|
|
2
|
+
export class MessageParseError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'MessageParseError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** The params of a request, written as the Go SDK writes them; a method the protocol does not know is written as plain JSON. */
|
|
2
|
+
export declare function encodeParams(method: string, params: unknown): string;
|
|
3
|
+
/** The result of a method, written as the Go SDK writes it. */
|
|
4
|
+
export declare function encodeResult(method: string, result: unknown): string;
|
|
5
|
+
/** The params of a received request, shaped as the generated types promise (see normalizeBySchema). Absent params are an empty object. */
|
|
6
|
+
export declare function decodeParams(method: string, params: unknown): unknown;
|
|
7
|
+
/** The result of a received response, shaped as the generated types promise. */
|
|
8
|
+
export declare function decodeResult(method: string, result: unknown): unknown;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { protocolSchema } from '../protocol/index.js';
|
|
2
|
+
import { encodeBySchema } from './encode-by-schema.algorithm.js';
|
|
3
|
+
import { encodeJsonValue } from './encode-json-value.algorithm.js';
|
|
4
|
+
import { normalizeBySchema } from './normalize-by-schema.algorithm.js';
|
|
5
|
+
function rowOf(method) {
|
|
6
|
+
return Object.hasOwn(protocolSchema['x-methods'], method) ? protocolSchema['x-methods'][method] : undefined;
|
|
7
|
+
}
|
|
8
|
+
/** The params of a request, written as the Go SDK writes them; a method the protocol does not know is written as plain JSON. */
|
|
9
|
+
export function encodeParams(method, params) {
|
|
10
|
+
return encode(method, 'params', params);
|
|
11
|
+
}
|
|
12
|
+
/** The result of a method, written as the Go SDK writes it. */
|
|
13
|
+
export function encodeResult(method, result) {
|
|
14
|
+
return encode(method, 'result', result);
|
|
15
|
+
}
|
|
16
|
+
/** The params of a received request, shaped as the generated types promise (see normalizeBySchema). Absent params are an empty object. */
|
|
17
|
+
export function decodeParams(method, params) {
|
|
18
|
+
return decode(method, 'params', params ?? {});
|
|
19
|
+
}
|
|
20
|
+
/** The result of a received response, shaped as the generated types promise. */
|
|
21
|
+
export function decodeResult(method, result) {
|
|
22
|
+
return decode(method, 'result', result ?? {});
|
|
23
|
+
}
|
|
24
|
+
function encode(method, side, value) {
|
|
25
|
+
const row = rowOf(method);
|
|
26
|
+
return row === undefined ? encodeJsonValue(value, side) : encodeBySchema(protocolSchema, row[side], value, side);
|
|
27
|
+
}
|
|
28
|
+
function decode(method, side, value) {
|
|
29
|
+
const row = rowOf(method);
|
|
30
|
+
return row === undefined ? value : normalizeBySchema(protocolSchema, row[side], value, side);
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SchemaDocument, SchemaNode } from '../protocol/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* Shapes a received value the way the Go SDK's decoder does, so the types the
|
|
4
|
+
* generated interfaces promise hold:
|
|
5
|
+
*
|
|
6
|
+
* - a required member that is absent or `null` is its zero value (`[]`, `{}`,
|
|
7
|
+
* `''`, `0`, `false`): the Go host writes a nil slice as `null`;
|
|
8
|
+
* - an optional member that is `null` is removed;
|
|
9
|
+
* - members the schema does not know stay as they are.
|
|
10
|
+
*
|
|
11
|
+
* It throws {@link ParamsError} where a value has the wrong JSON type. The
|
|
12
|
+
* value is edited in place and returned.
|
|
13
|
+
*/
|
|
14
|
+
export declare function normalizeBySchema(schema: SchemaDocument, node: SchemaNode, value: unknown, path: string, depth?: number): unknown;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ParamsError } from './params-error.error.js';
|
|
2
|
+
import { baseNode, isNullable, kindOf } from './schema-lookup.algorithm.js';
|
|
3
|
+
const MAX_DEPTH = 64;
|
|
4
|
+
/** The JSON test and the words for the error of each scalar kind. */
|
|
5
|
+
const SCALARS = {
|
|
6
|
+
string: { is: value => typeof value === 'string', article: 'a string' },
|
|
7
|
+
integer: { is: value => typeof value === 'number' && Number.isSafeInteger(value), article: 'an integer' },
|
|
8
|
+
number: { is: value => typeof value === 'number', article: 'a number' },
|
|
9
|
+
boolean: { is: value => typeof value === 'boolean', article: 'a boolean' },
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Shapes a received value the way the Go SDK's decoder does, so the types the
|
|
13
|
+
* generated interfaces promise hold:
|
|
14
|
+
*
|
|
15
|
+
* - a required member that is absent or `null` is its zero value (`[]`, `{}`,
|
|
16
|
+
* `''`, `0`, `false`): the Go host writes a nil slice as `null`;
|
|
17
|
+
* - an optional member that is `null` is removed;
|
|
18
|
+
* - members the schema does not know stay as they are.
|
|
19
|
+
*
|
|
20
|
+
* It throws {@link ParamsError} where a value has the wrong JSON type. The
|
|
21
|
+
* value is edited in place and returned.
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeBySchema(schema, node, value, path, depth = 0) {
|
|
24
|
+
if (depth > MAX_DEPTH)
|
|
25
|
+
throw new ParamsError(`${path} is nested too deeply`);
|
|
26
|
+
if (value === null || value === undefined)
|
|
27
|
+
return isNullable(node) ? null : zeroOf(schema, node, path, depth);
|
|
28
|
+
const kind = kindOf(schema, node);
|
|
29
|
+
const base = baseNode(schema, node);
|
|
30
|
+
const scalar = SCALARS[kind];
|
|
31
|
+
if (scalar !== undefined) {
|
|
32
|
+
if (!scalar.is(value))
|
|
33
|
+
throw wrongType(path, scalar.article, value);
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
if (kind === 'array') {
|
|
37
|
+
if (!Array.isArray(value))
|
|
38
|
+
throw wrongType(path, 'an array', value);
|
|
39
|
+
return value.map((item, i) => normalizeBySchema(schema, base.items ?? {}, item, `${path}[${i}]`, depth + 1));
|
|
40
|
+
}
|
|
41
|
+
if (kind === 'map')
|
|
42
|
+
return normalizeMap(schema, base, value, path, depth);
|
|
43
|
+
return kind === 'struct' ? normalizeStruct(schema, base, value, path, depth) : value;
|
|
44
|
+
}
|
|
45
|
+
function normalizeMap(schema, base, value, path, depth) {
|
|
46
|
+
if (!isObject(value))
|
|
47
|
+
throw wrongType(path, 'an object', value);
|
|
48
|
+
const values = typeof base.additionalProperties === 'object' ? base.additionalProperties : {};
|
|
49
|
+
const keepsNull = kindOf(schema, values) === 'any';
|
|
50
|
+
for (const [name, member] of Object.entries(value)) {
|
|
51
|
+
if (member !== null)
|
|
52
|
+
value[name] = normalizeBySchema(schema, values, member, `${path}.${name}`, depth + 1);
|
|
53
|
+
else if (!keepsNull)
|
|
54
|
+
delete value[name];
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
function normalizeStruct(schema, base, value, path, depth) {
|
|
59
|
+
if (!isObject(value))
|
|
60
|
+
throw wrongType(path, 'an object', value);
|
|
61
|
+
const required = new Set(base.required);
|
|
62
|
+
const properties = Object.entries(base.properties ?? {});
|
|
63
|
+
for (const [name, property] of properties) {
|
|
64
|
+
const member = value[name];
|
|
65
|
+
const at = `${path}.${name}`;
|
|
66
|
+
if (member !== undefined && member !== null) {
|
|
67
|
+
value[name] = normalizeBySchema(schema, property, member, at, depth + 1);
|
|
68
|
+
}
|
|
69
|
+
else if (required.has(name)) {
|
|
70
|
+
const zero = zeroOf(schema, property, at, depth + 1);
|
|
71
|
+
if (zero !== undefined)
|
|
72
|
+
value[name] = zero;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
delete value[name];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
/** The value Go's decoder leaves in a member the message did not carry. */
|
|
81
|
+
function zeroOf(schema, node, path, depth) {
|
|
82
|
+
if (isNullable(node))
|
|
83
|
+
return null;
|
|
84
|
+
const kind = kindOf(schema, node);
|
|
85
|
+
if (kind === 'string')
|
|
86
|
+
return '';
|
|
87
|
+
if (kind === 'integer' || kind === 'number')
|
|
88
|
+
return 0;
|
|
89
|
+
if (kind === 'boolean')
|
|
90
|
+
return false;
|
|
91
|
+
if (kind === 'array')
|
|
92
|
+
return [];
|
|
93
|
+
if (kind === 'map')
|
|
94
|
+
return {};
|
|
95
|
+
return kind === 'struct' ? normalizeBySchema(schema, node, {}, path, depth + 1) : undefined;
|
|
96
|
+
}
|
|
97
|
+
function isObject(value) {
|
|
98
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
function wrongType(path, expected, value) {
|
|
101
|
+
const got = Array.isArray(value) ? 'an array' : (typeof value === 'object' ? 'an object' : `a ${typeof value}`);
|
|
102
|
+
return new ParamsError(`${path} must be ${expected}, not ${got}`);
|
|
103
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A received message whose params (or result) do not have the shape the schema describes. The serve loop answers it with the code InvalidParams. */
|
|
2
|
+
export class ParamsError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = 'ParamsError';
|
|
6
|
+
}
|
|
7
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SchemaDocument, SchemaNode } from '../protocol/index.ts';
|
|
2
|
+
/** What a schema node holds, as far as the codec cares. */
|
|
3
|
+
export type NodeKind = 'string' | 'integer' | 'number' | 'boolean' | 'array' | 'struct' | 'map' | 'any';
|
|
4
|
+
/** Follows `$ref` to the definition it names. */
|
|
5
|
+
export declare function resolveNode(schema: SchemaDocument, node: SchemaNode): SchemaNode;
|
|
6
|
+
/** Whether `null` is a value of the node: `["integer", "null"]`, or an `anyOf` with null. */
|
|
7
|
+
export declare function isNullable(node: SchemaNode): boolean;
|
|
8
|
+
/** The node without its `null` alternative. */
|
|
9
|
+
export declare function withoutNull(schema: SchemaDocument, node: SchemaNode): SchemaNode;
|
|
10
|
+
/** The kind of value a node describes, after `$ref` and null are set aside. */
|
|
11
|
+
export declare function kindOf(schema: SchemaDocument, node: SchemaNode): NodeKind;
|
|
12
|
+
/** The node as the codec walks it: nullability removed, references followed. */
|
|
13
|
+
export declare function baseNode(schema: SchemaDocument, node: SchemaNode): SchemaNode;
|