@dbx-app/plugin-cli 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 +40 -0
- package/bin/dbx-plugin.js +86 -0
- package/package.json +46 -0
- package/sdk-root/README.md +3 -0
- package/sdk-root/plugins/sdk/go/dbx-plugin-sdk/README.md +17 -0
- package/sdk-root/plugins/sdk/go/dbx-plugin-sdk/go.mod +3 -0
- package/sdk-root/plugins/sdk/go/dbx-plugin-sdk/sdk.go +244 -0
- package/sdk-root/plugins/sdk/go/dbx-plugin-sdk/sdk_test.go +62 -0
- package/sdk-root/plugins/sdk/rust/dbx-plugin-sdk/Cargo.toml +12 -0
- package/sdk-root/plugins/sdk/rust/dbx-plugin-sdk/README.md +87 -0
- package/sdk-root/plugins/sdk/rust/dbx-plugin-sdk/src/lib.rs +432 -0
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @dbx-app/plugin-cli
|
|
2
|
+
|
|
3
|
+
Precompiled `dbx-plugin` CLI for creating and packaging DBX plugins. Installing this package does not compile the CLI and does not require a DBX source checkout.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install --global @dbx-app/plugin-cli
|
|
7
|
+
dbx-plugin create my-plugin
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Or run it without a global installation:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx @dbx-app/plugin-cli create my-plugin
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The package selects a precompiled binary for macOS, Linux, or Windows and bundles the matching Rust and Go DBX plugin SDK sources. Frontend-only plugins require only Node.js. Rust and Go are needed only when the plugin itself has a Rust or Go backend.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
dbx-plugin create my-plugin --template frontend
|
|
20
|
+
dbx-plugin create my-rust-plugin --template rust
|
|
21
|
+
dbx-plugin create my-go-plugin --template go
|
|
22
|
+
dbx-plugin package my-plugin
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## 中文说明
|
|
26
|
+
|
|
27
|
+
`@dbx-app/plugin-cli` 提供预编译的 `dbx-plugin` 命令。安装时不会编译 CLI,也不需要克隆 DBX 源码仓库。
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install -g @dbx-app/plugin-cli
|
|
31
|
+
dbx-plugin create my-plugin
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
也可以直接使用:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx @dbx-app/plugin-cli create my-plugin
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
npm 主包会自动选择当前操作系统对应的预编译二进制,并携带匹配版本的 Rust 和 Go 插件 SDK。纯前端插件只需要 Node.js;只有插件自身包含 Rust 或 Go 后端时才需要对应语言的编译环境。
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
11
|
+
const platformPackages = {
|
|
12
|
+
"darwin-arm64": ["@dbx-app/plugin-cli-darwin-arm64", "dbx-plugin"],
|
|
13
|
+
"darwin-x64": ["@dbx-app/plugin-cli-darwin-x64", "dbx-plugin"],
|
|
14
|
+
"linux-arm64": ["@dbx-app/plugin-cli-linux-arm64-gnu", "dbx-plugin"],
|
|
15
|
+
"linux-x64": ["@dbx-app/plugin-cli-linux-x64-gnu", "dbx-plugin"],
|
|
16
|
+
"win32-arm64": ["@dbx-app/plugin-cli-win32-arm64", "dbx-plugin.exe"],
|
|
17
|
+
"win32-x64": ["@dbx-app/plugin-cli-win32-x64", "dbx-plugin.exe"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function platformTarget() {
|
|
21
|
+
const platform = `${process.platform}-${process.arch}`;
|
|
22
|
+
const target = platformPackages[platform];
|
|
23
|
+
if (!target) {
|
|
24
|
+
throw new Error(`DBX Plugin CLI does not provide a binary for ${platform}.`);
|
|
25
|
+
}
|
|
26
|
+
return target;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveBinary() {
|
|
30
|
+
if (process.env.DBX_PLUGIN_CLI_BINARY) {
|
|
31
|
+
if (!existsSync(process.env.DBX_PLUGIN_CLI_BINARY)) {
|
|
32
|
+
throw new Error(`DBX_PLUGIN_CLI_BINARY does not exist: ${process.env.DBX_PLUGIN_CLI_BINARY}`);
|
|
33
|
+
}
|
|
34
|
+
return process.env.DBX_PLUGIN_CLI_BINARY;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const [packageName, binaryName] = platformTarget();
|
|
38
|
+
let manifest;
|
|
39
|
+
try {
|
|
40
|
+
manifest = require.resolve(`${packageName}/package.json`);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`The optional package ${packageName} was not installed. Reinstall @dbx-app/plugin-cli without --no-optional.`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const binary = join(dirname(manifest), "bin", binaryName);
|
|
48
|
+
if (!existsSync(binary)) {
|
|
49
|
+
throw new Error(`The DBX Plugin CLI binary is missing from ${packageName}.`);
|
|
50
|
+
}
|
|
51
|
+
return binary;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function bundledSdkRoot() {
|
|
55
|
+
const sdkRoot = join(packageRoot, "sdk-root");
|
|
56
|
+
const rustManifest = join(sdkRoot, "plugins", "sdk", "rust", "dbx-plugin-sdk", "Cargo.toml");
|
|
57
|
+
const goManifest = join(sdkRoot, "plugins", "sdk", "go", "dbx-plugin-sdk", "go.mod");
|
|
58
|
+
return existsSync(rustManifest) && existsSync(goManifest) ? sdkRoot : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
if (process.argv[2] === "--verify-platform") {
|
|
63
|
+
platformTarget();
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const binary = resolveBinary();
|
|
68
|
+
const env = { ...process.env };
|
|
69
|
+
delete env.DBX_PLUGIN_CLI_BINARY;
|
|
70
|
+
if (!env.DBX_PLUGIN_SDK_ROOT) {
|
|
71
|
+
const sdkRoot = bundledSdkRoot();
|
|
72
|
+
if (sdkRoot) env.DBX_PLUGIN_SDK_ROOT = sdkRoot;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = spawnSync(binary, process.argv.slice(2), { stdio: "inherit", env });
|
|
76
|
+
if (result.error) {
|
|
77
|
+
throw result.error;
|
|
78
|
+
}
|
|
79
|
+
if (result.signal) {
|
|
80
|
+
process.kill(process.pid, result.signal);
|
|
81
|
+
}
|
|
82
|
+
process.exit(result.status ?? 1);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dbx-app/plugin-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Create and package DBX plugins without compiling the DBX plugin CLI",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"cli",
|
|
7
|
+
"database",
|
|
8
|
+
"dbx",
|
|
9
|
+
"plugin",
|
|
10
|
+
"plugin-development"
|
|
11
|
+
],
|
|
12
|
+
"homepage": "https://github.com/t8y2/dbx/tree/main/plugins",
|
|
13
|
+
"license": "Apache-2.0",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/t8y2/dbx",
|
|
17
|
+
"directory": "packages/plugin-cli"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"dbx-plugin": "bin/dbx-plugin.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"README.md",
|
|
25
|
+
"sdk-root"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "node --check bin/dbx-plugin.js && node --check scripts/stage-sdk.mjs",
|
|
30
|
+
"test": "node --test tests/*.test.mjs",
|
|
31
|
+
"prepack": "node scripts/stage-sdk.mjs",
|
|
32
|
+
"postpack": "node scripts/stage-sdk.mjs --clean",
|
|
33
|
+
"prepublishOnly": "npm run build && npm test"
|
|
34
|
+
},
|
|
35
|
+
"optionalDependencies": {
|
|
36
|
+
"@dbx-app/plugin-cli-darwin-arm64": "0.1.0",
|
|
37
|
+
"@dbx-app/plugin-cli-darwin-x64": "0.1.0",
|
|
38
|
+
"@dbx-app/plugin-cli-linux-arm64-gnu": "0.1.0",
|
|
39
|
+
"@dbx-app/plugin-cli-linux-x64-gnu": "0.1.0",
|
|
40
|
+
"@dbx-app/plugin-cli-win32-arm64": "0.1.0",
|
|
41
|
+
"@dbx-app/plugin-cli-win32-x64": "0.1.0"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18.18.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# DBX Go plugin SDK
|
|
2
|
+
|
|
3
|
+
Go SDK for DBX native sidecar plugins using protocol v1 over JSON Lines stdin/stdout.
|
|
4
|
+
|
|
5
|
+
```go
|
|
6
|
+
metadata := dbxpluginsdk.Metadata{
|
|
7
|
+
ID: "vendor.example",
|
|
8
|
+
Version: "1.0.0",
|
|
9
|
+
Capabilities: []string{"events"},
|
|
10
|
+
}
|
|
11
|
+
server := dbxpluginsdk.NewServer(metadata, handler)
|
|
12
|
+
if err := server.Serve(); err != nil {
|
|
13
|
+
log.Fatal(err)
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Keep stdout reserved for protocol messages. Write diagnostics to stderr.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
package dbxpluginsdk
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"bytes"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"os"
|
|
11
|
+
"sync"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
const ProtocolVersion = 1
|
|
15
|
+
|
|
16
|
+
const maxJSONBytes = 8 * 1024 * 1024
|
|
17
|
+
|
|
18
|
+
type Metadata struct {
|
|
19
|
+
ID string
|
|
20
|
+
Version string
|
|
21
|
+
Capabilities []string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type RequestContext struct {
|
|
25
|
+
RequestID json.RawMessage
|
|
26
|
+
Driver string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type PluginError struct {
|
|
30
|
+
Code int `json:"code"`
|
|
31
|
+
Message string `json:"message"`
|
|
32
|
+
Data any `json:"data,omitempty"`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func NewError(code int, message string) *PluginError {
|
|
36
|
+
return &PluginError{Code: code, Message: message}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func MethodNotFound(method string) *PluginError {
|
|
40
|
+
return NewError(-32601, fmt.Sprintf("Method not found: %s", method))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Handler interface {
|
|
44
|
+
Handle(context RequestContext, method string, params json.RawMessage, emitter *Emitter) (any, *PluginError)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type HandlerFunc func(context RequestContext, method string, params json.RawMessage, emitter *Emitter) (any, *PluginError)
|
|
48
|
+
|
|
49
|
+
func (handler HandlerFunc) Handle(
|
|
50
|
+
context RequestContext,
|
|
51
|
+
method string,
|
|
52
|
+
params json.RawMessage,
|
|
53
|
+
emitter *Emitter,
|
|
54
|
+
) (any, *PluginError) {
|
|
55
|
+
return handler(context, method, params, emitter)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Emitter struct {
|
|
59
|
+
writer io.Writer
|
|
60
|
+
mutex *sync.Mutex
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func (emitter *Emitter) Event(method string, params any) *PluginError {
|
|
64
|
+
if !validProtocolName(method) {
|
|
65
|
+
return NewError(-32600, "Invalid event method")
|
|
66
|
+
}
|
|
67
|
+
return emitter.write(map[string]any{
|
|
68
|
+
"jsonrpc": "2.0",
|
|
69
|
+
"method": method,
|
|
70
|
+
"params": params,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func (emitter *Emitter) respond(id json.RawMessage, result any, pluginError *PluginError) *PluginError {
|
|
75
|
+
response := map[string]any{"jsonrpc": "2.0", "id": id}
|
|
76
|
+
if pluginError != nil {
|
|
77
|
+
response["error"] = pluginError
|
|
78
|
+
} else {
|
|
79
|
+
response["result"] = result
|
|
80
|
+
}
|
|
81
|
+
return emitter.write(response)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
func (emitter *Emitter) write(value any) *PluginError {
|
|
85
|
+
payload, err := json.Marshal(value)
|
|
86
|
+
if err != nil {
|
|
87
|
+
return NewError(-32603, err.Error())
|
|
88
|
+
}
|
|
89
|
+
if len(payload) > maxJSONBytes {
|
|
90
|
+
return NewError(-32600, "JSON message is too large")
|
|
91
|
+
}
|
|
92
|
+
emitter.mutex.Lock()
|
|
93
|
+
defer emitter.mutex.Unlock()
|
|
94
|
+
if _, err := emitter.writer.Write(append(payload, '\n')); err != nil {
|
|
95
|
+
return NewError(-32000, err.Error())
|
|
96
|
+
}
|
|
97
|
+
return nil
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type Server struct {
|
|
101
|
+
metadata Metadata
|
|
102
|
+
handler Handler
|
|
103
|
+
input io.Reader
|
|
104
|
+
output io.Writer
|
|
105
|
+
errors io.Writer
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func NewServer(metadata Metadata, handler Handler) *Server {
|
|
109
|
+
return &Server{
|
|
110
|
+
metadata: metadata,
|
|
111
|
+
handler: handler,
|
|
112
|
+
input: os.Stdin,
|
|
113
|
+
output: os.Stdout,
|
|
114
|
+
errors: os.Stderr,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func (server *Server) WithIO(input io.Reader, output io.Writer, errorsWriter io.Writer) *Server {
|
|
119
|
+
server.input = input
|
|
120
|
+
server.output = output
|
|
121
|
+
server.errors = errorsWriter
|
|
122
|
+
return server
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
func (server *Server) Serve() error {
|
|
126
|
+
if server.handler == nil {
|
|
127
|
+
return errors.New("plugin handler is required")
|
|
128
|
+
}
|
|
129
|
+
if !validProtocolName(server.metadata.ID) {
|
|
130
|
+
return errors.New("plugin id is invalid")
|
|
131
|
+
}
|
|
132
|
+
emitter := &Emitter{writer: server.output, mutex: &sync.Mutex{}}
|
|
133
|
+
scanner := bufio.NewScanner(server.input)
|
|
134
|
+
scanner.Buffer(make([]byte, 64*1024), maxJSONBytes)
|
|
135
|
+
var workers sync.WaitGroup
|
|
136
|
+
for scanner.Scan() {
|
|
137
|
+
payload := bytes.TrimSpace(scanner.Bytes())
|
|
138
|
+
if len(payload) == 0 {
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
request, err := decodeRequest(payload)
|
|
142
|
+
if err != nil {
|
|
143
|
+
fmt.Fprintf(server.errors, "[dbx-plugin-sdk-go] %v\n", err)
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
if request.Method == "plugin/initialize" {
|
|
147
|
+
if len(request.ID) == 0 {
|
|
148
|
+
fmt.Fprintln(server.errors, "[dbx-plugin-sdk-go] plugin/initialize must be a request")
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
result, pluginError := server.initialize(request.Params)
|
|
152
|
+
if writeError := emitter.respond(request.ID, result, pluginError); writeError != nil {
|
|
153
|
+
return errors.New(writeError.Message)
|
|
154
|
+
}
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
workers.Add(1)
|
|
158
|
+
go func(request protocolRequest) {
|
|
159
|
+
defer workers.Done()
|
|
160
|
+
result, pluginError := server.handler.Handle(
|
|
161
|
+
RequestContext{RequestID: request.ID, Driver: request.Driver},
|
|
162
|
+
request.Method,
|
|
163
|
+
request.Params,
|
|
164
|
+
emitter,
|
|
165
|
+
)
|
|
166
|
+
if len(request.ID) == 0 {
|
|
167
|
+
if pluginError != nil {
|
|
168
|
+
fmt.Fprintf(server.errors, "[dbx-plugin-sdk-go] %s\n", pluginError.Message)
|
|
169
|
+
}
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if writeError := emitter.respond(request.ID, result, pluginError); writeError != nil {
|
|
173
|
+
fmt.Fprintf(server.errors, "[dbx-plugin-sdk-go] failed to write response: %s\n", writeError.Message)
|
|
174
|
+
}
|
|
175
|
+
}(request)
|
|
176
|
+
}
|
|
177
|
+
workers.Wait()
|
|
178
|
+
return scanner.Err()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func (server *Server) initialize(params json.RawMessage) (any, *PluginError) {
|
|
182
|
+
var request struct {
|
|
183
|
+
Host struct {
|
|
184
|
+
ProtocolVersions []int `json:"protocolVersions"`
|
|
185
|
+
} `json:"host"`
|
|
186
|
+
}
|
|
187
|
+
if err := json.Unmarshal(params, &request); err != nil {
|
|
188
|
+
return nil, NewError(-32602, "Invalid initialize parameters")
|
|
189
|
+
}
|
|
190
|
+
for _, version := range request.Host.ProtocolVersions {
|
|
191
|
+
if version == ProtocolVersion {
|
|
192
|
+
return map[string]any{
|
|
193
|
+
"protocolVersion": ProtocolVersion,
|
|
194
|
+
"capabilities": server.metadata.Capabilities,
|
|
195
|
+
"plugin": map[string]string{
|
|
196
|
+
"id": server.metadata.ID,
|
|
197
|
+
"version": server.metadata.Version,
|
|
198
|
+
},
|
|
199
|
+
}, nil
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return nil, NewError(-32001, "DBX and plugin do not share a protocol version")
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
type protocolRequest struct {
|
|
206
|
+
JSONRPC string `json:"jsonrpc"`
|
|
207
|
+
ID json.RawMessage `json:"id"`
|
|
208
|
+
Driver string `json:"driver"`
|
|
209
|
+
Method string `json:"method"`
|
|
210
|
+
Params json.RawMessage `json:"params"`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
func decodeRequest(payload []byte) (protocolRequest, error) {
|
|
214
|
+
var request protocolRequest
|
|
215
|
+
if err := json.Unmarshal(payload, &request); err != nil {
|
|
216
|
+
return request, err
|
|
217
|
+
}
|
|
218
|
+
if request.JSONRPC != "2.0" {
|
|
219
|
+
return request, errors.New("request does not declare jsonrpc 2.0")
|
|
220
|
+
}
|
|
221
|
+
if !validProtocolName(request.Method) {
|
|
222
|
+
return request, errors.New("request method is invalid")
|
|
223
|
+
}
|
|
224
|
+
if len(request.Params) == 0 {
|
|
225
|
+
request.Params = json.RawMessage("null")
|
|
226
|
+
}
|
|
227
|
+
return request, nil
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
func validProtocolName(value string) bool {
|
|
231
|
+
if len(value) == 0 || len(value) > 256 {
|
|
232
|
+
return false
|
|
233
|
+
}
|
|
234
|
+
for index, character := range value {
|
|
235
|
+
if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9' {
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if index > 0 && (character == '.' || character == '_' || character == ':' || character == '/' || character == '-') {
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
return true
|
|
244
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
package dbxpluginsdk
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"sync"
|
|
7
|
+
"testing"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestServerInitializesAndDispatches(t *testing.T) {
|
|
11
|
+
input := bytes.NewBufferString(
|
|
12
|
+
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"plugin/initialize\",\"params\":{\"host\":{\"protocolVersions\":[1]}}}\n" +
|
|
13
|
+
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"sample/ping\",\"params\":{\"name\":\"DBX\"}}\n",
|
|
14
|
+
)
|
|
15
|
+
var output bytes.Buffer
|
|
16
|
+
server := NewServer(
|
|
17
|
+
Metadata{ID: "sample.plugin", Version: "1.0.0", Capabilities: []string{"commands"}},
|
|
18
|
+
HandlerFunc(func(_ RequestContext, method string, _ json.RawMessage, _ *Emitter) (any, *PluginError) {
|
|
19
|
+
if method != "sample/ping" {
|
|
20
|
+
return nil, MethodNotFound(method)
|
|
21
|
+
}
|
|
22
|
+
return map[string]any{"ok": true}, nil
|
|
23
|
+
}),
|
|
24
|
+
).WithIO(input, &output, &bytes.Buffer{})
|
|
25
|
+
if err := server.Serve(); err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
var responses []map[string]any
|
|
29
|
+
for _, line := range bytes.Split(bytes.TrimSpace(output.Bytes()), []byte{'\n'}) {
|
|
30
|
+
var response map[string]any
|
|
31
|
+
if err := json.Unmarshal(line, &response); err != nil {
|
|
32
|
+
t.Fatal(err)
|
|
33
|
+
}
|
|
34
|
+
responses = append(responses, response)
|
|
35
|
+
}
|
|
36
|
+
if len(responses) != 2 {
|
|
37
|
+
t.Fatalf("expected 2 responses, got %d", len(responses))
|
|
38
|
+
}
|
|
39
|
+
initialize := responses[0]["result"].(map[string]any)
|
|
40
|
+
if initialize["protocolVersion"] != float64(ProtocolVersion) {
|
|
41
|
+
t.Fatalf("unexpected initialize response: %#v", initialize)
|
|
42
|
+
}
|
|
43
|
+
pong := responses[1]["result"].(map[string]any)
|
|
44
|
+
if pong["ok"] != true {
|
|
45
|
+
t.Fatalf("unexpected handler response: %#v", pong)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func TestEmitterWritesEvents(t *testing.T) {
|
|
50
|
+
var output bytes.Buffer
|
|
51
|
+
emitter := &Emitter{writer: &output, mutex: &sync.Mutex{}}
|
|
52
|
+
if pluginError := emitter.Event("sample/progress", map[string]any{"value": 1}); pluginError != nil {
|
|
53
|
+
t.Fatal(pluginError.Message)
|
|
54
|
+
}
|
|
55
|
+
var event map[string]any
|
|
56
|
+
if err := json.Unmarshal(bytes.TrimSpace(output.Bytes()), &event); err != nil {
|
|
57
|
+
t.Fatal(err)
|
|
58
|
+
}
|
|
59
|
+
if event["method"] != "sample/progress" {
|
|
60
|
+
t.Fatalf("unexpected event: %#v", event)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "dbx-plugin-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
edition = "2021"
|
|
5
|
+
license = "Apache-2.0"
|
|
6
|
+
description = "Sidecar protocol SDK for DBX plugins"
|
|
7
|
+
|
|
8
|
+
[workspace]
|
|
9
|
+
|
|
10
|
+
[dependencies]
|
|
11
|
+
serde = { version = "1", features = ["derive"] }
|
|
12
|
+
serde_json = "1"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# dbx-plugin-sdk
|
|
2
|
+
|
|
3
|
+
Rust SDK for DBX sidecar protocol v1.
|
|
4
|
+
|
|
5
|
+
It provides:
|
|
6
|
+
|
|
7
|
+
- `plugin/initialize` negotiation and backend identity reporting;
|
|
8
|
+
- concurrent JSON-RPC request dispatch through a bounded worker pool;
|
|
9
|
+
- JSON-RPC responses and plugin events;
|
|
10
|
+
- JSON Lines and framed transports;
|
|
11
|
+
- binary input/output channels for framed plugins;
|
|
12
|
+
- bounded JSON and binary message sizes.
|
|
13
|
+
|
|
14
|
+
## Minimal sidecar
|
|
15
|
+
|
|
16
|
+
```rust
|
|
17
|
+
use dbx_plugin_sdk::{
|
|
18
|
+
PluginEmitter, PluginError, PluginHandler, PluginMetadata, PluginServer,
|
|
19
|
+
RequestContext,
|
|
20
|
+
};
|
|
21
|
+
use serde_json::{json, Value};
|
|
22
|
+
|
|
23
|
+
struct Example;
|
|
24
|
+
|
|
25
|
+
impl PluginHandler for Example {
|
|
26
|
+
fn handle(
|
|
27
|
+
&self,
|
|
28
|
+
_context: RequestContext,
|
|
29
|
+
method: &str,
|
|
30
|
+
params: Value,
|
|
31
|
+
emitter: &PluginEmitter,
|
|
32
|
+
) -> Result<Value, PluginError> {
|
|
33
|
+
match method {
|
|
34
|
+
"example/echo" => {
|
|
35
|
+
emitter.event("example/progress", json!({ "done": true }))?;
|
|
36
|
+
Ok(params)
|
|
37
|
+
}
|
|
38
|
+
_ => Err(PluginError::method_not_found(method)),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
fn main() -> std::io::Result<()> {
|
|
44
|
+
PluginServer::new(
|
|
45
|
+
PluginMetadata::new("vendor.example", env!("CARGO_PKG_VERSION"))
|
|
46
|
+
.with_capability("events"),
|
|
47
|
+
Example,
|
|
48
|
+
)
|
|
49
|
+
.serve()
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The metadata ID and version must exactly match `manifest.json`; DBX rejects a mismatched backend during initialization.
|
|
54
|
+
|
|
55
|
+
## Framed transport
|
|
56
|
+
|
|
57
|
+
Use framed transport for PTY, SFTP, file transfer, or other binary streams:
|
|
58
|
+
|
|
59
|
+
```rust
|
|
60
|
+
use dbx_plugin_sdk::{PluginServer, PluginTransport};
|
|
61
|
+
|
|
62
|
+
PluginServer::new(metadata, handler)
|
|
63
|
+
.transport(PluginTransport::Framed)
|
|
64
|
+
.serve()?;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Implement `PluginHandler::handle_binary` for host-to-plugin frames and call `PluginEmitter::binary` for plugin-to-host frames. The manifest must declare `"transport": "stdio-framed"`; workbench UI binary access requires the `host.binary` permission.
|
|
68
|
+
|
|
69
|
+
The server defaults to 2-16 worker threads (based on available parallelism) and a 256-job queue. CPU-heavy or latency-sensitive plugins can tune both without falling back to unbounded thread creation:
|
|
70
|
+
|
|
71
|
+
```rust
|
|
72
|
+
PluginServer::new(metadata, handler)
|
|
73
|
+
.worker_threads(8)
|
|
74
|
+
.work_queue_capacity(512)
|
|
75
|
+
.serve()?;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Process rules
|
|
79
|
+
|
|
80
|
+
- Reserve stdout for protocol traffic.
|
|
81
|
+
- Write diagnostics to stderr.
|
|
82
|
+
- Keep plugin-owned sessions in the handler or another process-wide registry.
|
|
83
|
+
- Make connect/disconnect idempotent.
|
|
84
|
+
- Use application-level cancellation and chunk acknowledgements for long-running transfers.
|
|
85
|
+
- Never send connection secrets in plugin events or workbench context.
|
|
86
|
+
|
|
87
|
+
See `plugins/examples/hello-workbench` for a complete package.
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
use std::io::{self, BufRead, BufReader, Read, Write};
|
|
2
|
+
use std::sync::{mpsc, Arc, Mutex};
|
|
3
|
+
use std::thread;
|
|
4
|
+
|
|
5
|
+
use serde::{Deserialize, Serialize};
|
|
6
|
+
use serde_json::Value;
|
|
7
|
+
|
|
8
|
+
pub const PROTOCOL_VERSION: u32 = 1;
|
|
9
|
+
const MAX_JSON_BYTES: usize = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_BINARY_BYTES: usize = 64 * 1024 * 1024;
|
|
11
|
+
const FRAME_KIND_JSON: u8 = 0;
|
|
12
|
+
const FRAME_KIND_BINARY: u8 = 1;
|
|
13
|
+
const DEFAULT_WORK_QUEUE_CAPACITY: usize = 256;
|
|
14
|
+
|
|
15
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
16
|
+
pub enum PluginTransport {
|
|
17
|
+
JsonLines,
|
|
18
|
+
Framed,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[derive(Debug, Clone)]
|
|
22
|
+
pub struct PluginMetadata {
|
|
23
|
+
pub id: String,
|
|
24
|
+
pub version: String,
|
|
25
|
+
pub capabilities: Vec<String>,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
impl PluginMetadata {
|
|
29
|
+
pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
|
|
30
|
+
Self { id: id.into(), version: version.into(), capabilities: Vec::new() }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
pub fn with_capability(mut self, capability: impl Into<String>) -> Self {
|
|
34
|
+
self.capabilities.push(capability.into());
|
|
35
|
+
self
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Debug, Clone)]
|
|
40
|
+
pub struct RequestContext {
|
|
41
|
+
pub request_id: Option<u64>,
|
|
42
|
+
pub driver: Option<String>,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[derive(Debug, Clone, Serialize)]
|
|
46
|
+
pub struct PluginError {
|
|
47
|
+
pub code: i32,
|
|
48
|
+
pub message: String,
|
|
49
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
50
|
+
pub data: Option<Value>,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
impl PluginError {
|
|
54
|
+
pub fn new(code: i32, message: impl Into<String>) -> Self {
|
|
55
|
+
Self { code, message: message.into(), data: None }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
pub fn method_not_found(method: &str) -> Self {
|
|
59
|
+
Self::new(-32601, format!("Method not found: {method}"))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
pub trait PluginHandler: Send + Sync + 'static {
|
|
64
|
+
fn handle(
|
|
65
|
+
&self,
|
|
66
|
+
context: RequestContext,
|
|
67
|
+
method: &str,
|
|
68
|
+
params: Value,
|
|
69
|
+
emitter: &PluginEmitter,
|
|
70
|
+
) -> Result<Value, PluginError>;
|
|
71
|
+
|
|
72
|
+
fn handle_binary(&self, _channel: &str, _data: Vec<u8>, _emitter: &PluginEmitter) -> Result<(), PluginError> {
|
|
73
|
+
Err(PluginError::new(-32601, "Binary input is not supported"))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
#[derive(Clone)]
|
|
78
|
+
pub struct PluginEmitter {
|
|
79
|
+
output: Arc<Mutex<Box<dyn Write + Send>>>,
|
|
80
|
+
transport: PluginTransport,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
impl PluginEmitter {
|
|
84
|
+
pub fn event(&self, method: &str, params: Value) -> Result<(), PluginError> {
|
|
85
|
+
validate_protocol_name(method)?;
|
|
86
|
+
self.write_json(&serde_json::json!({
|
|
87
|
+
"jsonrpc": "2.0",
|
|
88
|
+
"method": method,
|
|
89
|
+
"params": params
|
|
90
|
+
}))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pub fn binary(&self, channel: &str, data: &[u8]) -> Result<(), PluginError> {
|
|
94
|
+
if self.transport != PluginTransport::Framed {
|
|
95
|
+
return Err(PluginError::new(-32000, "Binary messages require framed transport"));
|
|
96
|
+
}
|
|
97
|
+
validate_protocol_name(channel)?;
|
|
98
|
+
if data.len() > MAX_BINARY_BYTES {
|
|
99
|
+
return Err(PluginError::new(-32600, "Binary message is too large"));
|
|
100
|
+
}
|
|
101
|
+
let channel = channel.as_bytes();
|
|
102
|
+
if channel.len() > u16::MAX as usize {
|
|
103
|
+
return Err(PluginError::new(-32600, "Binary channel is too long"));
|
|
104
|
+
}
|
|
105
|
+
let payload_len = 2 + channel.len() + data.len();
|
|
106
|
+
let mut output = self.output.lock().map_err(|_| PluginError::new(-32000, "Plugin output lock is poisoned"))?;
|
|
107
|
+
output.write_all(&[FRAME_KIND_BINARY]).map_err(io_error)?;
|
|
108
|
+
output.write_all(&(payload_len as u32).to_be_bytes()).map_err(io_error)?;
|
|
109
|
+
output.write_all(&(channel.len() as u16).to_be_bytes()).map_err(io_error)?;
|
|
110
|
+
output.write_all(channel).map_err(io_error)?;
|
|
111
|
+
output.write_all(data).map_err(io_error)?;
|
|
112
|
+
output.flush().map_err(io_error)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
fn respond(&self, id: u64, result: Result<Value, PluginError>) -> Result<(), PluginError> {
|
|
116
|
+
match result {
|
|
117
|
+
Ok(result) => self.write_json(&serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": result })),
|
|
118
|
+
Err(error) => self.write_json(&serde_json::json!({ "jsonrpc": "2.0", "id": id, "error": error })),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fn write_json(&self, value: &Value) -> Result<(), PluginError> {
|
|
123
|
+
let payload = serde_json::to_vec(value).map_err(|error| PluginError::new(-32603, error.to_string()))?;
|
|
124
|
+
if payload.len() > MAX_JSON_BYTES {
|
|
125
|
+
return Err(PluginError::new(-32600, "JSON message is too large"));
|
|
126
|
+
}
|
|
127
|
+
let mut output = self.output.lock().map_err(|_| PluginError::new(-32000, "Plugin output lock is poisoned"))?;
|
|
128
|
+
match self.transport {
|
|
129
|
+
PluginTransport::JsonLines => {
|
|
130
|
+
output.write_all(&payload).map_err(io_error)?;
|
|
131
|
+
output.write_all(b"\n").map_err(io_error)?;
|
|
132
|
+
}
|
|
133
|
+
PluginTransport::Framed => {
|
|
134
|
+
output.write_all(&[FRAME_KIND_JSON]).map_err(io_error)?;
|
|
135
|
+
output.write_all(&(payload.len() as u32).to_be_bytes()).map_err(io_error)?;
|
|
136
|
+
output.write_all(&payload).map_err(io_error)?;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
output.flush().map_err(io_error)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pub struct PluginServer<H> {
|
|
144
|
+
metadata: PluginMetadata,
|
|
145
|
+
handler: Arc<H>,
|
|
146
|
+
transport: PluginTransport,
|
|
147
|
+
worker_threads: usize,
|
|
148
|
+
work_queue_capacity: usize,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
impl<H: PluginHandler> PluginServer<H> {
|
|
152
|
+
pub fn new(metadata: PluginMetadata, handler: H) -> Self {
|
|
153
|
+
Self {
|
|
154
|
+
metadata,
|
|
155
|
+
handler: Arc::new(handler),
|
|
156
|
+
transport: PluginTransport::JsonLines,
|
|
157
|
+
worker_threads: default_worker_threads(),
|
|
158
|
+
work_queue_capacity: DEFAULT_WORK_QUEUE_CAPACITY,
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
pub fn transport(mut self, transport: PluginTransport) -> Self {
|
|
163
|
+
self.transport = transport;
|
|
164
|
+
self
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
pub fn worker_threads(mut self, worker_threads: usize) -> Self {
|
|
168
|
+
self.worker_threads = worker_threads.max(1);
|
|
169
|
+
self
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
pub fn work_queue_capacity(mut self, work_queue_capacity: usize) -> Self {
|
|
173
|
+
self.work_queue_capacity = work_queue_capacity.max(1);
|
|
174
|
+
self
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
pub fn serve(self) -> io::Result<()> {
|
|
178
|
+
let emitter = PluginEmitter { output: Arc::new(Mutex::new(Box::new(io::stdout()))), transport: self.transport };
|
|
179
|
+
let workers = WorkerPool::new(self.worker_threads, self.work_queue_capacity)?;
|
|
180
|
+
match self.transport {
|
|
181
|
+
PluginTransport::JsonLines => self.serve_json_lines(BufReader::new(io::stdin()), emitter, &workers),
|
|
182
|
+
PluginTransport::Framed => self.serve_framed(io::stdin(), emitter, &workers),
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn serve_json_lines<R: BufRead>(
|
|
187
|
+
&self,
|
|
188
|
+
mut input: R,
|
|
189
|
+
emitter: PluginEmitter,
|
|
190
|
+
workers: &WorkerPool,
|
|
191
|
+
) -> io::Result<()> {
|
|
192
|
+
loop {
|
|
193
|
+
let Some(line) = read_limited_line(&mut input, MAX_JSON_BYTES)? else {
|
|
194
|
+
return Ok(());
|
|
195
|
+
};
|
|
196
|
+
if line.iter().all(u8::is_ascii_whitespace) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if let Err(error) = self.dispatch_json(&line, emitter.clone(), workers) {
|
|
200
|
+
eprintln!("[dbx-plugin-sdk] {error}");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
fn serve_framed<R: Read>(&self, mut input: R, emitter: PluginEmitter, workers: &WorkerPool) -> io::Result<()> {
|
|
206
|
+
loop {
|
|
207
|
+
let mut header = [0u8; 5];
|
|
208
|
+
match input.read_exact(&mut header) {
|
|
209
|
+
Ok(()) => {}
|
|
210
|
+
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(()),
|
|
211
|
+
Err(error) => return Err(error),
|
|
212
|
+
}
|
|
213
|
+
let kind = header[0];
|
|
214
|
+
let length = u32::from_be_bytes(header[1..5].try_into().unwrap()) as usize;
|
|
215
|
+
let maximum = if kind == FRAME_KIND_JSON { MAX_JSON_BYTES } else { MAX_BINARY_BYTES + 1024 };
|
|
216
|
+
if length > maximum {
|
|
217
|
+
return Err(io::Error::new(io::ErrorKind::InvalidData, "plugin frame is too large"));
|
|
218
|
+
}
|
|
219
|
+
let mut payload = vec![0; length];
|
|
220
|
+
input.read_exact(&mut payload)?;
|
|
221
|
+
match kind {
|
|
222
|
+
FRAME_KIND_JSON => {
|
|
223
|
+
if let Err(error) = self.dispatch_json(&payload, emitter.clone(), workers) {
|
|
224
|
+
eprintln!("[dbx-plugin-sdk] {error}");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
FRAME_KIND_BINARY => {
|
|
228
|
+
if let Err(error) = self.dispatch_binary(payload, emitter.clone(), workers) {
|
|
229
|
+
eprintln!("[dbx-plugin-sdk] {error}");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
_ => return Err(io::Error::new(io::ErrorKind::InvalidData, "unknown plugin frame kind")),
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fn dispatch_json(&self, payload: &[u8], emitter: PluginEmitter, workers: &WorkerPool) -> Result<(), String> {
|
|
238
|
+
let request: ProtocolRequest = serde_json::from_slice(payload).map_err(|error| error.to_string())?;
|
|
239
|
+
if request.jsonrpc.as_deref() != Some("2.0") {
|
|
240
|
+
return Err("request does not declare jsonrpc 2.0".to_string());
|
|
241
|
+
}
|
|
242
|
+
validate_protocol_name(&request.method).map_err(|error| error.message)?;
|
|
243
|
+
if request.method == "plugin/initialize" {
|
|
244
|
+
let id = request.id.ok_or("plugin/initialize must be a request")?;
|
|
245
|
+
let supported = request
|
|
246
|
+
.params
|
|
247
|
+
.get("host")
|
|
248
|
+
.and_then(|host| host.get("protocolVersions"))
|
|
249
|
+
.and_then(Value::as_array)
|
|
250
|
+
.is_some_and(|versions| {
|
|
251
|
+
versions.iter().any(|version| version.as_u64() == Some(PROTOCOL_VERSION as u64))
|
|
252
|
+
});
|
|
253
|
+
let result = if supported {
|
|
254
|
+
Ok(serde_json::json!({
|
|
255
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
256
|
+
"capabilities": self.metadata.capabilities,
|
|
257
|
+
"plugin": { "id": self.metadata.id, "version": self.metadata.version }
|
|
258
|
+
}))
|
|
259
|
+
} else {
|
|
260
|
+
Err(PluginError::new(-32001, "DBX and plugin do not share a protocol version"))
|
|
261
|
+
};
|
|
262
|
+
return emitter.respond(id, result).map_err(|error| error.message);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let handler = self.handler.clone();
|
|
266
|
+
workers.submit(move || {
|
|
267
|
+
let context = RequestContext { request_id: request.id, driver: request.driver };
|
|
268
|
+
let result = handler.handle(context, &request.method, request.params, &emitter);
|
|
269
|
+
if let Some(id) = request.id {
|
|
270
|
+
if let Err(error) = emitter.respond(id, result) {
|
|
271
|
+
eprintln!("[dbx-plugin-sdk] failed to write response: {}", error.message);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
fn dispatch_binary(&self, payload: Vec<u8>, emitter: PluginEmitter, workers: &WorkerPool) -> Result<(), String> {
|
|
278
|
+
if payload.len() < 2 {
|
|
279
|
+
return Err("invalid binary frame".to_string());
|
|
280
|
+
}
|
|
281
|
+
let channel_len = u16::from_be_bytes([payload[0], payload[1]]) as usize;
|
|
282
|
+
if channel_len == 0 || payload.len() < 2 + channel_len {
|
|
283
|
+
return Err("invalid binary channel".to_string());
|
|
284
|
+
}
|
|
285
|
+
let channel = std::str::from_utf8(&payload[2..2 + channel_len])
|
|
286
|
+
.map_err(|_| "binary channel is not UTF-8".to_string())?
|
|
287
|
+
.to_string();
|
|
288
|
+
validate_protocol_name(&channel).map_err(|error| error.message)?;
|
|
289
|
+
let data = payload[2 + channel_len..].to_vec();
|
|
290
|
+
let handler = self.handler.clone();
|
|
291
|
+
workers.submit(move || {
|
|
292
|
+
if let Err(error) = handler.handle_binary(&channel, data, &emitter) {
|
|
293
|
+
eprintln!("[dbx-plugin-sdk] binary handler failed: {}", error.message);
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
#[derive(Debug, Deserialize)]
|
|
300
|
+
struct ProtocolRequest {
|
|
301
|
+
jsonrpc: Option<String>,
|
|
302
|
+
#[serde(default)]
|
|
303
|
+
id: Option<u64>,
|
|
304
|
+
#[serde(default)]
|
|
305
|
+
driver: Option<String>,
|
|
306
|
+
method: String,
|
|
307
|
+
#[serde(default)]
|
|
308
|
+
params: Value,
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
type PluginJob = Box<dyn FnOnce() + Send + 'static>;
|
|
312
|
+
|
|
313
|
+
struct WorkerPool {
|
|
314
|
+
sender: mpsc::SyncSender<PluginJob>,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
impl WorkerPool {
|
|
318
|
+
fn new(worker_threads: usize, queue_capacity: usize) -> io::Result<Self> {
|
|
319
|
+
let (sender, receiver) = mpsc::sync_channel::<PluginJob>(queue_capacity);
|
|
320
|
+
let receiver = Arc::new(Mutex::new(receiver));
|
|
321
|
+
for index in 0..worker_threads {
|
|
322
|
+
let receiver = receiver.clone();
|
|
323
|
+
thread::Builder::new().name(format!("dbx-plugin-worker-{index}")).spawn(move || loop {
|
|
324
|
+
let job = match receiver.lock() {
|
|
325
|
+
Ok(receiver) => receiver.recv(),
|
|
326
|
+
Err(_) => return,
|
|
327
|
+
};
|
|
328
|
+
match job {
|
|
329
|
+
Ok(job) => job(),
|
|
330
|
+
Err(_) => return,
|
|
331
|
+
}
|
|
332
|
+
})?;
|
|
333
|
+
}
|
|
334
|
+
Ok(Self { sender })
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
fn submit(&self, job: impl FnOnce() + Send + 'static) -> Result<(), String> {
|
|
338
|
+
self.sender.send(Box::new(job)).map_err(|_| "plugin worker pool is unavailable".to_string())
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
fn default_worker_threads() -> usize {
|
|
343
|
+
thread::available_parallelism().map(usize::from).unwrap_or(4).clamp(2, 16)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
fn read_limited_line<R: BufRead>(reader: &mut R, maximum: usize) -> io::Result<Option<Vec<u8>>> {
|
|
347
|
+
let mut output = Vec::new();
|
|
348
|
+
loop {
|
|
349
|
+
let available = reader.fill_buf()?;
|
|
350
|
+
if available.is_empty() {
|
|
351
|
+
return if output.is_empty() { Ok(None) } else { Ok(Some(output)) };
|
|
352
|
+
}
|
|
353
|
+
let take = available.iter().position(|byte| *byte == b'\n').map(|index| index + 1).unwrap_or(available.len());
|
|
354
|
+
if output.len().saturating_add(take) > maximum {
|
|
355
|
+
return Err(io::Error::new(io::ErrorKind::InvalidData, "plugin JSON line is too large"));
|
|
356
|
+
}
|
|
357
|
+
output.extend_from_slice(&available[..take]);
|
|
358
|
+
reader.consume(take);
|
|
359
|
+
if output.last() == Some(&b'\n') {
|
|
360
|
+
return Ok(Some(output));
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
fn validate_protocol_name(value: &str) -> Result<(), PluginError> {
|
|
366
|
+
if value.is_empty() || value.len() > 256 || value.chars().any(char::is_whitespace) {
|
|
367
|
+
return Err(PluginError::new(-32600, "Protocol name is invalid"));
|
|
368
|
+
}
|
|
369
|
+
Ok(())
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
#[cfg(test)]
|
|
373
|
+
mod tests {
|
|
374
|
+
use std::io::Cursor;
|
|
375
|
+
use std::sync::mpsc;
|
|
376
|
+
use std::time::Duration;
|
|
377
|
+
|
|
378
|
+
use super::{
|
|
379
|
+
read_limited_line, PluginError, PluginHandler, PluginMetadata, PluginServer, RequestContext, WorkerPool,
|
|
380
|
+
};
|
|
381
|
+
use crate::PluginEmitter;
|
|
382
|
+
use serde_json::Value;
|
|
383
|
+
|
|
384
|
+
struct NoopHandler;
|
|
385
|
+
|
|
386
|
+
impl PluginHandler for NoopHandler {
|
|
387
|
+
fn handle(
|
|
388
|
+
&self,
|
|
389
|
+
_context: RequestContext,
|
|
390
|
+
method: &str,
|
|
391
|
+
_params: Value,
|
|
392
|
+
_emitter: &PluginEmitter,
|
|
393
|
+
) -> Result<Value, PluginError> {
|
|
394
|
+
Err(PluginError::method_not_found(method))
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#[test]
|
|
399
|
+
fn worker_pool_executes_queued_jobs() {
|
|
400
|
+
let pool = WorkerPool::new(2, 4).unwrap();
|
|
401
|
+
let (sender, receiver) = mpsc::channel();
|
|
402
|
+
for value in 0..4 {
|
|
403
|
+
let sender = sender.clone();
|
|
404
|
+
pool.submit(move || sender.send(value).unwrap()).unwrap();
|
|
405
|
+
}
|
|
406
|
+
drop(sender);
|
|
407
|
+
|
|
408
|
+
let mut values = (0..4).map(|_| receiver.recv_timeout(Duration::from_secs(1)).unwrap()).collect::<Vec<_>>();
|
|
409
|
+
values.sort_unstable();
|
|
410
|
+
assert_eq!(values, vec![0, 1, 2, 3]);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#[test]
|
|
414
|
+
fn server_configuration_clamps_zero_worker_values() {
|
|
415
|
+
let server = PluginServer::new(PluginMetadata::new("sample", "1.0.0"), NoopHandler)
|
|
416
|
+
.worker_threads(0)
|
|
417
|
+
.work_queue_capacity(0);
|
|
418
|
+
|
|
419
|
+
assert_eq!(server.worker_threads, 1);
|
|
420
|
+
assert_eq!(server.work_queue_capacity, 1);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#[test]
|
|
424
|
+
fn limited_line_reader_rejects_oversized_messages() {
|
|
425
|
+
let mut reader = Cursor::new(b"12345\n".to_vec());
|
|
426
|
+
assert!(read_limited_line(&mut reader, 4).unwrap_err().to_string().contains("too large"));
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
fn io_error(error: io::Error) -> PluginError {
|
|
431
|
+
PluginError::new(-32000, error.to_string())
|
|
432
|
+
}
|