readthemanual 0.0.1
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/README.md +196 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +206 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +134 -0
- package/dist/db.d.ts +33 -0
- package/dist/db.js +153 -0
- package/dist/ingest.d.ts +9 -0
- package/dist/ingest.js +163 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +105 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +111 -0
- package/docs-search/package-lock.json +1090 -0
- package/docs-search/package.json +30 -0
- package/docs-search/src/cli.ts +129 -0
- package/docs-search/src/db.ts +192 -0
- package/docs-search/src/ingest.ts +168 -0
- package/docs-search/src/server.ts +145 -0
- package/docs-search/tsconfig.json +17 -0
- package/package.json +32 -0
- package/src/cli.ts +221 -0
- package/src/config.ts +143 -0
- package/src/db.ts +207 -0
- package/src/ingest.ts +200 -0
- package/src/mcp.ts +124 -0
- package/src/server.ts +145 -0
- package/tsconfig.json +17 -0
package/src/ingest.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { openDb, clearDocs, insertSection } from "./db";
|
|
2
|
+
import { loadConfig, getDbPath, getDocsDir, type SourceConfig } from "./config";
|
|
3
|
+
import type Database from "better-sqlite3";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
|
|
7
|
+
interface TreeEntry {
|
|
8
|
+
path: string;
|
|
9
|
+
type: string;
|
|
10
|
+
url: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function fetchTree(repo: string, branch: string): Promise<TreeEntry[]> {
|
|
14
|
+
const url = `https://api.github.com/repos/${repo}/git/trees/${branch}?recursive=1`;
|
|
15
|
+
const res = await fetch(url, {
|
|
16
|
+
headers: {
|
|
17
|
+
Accept: "application/vnd.github.v3+json",
|
|
18
|
+
...(process.env.GITHUB_TOKEN
|
|
19
|
+
? { Authorization: `token ${process.env.GITHUB_TOKEN}` }
|
|
20
|
+
: {}),
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
if (!res.ok) throw new Error(`GitHub tree API: ${res.status} ${res.statusText}`);
|
|
24
|
+
const data = (await res.json()) as { tree: TreeEntry[] };
|
|
25
|
+
return data.tree;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function fetchRawFile(repo: string, branch: string, filePath: string): Promise<string> {
|
|
29
|
+
const url = `https://raw.githubusercontent.com/${repo}/${branch}/${filePath}`;
|
|
30
|
+
const res = await fetch(url);
|
|
31
|
+
if (!res.ok) throw new Error(`Failed to fetch ${filePath}: ${res.status}`);
|
|
32
|
+
return res.text();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function detectLanguage(filePath: string): string {
|
|
36
|
+
const match = filePath.match(/^docs\/i18n\/([a-z]{2}(?:-[A-Z]{2})?)\//);
|
|
37
|
+
if (match) return match[1];
|
|
38
|
+
return "en";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Section {
|
|
42
|
+
heading: string;
|
|
43
|
+
content: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function splitByHeadings(markdown: string): Section[] {
|
|
47
|
+
const lines = markdown.split("\n");
|
|
48
|
+
const sections: Section[] = [];
|
|
49
|
+
let currentHeading = "(intro)";
|
|
50
|
+
let currentLines: string[] = [];
|
|
51
|
+
|
|
52
|
+
for (const line of lines) {
|
|
53
|
+
const headingMatch = line.match(/^(#{1,3})\s+(.+)/);
|
|
54
|
+
if (headingMatch) {
|
|
55
|
+
if (currentLines.length > 0) {
|
|
56
|
+
const content = currentLines.join("\n").trim();
|
|
57
|
+
if (content.length > 0) {
|
|
58
|
+
sections.push({ heading: currentHeading, content });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
currentHeading = headingMatch[2];
|
|
62
|
+
currentLines = [];
|
|
63
|
+
} else {
|
|
64
|
+
currentLines.push(line);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (currentLines.length > 0) {
|
|
69
|
+
const content = currentLines.join("\n").trim();
|
|
70
|
+
if (content.length > 0) {
|
|
71
|
+
sections.push({ heading: currentHeading, content });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return sections;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function ingestSource(
|
|
79
|
+
db: Database.Database,
|
|
80
|
+
source: SourceConfig,
|
|
81
|
+
opts: { allLangs?: boolean; verbose?: boolean }
|
|
82
|
+
): Promise<{ filesProcessed: number; sectionsInserted: number }> {
|
|
83
|
+
const log = opts.verbose ? console.log : () => {};
|
|
84
|
+
const repo = source.repo;
|
|
85
|
+
const branch = source.branch ?? "main";
|
|
86
|
+
const docsPrefix = source.docsPrefix ?? "docs/";
|
|
87
|
+
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
const docsDir = getDocsDir(config, source.name);
|
|
90
|
+
|
|
91
|
+
log(`[${source.name}] Fetching tree from ${repo}@${branch}...`);
|
|
92
|
+
const tree = await fetchTree(repo, branch);
|
|
93
|
+
|
|
94
|
+
const mdFiles = tree.filter((entry) => {
|
|
95
|
+
if (entry.type !== "blob") return false;
|
|
96
|
+
if (!entry.path.startsWith(docsPrefix)) return false;
|
|
97
|
+
if (!entry.path.endsWith(".md")) return false;
|
|
98
|
+
if (/\.(jpg|png|svg|gif)$/i.test(entry.path)) return false;
|
|
99
|
+
if (!opts.allLangs) {
|
|
100
|
+
const lang = detectLanguage(entry.path);
|
|
101
|
+
if (lang !== "en") return false;
|
|
102
|
+
}
|
|
103
|
+
return true;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
log(`[${source.name}] Found ${mdFiles.length} markdown files.`);
|
|
107
|
+
|
|
108
|
+
// Save docs locally
|
|
109
|
+
fs.rmSync(docsDir, { recursive: true, force: true });
|
|
110
|
+
fs.mkdirSync(docsDir, { recursive: true });
|
|
111
|
+
|
|
112
|
+
let filesProcessed = 0;
|
|
113
|
+
let sectionsInserted = 0;
|
|
114
|
+
const allSections: { file_path: string; heading: string; content: string; language: string }[] = [];
|
|
115
|
+
|
|
116
|
+
const batchSize = 10;
|
|
117
|
+
for (let i = 0; i < mdFiles.length; i += batchSize) {
|
|
118
|
+
const batch = mdFiles.slice(i, i + batchSize);
|
|
119
|
+
const results = await Promise.all(
|
|
120
|
+
batch.map(async (entry) => {
|
|
121
|
+
const content = await fetchRawFile(repo, branch, entry.path);
|
|
122
|
+
return { path: entry.path, content };
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
for (const { path: filePath, content } of results) {
|
|
127
|
+
// Strip docsPrefix so we don't get docs/docs/ nesting
|
|
128
|
+
const relativePath = filePath.startsWith(docsPrefix)
|
|
129
|
+
? filePath.slice(docsPrefix.length)
|
|
130
|
+
: filePath;
|
|
131
|
+
|
|
132
|
+
// Write raw markdown to disk
|
|
133
|
+
const localPath = path.join(docsDir, relativePath);
|
|
134
|
+
fs.mkdirSync(path.dirname(localPath), { recursive: true });
|
|
135
|
+
fs.writeFileSync(localPath, content, "utf-8");
|
|
136
|
+
|
|
137
|
+
const lang = detectLanguage(filePath);
|
|
138
|
+
const sections = splitByHeadings(content);
|
|
139
|
+
for (const section of sections) {
|
|
140
|
+
allSections.push({
|
|
141
|
+
file_path: `${source.name}/${relativePath}`,
|
|
142
|
+
heading: section.heading,
|
|
143
|
+
content: section.content,
|
|
144
|
+
language: lang,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
filesProcessed++;
|
|
148
|
+
log(` [${filesProcessed}/${mdFiles.length}] ${filePath} (${sections.length} sections)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const insertMany = db.transaction(
|
|
153
|
+
(sections: { file_path: string; heading: string; content: string; language: string }[]) => {
|
|
154
|
+
for (const s of sections) {
|
|
155
|
+
insertSection(db, s);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
insertMany(allSections);
|
|
160
|
+
sectionsInserted = allSections.length;
|
|
161
|
+
|
|
162
|
+
return { filesProcessed, sectionsInserted };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function ingest(opts: {
|
|
166
|
+
dbPath?: string;
|
|
167
|
+
source?: string;
|
|
168
|
+
allLangs?: boolean;
|
|
169
|
+
verbose?: boolean;
|
|
170
|
+
}): Promise<{ filesProcessed: number; sectionsInserted: number }> {
|
|
171
|
+
const config = loadConfig();
|
|
172
|
+
const dbPath = opts.dbPath ?? getDbPath(config);
|
|
173
|
+
const db = openDb(dbPath);
|
|
174
|
+
const log = opts.verbose ? console.log : () => {};
|
|
175
|
+
|
|
176
|
+
// Filter to a single source if requested
|
|
177
|
+
const sources = opts.source
|
|
178
|
+
? config.sources.filter((s) => s.name === opts.source)
|
|
179
|
+
: config.sources;
|
|
180
|
+
|
|
181
|
+
if (sources.length === 0) {
|
|
182
|
+
const available = config.sources.map((s) => s.name).join(", ");
|
|
183
|
+
throw new Error(`Source "${opts.source}" not found in config. Available: ${available}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
clearDocs(db);
|
|
187
|
+
|
|
188
|
+
let totalFiles = 0;
|
|
189
|
+
let totalSections = 0;
|
|
190
|
+
|
|
191
|
+
for (const source of sources) {
|
|
192
|
+
const result = await ingestSource(db, source, opts);
|
|
193
|
+
totalFiles += result.filesProcessed;
|
|
194
|
+
totalSections += result.sectionsInserted;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
db.close();
|
|
198
|
+
log(`Done. ${totalFiles} files, ${totalSections} sections indexed from ${sources.length} source(s).`);
|
|
199
|
+
return { filesProcessed: totalFiles, sectionsInserted: totalSections };
|
|
200
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { openDb, search, getDocument, listDocuments } from "./db";
|
|
5
|
+
import { loadConfig, getDbPath, getDefaultSource } from "./config";
|
|
6
|
+
import path from "path";
|
|
7
|
+
|
|
8
|
+
export async function startMcp(opts: { dbPath?: string }): Promise<void> {
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
const dbPath = opts.dbPath ?? getDbPath(config);
|
|
11
|
+
const db = openDb(dbPath);
|
|
12
|
+
|
|
13
|
+
const server = new McpServer({
|
|
14
|
+
name: "rtm",
|
|
15
|
+
version: "0.0.1",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
server.registerTool(
|
|
19
|
+
"search",
|
|
20
|
+
{
|
|
21
|
+
title: "Search docs",
|
|
22
|
+
description: "Full-text search across indexed documentation. Returns matching sections with snippets.",
|
|
23
|
+
inputSchema: {
|
|
24
|
+
query: z.string().describe("Search query"),
|
|
25
|
+
source: z.string().optional().describe("Source name to scope search (default: last used source)"),
|
|
26
|
+
language: z.string().optional().describe("Filter by language code (e.g. 'en')"),
|
|
27
|
+
limit: z.number().optional().describe("Max results (default: 10)"),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async ({ query, source, language, limit }) => {
|
|
31
|
+
const cfg = loadConfig();
|
|
32
|
+
const sourceName = source ?? getDefaultSource(cfg);
|
|
33
|
+
const { results, total } = search(db, query, {
|
|
34
|
+
language,
|
|
35
|
+
limit: limit ?? 10,
|
|
36
|
+
sourcePrefix: sourceName ? `${sourceName}/` : undefined,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const text = results.length === 0
|
|
40
|
+
? "No results found."
|
|
41
|
+
: results.map((r) => {
|
|
42
|
+
const fullPath = path.join(cfg.dataDir, r.file_path);
|
|
43
|
+
const clean = r.snippet.replace(/<\/?mark>/g, "");
|
|
44
|
+
return `[${r.id}] ${r.heading}\n ${fullPath}\n ${clean}`;
|
|
45
|
+
}).join("\n\n") + `\n\n${results.length} of ${total} match(es)`;
|
|
46
|
+
|
|
47
|
+
return { content: [{ type: "text" as const, text }] };
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
server.registerTool(
|
|
52
|
+
"get_document",
|
|
53
|
+
{
|
|
54
|
+
title: "Get document",
|
|
55
|
+
description: "Retrieve a full document section by its ID.",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
id: z.number().describe("Document section ID"),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
async ({ id }) => {
|
|
61
|
+
const doc = getDocument(db, id);
|
|
62
|
+
if (!doc) {
|
|
63
|
+
return { content: [{ type: "text" as const, text: `Document ${id} not found.` }] };
|
|
64
|
+
}
|
|
65
|
+
const cfg = loadConfig();
|
|
66
|
+
const fullPath = path.join(cfg.dataDir, doc.file_path);
|
|
67
|
+
return {
|
|
68
|
+
content: [{
|
|
69
|
+
type: "text" as const,
|
|
70
|
+
text: `# ${doc.heading}\n${fullPath} (${doc.language})\n\n${doc.content}`,
|
|
71
|
+
}],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
server.registerTool(
|
|
77
|
+
"list_sources",
|
|
78
|
+
{
|
|
79
|
+
title: "List sources",
|
|
80
|
+
description: "List configured documentation sources.",
|
|
81
|
+
inputSchema: {},
|
|
82
|
+
},
|
|
83
|
+
async () => {
|
|
84
|
+
const cfg = loadConfig();
|
|
85
|
+
const defaultSrc = getDefaultSource(cfg);
|
|
86
|
+
const lines = cfg.sources.map((s) => {
|
|
87
|
+
const marker = s.name === defaultSrc ? " (default)" : "";
|
|
88
|
+
return `- ${s.name}: ${s.repo}@${s.branch ?? "main"}${marker}`;
|
|
89
|
+
});
|
|
90
|
+
return { content: [{ type: "text" as const, text: lines.join("\n") }] };
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
server.registerTool(
|
|
95
|
+
"list_documents",
|
|
96
|
+
{
|
|
97
|
+
title: "List documents",
|
|
98
|
+
description: "List indexed document sections.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
language: z.string().optional().describe("Filter by language"),
|
|
101
|
+
limit: z.number().optional().describe("Max results (default: 50)"),
|
|
102
|
+
offset: z.number().optional().describe("Offset for pagination"),
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
async ({ language, limit, offset }) => {
|
|
106
|
+
const result = listDocuments(db, {
|
|
107
|
+
language,
|
|
108
|
+
limit: limit ?? 50,
|
|
109
|
+
offset: offset ?? 0,
|
|
110
|
+
});
|
|
111
|
+
const cfg = loadConfig();
|
|
112
|
+
const lines = result.docs.map((d) => {
|
|
113
|
+
const fullPath = path.join(cfg.dataDir, d.file_path);
|
|
114
|
+
return `[${d.id}] ${fullPath} — ${d.heading}`;
|
|
115
|
+
});
|
|
116
|
+
const text = lines.join("\n") + `\n\n${result.docs.length} of ${result.total} total`;
|
|
117
|
+
return { content: [{ type: "text" as const, text }] };
|
|
118
|
+
}
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const transport = new StdioServerTransport();
|
|
122
|
+
await server.connect(transport);
|
|
123
|
+
console.error("rtm MCP server running on stdio");
|
|
124
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { openDb, search, getDocument, listDocuments } from "./db";
|
|
4
|
+
import type Database from "better-sqlite3";
|
|
5
|
+
|
|
6
|
+
interface JsonRpcRequest {
|
|
7
|
+
jsonrpc: "2.0";
|
|
8
|
+
id: string | number | null;
|
|
9
|
+
method: string;
|
|
10
|
+
params?: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface JsonRpcResponse {
|
|
14
|
+
jsonrpc: "2.0";
|
|
15
|
+
id: string | number | null;
|
|
16
|
+
result?: unknown;
|
|
17
|
+
error?: { code: number; message: string; data?: unknown };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function rpcError(
|
|
21
|
+
id: string | number | null,
|
|
22
|
+
code: number,
|
|
23
|
+
message: string
|
|
24
|
+
): JsonRpcResponse {
|
|
25
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rpcResult(id: string | number | null, result: unknown): JsonRpcResponse {
|
|
29
|
+
return { jsonrpc: "2.0", id, result };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function handleRpc(db: Database.Database, req: JsonRpcRequest): JsonRpcResponse {
|
|
33
|
+
const { id, method, params } = req;
|
|
34
|
+
|
|
35
|
+
switch (method) {
|
|
36
|
+
case "search": {
|
|
37
|
+
const query = params?.query;
|
|
38
|
+
if (typeof query !== "string" || query.trim().length === 0) {
|
|
39
|
+
return rpcError(id, -32602, "params.query is required and must be a non-empty string");
|
|
40
|
+
}
|
|
41
|
+
const language = typeof params?.language === "string" ? params.language : undefined;
|
|
42
|
+
const limit = typeof params?.limit === "number" ? params.limit : undefined;
|
|
43
|
+
const { results, total } = search(db, query, { language, limit });
|
|
44
|
+
return rpcResult(id, { results, total, count: results.length });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
case "get_document": {
|
|
48
|
+
const docId = params?.id;
|
|
49
|
+
if (typeof docId !== "number") {
|
|
50
|
+
return rpcError(id, -32602, "params.id is required and must be a number");
|
|
51
|
+
}
|
|
52
|
+
const doc = getDocument(db, docId);
|
|
53
|
+
if (!doc) {
|
|
54
|
+
return rpcError(id, -32602, `Document with id ${docId} not found`);
|
|
55
|
+
}
|
|
56
|
+
return rpcResult(id, doc);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case "list_documents": {
|
|
60
|
+
const language = typeof params?.language === "string" ? params.language : undefined;
|
|
61
|
+
const limit = typeof params?.limit === "number" ? params.limit : undefined;
|
|
62
|
+
const offset = typeof params?.offset === "number" ? params.offset : undefined;
|
|
63
|
+
const result = listDocuments(db, { language, limit, offset });
|
|
64
|
+
return rpcResult(id, result);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
default:
|
|
68
|
+
return rpcError(id, -32601, `Method '${method}' not found`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createApp(db: Database.Database): Hono {
|
|
73
|
+
const app = new Hono();
|
|
74
|
+
|
|
75
|
+
app.post("/rpc", async (c) => {
|
|
76
|
+
let body: unknown;
|
|
77
|
+
try {
|
|
78
|
+
body = await c.req.json();
|
|
79
|
+
} catch {
|
|
80
|
+
return c.json(rpcError(null, -32700, "Parse error"), 200);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Batch support
|
|
84
|
+
if (Array.isArray(body)) {
|
|
85
|
+
const responses = body.map((req) => {
|
|
86
|
+
if (!isValidRequest(req)) {
|
|
87
|
+
return rpcError(req?.id ?? null, -32600, "Invalid JSON-RPC request");
|
|
88
|
+
}
|
|
89
|
+
return handleRpc(db, req);
|
|
90
|
+
});
|
|
91
|
+
return c.json(responses, 200);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!isValidRequest(body)) {
|
|
95
|
+
return c.json(
|
|
96
|
+
rpcError((body as any)?.id ?? null, -32600, "Invalid JSON-RPC request"),
|
|
97
|
+
200
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return c.json(handleRpc(db, body), 200);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Health check
|
|
105
|
+
app.get("/health", (c) => c.json({ status: "ok" }));
|
|
106
|
+
|
|
107
|
+
return app;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function isValidRequest(obj: unknown): obj is JsonRpcRequest {
|
|
111
|
+
if (typeof obj !== "object" || obj === null) return false;
|
|
112
|
+
const r = obj as Record<string, unknown>;
|
|
113
|
+
return r.jsonrpc === "2.0" && typeof r.method === "string";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
import net from "net";
|
|
117
|
+
|
|
118
|
+
function isPortInUse(port: number): Promise<boolean> {
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
const server = net.createServer();
|
|
121
|
+
server.once("error", () => resolve(true));
|
|
122
|
+
server.once("listening", () => {
|
|
123
|
+
server.close();
|
|
124
|
+
resolve(false);
|
|
125
|
+
});
|
|
126
|
+
server.listen(port);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function startServer(opts: { dbPath?: string; port?: number }): Promise<void> {
|
|
131
|
+
const port = opts.port ?? 3777;
|
|
132
|
+
|
|
133
|
+
if (await isPortInUse(port)) {
|
|
134
|
+
console.error(`Port ${port} is already in use. Another instance may be running.`);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const db = openDb(opts.dbPath);
|
|
139
|
+
const app = createApp(db);
|
|
140
|
+
|
|
141
|
+
serve({ fetch: app.fetch, port }, () => {
|
|
142
|
+
console.log(`rtm server listening on http://localhost:${port}`);
|
|
143
|
+
console.log(`JSON-RPC endpoint: POST http://localhost:${port}/rpc`);
|
|
144
|
+
});
|
|
145
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"declaration": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|