@peaske7/readit 0.1.7 → 0.1.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peaske7/readit",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "A CLI tool to review Markdown documents with inline comments",
5
5
  "author": "Jay Shimada <peaske@pm.me>",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -38,7 +38,12 @@ const TOASTER_OPTIONS = {
38
38
  },
39
39
  };
40
40
 
41
- function AppContent() {
41
+ interface AppContentProps {
42
+ document: NonNullable<ReturnType<typeof useDocument>["document"]>;
43
+ reload: ReturnType<typeof useDocument>["reload"];
44
+ }
45
+
46
+ function AppContent({ document, reload }: AppContentProps) {
42
47
  const { t } = useLocale();
43
48
  const {
44
49
  comments,
@@ -53,8 +58,6 @@ function AppContent() {
53
58
  navigateNext,
54
59
  } = use(CommentContext)!;
55
60
 
56
- const { document, reload } = useDocument();
57
-
58
61
  const {
59
62
  selection,
60
63
  highlightPositions,
@@ -140,11 +143,6 @@ function AppContent() {
140
143
  }
141
144
  }, []);
142
145
 
143
- useEffect(() => {
144
- const eventSource = new EventSource("/api/heartbeat");
145
- return () => eventSource.close();
146
- }, []);
147
-
148
146
  // Scroll save/restore for tab switching
149
147
  const setScrollY = useAppStore((s) => s.setScrollY);
150
148
  const savedScrollY = useAppStore(
@@ -338,11 +336,16 @@ function useTabKeyboardShortcuts() {
338
336
 
339
337
  function App() {
340
338
  const { t } = useLocale();
341
- const { document, error, isInitialized } = useDocument();
339
+ const { document, error, isInitialized, reload } = useDocument();
342
340
  const documentOrder = useAppStore((s) => s.documentOrder);
343
341
 
344
342
  useTabKeyboardShortcuts();
345
343
 
344
+ useEffect(() => {
345
+ const eventSource = new EventSource("/api/heartbeat");
346
+ return () => eventSource.close();
347
+ }, []);
348
+
346
349
  if (error) {
347
350
  return (
348
351
  <div className="min-h-screen bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 flex items-center justify-center">
@@ -403,7 +406,7 @@ function App() {
403
406
  fileName={document.fileName}
404
407
  documentType={document.type}
405
408
  >
406
- <AppContent />
409
+ <AppContent document={document} reload={reload} />
407
410
  </CommentProvider>
408
411
  </LayoutProvider>
409
412
  </>
package/src/cli/index.ts CHANGED
@@ -34,17 +34,113 @@ interface ServerInfo {
34
34
  pid: number;
35
35
  }
36
36
 
37
- async function discoverServer(): Promise<ServerInfo | null> {
38
- const serverInfoPath = join(os.homedir(), ".readit", "server.json");
37
+ interface ServerTarget {
38
+ kind: "existing" | "started";
39
+ port: number;
40
+ url: string;
41
+ server?: { stop(): void };
42
+ }
43
+
44
+ const READIT_DIR = join(os.homedir(), ".readit");
45
+ const SERVER_INFO_PATH = join(READIT_DIR, "server.json");
46
+ const SERVER_LOCK_PATH = join(READIT_DIR, "server.lock");
47
+ const SERVER_LOCK_MAX_AGE_MS = 30_000;
48
+ const SERVER_LOCK_TIMEOUT_MS = 10_000;
49
+ const SERVER_LOCK_WAIT_MS = 100;
50
+
51
+ function isAlive(pid: number): boolean {
52
+ try {
53
+ process.kill(pid, 0);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function getErrnoCode(err: unknown): string | undefined {
61
+ return err instanceof Error && "code" in err
62
+ ? (err as NodeJS.ErrnoException).code
63
+ : undefined;
64
+ }
65
+
66
+ function sleep(ms: number): Promise<void> {
67
+ return new Promise((resolve) => setTimeout(resolve, ms));
68
+ }
69
+
70
+ async function clearStaleServerLock(): Promise<void> {
71
+ try {
72
+ const [stats, content] = await Promise.all([
73
+ fs.stat(SERVER_LOCK_PATH),
74
+ fs.readFile(SERVER_LOCK_PATH, "utf-8").catch(() => ""),
75
+ ]);
76
+
77
+ const age = Date.now() - stats.mtimeMs;
78
+ let pid: number | undefined;
79
+
80
+ if (content) {
81
+ try {
82
+ const lock = JSON.parse(content) as { pid?: number };
83
+ pid = lock.pid;
84
+ } catch {
85
+ // Ignore malformed lock files and fall back to age-based cleanup.
86
+ }
87
+ }
88
+
89
+ if (age > SERVER_LOCK_MAX_AGE_MS || (pid !== undefined && !isAlive(pid))) {
90
+ await fs.unlink(SERVER_LOCK_PATH).catch(() => {});
91
+ }
92
+ } catch (err) {
93
+ if (getErrnoCode(err) !== "ENOENT") throw err;
94
+ }
95
+ }
96
+
97
+ async function withServerLock<T>(run: () => Promise<T>): Promise<T> {
98
+ await fs.mkdir(READIT_DIR, { recursive: true });
99
+ const start = Date.now();
100
+
101
+ while (true) {
102
+ let handle: fs.FileHandle | undefined;
103
+
104
+ try {
105
+ handle = await fs.open(SERVER_LOCK_PATH, "wx");
106
+ await handle.writeFile(
107
+ JSON.stringify({ pid: process.pid, createdAt: Date.now() }),
108
+ "utf-8",
109
+ );
110
+
111
+ try {
112
+ return await run();
113
+ } finally {
114
+ await handle.close().catch(() => {});
115
+ await fs.unlink(SERVER_LOCK_PATH).catch(() => {});
116
+ }
117
+ } catch (err) {
118
+ if (handle) {
119
+ await handle.close().catch(() => {});
120
+ }
121
+
122
+ if (getErrnoCode(err) !== "EEXIST") {
123
+ throw err;
124
+ }
125
+
126
+ await clearStaleServerLock();
127
+
128
+ if (Date.now() - start >= SERVER_LOCK_TIMEOUT_MS) {
129
+ throw new Error("Timed out waiting for readit server lock");
130
+ }
131
+
132
+ await sleep(SERVER_LOCK_WAIT_MS);
133
+ }
134
+ }
135
+ }
39
136
 
137
+ async function discoverServer(): Promise<ServerInfo | null> {
40
138
  try {
41
- const content = readFileSync(serverInfoPath, "utf-8");
139
+ const content = readFileSync(SERVER_INFO_PATH, "utf-8");
42
140
  const info: ServerInfo = JSON.parse(content);
43
141
 
44
142
  // Verify the process is alive
45
- try {
46
- process.kill(info.pid, 0);
47
- } catch {
143
+ if (!isAlive(info.pid)) {
48
144
  return null;
49
145
  }
50
146
 
@@ -62,6 +158,65 @@ async function discoverServer(): Promise<ServerInfo | null> {
62
158
  }
63
159
  }
64
160
 
161
+ async function attachFiles(
162
+ server: ServerInfo,
163
+ files: { path: string; type: DocumentType }[],
164
+ ): Promise<void> {
165
+ for (const file of files) {
166
+ try {
167
+ const res = await fetch(`http://127.0.0.1:${server.port}/api/documents`, {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ path: file.path }),
171
+ });
172
+
173
+ if (!res.ok) {
174
+ const data = await res.json();
175
+ console.error(`error: failed to add ${file.path}: ${data.error}`);
176
+ process.exit(1);
177
+ }
178
+
179
+ const data = await res.json();
180
+ if (data.status === "added") {
181
+ console.log(`Added: ${data.fileName} (${data.type})`);
182
+ } else {
183
+ console.log(`Present: ${data.fileName} (${data.type})`);
184
+ }
185
+ } catch (err) {
186
+ console.error(
187
+ "error: failed to connect to server:",
188
+ err instanceof Error ? err.message : err,
189
+ );
190
+ process.exit(1);
191
+ }
192
+ }
193
+ }
194
+
195
+ async function getServerTarget(
196
+ files: FileEntry[],
197
+ port: number,
198
+ host: string,
199
+ ): Promise<ServerTarget> {
200
+ return withServerLock(async () => {
201
+ const server = await discoverServer();
202
+ if (server) {
203
+ return {
204
+ kind: "existing",
205
+ port: server.port,
206
+ url: `http://127.0.0.1:${server.port}`,
207
+ };
208
+ }
209
+
210
+ const started = await startServer({ files, port, host });
211
+ return {
212
+ kind: "started",
213
+ port: started.port,
214
+ url: started.url,
215
+ server: started.server,
216
+ };
217
+ });
218
+ }
219
+
65
220
  /**
66
221
  * Recursively find all .comments.md files in a directory.
67
222
  */
@@ -525,50 +680,6 @@ program
525
680
  resolvedFiles.push({ path: filePath, type });
526
681
  }
527
682
 
528
- // Try to find running server
529
- const server = await discoverServer();
530
-
531
- if (server) {
532
- // Send files to running server
533
- for (const file of resolvedFiles) {
534
- try {
535
- const res = await fetch(
536
- `http://127.0.0.1:${server.port}/api/documents`,
537
- {
538
- method: "POST",
539
- headers: { "Content-Type": "application/json" },
540
- body: JSON.stringify({ path: file.path }),
541
- },
542
- );
543
-
544
- if (!res.ok) {
545
- const data = await res.json();
546
- console.error(`error: failed to add ${file.path}: ${data.error}`);
547
- process.exit(1);
548
- }
549
-
550
- const data = await res.json();
551
- if (data.status === "added") {
552
- console.log(`Added: ${data.fileName} (${data.type})`);
553
- } else {
554
- console.log(`Present: ${data.fileName} (${data.type})`);
555
- }
556
- } catch (err) {
557
- console.error(
558
- "error: failed to connect to server:",
559
- err instanceof Error ? err.message : err,
560
- );
561
- process.exit(1);
562
- }
563
- }
564
-
565
- console.log(`\nServer: http://127.0.0.1:${server.port}`);
566
- return;
567
- }
568
-
569
- // No running server — start one
570
- console.log("No running server found, starting new one...\n");
571
-
572
683
  const files = resolvedFiles.map((f) => ({
573
684
  type: f.type,
574
685
  filePath: f.path,
@@ -576,11 +687,20 @@ program
576
687
 
577
688
  const preferredPort = Number.parseInt(options.port, 10);
578
689
  try {
579
- const { url, server: newServer } = await startServer({
690
+ const target = await getServerTarget(
580
691
  files,
581
- port: preferredPort,
582
- host: options.host,
583
- });
692
+ preferredPort,
693
+ options.host,
694
+ );
695
+
696
+ if (target.kind === "existing") {
697
+ await attachFiles(
698
+ { port: target.port, pid: process.pid },
699
+ resolvedFiles,
700
+ );
701
+ console.log(`\nServer: ${target.url}`);
702
+ return;
703
+ }
584
704
 
585
705
  const fileList = files.map((f) => ` ${f.filePath} (${f.type})`);
586
706
  console.log(`
@@ -588,17 +708,17 @@ readit - Document Review Tool
588
708
 
589
709
  ${files.length === 1 ? "File:" : "Files:"}
590
710
  ${fileList.join("\n")}
591
- URL: ${url}
711
+ URL: ${target.url}
592
712
 
593
713
  Server running. Close browser tab to stop.
594
714
  Press Ctrl+C to force stop.
595
715
  `);
596
716
 
597
- open(url);
717
+ open(target.url);
598
718
 
599
719
  process.on("SIGINT", async () => {
600
720
  console.log("\n\nShutting down...");
601
- newServer.stop();
721
+ target.server?.stop();
602
722
  await removeServerInfo();
603
723
  process.exit(0);
604
724
  });
@@ -157,7 +157,7 @@ export function DocumentViewer({
157
157
 
158
158
  // Double RAF: ensures React commit phase completes before DOM queries.
159
159
  // See: https://github.com/facebook/react/issues/20863
160
- // biome-ignore lint/correctness/useExhaustiveDependencies: must reapply highlights when content changes
160
+ // biome-ignore lint/correctness/useExhaustiveDependencies: must reapply highlights when content or components change
161
161
  useEffect(() => {
162
162
  if (type !== "markdown") return;
163
163
 
@@ -186,7 +186,9 @@ export function DocumentViewer({
186
186
  cancelAnimationFrame(outerFrameId);
187
187
  cancelAnimationFrame(innerFrameId);
188
188
  };
189
- }, [comments, content, type]);
189
+ // editorScheme/workingDirectory: when these change, markdownComponents memo recomputes,
190
+ // react-markdown replaces the DOM, so highlights must be reapplied
191
+ }, [comments, content, type, editorScheme, workingDirectory]);
190
192
 
191
193
  useEffect(() => {
192
194
  if (type !== "markdown") return;
@@ -509,27 +509,30 @@ function createDocumentStream(
509
509
  });
510
510
  }
511
511
 
512
- function createHeartbeat(isDev: boolean): Response {
512
+ function createHeartbeat(
513
+ onOpen: (controller: ReadableStreamDefaultController) => void,
514
+ onClose: (controller: ReadableStreamDefaultController) => void,
515
+ ): Response {
513
516
  let interval: ReturnType<typeof setInterval>;
517
+ let captured: ReadableStreamDefaultController;
514
518
 
515
519
  const stream = new ReadableStream({
516
520
  start(controller) {
521
+ captured = controller;
517
522
  controller.enqueue("data: connected\n\n");
523
+ onOpen(controller);
518
524
  interval = setInterval(() => {
519
525
  try {
520
526
  controller.enqueue("data: ping\n\n");
521
527
  } catch {
522
528
  clearInterval(interval);
529
+ onClose(controller);
523
530
  }
524
531
  }, 5000);
525
532
  },
526
533
  cancel() {
527
534
  clearInterval(interval);
528
- if (isDev) return;
529
- setTimeout(() => {
530
- console.log("\nBrowser disconnected, shutting down...");
531
- process.exit(0);
532
- }, 100);
535
+ onClose(captured);
533
536
  },
534
537
  });
535
538
 
@@ -605,6 +608,8 @@ function createServer(options: ServerOptions): ServerWithWatchers {
605
608
 
606
609
  const defaultPath = fileOrder[0];
607
610
  const sseClients = new Set<ReadableStreamDefaultController>();
611
+ const heartbeatClients = new Set<ReadableStreamDefaultController>();
612
+ let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
608
613
 
609
614
  function sendEvent(event: unknown): void {
610
615
  const message = `data: ${JSON.stringify(event)}\n\n`;
@@ -617,6 +622,31 @@ function createServer(options: ServerOptions): ServerWithWatchers {
617
622
  }
618
623
  }
619
624
 
625
+ function clearShutdownTimer(): void {
626
+ if (!shutdownTimer) return;
627
+ clearTimeout(shutdownTimer);
628
+ shutdownTimer = null;
629
+ }
630
+
631
+ function onHeartbeatOpen(controller: ReadableStreamDefaultController): void {
632
+ heartbeatClients.add(controller);
633
+ clearShutdownTimer();
634
+ }
635
+
636
+ function onHeartbeatClose(controller: ReadableStreamDefaultController): void {
637
+ heartbeatClients.delete(controller);
638
+ if (isDev || heartbeatClients.size > 0 || shutdownTimer) return;
639
+
640
+ shutdownTimer = setTimeout(() => {
641
+ if (heartbeatClients.size > 0) {
642
+ clearShutdownTimer();
643
+ return;
644
+ }
645
+ console.log("\nBrowser disconnected, shutting down...");
646
+ process.exit(0);
647
+ }, 1500);
648
+ }
649
+
620
650
  async function ensureFileContent(filePath: string): Promise<string> {
621
651
  const state = fileMap.get(filePath);
622
652
  if (!state) {
@@ -808,7 +838,7 @@ function createServer(options: ServerOptions): ServerWithWatchers {
808
838
  }
809
839
 
810
840
  if (pathname === "/api/heartbeat" && method === "GET") {
811
- return createHeartbeat(isDev);
841
+ return createHeartbeat(onHeartbeatOpen, onHeartbeatClose);
812
842
  }
813
843
 
814
844
  // Comments routes