@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.
@@ -0,0 +1,60 @@
1
+ export declare function validatePlatformUrl(raw: string): string;
2
+ export declare function allowedPluginRequest(method: string, path: string): boolean;
3
+ export declare class RemoteStudioConnector {
4
+ private discovery?;
5
+ private platformOrigin;
6
+ private state;
7
+ private verifier;
8
+ private deviceId;
9
+ private credentials?;
10
+ private ticket?;
11
+ private socket?;
12
+ private reconnectTimer?;
13
+ private heartbeatTimer?;
14
+ private closed;
15
+ private phase;
16
+ private error;
17
+ status(): {
18
+ phase: "idle" | "authorizing" | "connecting" | "connected" | "disconnected";
19
+ error: string;
20
+ platformOrigin: string;
21
+ connectionId: string;
22
+ user: {
23
+ id: string;
24
+ name?: string;
25
+ username?: string;
26
+ } | null;
27
+ };
28
+ begin(rawPlatformUrl: string): Promise<{
29
+ authorizeUrl: string;
30
+ platformOrigin: string;
31
+ }>;
32
+ complete(code: string, state: string): Promise<{
33
+ phase: "idle" | "authorizing" | "connecting" | "connected" | "disconnected";
34
+ error: string;
35
+ platformOrigin: string;
36
+ connectionId: string;
37
+ user: {
38
+ id: string;
39
+ name?: string;
40
+ username?: string;
41
+ } | null;
42
+ }>;
43
+ private post;
44
+ private renewAccessToken;
45
+ openDebugSession(pluginId: string): Promise<{
46
+ sessionId: string;
47
+ projectId: string;
48
+ connectionId: string;
49
+ active: boolean;
50
+ }>;
51
+ createEmbedTicket(debugSessionId: string, codeChallenge: string): Promise<{
52
+ ticket: string;
53
+ expiresAt: string;
54
+ }>;
55
+ private openSocket;
56
+ private forward;
57
+ private uploadCatalogAssets;
58
+ private scheduleReconnect;
59
+ disconnect(): Promise<void>;
60
+ }
@@ -0,0 +1,295 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ const CLIENT_ID = 'easyai:canvas-plugin-dev';
3
+ const studioOrigin = () => `http://localhost:${Number(process.env.PORT ?? 4310)}`;
4
+ const callbackUrl = () => `${studioOrigin()}/studio/oauth/callback`;
5
+ const MAX_RPC_BODY_BYTES = 2_000_000;
6
+ export function validatePlatformUrl(raw) {
7
+ const url = new URL(raw.trim());
8
+ if (url.username || url.password || url.pathname !== '/' || url.search || url.hash ||
9
+ (url.protocol !== 'https:' && !(url.protocol === 'http:' &&
10
+ ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))))
11
+ throw new Error('请输入画布平台首页 HTTPS 地址;本机可使用 HTTP');
12
+ return url.origin;
13
+ }
14
+ export function allowedPluginRequest(method, path) {
15
+ if (method === 'GET' && path === '/v1/catalog')
16
+ return true;
17
+ if (method === 'POST' && /^\/v1\/plugins\/[A-Za-z0-9_.-]+\/(?:queries\/[A-Za-z0-9_.-]+|normalize)$/.test(path))
18
+ return true;
19
+ if (method === 'POST' && path === '/v1/executions')
20
+ return true;
21
+ if (method === 'GET' && /^\/v1\/executions\/[A-Za-z0-9_.-]+$/.test(path))
22
+ return true;
23
+ if (method === 'POST' && /^\/v1\/executions\/[A-Za-z0-9_.-]+\/cancel$/.test(path))
24
+ return true;
25
+ return false;
26
+ }
27
+ export class RemoteStudioConnector {
28
+ discovery;
29
+ platformOrigin = '';
30
+ state = '';
31
+ verifier = '';
32
+ deviceId = randomUUID();
33
+ credentials;
34
+ ticket;
35
+ socket;
36
+ reconnectTimer;
37
+ heartbeatTimer;
38
+ closed = true;
39
+ phase = 'idle';
40
+ error = '';
41
+ status() {
42
+ return { phase: this.phase, error: this.error, platformOrigin: this.platformOrigin,
43
+ connectionId: this.ticket?.connectionId || '', user: this.credentials?.user || null };
44
+ }
45
+ async begin(rawPlatformUrl) {
46
+ await this.disconnect();
47
+ this.platformOrigin = validatePlatformUrl(rawPlatformUrl);
48
+ const response = await fetch(`${this.platformOrigin}/.well-known/easyai-plugin-debug`, {
49
+ redirect: 'error', signal: AbortSignal.timeout(8000),
50
+ });
51
+ if (!response.ok)
52
+ throw new Error(`画布平台能力检查失败(${response.status})`);
53
+ const discovery = await response.json();
54
+ if (discovery.protocol !== 'easyai.plugin-debug/v1' || !discovery.enabled)
55
+ throw new Error('此画布平台未开放插件调试');
56
+ const api = new URL(discovery.apiUrl);
57
+ const ws = new URL(discovery.wsUrl);
58
+ if (api.username || api.password || api.search || api.hash ||
59
+ (api.protocol !== 'https:' && !(api.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(api.hostname))) ||
60
+ (ws.protocol !== 'wss:' && !(ws.protocol === 'ws:' && ['localhost', '127.0.0.1', '[::1]'].includes(ws.hostname))) ||
61
+ discovery.workbenchOrigin !== studioOrigin())
62
+ throw new Error('画布平台调试地址配置无效');
63
+ this.discovery = discovery;
64
+ this.closed = false;
65
+ this.state = randomBytes(24).toString('base64url');
66
+ this.verifier = randomBytes(32).toString('base64url');
67
+ this.phase = 'authorizing';
68
+ const challenge = createHash('sha256').update(this.verifier).digest('base64url');
69
+ const authorize = new URL(`${api.toString().replace(/\/+$/, '')}/auth/client/authorize`);
70
+ for (const [key, value] of Object.entries({
71
+ response_type: 'code', client_id: CLIENT_ID, redirect_uri: callbackUrl(),
72
+ state: this.state, code_challenge: challenge, code_challenge_method: 'S256',
73
+ scope: 'plugin-debug:connect', agent_name: '画布插件开发工作台', agent_instance: this.deviceId,
74
+ }))
75
+ authorize.searchParams.set(key, value);
76
+ return { authorizeUrl: authorize.toString(), platformOrigin: this.platformOrigin };
77
+ }
78
+ async complete(code, state) {
79
+ if (!this.discovery || this.closed || this.phase !== 'authorizing' || !code || state !== this.state)
80
+ throw new Error('登录回调无效或已过期,请重新连接');
81
+ this.phase = 'connecting';
82
+ const credentials = await this.post('/auth/client/token', {
83
+ code, state, codeVerifier: this.verifier, redirectUri: callbackUrl(),
84
+ deviceId: this.deviceId, clientId: CLIENT_ID,
85
+ });
86
+ this.state = '';
87
+ this.verifier = '';
88
+ this.credentials = credentials;
89
+ this.ticket = await this.post('/canvas-workflow/plugin-debug-connectors', {}, true);
90
+ await this.openSocket();
91
+ return this.status();
92
+ }
93
+ async post(path, body, authenticated = false) {
94
+ if (!this.discovery)
95
+ throw new Error('尚未选择画布平台');
96
+ const response = await fetch(`${this.discovery.apiUrl.replace(/\/+$/, '')}${path}`, {
97
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(35_000),
98
+ headers: { 'content-type': 'application/json',
99
+ ...(authenticated && this.credentials ? { authorization: `Bearer ${this.credentials.accessToken}` } : {}) },
100
+ body: JSON.stringify(body),
101
+ });
102
+ const data = await response.json().catch(() => ({}));
103
+ if (!response.ok)
104
+ throw new Error(data.message || data.error || `平台返回 ${response.status}`);
105
+ return data;
106
+ }
107
+ async renewAccessToken() {
108
+ if (!this.credentials)
109
+ throw new Error('开发授权已失效');
110
+ if (Date.parse(this.credentials.accessTokenExpiresAt) > Date.now() + 60_000)
111
+ return;
112
+ const next = await this.post('/auth/client/refresh', {
113
+ refreshToken: this.credentials.refreshToken, sessionId: this.credentials.sessionId,
114
+ deviceId: this.deviceId, clientId: CLIENT_ID,
115
+ });
116
+ this.credentials = next;
117
+ }
118
+ async openDebugSession(pluginId) {
119
+ if (!this.ticket || this.phase !== 'connected')
120
+ throw new Error('请先连接画布平台');
121
+ await this.renewAccessToken();
122
+ return this.post(`/canvas-workflow/plugin-debug-connectors/${this.ticket.connectionId}/open`, { pluginId }, true);
123
+ }
124
+ async createEmbedTicket(debugSessionId, codeChallenge) {
125
+ if (!this.ticket || this.phase !== 'connected')
126
+ throw new Error('请先连接画布平台');
127
+ await this.renewAccessToken();
128
+ return this.post(`/canvas-workflow/plugin-debug-connectors/${this.ticket.connectionId}/embed-ticket`, { debugSessionId, codeChallenge }, true);
129
+ }
130
+ async openSocket() {
131
+ if (!this.discovery || !this.ticket || this.closed)
132
+ return;
133
+ await this.renewAccessToken();
134
+ const fresh = await this.post(`/canvas-workflow/plugin-debug-connectors/${encodeURIComponent(this.ticket.connectionId)}/ticket`, {}, true);
135
+ this.ticket = fresh;
136
+ const url = new URL(this.discovery.wsUrl);
137
+ url.searchParams.set('channel', fresh.channel);
138
+ url.searchParams.set('client_id', fresh.clientId);
139
+ this.phase = 'connecting';
140
+ await new Promise((resolve, reject) => {
141
+ const socket = new WebSocket(url.toString());
142
+ this.socket = socket;
143
+ const timer = setTimeout(() => reject(new Error('WebSocket 连接超时')), 10_000);
144
+ const fail = (error) => { clearTimeout(timer); reject(error); };
145
+ socket.onmessage = (event) => {
146
+ let message;
147
+ try {
148
+ message = JSON.parse(String(event.data));
149
+ }
150
+ catch {
151
+ return;
152
+ }
153
+ if (message.type === 'session.challenge')
154
+ socket.send(JSON.stringify({ type: 'session.initialize' }));
155
+ else if (message.type === 'session.initialized')
156
+ socket.send(JSON.stringify({
157
+ type: 'session.authenticate', method: 'ws_ticket', credential: fresh.ticket,
158
+ }));
159
+ else if (message.type === 'session.ready') {
160
+ clearTimeout(timer);
161
+ this.phase = 'connected';
162
+ this.error = '';
163
+ this.heartbeatTimer = setInterval(() => {
164
+ if (socket.readyState === WebSocket.OPEN)
165
+ socket.send('ping');
166
+ }, 25_000);
167
+ resolve();
168
+ }
169
+ else if (message.type === 'plugin.debug.request')
170
+ void this.forward(socket, message);
171
+ else if (message.type === 'session.error' || message.type === 'session.auth.rejected')
172
+ fail(new Error(String(message.reason || 'WebSocket 授权失败')));
173
+ };
174
+ socket.onerror = () => fail(new Error('WebSocket 连接失败'));
175
+ socket.onclose = (event) => {
176
+ clearTimeout(timer);
177
+ if (this.heartbeatTimer)
178
+ clearInterval(this.heartbeatTimer);
179
+ if (!this.closed) {
180
+ this.phase = 'disconnected';
181
+ this.error = `连接已断开(${event.code}${event.reason ? `:${event.reason}` : ''}),正在重连`;
182
+ this.scheduleReconnect();
183
+ }
184
+ };
185
+ });
186
+ }
187
+ async forward(socket, message) {
188
+ if (!this.ticket || message.connectionId !== this.ticket.connectionId ||
189
+ typeof message.requestId !== 'string' || typeof message.replyNodeId !== 'string')
190
+ return;
191
+ const method = String(message.method || '');
192
+ const path = String(message.path || '');
193
+ let status = 400;
194
+ let body = { error: '插件调试请求不在允许范围内' };
195
+ if (allowedPluginRequest(method, path) &&
196
+ Buffer.byteLength(JSON.stringify(message.body ?? null)) <= MAX_RPC_BODY_BYTES) {
197
+ try {
198
+ const response = await fetch(`http://127.0.0.1:${Number(process.env.PORT ?? 4310)}${path}`, {
199
+ method, redirect: 'error', signal: AbortSignal.timeout(24_000),
200
+ headers: { 'content-type': 'application/json' },
201
+ body: message.body === undefined ? undefined : JSON.stringify(message.body),
202
+ });
203
+ status = response.status;
204
+ const raw = await response.text();
205
+ if (Buffer.byteLength(raw) > 4_000_000)
206
+ throw new Error('插件响应超过大小限制');
207
+ body = JSON.parse(raw);
208
+ if (response.ok && path === '/v1/catalog')
209
+ await this.uploadCatalogAssets(body);
210
+ }
211
+ catch (error) {
212
+ status = 502;
213
+ body = { error: error.message || '本地插件请求失败' };
214
+ }
215
+ }
216
+ if (socket.readyState === WebSocket.OPEN)
217
+ socket.send(JSON.stringify({
218
+ type: 'plugin.debug.response', requestId: message.requestId,
219
+ connectionId: this.ticket.connectionId, replyNodeId: message.replyNodeId, status, body,
220
+ }));
221
+ }
222
+ async uploadCatalogAssets(catalog) {
223
+ if (!this.discovery || !this.ticket || !this.credentials ||
224
+ !catalog || typeof catalog !== 'object')
225
+ return;
226
+ const record = catalog;
227
+ const bundles = [record.uiBundle, ...((Array.isArray(record.plugins) ? record.plugins : [])
228
+ .map((plugin) => plugin?.uiBundle))].filter(Boolean);
229
+ const uploads = new Map();
230
+ for (const bundle of bundles) {
231
+ if (typeof bundle.digest === 'string' && typeof bundle.url === 'string')
232
+ uploads.set(bundle.digest, { url: bundle.url, mimeType: 'application/javascript' });
233
+ if (typeof bundle.cssDigest === 'string' && typeof bundle.cssUrl === 'string')
234
+ uploads.set(bundle.cssDigest, { url: bundle.cssUrl, mimeType: 'text/css' });
235
+ }
236
+ await this.renewAccessToken();
237
+ for (const [digest, asset] of uploads) {
238
+ if (!/^[a-f0-9]{64}$/.test(digest))
239
+ throw new Error('插件界面文件摘要无效');
240
+ const source = new URL(asset.url, `http://127.0.0.1:${Number(process.env.PORT ?? 4310)}`);
241
+ if (!['localhost', '127.0.0.1'].includes(source.hostname) ||
242
+ !['/ui/', '/packages/'].some(prefix => source.pathname.startsWith(prefix)) ||
243
+ source.search || source.hash)
244
+ throw new Error('插件界面文件地址无效');
245
+ const loaded = await fetch(`http://127.0.0.1:${Number(process.env.PORT ?? 4310)}${source.pathname}`, {
246
+ redirect: 'error', signal: AbortSignal.timeout(8000),
247
+ });
248
+ if (!loaded.ok)
249
+ throw new Error(`插件界面文件读取失败(${loaded.status})`);
250
+ const bytes = Buffer.from(await loaded.arrayBuffer());
251
+ if (bytes.length > 5_000_000 || createHash('sha256').update(bytes).digest('hex') !== digest)
252
+ throw new Error('插件界面文件摘要不匹配或超过大小限制');
253
+ const uploaded = await fetch(`${this.discovery.apiUrl.replace(/\/+$/, '')}/canvas-workflow/plugin-debug-connectors/${this.ticket.connectionId}/assets/${digest}`, {
254
+ method: 'PUT', redirect: 'error', signal: AbortSignal.timeout(15_000),
255
+ headers: { authorization: `Bearer ${this.credentials.accessToken}`,
256
+ 'content-type': asset.mimeType, 'content-length': String(bytes.length) },
257
+ body: bytes,
258
+ });
259
+ if (!uploaded.ok) {
260
+ const reason = await uploaded.json().catch(() => ({}));
261
+ throw new Error(String(reason.message || `插件界面文件上传失败(${uploaded.status})`));
262
+ }
263
+ }
264
+ }
265
+ scheduleReconnect() {
266
+ if (this.closed || this.reconnectTimer)
267
+ return;
268
+ this.reconnectTimer = setTimeout(() => {
269
+ this.reconnectTimer = undefined;
270
+ void this.openSocket().catch((error) => {
271
+ this.error = error.message;
272
+ this.scheduleReconnect();
273
+ });
274
+ }, 2000);
275
+ }
276
+ async disconnect() {
277
+ this.closed = true;
278
+ if (this.reconnectTimer)
279
+ clearTimeout(this.reconnectTimer);
280
+ if (this.heartbeatTimer)
281
+ clearInterval(this.heartbeatTimer);
282
+ this.socket?.close();
283
+ if (this.discovery && this.ticket && this.credentials) {
284
+ await fetch(`${this.discovery.apiUrl.replace(/\/+$/, '')}/canvas-workflow/plugin-debug-connectors/${this.ticket.connectionId}`, {
285
+ method: 'DELETE', headers: { authorization: `Bearer ${this.credentials.accessToken}` },
286
+ signal: AbortSignal.timeout(5000),
287
+ }).catch(() => undefined);
288
+ }
289
+ this.discovery = undefined;
290
+ this.credentials = undefined;
291
+ this.ticket = undefined;
292
+ this.phase = 'idle';
293
+ this.error = '';
294
+ }
295
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@easyai-canvas/canvas-plugin-sdk",
3
+ "version": "0.1.0",
4
+ "description": "EasyAI 画布插件开发工具与运行时",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.22.0 <23"
8
+ },
9
+ "bin": {
10
+ "easyai-plugin": "bin/easyai-plugin.mjs"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/sdk/index.d.ts",
15
+ "default": "./dist/sdk/index.js"
16
+ },
17
+ "./protocol": {
18
+ "types": "./dist/protocol/index.d.ts",
19
+ "default": "./dist/protocol/index.js"
20
+ },
21
+ "./ui": {
22
+ "types": "./dist/sdk/frontend.d.ts",
23
+ "default": "./dist/sdk/frontend.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "dist",
29
+ "tools",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "better-sqlite3": "12.4.1",
38
+ "jszip": "3.10.1",
39
+ "vite": "8.3.1"
40
+ },
41
+ "peerDependencies": {
42
+ "vue": "^3.5.0"
43
+ },
44
+ "license": "SEE LICENSE IN LICENSE"
45
+ }
@@ -0,0 +1,99 @@
1
+ import { build } from 'vite';
2
+ import JSZip from 'jszip';
3
+ import { createHash } from 'node:crypto';
4
+ import { readFile, mkdir, writeFile, readdir } from 'node:fs/promises';
5
+ import { dirname, resolve } from 'node:path';
6
+ import { pathToFileURL } from 'node:url';
7
+
8
+ export async function buildPluginPackage({
9
+ serverEntry,
10
+ uiEntry,
11
+ globalName,
12
+ output,
13
+ }) {
14
+ const directory = resolve(output.replace(/\.zip$/, ''));
15
+ await build({
16
+ configFile: false,
17
+ publicDir: false,
18
+ ssr: { target: 'node', noExternal: true },
19
+ build: {
20
+ ssr: serverEntry,
21
+ outDir: directory,
22
+ emptyOutDir: true,
23
+ minify: false,
24
+ target: 'node22',
25
+ rollupOptions: {
26
+ output: { format: 'es', entryFileNames: 'server/plugin.mjs' },
27
+ },
28
+ },
29
+ });
30
+ const plugin = (
31
+ await import(pathToFileURL(resolve(directory, 'server/plugin.mjs')).href)
32
+ ).default;
33
+ await build({
34
+ configFile: false,
35
+ publicDir: false,
36
+ define: { 'process.env.NODE_ENV': JSON.stringify('production') },
37
+ build: {
38
+ outDir: directory,
39
+ emptyOutDir: false,
40
+ cssCodeSplit: false,
41
+ lib: {
42
+ entry: uiEntry,
43
+ name: globalName,
44
+ formats: ['iife'],
45
+ fileName: () => 'ui/plugin.js',
46
+ },
47
+ rollupOptions: {
48
+ external: ['vue'],
49
+ output: { globals: { vue: 'EasyAIPluginHost.vue' } },
50
+ },
51
+ },
52
+ });
53
+ const zip = new JSZip();
54
+ const files = {};
55
+ const emitted = await readdir(directory, {
56
+ recursive: true,
57
+ withFileTypes: true,
58
+ });
59
+ let css;
60
+ for (const entry of emitted.filter((e) => e.isFile())) {
61
+ const name = resolve(entry.parentPath, entry.name)
62
+ .slice(directory.length + 1)
63
+ .replaceAll('\\', '/');
64
+ if (name.endsWith('.css')) {
65
+ if (css) throw new Error('插件包只支持单个合并后的样式入口');
66
+ css = name;
67
+ }
68
+ const bytes = await readFile(resolve(directory, name));
69
+ files[name] = createHash('sha256').update(bytes).digest('hex');
70
+ zip.file(name, bytes, { date: new Date('2026-01-01T00:00:00Z') });
71
+ }
72
+ const descriptor = {
73
+ format: 'easyai.plugin-package/v2',
74
+ manifest: plugin.manifest,
75
+ runtime: {
76
+ nodeMajor: 22,
77
+ capabilities: [
78
+ ...(plugin.manifest.contributes.nodes?.length ? ['nodes'] : []),
79
+ ...(plugin.manifest.contributes.actions?.length ? ['actions'] : []),
80
+ ...(Object.keys(plugin.queries ?? {}).length ? ['queries'] : []),
81
+ ],
82
+ entry: 'server/plugin.mjs',
83
+ queries: Object.keys(plugin.queries ?? {}),
84
+ handlers: Object.keys(plugin.handlers ?? {}),
85
+ },
86
+ ui: { entry: 'ui/plugin.js', globalName, ...(css ? { css } : {}) },
87
+ files,
88
+ };
89
+ zip.file('plugin.json', JSON.stringify(descriptor, null, 2), {
90
+ date: new Date('2026-01-01T00:00:00Z'),
91
+ });
92
+ await mkdir(dirname(resolve(output)), { recursive: true });
93
+ await writeFile(
94
+ output,
95
+ await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }),
96
+ );
97
+ console.log(`插件包:${output}`);
98
+ return output;
99
+ }