@easyai-canvas/canvas-plugin-sdk 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/LICENSE +5 -0
- package/README.md +22 -0
- package/bin/easyai-plugin.mjs +267 -0
- package/dist/customer-main.d.ts +1 -0
- package/dist/customer-main.js +256 -0
- package/dist/protocol/index.d.ts +295 -0
- package/dist/protocol/index.js +454 -0
- package/dist/runtime/auth.d.ts +1 -0
- package/dist/runtime/auth.js +68 -0
- package/dist/runtime/engine.d.ts +49 -0
- package/dist/runtime/engine.js +296 -0
- package/dist/runtime/host-files.d.ts +16 -0
- package/dist/runtime/host-files.js +208 -0
- package/dist/runtime/instance-lock.d.ts +1 -0
- package/dist/runtime/instance-lock.js +52 -0
- package/dist/runtime/package-runtime.d.ts +44 -0
- package/dist/runtime/package-runtime.js +361 -0
- package/dist/runtime/packaged-plugins.d.ts +14 -0
- package/dist/runtime/packaged-plugins.js +58 -0
- package/dist/runtime/plugin-config.d.ts +4 -0
- package/dist/runtime/plugin-config.js +31 -0
- package/dist/runtime/store.d.ts +48 -0
- package/dist/runtime/store.js +151 -0
- package/dist/runtime/worker.d.ts +1 -0
- package/dist/runtime/worker.js +38 -0
- package/dist/sdk/frontend.d.ts +62 -0
- package/dist/sdk/frontend.js +40 -0
- package/dist/sdk/index.d.ts +38 -0
- package/dist/sdk/index.js +10 -0
- package/dist/studio/index.html +649 -0
- package/dist/studio/remote-connector.d.ts +60 -0
- package/dist/studio/remote-connector.js +295 -0
- package/package.json +45 -0
- package/tools/build-plugin-package.mjs +99 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
Copyright (c) EasyAI. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This package is publicly available for download. Use, modification, and
|
|
4
|
+
redistribution are permitted only under a separate EasyAI customer agreement.
|
|
5
|
+
The availability of this package on npm does not grant a license by itself.
|
package/README.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# EasyAI 画布插件 SDK
|
|
2
|
+
|
|
3
|
+
此包提供画布插件的公开协议、业务声明 API、Vue 组件上下文、项目脚手架、构建工具与独立服务运行时。插件以管理员审核的受信代码运行。
|
|
4
|
+
|
|
5
|
+
SDK 为商业专有软件。npm 公开下载不自动授予使用或再分发许可,具体权限以 EasyAI 客户协议为准。
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @easyai-canvas/canvas-plugin-sdk init my-plugin
|
|
9
|
+
cd my-plugin
|
|
10
|
+
npm run dev
|
|
11
|
+
npm run test:contract
|
|
12
|
+
npm run build
|
|
13
|
+
npm start
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`init --template node|action|combined` 选择项目类型;`--no-install` 跳过依赖安装。开发工作台位于 `http://localhost:4310/studio`,由浏览器完成 EasyAI 平台授权。`npm run build` 输出 `dist/plugin.zip`,可由管理员导入平台托管,或用 `npm start` 运行独立插件服务。
|
|
17
|
+
|
|
18
|
+
平台托管只接纳运行不超过 20 秒的节点任务;需要长任务、GPU 或自有基础设施时使用独立服务。构建还会生成 `dist/plugin.zip.sha256` 供管理员核对。独立服务生产环境必须提供 `PLUGIN_SERVICE_SIGNING_SECRET`、与平台连接 ID 一致的 `PLUGIN_SERVICE_AUDIENCE` 和持久化的 `PLUGIN_DATA_DIR`。业务密钥放在插件配置或部署环境,不写入前端组件。
|
|
19
|
+
|
|
20
|
+
托管节点暂不支持文件和画布宿主能力。管理员导入 ZIP 后默认停用,审核插件代码、声明能力和版本摘要后启用。版本切换不会改变已提交任务使用的包摘要。
|
|
21
|
+
|
|
22
|
+
公开导入路径:`@easyai-canvas/canvas-plugin-sdk`、`@easyai-canvas/canvas-plugin-sdk/protocol`、`@easyai-canvas/canvas-plugin-sdk/ui`。客户项目不需访问 EasyAI 画布源码。
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { existsSync, watch } from 'node:fs';
|
|
5
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const packageRoot = fileURLToPath(new URL('..', import.meta.url));
|
|
11
|
+
const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'));
|
|
12
|
+
const runtimeEntry = join(packageRoot, 'dist/customer-main.js');
|
|
13
|
+
const builderEntry = join(packageRoot, 'tools/build-plugin-package.mjs');
|
|
14
|
+
const cwd = process.cwd();
|
|
15
|
+
const command = process.argv[2];
|
|
16
|
+
const args = process.argv.slice(3);
|
|
17
|
+
|
|
18
|
+
function fail(message) { throw new Error(message); }
|
|
19
|
+
function nodeVersion() {
|
|
20
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
21
|
+
if (major !== 22 || minor < 22) fail('请使用 Node 22.22.0 或更新的 Node 22 版本');
|
|
22
|
+
}
|
|
23
|
+
function safeName(value) {
|
|
24
|
+
const name = value.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
25
|
+
if (!name || name.length > 50) fail('项目目录名需包含英文字母或数字,且不超过 50 个字符');
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
28
|
+
function pluginSource(id, title, template) {
|
|
29
|
+
const node = template !== 'action';
|
|
30
|
+
const action = template !== 'node';
|
|
31
|
+
return `import { definePlugin, defineNode } from '${metadata.name}';
|
|
32
|
+
import { PROTOCOL } from '${metadata.name}/protocol';
|
|
33
|
+
|
|
34
|
+
export default definePlugin({
|
|
35
|
+
manifest: {
|
|
36
|
+
protocol: PROTOCOL, id: '${id}', name: '${title}', release: '1',
|
|
37
|
+
contributes: {
|
|
38
|
+
${node ? `nodes: [defineNode({
|
|
39
|
+
type: '${id}.echo', name: '文本回显', execution: 'remote',
|
|
40
|
+
inputs: [], outputs: [{ id: 'out', type: 'text' }],
|
|
41
|
+
parameters: [{ key: 'text', label: '文本', type: 'string', default: '你好,画布' }],
|
|
42
|
+
ui: { preview: 'EchoPreview', panel: 'EchoPanel' }, timeoutMs: 20000,
|
|
43
|
+
})],` : ''}
|
|
44
|
+
${action ? `actions: [{
|
|
45
|
+
id: 'select-image', name: '添加图片链接', action: 'asset-selection',
|
|
46
|
+
presentation: 'dialog', mediaTypes: ['image'], maxCount: 1,
|
|
47
|
+
ui: { component: 'ImageSelector' }, menu: { group: 'resources' },
|
|
48
|
+
}],` : ''}
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
handlers: {${node ? `
|
|
52
|
+
'${id}.echo': request => ({ outputs: { out: {
|
|
53
|
+
type: 'text', payload: { items: [{ content: String(request.parameters.text ?? '') }] },
|
|
54
|
+
} } }),` : ''}
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
function uiSource(template) {
|
|
60
|
+
const node = template !== 'action';
|
|
61
|
+
const action = template !== 'node';
|
|
62
|
+
return `import { defineComponent, h, ref } from 'vue';
|
|
63
|
+
${node ? `import { usePluginNode } from '${metadata.name}/ui';` : ''}
|
|
64
|
+
${action ? `import { usePluginAction } from '${metadata.name}/ui';` : ''}
|
|
65
|
+
|
|
66
|
+
${node ? `export const EchoPreview = defineComponent({ setup() {
|
|
67
|
+
const ctx = usePluginNode();
|
|
68
|
+
return () => h('p', String((ctx.output.value?.payload.items as Array<{ content: string }> | undefined)?.[0]?.content ?? '等待执行'));
|
|
69
|
+
} });
|
|
70
|
+
|
|
71
|
+
export const EchoPanel = defineComponent({ setup() {
|
|
72
|
+
const ctx = usePluginNode();
|
|
73
|
+
return () => h('div', [
|
|
74
|
+
h('input', { value: String(ctx.parameters.value.text ?? ''),
|
|
75
|
+
onInput: (event: Event) => ctx.updateParameters({ text: (event.target as HTMLInputElement).value }) }),
|
|
76
|
+
h('button', { onClick: () => ctx.execute() }, '运行'),
|
|
77
|
+
]);
|
|
78
|
+
} });` : ''}
|
|
79
|
+
${action ? `export const ImageSelector = defineComponent({ setup() {
|
|
80
|
+
const ctx = usePluginAction();
|
|
81
|
+
const url = ref('');
|
|
82
|
+
const error = ref('');
|
|
83
|
+
return () => h('div', [
|
|
84
|
+
h('label', ['图片 HTTPS 地址', h('input', { value: url.value,
|
|
85
|
+
onInput: (event: Event) => { url.value = (event.target as HTMLInputElement).value; } })]),
|
|
86
|
+
h('button', { onClick: async () => {
|
|
87
|
+
try { await ctx.submit([{ type: 'image', url: url.value }]); }
|
|
88
|
+
catch (cause) { error.value = cause instanceof Error ? cause.message : '添加失败'; }
|
|
89
|
+
} }, '添加到画布'),
|
|
90
|
+
error.value ? h('p', { role: 'alert' }, error.value) : null,
|
|
91
|
+
]);
|
|
92
|
+
} });` : ''}
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
95
|
+
function testSource(id, template) {
|
|
96
|
+
const node = template !== 'action';
|
|
97
|
+
const action = template !== 'node';
|
|
98
|
+
return `import test from 'node:test';
|
|
99
|
+
import assert from 'node:assert/strict';
|
|
100
|
+
import plugin from '../src/plugin.ts';
|
|
101
|
+
import { validateManifest, validateResult } from '${metadata.name}/protocol';
|
|
102
|
+
|
|
103
|
+
test('插件声明和业务处理器符合画布契约', async () => {
|
|
104
|
+
const manifest = validateManifest(plugin.manifest);
|
|
105
|
+
assert.equal(manifest.id, '${id}');
|
|
106
|
+
${node ? `const definition = manifest.contributes.nodes![0]!;
|
|
107
|
+
const result = await plugin.handlers[definition.type]!({
|
|
108
|
+
executionId: 'contract-test', pluginId: manifest.id, nodeType: definition.type,
|
|
109
|
+
parameters: { text: '测试' }, inputsByPort: {}, context: { nodeId: 'n1', userId: 'u1' },
|
|
110
|
+
}, {} as never);
|
|
111
|
+
const items = validateResult(result, definition).outputs.out.payload.items as Array<{ content: string }>;
|
|
112
|
+
assert.equal(items[0]?.content, '测试');` : ''}
|
|
113
|
+
${action ? `assert.equal(manifest.contributes.actions?.[0]?.action, 'asset-selection');` : ''}
|
|
114
|
+
});
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
async function init() {
|
|
118
|
+
const templateArg = args.find(arg => arg.startsWith('--template='));
|
|
119
|
+
const template = templateArg?.slice('--template='.length) ?? (args.includes('--template') ? args[args.indexOf('--template') + 1] : 'node');
|
|
120
|
+
if (!['node', 'action', 'combined'].includes(template)) fail('模板只支持 node、action 或 combined');
|
|
121
|
+
const targetArg = args.find((arg, index) => !arg.startsWith('--') && args[index - 1] !== '--template');
|
|
122
|
+
if (!targetArg) fail('用法:easyai-plugin init <目录> [--template node|action|combined] [--no-install]');
|
|
123
|
+
const target = resolve(targetArg);
|
|
124
|
+
if (existsSync(target) && (await readdir(target)).length) fail('目标目录非空,不会覆盖已有文件');
|
|
125
|
+
const slug = safeName(basename(target));
|
|
126
|
+
const id = `customer.${slug.replaceAll('-', '.')}`;
|
|
127
|
+
const title = slug.replaceAll('-', ' ');
|
|
128
|
+
await mkdir(join(target, 'src'), { recursive: true });
|
|
129
|
+
await mkdir(join(target, 'test'), { recursive: true });
|
|
130
|
+
const spec = process.env.EASYAI_SDK_INSTALL_SPEC ?? metadata.version;
|
|
131
|
+
const project = {
|
|
132
|
+
name: slug, version: '0.1.0', private: true, type: 'module',
|
|
133
|
+
scripts: { dev: 'easyai-plugin dev', 'test:contract': 'easyai-plugin test:contract',
|
|
134
|
+
build: 'easyai-plugin build', start: 'easyai-plugin start', doctor: 'easyai-plugin doctor' },
|
|
135
|
+
dependencies: { [metadata.name]: spec, vue: '3.5.21' },
|
|
136
|
+
devDependencies: { '@types/node': '22.20.1', tsx: '4.20.5', typescript: '5.9.2' },
|
|
137
|
+
};
|
|
138
|
+
const files = {
|
|
139
|
+
'package.json': `${JSON.stringify(project, null, 2)}\n`,
|
|
140
|
+
'src/plugin.ts': pluginSource(id, title, template),
|
|
141
|
+
'src/ui.ts': uiSource(template),
|
|
142
|
+
'test/plugin.test.ts': testSource(id, template),
|
|
143
|
+
'.gitignore': 'node_modules/\ndist/\n.easyai-plugin/\n.env\n',
|
|
144
|
+
'.env.example': 'PORT=4310\nPLUGIN_DATA_DIR=./.easyai-plugin\nPLUGIN_AUTH_MODE=token\nPLUGIN_SERVICE_AUDIENCE=customer-plugin\n# PLUGIN_SERVICE_SIGNING_SECRET=由部署环境安全注入\n',
|
|
145
|
+
'Dockerfile': 'FROM node:22.22.0\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev\nCOPY dist/plugin.zip ./dist/plugin.zip\nENV NODE_ENV=production PLUGIN_BUILTINS=0\nCMD ["npm", "start"]\n',
|
|
146
|
+
'README.md': `# ${title}\n\n运行 npm run dev,打开 http://localhost:4310/studio,在浏览器中连接 EasyAI 画布。\n运行 npm run test:contract && npm run build 生成 dist/plugin.zip 和 dist/plugin.zip.sha256。\n独立部署时配置插件服务签名密钥并运行 npm start;平台托管时由管理员审核并导入 ZIP。\n`,
|
|
147
|
+
'tsconfig.json': JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'NodeNext', moduleResolution: 'NodeNext', strict: true, skipLibCheck: true, allowImportingTsExtensions: true, lib: ['ES2022', 'DOM'], types: ['node'], noEmit: true }, include: ['src/**/*.ts', 'test/**/*.ts'] }, null, 2) + '\n',
|
|
148
|
+
};
|
|
149
|
+
for (const [path, content] of Object.entries(files)) await writeFile(join(target, path), content, { flag: 'wx' });
|
|
150
|
+
console.log(`已创建 ${target}(${template})`);
|
|
151
|
+
if (!args.includes('--no-install')) await run('npm', ['install'], target);
|
|
152
|
+
console.log(`下一步:cd ${target} && npm run dev`);
|
|
153
|
+
}
|
|
154
|
+
async function run(executable, argv, directory = cwd, env = process.env) {
|
|
155
|
+
await new Promise((resolvePromise, reject) => {
|
|
156
|
+
const child = spawn(executable, argv, { cwd: directory, env, stdio: 'inherit' });
|
|
157
|
+
child.once('error', reject);
|
|
158
|
+
child.once('exit', code => code === 0 ? resolvePromise() : reject(new Error(`${executable} 退出码 ${code}`)));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async function build() {
|
|
162
|
+
nodeVersion();
|
|
163
|
+
for (const path of ['src/plugin.ts', 'src/ui.ts']) if (!existsSync(join(cwd, path))) fail(`缺少 ${path}`);
|
|
164
|
+
const { buildPluginPackage } = await import(builderEntry);
|
|
165
|
+
const output = join(cwd, 'dist/plugin.zip');
|
|
166
|
+
await buildPluginPackage({ serverEntry: join(cwd, 'src/plugin.ts'), uiEntry: join(cwd, 'src/ui.ts'),
|
|
167
|
+
globalName: `EasyAIPlugin${createHash('sha256').update(cwd).digest('hex').slice(0, 12)}`, output });
|
|
168
|
+
const bytes = await readFile(output);
|
|
169
|
+
const { inspectPluginPackage } = await import(join(packageRoot, 'dist/runtime/package-runtime.js'));
|
|
170
|
+
await inspectPluginPackage(bytes);
|
|
171
|
+
const digest = createHash('sha256').update(bytes).digest('hex');
|
|
172
|
+
await writeFile(`${output}.sha256`, `${digest} plugin.zip\n`);
|
|
173
|
+
console.log(`SHA-256 ${digest}(dist/plugin.zip.sha256)`);
|
|
174
|
+
}
|
|
175
|
+
function serviceEnv(dev) {
|
|
176
|
+
const zip = join(cwd, 'dist/plugin.zip');
|
|
177
|
+
if (!existsSync(zip)) fail('缺少 dist/plugin.zip;请先运行 npm run build');
|
|
178
|
+
if (!existsSync(runtimeEntry)) fail('SDK 运行时未构建');
|
|
179
|
+
if (!dev && !process.env.PLUGIN_SERVICE_SIGNING_SECRET) fail('生产服务需要 PLUGIN_SERVICE_SIGNING_SECRET');
|
|
180
|
+
return { ...process.env, NODE_ENV: dev ? 'development' : 'production',
|
|
181
|
+
PLUGIN_BUILTINS: '0', PLUGIN_PACKAGES: JSON.stringify([zip]),
|
|
182
|
+
PLUGIN_DATA_DIR: process.env.PLUGIN_DATA_DIR ?? join(cwd, '.easyai-plugin'),
|
|
183
|
+
...(dev ? { PLUGIN_AUTH_MODE: 'local', PLUGIN_BIND: '127.0.0.1', PLUGIN_DEV_WATCH: '1' } : {}) };
|
|
184
|
+
}
|
|
185
|
+
async function start() {
|
|
186
|
+
nodeVersion();
|
|
187
|
+
await run(process.execPath, [runtimeEntry], cwd, serviceEnv(false));
|
|
188
|
+
}
|
|
189
|
+
async function dev() {
|
|
190
|
+
await build();
|
|
191
|
+
let child;
|
|
192
|
+
const dataDir = process.env.PLUGIN_DATA_DIR ?? join(cwd, '.easyai-plugin');
|
|
193
|
+
await mkdir(dataDir, { recursive: true });
|
|
194
|
+
const status = async (state, message = '') => writeFile(join(dataDir, 'dev-build-status.json'),
|
|
195
|
+
JSON.stringify({ state, message: message.slice(0, 500), updatedAt: new Date().toISOString() }));
|
|
196
|
+
const launch = () => {
|
|
197
|
+
child = spawn(process.execPath, [runtimeEntry], { cwd, env: serviceEnv(true), stdio: 'inherit' });
|
|
198
|
+
child.on('error', error => console.error(error));
|
|
199
|
+
};
|
|
200
|
+
await status('ready');
|
|
201
|
+
launch();
|
|
202
|
+
console.log(`真实画布调试工作台:http://localhost:${process.env.PORT ?? 4310}/studio`);
|
|
203
|
+
let timer;
|
|
204
|
+
let building = false;
|
|
205
|
+
const sourceWatcher = watch(join(cwd, 'src'), { recursive: true }, () => {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
timer = setTimeout(async () => {
|
|
208
|
+
if (building) return;
|
|
209
|
+
building = true;
|
|
210
|
+
await status('building');
|
|
211
|
+
try {
|
|
212
|
+
await build();
|
|
213
|
+
child?.kill('SIGTERM');
|
|
214
|
+
await new Promise(resolvePromise => child?.once('exit', resolvePromise));
|
|
215
|
+
launch();
|
|
216
|
+
await status('ready');
|
|
217
|
+
} catch (error) {
|
|
218
|
+
await status('error', String(error));
|
|
219
|
+
console.error(error);
|
|
220
|
+
} finally { building = false; }
|
|
221
|
+
}, 350);
|
|
222
|
+
});
|
|
223
|
+
const close = () => { sourceWatcher.close(); child?.kill('SIGTERM'); process.exit(0); };
|
|
224
|
+
process.once('SIGINT', close);
|
|
225
|
+
process.once('SIGTERM', close);
|
|
226
|
+
}
|
|
227
|
+
async function contractTest() {
|
|
228
|
+
await build();
|
|
229
|
+
const tests = (await readdir(join(cwd, 'test'))).filter(name => name.endsWith('.test.ts'));
|
|
230
|
+
if (!tests.length) fail('缺少 test/*.test.ts 契约测试');
|
|
231
|
+
await run(process.execPath, ['--import', 'tsx', '--test', ...tests.map(name => join(cwd, 'test', name))]);
|
|
232
|
+
}
|
|
233
|
+
async function doctor() {
|
|
234
|
+
const checks = [];
|
|
235
|
+
const [major, minor] = process.versions.node.split('.').map(Number);
|
|
236
|
+
checks.push({ name: 'Node 22.22+', ok: major === 22 && minor >= 22 });
|
|
237
|
+
checks.push({ name: '插件入口', ok: existsSync(join(cwd, 'src/plugin.ts')) });
|
|
238
|
+
checks.push({ name: 'UI 入口', ok: existsSync(join(cwd, 'src/ui.ts')) });
|
|
239
|
+
const port = Number(process.env.PORT ?? 4310);
|
|
240
|
+
const available = await new Promise(resolvePromise => {
|
|
241
|
+
const server = net.createServer();
|
|
242
|
+
server.once('error', () => resolvePromise(false));
|
|
243
|
+
server.listen(port, '127.0.0.1', () => server.close(() => resolvePromise(true)));
|
|
244
|
+
});
|
|
245
|
+
checks.push({ name: `本地端口 ${port}`, ok: available });
|
|
246
|
+
const platform = args.find(arg => arg.startsWith('--platform='))?.slice('--platform='.length);
|
|
247
|
+
if (platform) {
|
|
248
|
+
const url = new URL('/.well-known/easyai-plugin-debug', platform);
|
|
249
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(8000) }).catch(() => null);
|
|
250
|
+
checks.push({ name: '平台调试连接', ok: response?.ok === true });
|
|
251
|
+
}
|
|
252
|
+
for (const check of checks) console.log(`${check.ok ? '✓' : '✗'} ${check.name}`);
|
|
253
|
+
if (checks.some(check => !check.ok)) process.exitCode = 1;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
if (command === 'init') await init();
|
|
258
|
+
else if (command === 'dev') await dev();
|
|
259
|
+
else if (command === 'test:contract') await contractTest();
|
|
260
|
+
else if (command === 'build') await build();
|
|
261
|
+
else if (command === 'start') await start();
|
|
262
|
+
else if (command === 'doctor') await doctor();
|
|
263
|
+
else fail('用法:easyai-plugin <init|dev|test:contract|build|start|doctor>');
|
|
264
|
+
} catch (error) {
|
|
265
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
266
|
+
process.exitCode = 1;
|
|
267
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { Runtime } from './runtime/engine.js';
|
|
6
|
+
import { loadPackagedPlugins } from './runtime/packaged-plugins.js';
|
|
7
|
+
import { authenticate } from './runtime/auth.js';
|
|
8
|
+
import { RemoteStudioConnector } from './studio/remote-connector.js';
|
|
9
|
+
import { ContractError, object, validatePluginRuntimeContext } from './protocol/index.js';
|
|
10
|
+
const dataDir = resolve(process.env.PLUGIN_DATA_DIR ?? './.easyai-plugin');
|
|
11
|
+
const port = Number(process.env.PORT ?? 4310);
|
|
12
|
+
const packaged = await loadPackagedPlugins(JSON.parse(process.env.PLUGIN_PACKAGES ?? '[]'), join(dataDir, 'packages'));
|
|
13
|
+
const runtime = new Runtime(join(dataDir, 'runtime.sqlite'), packaged.plugins, packaged.presentations, packaged.executionEntries);
|
|
14
|
+
const studio = new RemoteStudioConnector();
|
|
15
|
+
if (process.env.PLUGIN_DEV_WATCH === '1') {
|
|
16
|
+
for (const plugin of packaged.plugins) {
|
|
17
|
+
const entry = runtime.catalog().plugins.find(item => item.manifest.id === plugin.manifest.id);
|
|
18
|
+
if (entry && !entry.enabled)
|
|
19
|
+
runtime.configure(plugin.manifest.id, { enabled: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function send(response, status, value, type = 'application/json') {
|
|
23
|
+
response.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
|
|
24
|
+
response.end(type === 'application/json' ? JSON.stringify(value) : value);
|
|
25
|
+
}
|
|
26
|
+
function redirect(response, path) {
|
|
27
|
+
response.writeHead(302, { Location: path });
|
|
28
|
+
response.end();
|
|
29
|
+
}
|
|
30
|
+
async function body(request) {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let size = 0;
|
|
33
|
+
for await (const part of request) {
|
|
34
|
+
const chunk = Buffer.from(part);
|
|
35
|
+
size += chunk.length;
|
|
36
|
+
if (size > 2_000_000)
|
|
37
|
+
throw new ContractError('请求内容超过限制', 413);
|
|
38
|
+
chunks.push(chunk);
|
|
39
|
+
}
|
|
40
|
+
if (!chunks.length)
|
|
41
|
+
return {};
|
|
42
|
+
try {
|
|
43
|
+
return object(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new ContractError('请求 JSON 无效', 400);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function localStudio(request) {
|
|
50
|
+
if (process.env.NODE_ENV === 'production' || !['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress ?? ''))
|
|
51
|
+
throw new ContractError('本地调试工作台仅供本机开发使用', 403);
|
|
52
|
+
}
|
|
53
|
+
async function devBuildStatus() {
|
|
54
|
+
if (process.env.PLUGIN_DEV_WATCH !== '1' || process.env.NODE_ENV === 'production')
|
|
55
|
+
return undefined;
|
|
56
|
+
return readFile(join(dataDir, 'dev-build-status.json'), 'utf8')
|
|
57
|
+
.then(value => JSON.parse(value)).catch(() => ({ state: 'ready' }));
|
|
58
|
+
}
|
|
59
|
+
async function studioCatalog() {
|
|
60
|
+
const catalog = runtime.catalog();
|
|
61
|
+
const status = studio.status();
|
|
62
|
+
const devBuild = await devBuildStatus();
|
|
63
|
+
return {
|
|
64
|
+
protocol: 'easyai.plugin-debug/v1', devBuild, platformOrigin: status.platformOrigin,
|
|
65
|
+
connectionId: status.connectionId, connectionPhase: status.phase,
|
|
66
|
+
plugins: catalog.plugins.map(entry => {
|
|
67
|
+
const bundle = entry.uiBundle;
|
|
68
|
+
const origin = `http://localhost:${port}`;
|
|
69
|
+
const uiBundle = bundle ? { ...bundle, url: new URL(bundle.url, origin).href,
|
|
70
|
+
...(bundle.cssUrl ? { cssUrl: new URL(bundle.cssUrl, origin).href } : {}) } : undefined;
|
|
71
|
+
return {
|
|
72
|
+
id: entry.manifest.id, name: entry.manifest.name, release: entry.manifest.release,
|
|
73
|
+
enabled: entry.enabled, manifest: entry.manifest, uiBundle,
|
|
74
|
+
nodes: entry.manifest.contributes.nodes?.length ?? 0,
|
|
75
|
+
actions: entry.manifest.contributes.actions?.length ?? 0,
|
|
76
|
+
package: packaged.downloads[entry.manifest.id] ? { mode: 'download' } : null,
|
|
77
|
+
buildKey: createHash('sha256').update(JSON.stringify({ revision: catalog.revision, entry })).digest('hex'),
|
|
78
|
+
};
|
|
79
|
+
}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const server = createServer(async (request, response) => {
|
|
83
|
+
try {
|
|
84
|
+
const method = request.method ?? 'GET';
|
|
85
|
+
const path = new URL(request.url ?? '/', `http://localhost:${port}`).pathname;
|
|
86
|
+
const allowedOrigins = (process.env.PLUGIN_ALLOWED_ORIGINS ?? (process.env.NODE_ENV === 'production'
|
|
87
|
+
? '' : 'http://localhost:3000,http://127.0.0.1:3000')).split(',').map(s => s.trim()).filter(Boolean);
|
|
88
|
+
if (request.headers.origin && allowedOrigins.includes(request.headers.origin)) {
|
|
89
|
+
response.setHeader('Access-Control-Allow-Origin', request.headers.origin);
|
|
90
|
+
response.setHeader('Vary', 'Origin');
|
|
91
|
+
response.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
92
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, OPTIONS');
|
|
93
|
+
}
|
|
94
|
+
if (method === 'OPTIONS') {
|
|
95
|
+
response.writeHead(204);
|
|
96
|
+
response.end();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (path === '/') {
|
|
100
|
+
redirect(response, process.env.NODE_ENV === 'production' ? '/health' : '/studio');
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (method === 'GET' && path === '/health') {
|
|
104
|
+
send(response, 200, { ready: true, protocol: 'easyai.plugin/v1' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const asset = /^\/packages\/([a-f0-9]{64})\/(ui\/plugin\.(?:js|css))$/.exec(path);
|
|
108
|
+
if (method === 'GET' && asset) {
|
|
109
|
+
const item = Object.values(packaged.presentations).find((value) => [value.uiBundle?.url, value.uiBundle?.cssUrl].includes(path));
|
|
110
|
+
if (!item)
|
|
111
|
+
throw new ContractError('插件界面资源不存在', 404);
|
|
112
|
+
const bytes = await readFile(join(dataDir, 'packages', asset[1], asset[2]));
|
|
113
|
+
response.writeHead(200, { 'Content-Type': path.endsWith('.css') ? 'text/css' : 'text/javascript',
|
|
114
|
+
'Cache-Control': 'public, max-age=31536000, immutable' });
|
|
115
|
+
response.end(bytes);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (path.startsWith('/v1/')) {
|
|
119
|
+
try {
|
|
120
|
+
await authenticate(request.headers.authorization, method === 'PATCH' || path.endsWith('/check'));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new ContractError('认证失败', 401, 'UNAUTHORIZED');
|
|
124
|
+
}
|
|
125
|
+
if (method === 'GET' && path === '/v1/catalog') {
|
|
126
|
+
send(response, 200, { ...runtime.catalog(), devBuild: await devBuildStatus() });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const plugin = /^\/v1\/plugins\/([A-Za-z0-9_.-]+)$/.exec(path);
|
|
130
|
+
if (method === 'PATCH' && plugin) {
|
|
131
|
+
send(response, 200, runtime.configure(plugin[1], await body(request)));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const check = /^\/v1\/plugins\/([A-Za-z0-9_.-]+)\/check$/.exec(path);
|
|
135
|
+
if (method === 'POST' && check) {
|
|
136
|
+
send(response, 200, await runtime.checkConfig(check[1]));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const query = /^\/v1\/plugins\/([A-Za-z0-9_.-]+)\/queries\/([A-Za-z0-9_.-]+)$/.exec(path);
|
|
140
|
+
if (method === 'POST' && query) {
|
|
141
|
+
const input = await body(request);
|
|
142
|
+
const abort = new AbortController();
|
|
143
|
+
const disconnected = () => { if (!response.writableEnded)
|
|
144
|
+
abort.abort(); };
|
|
145
|
+
response.once('close', disconnected);
|
|
146
|
+
try {
|
|
147
|
+
send(response, 200, await runtime.query(query[1], query[2], object(input.input, 'input'), validatePluginRuntimeContext(input.context), abort.signal));
|
|
148
|
+
}
|
|
149
|
+
finally {
|
|
150
|
+
response.removeListener('close', disconnected);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const normalize = /^\/v1\/plugins\/([A-Za-z0-9_.-]+)\/normalize$/.exec(path);
|
|
155
|
+
if (method === 'POST' && normalize) {
|
|
156
|
+
const input = await body(request);
|
|
157
|
+
send(response, 200, runtime.normalize(normalize[1], String(input.nodeType ?? ''), object(input.parameters, 'parameters')));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (method === 'POST' && path === '/v1/executions') {
|
|
161
|
+
send(response, 200, runtime.submit(await body(request)));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const execution = /^\/v1\/executions\/([A-Za-z0-9_.-]+)$/.exec(path);
|
|
165
|
+
if (method === 'GET' && execution) {
|
|
166
|
+
const record = runtime.store.getExecution(execution[1]);
|
|
167
|
+
if (!record)
|
|
168
|
+
throw new ContractError('任务不存在', 404, 'TASK_NOT_FOUND');
|
|
169
|
+
send(response, 200, record);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const cancel = /^\/v1\/executions\/([A-Za-z0-9_.-]+)\/cancel$/.exec(path);
|
|
173
|
+
if (method === 'POST' && cancel) {
|
|
174
|
+
send(response, 200, runtime.cancel(cancel[1]));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (path.startsWith('/studio')) {
|
|
179
|
+
localStudio(request);
|
|
180
|
+
if (method === 'GET' && path === '/studio') {
|
|
181
|
+
if (request.headers.host?.startsWith('127.0.0.1:')) {
|
|
182
|
+
redirect(response, `http://localhost:${port}/studio`);
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const html = await readFile(new URL('./studio/index.html', import.meta.url), 'utf8');
|
|
186
|
+
send(response, 200, html, 'text/html; charset=utf-8');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (method === 'GET' && path === '/studio/catalog') {
|
|
190
|
+
send(response, 200, await studioCatalog());
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (method === 'GET' && path === '/studio/status') {
|
|
194
|
+
send(response, 200, studio.status());
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (method === 'POST' && path === '/studio/connect') {
|
|
198
|
+
const input = await body(request);
|
|
199
|
+
send(response, 200, await studio.begin(String(input.platformUrl ?? '')));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (method === 'DELETE' && path === '/studio/connect') {
|
|
203
|
+
await studio.disconnect();
|
|
204
|
+
send(response, 200, { disconnected: true });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (method === 'POST' && path === '/studio/open') {
|
|
208
|
+
const input = await body(request);
|
|
209
|
+
send(response, 200, await studio.openDebugSession(String(input.pluginId ?? '')));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (method === 'POST' && path === '/studio/embed-ticket') {
|
|
213
|
+
const input = await body(request);
|
|
214
|
+
send(response, 200, await studio.createEmbedTicket(String(input.debugSessionId ?? ''), String(input.codeChallenge ?? '')));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (method === 'GET' && path === '/studio/oauth/callback') {
|
|
218
|
+
const url = new URL(request.url ?? '', `http://localhost:${port}`);
|
|
219
|
+
await studio.complete(url.searchParams.get('code') ?? '', url.searchParams.get('state') ?? '');
|
|
220
|
+
send(response, 200, `<!doctype html><meta charset="utf-8"><p>连接成功,可以返回插件工作台。</p><script>window.opener?.postMessage({type:'plugin-debug-auth-complete'},'http://localhost:${port}');window.close()</script>`, 'text/html; charset=utf-8');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const download = /^\/studio\/packages\/([A-Za-z0-9_.-]+)\/download$/.exec(path);
|
|
224
|
+
if (method === 'POST' && download) {
|
|
225
|
+
const origin = request.headers.origin;
|
|
226
|
+
if (origin && ![`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://[::1]:${port}`].includes(origin))
|
|
227
|
+
throw new ContractError('插件包下载来源无效', 403, 'INVALID_ORIGIN');
|
|
228
|
+
const item = packaged.downloads[download[1]];
|
|
229
|
+
if (!item)
|
|
230
|
+
throw new ContractError('插件包不存在', 404);
|
|
231
|
+
response.writeHead(200, { 'Content-Type': 'application/zip', 'Cache-Control': 'no-store',
|
|
232
|
+
'Content-Disposition': `attachment; filename="${download[1]}.zip"` });
|
|
233
|
+
response.end(item.bytes);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
throw new ContractError('接口不存在', 404);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
const status = error instanceof ContractError ? error.status : 500;
|
|
241
|
+
send(response, status, { error: status === 500 ? '插件服务异常' : error.message,
|
|
242
|
+
code: error instanceof ContractError ? error.code : 'PLUGIN_ERROR' });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
server.listen(port, process.env.PLUGIN_BIND ?? (process.env.NODE_ENV === 'production' ? '0.0.0.0' : '127.0.0.1'));
|
|
246
|
+
let shuttingDown = false;
|
|
247
|
+
for (const signal of ['SIGINT', 'SIGTERM'])
|
|
248
|
+
process.on(signal, async () => {
|
|
249
|
+
if (shuttingDown)
|
|
250
|
+
return;
|
|
251
|
+
shuttingDown = true;
|
|
252
|
+
server.close();
|
|
253
|
+
await studio.disconnect();
|
|
254
|
+
await runtime.close();
|
|
255
|
+
process.exit(0);
|
|
256
|
+
});
|