@mseep/affine-mcp-server 2.3.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 +22 -0
- package/README.md +270 -0
- package/bin/affine-mcp +5 -0
- package/dist/auth.js +61 -0
- package/dist/cli.js +726 -0
- package/dist/config.js +178 -0
- package/dist/edgeless/layout.js +222 -0
- package/dist/graphqlClient.js +116 -0
- package/dist/httpAuth.js +147 -0
- package/dist/httpDiagnostics.js +38 -0
- package/dist/index.js +209 -0
- package/dist/markdown/parse.js +559 -0
- package/dist/markdown/render.js +227 -0
- package/dist/markdown/types.js +1 -0
- package/dist/oauth.js +154 -0
- package/dist/sse.js +261 -0
- package/dist/toolSurface.js +349 -0
- package/dist/tools/accessTokens.js +45 -0
- package/dist/tools/auth.js +18 -0
- package/dist/tools/blobStorage.js +136 -0
- package/dist/tools/comments.js +104 -0
- package/dist/tools/docs.js +7478 -0
- package/dist/tools/history.js +22 -0
- package/dist/tools/icons.js +125 -0
- package/dist/tools/notifications.js +79 -0
- package/dist/tools/organize.js +1145 -0
- package/dist/tools/properties.js +426 -0
- package/dist/tools/user.js +13 -0
- package/dist/tools/userCRUD.js +77 -0
- package/dist/tools/workspaces.js +322 -0
- package/dist/util/explorerIcon.js +95 -0
- package/dist/util/mcp.js +28 -0
- package/dist/ws.js +113 -0
- package/docs/assets/edgeless-canvas-demo-advanced-dark.png +0 -0
- package/docs/assets/edgeless-canvas-demo-advanced-light.png +0 -0
- package/docs/client-setup.md +174 -0
- package/docs/configuration-and-deployment.md +265 -0
- package/docs/edgeless-canvas-cookbook.md +226 -0
- package/docs/getting-started.md +229 -0
- package/docs/tool-reference.md +200 -0
- package/docs/workflow-recipes.md +147 -0
- package/package.json +118 -0
- package/tool-manifest.json +99 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as Y from "yjs";
|
|
3
|
+
import FormData from "form-data";
|
|
4
|
+
import fetch from "node-fetch";
|
|
5
|
+
import { receipt, text } from "../util/mcp.js";
|
|
6
|
+
import { connectWorkspaceSocket, joinWorkspace, pushDocUpdate, wsUrlFromGraphQLEndpoint } from "../ws.js";
|
|
7
|
+
// Generate AFFiNE-style document ID
|
|
8
|
+
function generateDocId() {
|
|
9
|
+
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
|
|
10
|
+
let id = '';
|
|
11
|
+
for (let i = 0; i < 10; i++) {
|
|
12
|
+
id += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
13
|
+
}
|
|
14
|
+
return id;
|
|
15
|
+
}
|
|
16
|
+
// Create initial workspace data with a document
|
|
17
|
+
function createInitialWorkspaceData(workspaceName = 'New Workspace', avatar = '') {
|
|
18
|
+
// Create workspace root YDoc
|
|
19
|
+
const rootDoc = new Y.Doc();
|
|
20
|
+
// Set workspace metadata
|
|
21
|
+
const meta = rootDoc.getMap('meta');
|
|
22
|
+
meta.set('name', workspaceName);
|
|
23
|
+
meta.set('avatar', avatar);
|
|
24
|
+
// Create pages array with initial document
|
|
25
|
+
const pages = new Y.Array();
|
|
26
|
+
const firstDocId = generateDocId();
|
|
27
|
+
// Add first document metadata
|
|
28
|
+
const pageMetadata = new Y.Map();
|
|
29
|
+
pageMetadata.set('id', firstDocId);
|
|
30
|
+
pageMetadata.set('title', 'Welcome to ' + workspaceName);
|
|
31
|
+
pageMetadata.set('createDate', Date.now());
|
|
32
|
+
pageMetadata.set('tags', new Y.Array());
|
|
33
|
+
pages.push([pageMetadata]);
|
|
34
|
+
meta.set('pages', pages);
|
|
35
|
+
// Create settings
|
|
36
|
+
const setting = rootDoc.getMap('setting');
|
|
37
|
+
setting.set('collections', new Y.Array());
|
|
38
|
+
// Encode workspace update
|
|
39
|
+
const workspaceUpdate = Y.encodeStateAsUpdate(rootDoc);
|
|
40
|
+
// Create the actual document
|
|
41
|
+
const docYDoc = new Y.Doc();
|
|
42
|
+
const blocks = docYDoc.getMap('blocks');
|
|
43
|
+
// Create page block with proper structure
|
|
44
|
+
const pageId = generateDocId();
|
|
45
|
+
const pageBlock = new Y.Map();
|
|
46
|
+
pageBlock.set('sys:id', pageId);
|
|
47
|
+
pageBlock.set('sys:flavour', 'affine:page');
|
|
48
|
+
// Title as Y.Text
|
|
49
|
+
const titleText = new Y.Text();
|
|
50
|
+
titleText.insert(0, 'Welcome to ' + workspaceName);
|
|
51
|
+
pageBlock.set('prop:title', titleText);
|
|
52
|
+
// Children
|
|
53
|
+
const pageChildren = new Y.Array();
|
|
54
|
+
pageBlock.set('sys:children', pageChildren);
|
|
55
|
+
blocks.set(pageId, pageBlock);
|
|
56
|
+
// Add surface block (required)
|
|
57
|
+
const surfaceId = generateDocId();
|
|
58
|
+
const surfaceBlock = new Y.Map();
|
|
59
|
+
surfaceBlock.set('sys:id', surfaceId);
|
|
60
|
+
surfaceBlock.set('sys:flavour', 'affine:surface');
|
|
61
|
+
surfaceBlock.set('sys:parent', null);
|
|
62
|
+
surfaceBlock.set('sys:children', new Y.Array());
|
|
63
|
+
blocks.set(surfaceId, surfaceBlock);
|
|
64
|
+
pageChildren.push([surfaceId]);
|
|
65
|
+
// Add note block with xywh
|
|
66
|
+
const noteId = generateDocId();
|
|
67
|
+
const noteBlock = new Y.Map();
|
|
68
|
+
noteBlock.set('sys:id', noteId);
|
|
69
|
+
noteBlock.set('sys:flavour', 'affine:note');
|
|
70
|
+
noteBlock.set('sys:parent', null);
|
|
71
|
+
noteBlock.set('prop:displayMode', 'DocAndEdgeless');
|
|
72
|
+
noteBlock.set('prop:xywh', '[0,0,800,600]');
|
|
73
|
+
noteBlock.set('prop:index', 'a0');
|
|
74
|
+
noteBlock.set('prop:lockedBySelf', false);
|
|
75
|
+
const noteChildren = new Y.Array();
|
|
76
|
+
noteBlock.set('sys:children', noteChildren);
|
|
77
|
+
blocks.set(noteId, noteBlock);
|
|
78
|
+
pageChildren.push([noteId]);
|
|
79
|
+
// Add initial paragraph
|
|
80
|
+
const paragraphId = generateDocId();
|
|
81
|
+
const paragraphBlock = new Y.Map();
|
|
82
|
+
paragraphBlock.set('sys:id', paragraphId);
|
|
83
|
+
paragraphBlock.set('sys:flavour', 'affine:paragraph');
|
|
84
|
+
paragraphBlock.set('sys:parent', null);
|
|
85
|
+
paragraphBlock.set('sys:children', new Y.Array());
|
|
86
|
+
paragraphBlock.set('prop:type', 'text');
|
|
87
|
+
const paragraphText = new Y.Text();
|
|
88
|
+
paragraphText.insert(0, 'This workspace was created by AFFiNE MCP Server');
|
|
89
|
+
paragraphBlock.set('prop:text', paragraphText);
|
|
90
|
+
blocks.set(paragraphId, paragraphBlock);
|
|
91
|
+
noteChildren.push([paragraphId]);
|
|
92
|
+
// Set document metadata
|
|
93
|
+
const docMeta = docYDoc.getMap('meta');
|
|
94
|
+
docMeta.set('id', firstDocId);
|
|
95
|
+
docMeta.set('title', 'Welcome to ' + workspaceName);
|
|
96
|
+
docMeta.set('createDate', Date.now());
|
|
97
|
+
docMeta.set('tags', new Y.Array());
|
|
98
|
+
docMeta.set('version', 1);
|
|
99
|
+
// Encode document update
|
|
100
|
+
const docUpdate = Y.encodeStateAsUpdate(docYDoc);
|
|
101
|
+
return {
|
|
102
|
+
workspaceUpdate,
|
|
103
|
+
firstDocId,
|
|
104
|
+
docUpdate
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export function registerWorkspaceTools(server, gql) {
|
|
108
|
+
// LIST WORKSPACES
|
|
109
|
+
const listWorkspacesHandler = async () => {
|
|
110
|
+
try {
|
|
111
|
+
const query = `query { workspaces { id public enableAi createdAt } }`;
|
|
112
|
+
const data = await gql.request(query);
|
|
113
|
+
return text(data.workspaces || []);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return text({ error: error.message });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
server.registerTool("list_workspaces", {
|
|
120
|
+
title: "List Workspaces",
|
|
121
|
+
description: "List all available AFFiNE workspaces"
|
|
122
|
+
}, listWorkspacesHandler);
|
|
123
|
+
// GET WORKSPACE
|
|
124
|
+
const getWorkspaceHandler = async ({ id }) => {
|
|
125
|
+
try {
|
|
126
|
+
const query = `query GetWorkspace($id: String!) {
|
|
127
|
+
workspace(id: $id) {
|
|
128
|
+
id
|
|
129
|
+
public
|
|
130
|
+
enableAi
|
|
131
|
+
createdAt
|
|
132
|
+
permissions {
|
|
133
|
+
Workspace_Read
|
|
134
|
+
Workspace_CreateDoc
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}`;
|
|
138
|
+
const data = await gql.request(query, { id });
|
|
139
|
+
return text(data.workspace);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
return text({ error: error.message });
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
server.registerTool("get_workspace", {
|
|
146
|
+
title: "Get Workspace",
|
|
147
|
+
description: "Get details of a specific workspace",
|
|
148
|
+
inputSchema: {
|
|
149
|
+
id: z.string().describe("Workspace ID")
|
|
150
|
+
}
|
|
151
|
+
}, getWorkspaceHandler);
|
|
152
|
+
// CREATE WORKSPACE
|
|
153
|
+
const createWorkspaceHandler = async ({ name, avatar }) => {
|
|
154
|
+
try {
|
|
155
|
+
// Get endpoint and headers from GraphQL client
|
|
156
|
+
const endpoint = gql.endpoint;
|
|
157
|
+
const headers = gql.headers;
|
|
158
|
+
const cookie = gql.cookie;
|
|
159
|
+
const bearer = gql.bearer;
|
|
160
|
+
// Create initial workspace data
|
|
161
|
+
const { workspaceUpdate, firstDocId, docUpdate } = createInitialWorkspaceData(name, avatar || '');
|
|
162
|
+
// Only send workspace update - document will be created separately
|
|
163
|
+
const initData = Buffer.from(workspaceUpdate);
|
|
164
|
+
// Create multipart form
|
|
165
|
+
const form = new FormData();
|
|
166
|
+
// Add GraphQL operation
|
|
167
|
+
form.append('operations', JSON.stringify({
|
|
168
|
+
name: 'createWorkspace',
|
|
169
|
+
query: `mutation createWorkspace($init: Upload!) {
|
|
170
|
+
createWorkspace(init: $init) {
|
|
171
|
+
id
|
|
172
|
+
public
|
|
173
|
+
createdAt
|
|
174
|
+
enableAi
|
|
175
|
+
}
|
|
176
|
+
}`,
|
|
177
|
+
variables: { init: null }
|
|
178
|
+
}));
|
|
179
|
+
// Map file to variable
|
|
180
|
+
form.append('map', JSON.stringify({ '0': ['variables.init'] }));
|
|
181
|
+
// Add workspace init data
|
|
182
|
+
form.append('0', initData, {
|
|
183
|
+
filename: 'init.yjs',
|
|
184
|
+
contentType: 'application/octet-stream'
|
|
185
|
+
});
|
|
186
|
+
// Send request
|
|
187
|
+
const response = await fetch(endpoint, {
|
|
188
|
+
method: 'POST',
|
|
189
|
+
headers: {
|
|
190
|
+
...headers,
|
|
191
|
+
'Cookie': cookie,
|
|
192
|
+
...form.getHeaders()
|
|
193
|
+
},
|
|
194
|
+
body: form
|
|
195
|
+
});
|
|
196
|
+
const result = await response.json();
|
|
197
|
+
if (result.errors) {
|
|
198
|
+
throw new Error(result.errors[0].message);
|
|
199
|
+
}
|
|
200
|
+
const workspace = result.data.createWorkspace;
|
|
201
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
202
|
+
const baseUrl = process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '');
|
|
203
|
+
try {
|
|
204
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
205
|
+
try {
|
|
206
|
+
await joinWorkspace(socket, workspace.id);
|
|
207
|
+
const docUpdateBase64 = Buffer.from(docUpdate).toString('base64');
|
|
208
|
+
await pushDocUpdate(socket, workspace.id, firstDocId, docUpdateBase64);
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
socket.disconnect();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch (_wsError) {
|
|
215
|
+
// Keep workspace creation successful even if initial websocket sync fails.
|
|
216
|
+
return receipt("workspace.create", {
|
|
217
|
+
workspaceId: workspace.id,
|
|
218
|
+
...workspace,
|
|
219
|
+
name,
|
|
220
|
+
avatar,
|
|
221
|
+
firstDocId,
|
|
222
|
+
syncStatus: "partial",
|
|
223
|
+
status: "partial",
|
|
224
|
+
message: "Workspace created (document sync may be pending)",
|
|
225
|
+
url: `${baseUrl}/workspace/${workspace.id}`
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return receipt("workspace.create", {
|
|
229
|
+
workspaceId: workspace.id,
|
|
230
|
+
...workspace,
|
|
231
|
+
name,
|
|
232
|
+
avatar,
|
|
233
|
+
firstDocId,
|
|
234
|
+
syncStatus: "success",
|
|
235
|
+
status: "success",
|
|
236
|
+
message: "Workspace created successfully",
|
|
237
|
+
url: `${baseUrl}/workspace/${workspace.id}`
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
return text({
|
|
242
|
+
kind: "workspace.create",
|
|
243
|
+
ok: false,
|
|
244
|
+
status: "failed",
|
|
245
|
+
error: error.message,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
server.registerTool("create_workspace", {
|
|
250
|
+
title: "Create Workspace",
|
|
251
|
+
description: "Create a new workspace with initial document (accessible in UI)",
|
|
252
|
+
inputSchema: {
|
|
253
|
+
name: z.string().describe("Workspace name"),
|
|
254
|
+
avatar: z.string().optional().describe("Avatar emoji or URL")
|
|
255
|
+
}
|
|
256
|
+
}, createWorkspaceHandler);
|
|
257
|
+
// UPDATE WORKSPACE
|
|
258
|
+
const updateWorkspaceHandler = async ({ id, public: isPublic, enableAi }) => {
|
|
259
|
+
try {
|
|
260
|
+
const mutation = `
|
|
261
|
+
mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
|
|
262
|
+
updateWorkspace(input: $input) {
|
|
263
|
+
id
|
|
264
|
+
public
|
|
265
|
+
enableAi
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
const input = { id };
|
|
270
|
+
if (isPublic !== undefined)
|
|
271
|
+
input.public = isPublic;
|
|
272
|
+
if (enableAi !== undefined)
|
|
273
|
+
input.enableAi = enableAi;
|
|
274
|
+
const data = await gql.request(mutation, { input });
|
|
275
|
+
return receipt("workspace.update", {
|
|
276
|
+
workspaceId: id,
|
|
277
|
+
id,
|
|
278
|
+
...data.updateWorkspace,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
return text({ error: error.message });
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
server.registerTool("update_workspace", {
|
|
286
|
+
title: "Update Workspace",
|
|
287
|
+
description: "Update workspace settings",
|
|
288
|
+
inputSchema: {
|
|
289
|
+
id: z.string().describe("Workspace ID"),
|
|
290
|
+
public: z.boolean().optional().describe("Make workspace public"),
|
|
291
|
+
enableAi: z.boolean().optional().describe("Enable AI features")
|
|
292
|
+
}
|
|
293
|
+
}, updateWorkspaceHandler);
|
|
294
|
+
// DELETE WORKSPACE
|
|
295
|
+
const deleteWorkspaceHandler = async ({ id }) => {
|
|
296
|
+
try {
|
|
297
|
+
const mutation = `
|
|
298
|
+
mutation DeleteWorkspace($id: String!) {
|
|
299
|
+
deleteWorkspace(id: $id)
|
|
300
|
+
}
|
|
301
|
+
`;
|
|
302
|
+
const data = await gql.request(mutation, { id });
|
|
303
|
+
return receipt("workspace.delete", {
|
|
304
|
+
workspaceId: id,
|
|
305
|
+
id,
|
|
306
|
+
deleted: data.deleteWorkspace,
|
|
307
|
+
success: data.deleteWorkspace,
|
|
308
|
+
message: "Workspace deleted successfully",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
return text({ error: error.message });
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
server.registerTool("delete_workspace", {
|
|
316
|
+
title: "Delete Workspace",
|
|
317
|
+
description: "Delete a workspace permanently",
|
|
318
|
+
inputSchema: {
|
|
319
|
+
id: z.string().describe("Workspace ID")
|
|
320
|
+
}
|
|
321
|
+
}, deleteWorkspaceHandler);
|
|
322
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
import { loadDoc, pushDocUpdate } from "../ws.js";
|
|
3
|
+
/** Build the sub-document guid that holds a workspace's explorer icons. */
|
|
4
|
+
export function explorerIconDocId(workspaceId) {
|
|
5
|
+
return `db$${workspaceId}$explorerIcon`;
|
|
6
|
+
}
|
|
7
|
+
/** Build the per-entity map key for a document. */
|
|
8
|
+
export function docIconKey(docId) {
|
|
9
|
+
return `doc:${docId}`;
|
|
10
|
+
}
|
|
11
|
+
/** Build the per-entity map key for an organize folder. */
|
|
12
|
+
export function folderIconKey(folderId) {
|
|
13
|
+
return `folder:${folderId}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Coerce user input into the stored icon shape (or null to clear).
|
|
17
|
+
*
|
|
18
|
+
* - A bare string is treated as an emoji shorthand → `{ type: "emoji", unicode }`.
|
|
19
|
+
* - A `{ type: "emoji", unicode }` or `{ type: "icon", name }` object is passed
|
|
20
|
+
* through after validation. Named-icon `name` values are not validated against
|
|
21
|
+
* AFFiNE's fixed icon set — they are written as-is.
|
|
22
|
+
* - `null` clears the icon.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeIconInput(input) {
|
|
25
|
+
if (input === null)
|
|
26
|
+
return null;
|
|
27
|
+
if (typeof input === "string") {
|
|
28
|
+
const unicode = input.trim();
|
|
29
|
+
if (!unicode)
|
|
30
|
+
throw new Error("icon emoji string must not be empty.");
|
|
31
|
+
return { type: "emoji", unicode };
|
|
32
|
+
}
|
|
33
|
+
if (input.type === "emoji") {
|
|
34
|
+
const unicode = input.unicode?.trim();
|
|
35
|
+
if (!unicode)
|
|
36
|
+
throw new Error("emoji icon requires a non-empty `unicode`.");
|
|
37
|
+
return { type: "emoji", unicode };
|
|
38
|
+
}
|
|
39
|
+
if (input.type === "icon") {
|
|
40
|
+
const name = input.name?.trim();
|
|
41
|
+
if (!name)
|
|
42
|
+
throw new Error("named icon requires a non-empty `name`.");
|
|
43
|
+
return { type: "icon", name };
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Unsupported icon type: ${JSON.stringify(input.type)}.`);
|
|
46
|
+
}
|
|
47
|
+
async function loadExplorerIconDoc(socket, workspaceId) {
|
|
48
|
+
const snap = await loadDoc(socket, workspaceId, explorerIconDocId(workspaceId));
|
|
49
|
+
const doc = new Y.Doc();
|
|
50
|
+
if (snap.missing)
|
|
51
|
+
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
52
|
+
return doc;
|
|
53
|
+
}
|
|
54
|
+
function readIcon(doc, key) {
|
|
55
|
+
const map = doc.share.has(key) ? doc.getMap(key) : null;
|
|
56
|
+
if (!map || !map.has("icon"))
|
|
57
|
+
return null;
|
|
58
|
+
const raw = map.get("icon");
|
|
59
|
+
if (raw && typeof raw.toJSON === "function") {
|
|
60
|
+
return raw.toJSON();
|
|
61
|
+
}
|
|
62
|
+
return raw ?? null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Set or clear the sidebar icon for a single explorer entity.
|
|
66
|
+
*
|
|
67
|
+
* Loads the workspace's `explorerIcon` sub-doc, mutates only the target entry,
|
|
68
|
+
* and pushes the minimal delta. Passing `icon = null` removes the `icon` field
|
|
69
|
+
* while preserving the entry's `id`.
|
|
70
|
+
*/
|
|
71
|
+
export async function setExplorerIcon(socket, workspaceId, key, icon) {
|
|
72
|
+
const doc = await loadExplorerIconDoc(socket, workspaceId);
|
|
73
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
74
|
+
const map = doc.getMap(key);
|
|
75
|
+
if (!map.has("id"))
|
|
76
|
+
map.set("id", key);
|
|
77
|
+
if (icon === null) {
|
|
78
|
+
if (map.has("icon"))
|
|
79
|
+
map.delete("icon");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
map.set("icon", icon);
|
|
83
|
+
}
|
|
84
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
85
|
+
await pushDocUpdate(socket, workspaceId, explorerIconDocId(workspaceId), Buffer.from(delta).toString("base64"));
|
|
86
|
+
return { key, icon };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Read the current sidebar icon for a single explorer entity.
|
|
90
|
+
* Returns `null` when no icon is set (or the entry does not exist).
|
|
91
|
+
*/
|
|
92
|
+
export async function getExplorerIcon(socket, workspaceId, key) {
|
|
93
|
+
const doc = await loadExplorerIconDoc(socket, workspaceId);
|
|
94
|
+
return { key, icon: readIcon(doc, key) };
|
|
95
|
+
}
|
package/dist/util/mcp.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
function cloneJsonValue(data) {
|
|
2
|
+
if (data === undefined) {
|
|
3
|
+
return data;
|
|
4
|
+
}
|
|
5
|
+
return JSON.parse(JSON.stringify(data));
|
|
6
|
+
}
|
|
7
|
+
export function text(data) {
|
|
8
|
+
if (typeof data === "string") {
|
|
9
|
+
return { content: [{ type: "text", text: data }] };
|
|
10
|
+
}
|
|
11
|
+
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
|
|
12
|
+
const structuredContent = cloneJsonValue(data);
|
|
13
|
+
return {
|
|
14
|
+
content: [{ type: "text", text: JSON.stringify(structuredContent) }],
|
|
15
|
+
structuredContent,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: "text", text: JSON.stringify(data) }],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function receipt(kind, data) {
|
|
23
|
+
return text({
|
|
24
|
+
kind,
|
|
25
|
+
ok: true,
|
|
26
|
+
...data,
|
|
27
|
+
});
|
|
28
|
+
}
|
package/dist/ws.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { io } from "socket.io-client";
|
|
2
|
+
const DEFAULT_WS_CLIENT_VERSION = process.env.AFFINE_WS_CLIENT_VERSION || '0.26.0';
|
|
3
|
+
const WS_CONNECT_TIMEOUT_MS = Number(process.env.AFFINE_WS_CONNECT_TIMEOUT_MS || 10000);
|
|
4
|
+
const WS_ACK_TIMEOUT_MS = Number(process.env.AFFINE_WS_ACK_TIMEOUT_MS || 10000);
|
|
5
|
+
function ackErrorMessage(ack, fallback) {
|
|
6
|
+
const message = ack?.error?.message;
|
|
7
|
+
if (typeof message === "string" && message.trim())
|
|
8
|
+
return message;
|
|
9
|
+
return ack?.error ? fallback : null;
|
|
10
|
+
}
|
|
11
|
+
function emitWithAck(socket, event, payload, onAck) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
let settled = false;
|
|
14
|
+
const timeout = setTimeout(() => {
|
|
15
|
+
if (settled)
|
|
16
|
+
return;
|
|
17
|
+
settled = true;
|
|
18
|
+
reject(new Error(`${event} timeout after ${WS_ACK_TIMEOUT_MS}ms`));
|
|
19
|
+
}, WS_ACK_TIMEOUT_MS);
|
|
20
|
+
socket.emit(event, payload, (ack) => {
|
|
21
|
+
if (settled)
|
|
22
|
+
return;
|
|
23
|
+
settled = true;
|
|
24
|
+
clearTimeout(timeout);
|
|
25
|
+
try {
|
|
26
|
+
resolve(onAck(ack));
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
reject(err);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function wsUrlFromGraphQLEndpoint(endpoint) {
|
|
35
|
+
return endpoint
|
|
36
|
+
.replace('https://', 'wss://')
|
|
37
|
+
.replace('http://', 'ws://')
|
|
38
|
+
.replace(/\/graphql\/?$/, '');
|
|
39
|
+
}
|
|
40
|
+
export async function connectWorkspaceSocket(wsUrl, cookie, bearer) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let settled = false;
|
|
43
|
+
const extraHeaders = {};
|
|
44
|
+
if (cookie)
|
|
45
|
+
extraHeaders['Cookie'] = cookie;
|
|
46
|
+
if (bearer)
|
|
47
|
+
extraHeaders['Authorization'] = `Bearer ${bearer}`;
|
|
48
|
+
const socket = io(wsUrl, {
|
|
49
|
+
transports: ['websocket'],
|
|
50
|
+
path: '/socket.io/',
|
|
51
|
+
extraHeaders: Object.keys(extraHeaders).length ? extraHeaders : undefined,
|
|
52
|
+
autoConnect: true
|
|
53
|
+
});
|
|
54
|
+
const timeout = setTimeout(() => {
|
|
55
|
+
if (settled)
|
|
56
|
+
return;
|
|
57
|
+
settled = true;
|
|
58
|
+
cleanup();
|
|
59
|
+
socket.disconnect();
|
|
60
|
+
reject(new Error(`socket connect timeout after ${WS_CONNECT_TIMEOUT_MS}ms`));
|
|
61
|
+
}, WS_CONNECT_TIMEOUT_MS);
|
|
62
|
+
const onError = (err) => {
|
|
63
|
+
if (settled)
|
|
64
|
+
return;
|
|
65
|
+
settled = true;
|
|
66
|
+
cleanup();
|
|
67
|
+
socket.disconnect();
|
|
68
|
+
reject(err);
|
|
69
|
+
};
|
|
70
|
+
const onConnect = () => {
|
|
71
|
+
if (settled)
|
|
72
|
+
return;
|
|
73
|
+
settled = true;
|
|
74
|
+
cleanup();
|
|
75
|
+
resolve(socket);
|
|
76
|
+
};
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
clearTimeout(timeout);
|
|
79
|
+
socket.off('connect', onConnect);
|
|
80
|
+
socket.off('connect_error', onError);
|
|
81
|
+
};
|
|
82
|
+
socket.on('connect', onConnect);
|
|
83
|
+
socket.on('connect_error', onError);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
export async function joinWorkspace(socket, workspaceId, clientVersion = DEFAULT_WS_CLIENT_VERSION) {
|
|
87
|
+
return emitWithAck(socket, 'space:join', { spaceType: 'workspace', spaceId: workspaceId, clientVersion }, (ack) => {
|
|
88
|
+
const message = ackErrorMessage(ack, "join failed");
|
|
89
|
+
if (message)
|
|
90
|
+
throw new Error(message);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export async function loadDoc(socket, workspaceId, docId) {
|
|
94
|
+
return emitWithAck(socket, 'space:load-doc', { spaceType: 'workspace', spaceId: workspaceId, docId }, (ack) => {
|
|
95
|
+
if (ack?.error) {
|
|
96
|
+
if (ack.error.name === 'DOC_NOT_FOUND')
|
|
97
|
+
return {};
|
|
98
|
+
throw new Error(ackErrorMessage(ack, "load-doc failed") || "load-doc failed");
|
|
99
|
+
}
|
|
100
|
+
return ack?.data || {};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export async function pushDocUpdate(socket, workspaceId, docId, updateBase64) {
|
|
104
|
+
return emitWithAck(socket, 'space:push-doc-update', { spaceType: 'workspace', spaceId: workspaceId, docId, update: updateBase64 }, (ack) => {
|
|
105
|
+
const message = ackErrorMessage(ack, "push-doc-update failed");
|
|
106
|
+
if (message)
|
|
107
|
+
throw new Error(message);
|
|
108
|
+
return ack?.data?.timestamp || Date.now();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export function deleteDoc(socket, workspaceId, docId) {
|
|
112
|
+
socket.emit('space:delete-doc', { spaceType: 'workspace', spaceId: workspaceId, docId });
|
|
113
|
+
}
|
|
Binary file
|
|
Binary file
|