tandem-editor 0.2.0 → 0.2.2
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/dist/client/index.html
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
html, body, #root { height: 100%; }
|
|
10
10
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
|
11
11
|
</style>
|
|
12
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
+
<script type="module" crossorigin src="/assets/index-NqrmyYcr.js"></script>
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
package/dist/server/index.js
CHANGED
|
@@ -71,12 +71,14 @@ async function startHocuspocus(port) {
|
|
|
71
71
|
// stdout is the MCP wire — suppress the startup banner
|
|
72
72
|
async onConnect({ request, documentName }) {
|
|
73
73
|
const origin = request?.headers?.origin;
|
|
74
|
-
if (origin) {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
74
|
+
if (!origin) {
|
|
75
|
+
console.error("[Hocuspocus] Rejected connection: missing Origin header");
|
|
76
|
+
throw new Error("Connection rejected: missing origin header");
|
|
77
|
+
}
|
|
78
|
+
const url = new URL(origin);
|
|
79
|
+
if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
|
|
80
|
+
console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
|
|
81
|
+
throw new Error("Connection rejected: invalid origin");
|
|
80
82
|
}
|
|
81
83
|
console.error(`[Hocuspocus] Client connected to: ${documentName}`);
|
|
82
84
|
},
|
|
@@ -144,7 +146,8 @@ function freePort(port) {
|
|
|
144
146
|
} else {
|
|
145
147
|
freePortUnix(port);
|
|
146
148
|
}
|
|
147
|
-
} catch {
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error(`[Tandem] freePort(${port}): ${err instanceof Error ? err.message : err}`);
|
|
148
151
|
}
|
|
149
152
|
}
|
|
150
153
|
async function waitForPort(port, timeoutMs = 5e3) {
|
|
@@ -153,9 +156,7 @@ async function waitForPort(port, timeoutMs = 5e3) {
|
|
|
153
156
|
if (await tryBind(port)) return;
|
|
154
157
|
await new Promise((r) => setTimeout(r, 200));
|
|
155
158
|
}
|
|
156
|
-
|
|
157
|
-
`[Tandem] Warning: port ${port} still not available after ${timeoutMs}ms, proceeding anyway`
|
|
158
|
-
);
|
|
159
|
+
throw new Error(`Port ${port} still not available after ${timeoutMs}ms`);
|
|
159
160
|
}
|
|
160
161
|
function tryBind(port) {
|
|
161
162
|
return new Promise((resolve, reject) => {
|
|
@@ -275,7 +276,17 @@ async function loadSession(filePath) {
|
|
|
275
276
|
try {
|
|
276
277
|
const content = await fs.readFile(sessionPath, "utf-8");
|
|
277
278
|
return JSON.parse(content);
|
|
278
|
-
} catch {
|
|
279
|
+
} catch (err) {
|
|
280
|
+
const code = err.code;
|
|
281
|
+
if (code === "ENOENT") return null;
|
|
282
|
+
if (err instanceof SyntaxError) {
|
|
283
|
+
console.error(`[Tandem] Corrupted session file ${sessionPath}, removing:`, err.message);
|
|
284
|
+
await fs.unlink(sessionPath).catch((unlinkErr) => {
|
|
285
|
+
console.error(`[Tandem] Failed to remove corrupted session ${sessionPath}:`, unlinkErr);
|
|
286
|
+
});
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
console.error(`[Tandem] Failed to read session ${sessionPath}:`, err);
|
|
279
290
|
return null;
|
|
280
291
|
}
|
|
281
292
|
}
|
|
@@ -344,7 +355,20 @@ async function loadCtrlSession() {
|
|
|
344
355
|
const content = await fs.readFile(sessionPath, "utf-8");
|
|
345
356
|
const data = JSON.parse(content);
|
|
346
357
|
return data.ydocState ?? null;
|
|
347
|
-
} catch {
|
|
358
|
+
} catch (err) {
|
|
359
|
+
const code = err.code;
|
|
360
|
+
if (code === "ENOENT") return null;
|
|
361
|
+
if (err instanceof SyntaxError) {
|
|
362
|
+
console.error(`[Tandem] Corrupted ctrl session ${sessionPath}, removing:`, err.message);
|
|
363
|
+
await fs.unlink(sessionPath).catch((unlinkErr) => {
|
|
364
|
+
console.error(
|
|
365
|
+
`[Tandem] Failed to remove corrupted ctrl session ${sessionPath}:`,
|
|
366
|
+
unlinkErr
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
console.error(`[Tandem] Failed to read ctrl session:`, err);
|
|
348
372
|
return null;
|
|
349
373
|
}
|
|
350
374
|
}
|
|
@@ -378,18 +402,26 @@ async function listSessionFilePaths() {
|
|
|
378
402
|
}
|
|
379
403
|
async function cleanupSessions() {
|
|
380
404
|
let cleaned = 0;
|
|
405
|
+
let files;
|
|
381
406
|
try {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
407
|
+
files = await fs.readdir(SESSION_DIR);
|
|
408
|
+
} catch (err) {
|
|
409
|
+
if (err.code === "ENOENT") return 0;
|
|
410
|
+
console.error("[Tandem] Failed to read session directory:", err);
|
|
411
|
+
return 0;
|
|
412
|
+
}
|
|
413
|
+
const now = Date.now();
|
|
414
|
+
for (const file of files) {
|
|
415
|
+
try {
|
|
385
416
|
const filePath = path2.join(SESSION_DIR, file);
|
|
386
417
|
const stat = await fs.stat(filePath);
|
|
387
418
|
if (now - stat.mtimeMs > SESSION_MAX_AGE) {
|
|
388
419
|
await fs.unlink(filePath);
|
|
389
420
|
cleaned++;
|
|
390
421
|
}
|
|
422
|
+
} catch (err) {
|
|
423
|
+
console.error(`[Tandem] cleanupSessions: failed to process ${file}:`, err);
|
|
391
424
|
}
|
|
392
|
-
} catch {
|
|
393
425
|
}
|
|
394
426
|
return cleaned;
|
|
395
427
|
}
|
|
@@ -1200,9 +1232,11 @@ var init_docx_html = __esm({
|
|
|
1200
1232
|
del: () => ({ strike: {} }),
|
|
1201
1233
|
sup: () => ({ superscript: {} }),
|
|
1202
1234
|
sub: () => ({ subscript: {} }),
|
|
1203
|
-
a: (el) =>
|
|
1204
|
-
|
|
1205
|
-
|
|
1235
|
+
a: (el) => {
|
|
1236
|
+
const href = el.attribs.href || "";
|
|
1237
|
+
const safeHref = /^https?:\/\//i.test(href) || href.startsWith("mailto:") ? href : "";
|
|
1238
|
+
return { link: { href: safeHref } };
|
|
1239
|
+
}
|
|
1206
1240
|
};
|
|
1207
1241
|
BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
1208
1242
|
"h1",
|
|
@@ -2741,7 +2775,13 @@ async function openFileByPath(filePath, options) {
|
|
|
2741
2775
|
let resolved = path5.resolve(filePath);
|
|
2742
2776
|
try {
|
|
2743
2777
|
resolved = fsSync.realpathSync(resolved);
|
|
2744
|
-
} catch {
|
|
2778
|
+
} catch (err) {
|
|
2779
|
+
const code = err.code;
|
|
2780
|
+
if (code !== "ENOENT") {
|
|
2781
|
+
console.error(
|
|
2782
|
+
`[Tandem] realpathSync failed for ${filePath} (${code}), using path.resolve fallback`
|
|
2783
|
+
);
|
|
2784
|
+
}
|
|
2745
2785
|
resolved = path5.resolve(filePath);
|
|
2746
2786
|
}
|
|
2747
2787
|
if (process.platform === "win32" && (resolved.startsWith("\\\\") || resolved.startsWith("//"))) {
|
|
@@ -3026,25 +3066,29 @@ function toDocListEntry(d) {
|
|
|
3026
3066
|
};
|
|
3027
3067
|
}
|
|
3028
3068
|
function broadcastOpenDocs() {
|
|
3069
|
+
const docList = Array.from(openDocs.values()).map(toDocListEntry);
|
|
3070
|
+
const id = activeDocId;
|
|
3029
3071
|
try {
|
|
3030
|
-
const docList = Array.from(openDocs.values()).map(toDocListEntry);
|
|
3031
|
-
const id = activeDocId;
|
|
3032
3072
|
const ctrl = getOrCreateDocument(CTRL_ROOM);
|
|
3033
3073
|
const ctrlMeta = ctrl.getMap(Y_MAP_DOCUMENT_META);
|
|
3034
3074
|
ctrl.transact(() => {
|
|
3035
3075
|
ctrlMeta.set("openDocuments", docList);
|
|
3036
3076
|
ctrlMeta.set("activeDocumentId", id);
|
|
3037
3077
|
}, MCP_ORIGIN);
|
|
3038
|
-
|
|
3078
|
+
} catch (err) {
|
|
3079
|
+
console.error("[Tandem] broadcastOpenDocs: failed to update CTRL_ROOM:", err);
|
|
3080
|
+
}
|
|
3081
|
+
for (const [docId] of openDocs) {
|
|
3082
|
+
try {
|
|
3039
3083
|
const ydoc = getOrCreateDocument(docId);
|
|
3040
3084
|
const meta = ydoc.getMap(Y_MAP_DOCUMENT_META);
|
|
3041
3085
|
ydoc.transact(() => {
|
|
3042
3086
|
meta.set("openDocuments", docList);
|
|
3043
3087
|
meta.set("activeDocumentId", id);
|
|
3044
3088
|
}, MCP_ORIGIN);
|
|
3089
|
+
} catch (err) {
|
|
3090
|
+
console.error(`[Tandem] broadcastOpenDocs: failed to update doc ${docId}:`, err);
|
|
3045
3091
|
}
|
|
3046
|
-
} catch (err) {
|
|
3047
|
-
console.error("[Tandem] broadcastOpenDocs error:", err);
|
|
3048
3092
|
}
|
|
3049
3093
|
}
|
|
3050
3094
|
async function closeDocumentById(id) {
|
|
@@ -3112,7 +3156,8 @@ async function restoreOpenDocuments(previousActiveDocId) {
|
|
|
3112
3156
|
const code = err.code;
|
|
3113
3157
|
if (code === "ENOENT") {
|
|
3114
3158
|
console.error(`[Tandem] Skipping deleted file (removing stale session): ${filePath}`);
|
|
3115
|
-
deleteSession(filePath).catch(() => {
|
|
3159
|
+
deleteSession(filePath).catch((err2) => {
|
|
3160
|
+
console.error(`[Tandem] Failed to delete stale session for ${filePath}:`, err2);
|
|
3116
3161
|
});
|
|
3117
3162
|
} else {
|
|
3118
3163
|
console.error(`[Tandem] Failed to restore ${filePath}:`, err);
|
|
@@ -4201,7 +4246,14 @@ function createAnnotation(map, ydoc, type, anchored, content, extras) {
|
|
|
4201
4246
|
}
|
|
4202
4247
|
function collectAnnotations(map) {
|
|
4203
4248
|
const result = [];
|
|
4204
|
-
map.forEach((value) =>
|
|
4249
|
+
map.forEach((value, key) => {
|
|
4250
|
+
const ann = value;
|
|
4251
|
+
if (ann && typeof ann === "object" && typeof ann.id === "string" && typeof ann.type === "string" && typeof ann.status === "string" && ann.range && typeof ann.range.from === "number" && typeof ann.range.to === "number") {
|
|
4252
|
+
result.push(ann);
|
|
4253
|
+
} else {
|
|
4254
|
+
console.warn(`[Tandem] Skipping malformed annotation entry: ${key}`);
|
|
4255
|
+
}
|
|
4256
|
+
});
|
|
4205
4257
|
return result;
|
|
4206
4258
|
}
|
|
4207
4259
|
function registerAnnotationTools(server) {
|
|
@@ -4534,6 +4586,14 @@ async function applyChangesCore(documentId, author, backupPath) {
|
|
|
4534
4586
|
code: "INVALID_PATH"
|
|
4535
4587
|
});
|
|
4536
4588
|
}
|
|
4589
|
+
if (backupPath) {
|
|
4590
|
+
const resolvedBp = path8.resolve(backupPath);
|
|
4591
|
+
if (process.platform === "win32" && (resolvedBp.startsWith("\\\\") || resolvedBp.startsWith("//"))) {
|
|
4592
|
+
throw Object.assign(new Error("UNC paths are not supported for security reasons."), {
|
|
4593
|
+
code: "INVALID_PATH"
|
|
4594
|
+
});
|
|
4595
|
+
}
|
|
4596
|
+
}
|
|
4537
4597
|
const map = ydoc.getMap(Y_MAP_ANNOTATIONS);
|
|
4538
4598
|
const suggestions = [];
|
|
4539
4599
|
let pendingCount = 0;
|
|
@@ -4788,7 +4848,11 @@ function notifyStreamHandler(req, res) {
|
|
|
4788
4848
|
const keepalive = setInterval(() => {
|
|
4789
4849
|
try {
|
|
4790
4850
|
if (!res.writableEnded) res.write(": keepalive\n\n");
|
|
4791
|
-
} catch {
|
|
4851
|
+
} catch (err) {
|
|
4852
|
+
console.error(
|
|
4853
|
+
"[NotifyStream] Keepalive write failed, cleaning up:",
|
|
4854
|
+
err instanceof Error ? err.message : err
|
|
4855
|
+
);
|
|
4792
4856
|
cleanup();
|
|
4793
4857
|
}
|
|
4794
4858
|
}, CHANNEL_SSE_KEEPALIVE_MS);
|
|
@@ -5264,16 +5328,25 @@ function getFullText(docName) {
|
|
|
5264
5328
|
return extractText(doc);
|
|
5265
5329
|
}
|
|
5266
5330
|
function searchText(fullText, query, useRegex) {
|
|
5331
|
+
const MAX_MATCHES = 1e4;
|
|
5267
5332
|
const matches = [];
|
|
5268
5333
|
try {
|
|
5269
5334
|
const pattern = useRegex ? new RegExp(query, "gi") : new RegExp(escapeRegex(query), "gi");
|
|
5270
5335
|
let match;
|
|
5336
|
+
const start = Date.now();
|
|
5271
5337
|
while ((match = pattern.exec(fullText)) !== null) {
|
|
5272
5338
|
matches.push({
|
|
5273
5339
|
from: toFlatOffset(match.index),
|
|
5274
5340
|
to: toFlatOffset(match.index + match[0].length),
|
|
5275
5341
|
text: match[0]
|
|
5276
5342
|
});
|
|
5343
|
+
if (matches.length >= MAX_MATCHES) {
|
|
5344
|
+
return { matches, error: `Search capped at ${MAX_MATCHES} matches` };
|
|
5345
|
+
}
|
|
5346
|
+
if (Date.now() - start > 2e3) {
|
|
5347
|
+
return { matches, error: "Search timed out \u2014 simplify the regex pattern" };
|
|
5348
|
+
}
|
|
5349
|
+
if (match[0].length === 0) pattern.lastIndex++;
|
|
5277
5350
|
}
|
|
5278
5351
|
} catch (err) {
|
|
5279
5352
|
return { matches: [], error: `Invalid regex: ${getErrorMessage(err)}` };
|
|
@@ -5501,14 +5574,18 @@ async function startMcpServerHttp(port, host = "127.0.0.1") {
|
|
|
5501
5574
|
await currentTransport.handleRequest(req, res, req.body);
|
|
5502
5575
|
currentTransport = null;
|
|
5503
5576
|
});
|
|
5504
|
-
app.get(
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
5577
|
+
app.get(
|
|
5578
|
+
"/health",
|
|
5579
|
+
apiMiddleware,
|
|
5580
|
+
(_req, res) => {
|
|
5581
|
+
res.json({
|
|
5582
|
+
status: "ok",
|
|
5583
|
+
version: APP_VERSION,
|
|
5584
|
+
transport: "http",
|
|
5585
|
+
hasSession: currentTransport !== null
|
|
5586
|
+
});
|
|
5587
|
+
}
|
|
5588
|
+
);
|
|
5512
5589
|
app.get(
|
|
5513
5590
|
"/.well-known/oauth-protected-resource/mcp",
|
|
5514
5591
|
(_req, res) => {
|
|
@@ -5577,7 +5654,7 @@ function isKnownHocuspocusError(err) {
|
|
|
5577
5654
|
}
|
|
5578
5655
|
const msg = err.message;
|
|
5579
5656
|
if (msg.startsWith("WebSocket is not open")) return true;
|
|
5580
|
-
if (msg
|
|
5657
|
+
if (msg.includes("Unexpected end of array") || msg.includes("Integer out of Range")) return true;
|
|
5581
5658
|
if (msg.startsWith("Received a message with an unknown type:")) return true;
|
|
5582
5659
|
return false;
|
|
5583
5660
|
}
|
|
@@ -5770,7 +5847,11 @@ async function main() {
|
|
|
5770
5847
|
if (transportMode === "http") {
|
|
5771
5848
|
freePort(wsPort);
|
|
5772
5849
|
freePort(mcpPort);
|
|
5773
|
-
|
|
5850
|
+
try {
|
|
5851
|
+
await Promise.all([waitForPort(wsPort), waitForPort(mcpPort)]);
|
|
5852
|
+
} catch (err) {
|
|
5853
|
+
console.error(`[Tandem] ${err instanceof Error ? err.message : err} \u2014 proceeding anyway`);
|
|
5854
|
+
}
|
|
5774
5855
|
const [srv] = await Promise.all([
|
|
5775
5856
|
startMcpServerHttp(mcpPort),
|
|
5776
5857
|
startHocuspocus(wsPort).then(() => {
|
|
@@ -5806,7 +5887,11 @@ async function main() {
|
|
|
5806
5887
|
} else {
|
|
5807
5888
|
(async () => {
|
|
5808
5889
|
freePort(wsPort);
|
|
5809
|
-
|
|
5890
|
+
try {
|
|
5891
|
+
await waitForPort(wsPort);
|
|
5892
|
+
} catch (err) {
|
|
5893
|
+
console.error(`[Tandem] ${err instanceof Error ? err.message : err} \u2014 proceeding anyway`);
|
|
5894
|
+
}
|
|
5810
5895
|
await startHocuspocus(wsPort);
|
|
5811
5896
|
console.error(`[Tandem] Hocuspocus WebSocket server running on ws://localhost:${wsPort}`);
|
|
5812
5897
|
})().catch((err) => {
|