@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 +1 -1
- package/src/App.tsx +13 -10
- package/src/cli/index.ts +177 -57
- package/src/components/DocumentViewer/DocumentViewer.tsx +4 -2
- package/src/server/index.ts +37 -7
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -38,7 +38,12 @@ const TOASTER_OPTIONS = {
|
|
|
38
38
|
},
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
690
|
+
const target = await getServerTarget(
|
|
580
691
|
files,
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/src/server/index.ts
CHANGED
|
@@ -509,27 +509,30 @@ function createDocumentStream(
|
|
|
509
509
|
});
|
|
510
510
|
}
|
|
511
511
|
|
|
512
|
-
function createHeartbeat(
|
|
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
|
-
|
|
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(
|
|
841
|
+
return createHeartbeat(onHeartbeatOpen, onHeartbeatClose);
|
|
812
842
|
}
|
|
813
843
|
|
|
814
844
|
// Comments routes
|