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.
@@ -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-D5KAGBBp.js"></script>
12
+ <script type="module" crossorigin src="/assets/index-NqrmyYcr.js"></script>
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
@@ -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
- const url = new URL(origin);
76
- if (url.hostname !== "localhost" && url.hostname !== "127.0.0.1") {
77
- console.error(`[Hocuspocus] Rejected connection from origin: ${origin}`);
78
- throw new Error("Connection rejected: invalid origin");
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
- console.error(
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
- const files = await fs.readdir(SESSION_DIR);
383
- const now = Date.now();
384
- for (const file of files) {
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
- link: { href: el.attribs.href || "" }
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
- for (const [docId] of openDocs) {
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) => result.push(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("/health", (_req, res) => {
5505
- res.json({
5506
- status: "ok",
5507
- version: APP_VERSION,
5508
- transport: "http",
5509
- hasSession: currentTransport !== null
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 === "Unexpected end of array" || msg === "Integer out of Range") return true;
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
- await Promise.all([waitForPort(wsPort), waitForPort(mcpPort)]);
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
- await waitForPort(wsPort);
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) => {