canon-atlas 0.1.0
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/LICENSE +21 -0
- package/README.md +103 -0
- package/package.json +46 -0
- package/src/build.mjs +37 -0
- package/src/cli.mjs +120 -0
- package/src/clusters.mjs +11 -0
- package/src/config.mjs +42 -0
- package/src/embed.mjs +179 -0
- package/src/graph.mjs +11 -0
- package/src/markdown.mjs +10 -0
- package/src/page.mjs +140 -0
- package/src/scan.mjs +37 -0
- package/src/serve.mjs +380 -0
- package/src/ui/app.js +1505 -0
- package/src/ui/labels.cjs +38 -0
- package/src/ui/pipeline.cjs +867 -0
- package/src/ui/resolve.cjs +104 -0
- package/src/ui/shell.js +829 -0
- package/src/ui/style.css +558 -0
- package/vendor/LICENSE.d3 +17 -0
- package/vendor/d3.v7.min.js +2 -0
package/src/page.mjs
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/* Page assembly: one self-contained HTML document. d3, the stylesheet, the app,
|
|
2
|
+
and the pre-built graph data are all inlined, so the chart opens from file://
|
|
3
|
+
and ships as a single artifact. The only external reference is the Google
|
|
4
|
+
Fonts stylesheet, and the page falls back to system fonts without it.
|
|
5
|
+
|
|
6
|
+
buildData and scriptJson are the shared pipeline's, so the chart carries the
|
|
7
|
+
same wire data the live and pure-client paths build. Only the file inlining
|
|
8
|
+
below is Node-bound. */
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
|
|
15
|
+
const P = createRequire(import.meta.url)("./ui/pipeline.cjs");
|
|
16
|
+
export const scriptJson = P.scriptJson;
|
|
17
|
+
export const buildData = P.buildData;
|
|
18
|
+
|
|
19
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
20
|
+
const read = (p) => readFileSync(join(here, p), "utf8");
|
|
21
|
+
|
|
22
|
+
/* The head and body markup the chart and the app share. */
|
|
23
|
+
function pageHead(title) {
|
|
24
|
+
return `<meta charset="utf-8">
|
|
25
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
26
|
+
<title>${title.replace(/[<>&]/g, "")}</title>
|
|
27
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
28
|
+
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500&display=swap" rel="stylesheet">
|
|
29
|
+
<style>
|
|
30
|
+
${read("ui/style.css")}
|
|
31
|
+
</style>`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* The shell: a slim header, a rail of icon buttons owning sidebar panels on
|
|
35
|
+
the left, the constellation filling the center, and a tabbed reader on the
|
|
36
|
+
right that appears when a document opens. */
|
|
37
|
+
const APP_MARKUP = `<div id="app">
|
|
38
|
+
<div id="grid">
|
|
39
|
+
<nav id="rail" aria-label="panels">
|
|
40
|
+
<button id="rail-wiki" class="railbtn" title="documents" aria-pressed="false">
|
|
41
|
+
<svg viewBox="0 0 18 18" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
|
42
|
+
<circle cx="3.1" cy="4.5" r="1" fill="currentColor" stroke="none"/><line x1="6.4" y1="4.5" x2="15.2" y2="4.5"/>
|
|
43
|
+
<circle cx="3.1" cy="9" r="1" fill="currentColor" stroke="none"/><line x1="6.4" y1="9" x2="15.2" y2="9"/>
|
|
44
|
+
<circle cx="3.1" cy="13.5" r="1" fill="currentColor" stroke="none"/><line x1="6.4" y1="13.5" x2="15.2" y2="13.5"/>
|
|
45
|
+
</svg>
|
|
46
|
+
</button>
|
|
47
|
+
<button id="rail-map" class="railbtn" title="map: clusters, collections, color" aria-pressed="false">
|
|
48
|
+
<svg viewBox="0 0 18 18" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
|
|
49
|
+
<line x1="5.9" y1="5.3" x2="11.4" y2="6.9"/><line x1="12.4" y1="9.4" x2="7.5" y2="12.6"/>
|
|
50
|
+
<circle cx="4.4" cy="4.8" r="1.8"/><circle cx="13.5" cy="7.5" r="2.2"/><circle cx="6.3" cy="13.7" r="1.5"/>
|
|
51
|
+
</svg>
|
|
52
|
+
</button>
|
|
53
|
+
<button id="rail-folders" class="railbtn" title="folders" aria-pressed="false" hidden>
|
|
54
|
+
<svg viewBox="0 0 18 18" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" aria-hidden="true">
|
|
55
|
+
<path d="M2.2 4.6h4.3l1.6 1.8h7.7v7.2a1 1 0 0 1-1 1H3.2a1 1 0 0 1-1-1z"/>
|
|
56
|
+
</svg>
|
|
57
|
+
</button>
|
|
58
|
+
</nav>
|
|
59
|
+
<aside id="sidebar">
|
|
60
|
+
<div id="wiki" class="panel"></div>
|
|
61
|
+
<div id="overview" class="panel"></div>
|
|
62
|
+
<div id="folders" class="panel"></div>
|
|
63
|
+
</aside>
|
|
64
|
+
<div id="sbgrip" class="grip" title="drag to resize the side panel"></div>
|
|
65
|
+
<div id="stage">
|
|
66
|
+
<button id="refit" class="mapbtn" title="clear the selection and frame the whole corpus">refit</button>
|
|
67
|
+
<button id="lost" class="mapbtn lostbtn" hidden>the sky is off screen: bring it back</button>
|
|
68
|
+
</div>
|
|
69
|
+
<div id="sidegrip" class="grip" title="drag to resize the reader panel"></div>
|
|
70
|
+
<aside id="side">
|
|
71
|
+
<div id="tabs" role="tablist"></div>
|
|
72
|
+
<div id="reader"></div>
|
|
73
|
+
</aside>
|
|
74
|
+
</div>
|
|
75
|
+
</div>`;
|
|
76
|
+
|
|
77
|
+
export function renderPage(data) {
|
|
78
|
+
return `<!DOCTYPE html>
|
|
79
|
+
<html lang="en">
|
|
80
|
+
<head>
|
|
81
|
+
${pageHead(data.title || "canon-atlas")}
|
|
82
|
+
</head>
|
|
83
|
+
<body>
|
|
84
|
+
${APP_MARKUP}
|
|
85
|
+
<script>
|
|
86
|
+
${readFileSync(join(here, "..", "vendor", "d3.v7.min.js"), "utf8")}
|
|
87
|
+
</script>
|
|
88
|
+
<script>
|
|
89
|
+
var DATA = ${scriptJson(data)};
|
|
90
|
+
</script>
|
|
91
|
+
<script>
|
|
92
|
+
${read("ui/labels.cjs")}
|
|
93
|
+
</script>
|
|
94
|
+
<script>
|
|
95
|
+
${read("ui/resolve.cjs")}
|
|
96
|
+
</script>
|
|
97
|
+
<script>
|
|
98
|
+
${read("ui/app.js")}
|
|
99
|
+
</script>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/* The pure-client app: the same page with no baked-in data. The shared pipeline
|
|
106
|
+
and the folder shell are inlined instead, so it opens a directory in the
|
|
107
|
+
browser, builds the wire data client-side, and mounts. No DATA global means
|
|
108
|
+
app.js waits for the shell to call AtlasApp.mount rather than booting a chart.
|
|
109
|
+
Scripts load in dependency order: d3, labels, resolve, then pipeline (which
|
|
110
|
+
reads the resolve global), then app (which exposes AtlasApp), then shell. */
|
|
111
|
+
export function renderAppPage() {
|
|
112
|
+
return `<!DOCTYPE html>
|
|
113
|
+
<html lang="en">
|
|
114
|
+
<head>
|
|
115
|
+
${pageHead("canon-atlas")}
|
|
116
|
+
</head>
|
|
117
|
+
<body>
|
|
118
|
+
${APP_MARKUP}
|
|
119
|
+
<script>
|
|
120
|
+
${readFileSync(join(here, "..", "vendor", "d3.v7.min.js"), "utf8")}
|
|
121
|
+
</script>
|
|
122
|
+
<script>
|
|
123
|
+
${read("ui/labels.cjs")}
|
|
124
|
+
</script>
|
|
125
|
+
<script>
|
|
126
|
+
${read("ui/resolve.cjs")}
|
|
127
|
+
</script>
|
|
128
|
+
<script>
|
|
129
|
+
${read("ui/pipeline.cjs")}
|
|
130
|
+
</script>
|
|
131
|
+
<script>
|
|
132
|
+
${read("ui/app.js")}
|
|
133
|
+
</script>
|
|
134
|
+
<script>
|
|
135
|
+
${read("ui/shell.js")}
|
|
136
|
+
</script>
|
|
137
|
+
</body>
|
|
138
|
+
</html>
|
|
139
|
+
`;
|
|
140
|
+
}
|
package/src/scan.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* Corpus scan in Node: walk the root for markdown, read each claimed file, and
|
|
2
|
+
hand the text to the shared pipeline. Frontmatter parsing, link extraction,
|
|
3
|
+
and the per-document shaping live in ui/pipeline.cjs, so the browser scans
|
|
4
|
+
the same bytes into the same docs. Only the directory walk is fs-bound. */
|
|
5
|
+
|
|
6
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
7
|
+
import { join, relative } from "node:path";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { collectionFor } from "./config.mjs";
|
|
10
|
+
|
|
11
|
+
const P = createRequire(import.meta.url)("./ui/pipeline.cjs");
|
|
12
|
+
|
|
13
|
+
export const parseFrontmatter = P.parseFrontmatter;
|
|
14
|
+
export const extractLinks = P.extractLinks;
|
|
15
|
+
|
|
16
|
+
const SKIP_DIRS = new Set([".git", "node_modules", ".obsidian", ".atlas"]);
|
|
17
|
+
|
|
18
|
+
function walk(dir, root, acc) {
|
|
19
|
+
const entries = readdirSync(dir).sort();
|
|
20
|
+
for (const name of entries) {
|
|
21
|
+
if (SKIP_DIRS.has(name)) continue;
|
|
22
|
+
const full = join(dir, name);
|
|
23
|
+
const st = statSync(full);
|
|
24
|
+
if (st.isDirectory()) walk(full, root, acc);
|
|
25
|
+
else if (/\.(md|markdown)$/i.test(name)) acc.push(relative(root, full).split("\\").join("/"));
|
|
26
|
+
}
|
|
27
|
+
return acc;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function scanCorpus(root, config) {
|
|
31
|
+
const files = [];
|
|
32
|
+
for (const rel of walk(root, root, [])) {
|
|
33
|
+
if (!collectionFor(config, rel)) continue;
|
|
34
|
+
files.push({ path: rel, text: readFileSync(join(root, rel), "utf8") });
|
|
35
|
+
}
|
|
36
|
+
return P.scanFiles(files, config);
|
|
37
|
+
}
|
package/src/serve.mjs
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/* Serve: the same page as the build, with the corpus behind it. The server
|
|
2
|
+
binds to localhost only, because this mode edits the files it shows.
|
|
3
|
+
|
|
4
|
+
Write rules follow the collections: a mutable collection takes create, edit,
|
|
5
|
+
and delete; an immutable collection is append-only, so it takes create and
|
|
6
|
+
nothing else. Every path is confined to the corpus root, and confinement is
|
|
7
|
+
real: a symlink pointing out of the tree, or a symlinked alias onto another
|
|
8
|
+
collection, is refused rather than followed. Because the page inlines the
|
|
9
|
+
whole corpus and the API mutates it, the browser is kept off the door too:
|
|
10
|
+
loopback Host, same-origin Origin, and a JSON content type are required, so a
|
|
11
|
+
page on another origin cannot drive the API by DNS rebinding or a simple
|
|
12
|
+
cross-site POST. */
|
|
13
|
+
|
|
14
|
+
import { createServer } from "node:http";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
lstatSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
realpathSync,
|
|
22
|
+
renameSync,
|
|
23
|
+
statSync,
|
|
24
|
+
unlinkSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
29
|
+
import { loadConfig, loadVectors, collectionFor } from "./config.mjs";
|
|
30
|
+
import { scanCorpus } from "./scan.mjs";
|
|
31
|
+
import { buildGraph } from "./graph.mjs";
|
|
32
|
+
import { buildData, renderPage, renderAppPage } from "./page.mjs";
|
|
33
|
+
|
|
34
|
+
const CACHE_TTL_MS = 3000;
|
|
35
|
+
const BODY_LIMIT = 4_000_000;
|
|
36
|
+
|
|
37
|
+
function httpError(status, message) {
|
|
38
|
+
const e = new Error(message);
|
|
39
|
+
e.status = status;
|
|
40
|
+
return e;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/* The browser is not a trust boundary that 127.0.0.1 alone establishes: a
|
|
44
|
+
page on another origin can still POST here, and DNS rebinding can point a
|
|
45
|
+
hostname at this port. Require a loopback Host so a rebound name is
|
|
46
|
+
refused, and for mutations require a same-origin Origin and a JSON content
|
|
47
|
+
type so no simple cross-site request reaches the write path. */
|
|
48
|
+
function requireLoopbackHost(req) {
|
|
49
|
+
const host = req.headers.host || "";
|
|
50
|
+
const name = host.replace(/:\d+$/, "");
|
|
51
|
+
if (name !== "127.0.0.1" && name !== "localhost" && name !== "[::1]") {
|
|
52
|
+
throw httpError(403, "unexpected Host");
|
|
53
|
+
}
|
|
54
|
+
return host;
|
|
55
|
+
}
|
|
56
|
+
function guardMutation(req) {
|
|
57
|
+
const host = requireLoopbackHost(req);
|
|
58
|
+
const origin = req.headers.origin;
|
|
59
|
+
if (origin !== undefined && origin !== `http://${host}`) throw httpError(403, "cross-origin request refused");
|
|
60
|
+
// POST is the only mutating method a browser can send cross-site without a
|
|
61
|
+
// preflight, and only with a non-JSON content type. Requiring JSON there
|
|
62
|
+
// closes that path; PUT and DELETE are non-simple methods already gated by
|
|
63
|
+
// the preflight, so a bodyless DELETE is not forced to carry a type.
|
|
64
|
+
if (req.method === "POST") {
|
|
65
|
+
const ct = (req.headers["content-type"] || "").split(";")[0].trim().toLowerCase();
|
|
66
|
+
if (ct !== "application/json") throw httpError(415, "content-type must be application/json");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readBody(req) {
|
|
71
|
+
return new Promise((res, rej) => {
|
|
72
|
+
let body = "";
|
|
73
|
+
let over = false;
|
|
74
|
+
req.on("data", (c) => {
|
|
75
|
+
if (over) return;
|
|
76
|
+
body += c;
|
|
77
|
+
if (body.length > BODY_LIMIT) {
|
|
78
|
+
over = true;
|
|
79
|
+
req.destroy();
|
|
80
|
+
rej(httpError(413, "document too large"));
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
req.on("end", () => {
|
|
84
|
+
if (over) return;
|
|
85
|
+
try {
|
|
86
|
+
res(body ? JSON.parse(body) : {});
|
|
87
|
+
} catch {
|
|
88
|
+
rej(httpError(400, "invalid JSON"));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Write to a sibling temp file and rename over the destination, so a failed
|
|
95
|
+
write can never leave a document half-written or empty. */
|
|
96
|
+
function atomicWrite(full, content) {
|
|
97
|
+
const tmp = full + ".atlas-tmp-" + process.pid;
|
|
98
|
+
writeFileSync(tmp, content);
|
|
99
|
+
renameSync(tmp, full);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* One corpus behind one API: the graph and the documents of a single root,
|
|
103
|
+
with confinement and mutability enforced here rather than in any UI. serve
|
|
104
|
+
mounts one of these at /api; the app host mounts one per registered
|
|
105
|
+
workspace at /w/<id>/api, so both doors share every rule. */
|
|
106
|
+
export function corpusApi(rootArg) {
|
|
107
|
+
if (!existsSync(resolve(rootArg || "."))) throw new Error(`no such directory: ${resolve(rootArg || ".")}`);
|
|
108
|
+
// Canonicalize once: every confinement check compares against the real root,
|
|
109
|
+
// so a symlinked ancestor cannot smuggle a path back inside lexically.
|
|
110
|
+
const root = realpathSync(resolve(rootArg || "."));
|
|
111
|
+
|
|
112
|
+
let cache = null;
|
|
113
|
+
function data() {
|
|
114
|
+
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.data;
|
|
115
|
+
const config = loadConfig(root);
|
|
116
|
+
config.vectors = loadVectors(root, config);
|
|
117
|
+
const graph = buildGraph(scanCorpus(root, config));
|
|
118
|
+
cache = { at: Date.now(), data: buildData(config, graph, "live"), config };
|
|
119
|
+
return cache.data;
|
|
120
|
+
}
|
|
121
|
+
function config() {
|
|
122
|
+
data();
|
|
123
|
+
return cache.config;
|
|
124
|
+
}
|
|
125
|
+
const bust = () => (cache = null);
|
|
126
|
+
|
|
127
|
+
/* A client path is only ever a root-relative markdown file inside the tree,
|
|
128
|
+
reached without crossing a symlink. Lexical checks run first, then every
|
|
129
|
+
existing segment is lstat-ed so a link is refused before it is followed. */
|
|
130
|
+
function confine(rel) {
|
|
131
|
+
if (typeof rel !== "string" || !rel) throw httpError(400, "path required");
|
|
132
|
+
if (!/\.(md|markdown)$/i.test(rel)) throw httpError(400, "path must end in .md");
|
|
133
|
+
const norm = rel.split("\\").join("/");
|
|
134
|
+
const segments = norm.split("/");
|
|
135
|
+
if (norm.startsWith("/") || segments.some((s) => !s || s === "." || s === "..")) {
|
|
136
|
+
throw httpError(400, "path escapes the corpus");
|
|
137
|
+
}
|
|
138
|
+
const full = join(root, norm);
|
|
139
|
+
if (!(full + sep).startsWith(root + sep)) throw httpError(400, "path escapes the corpus");
|
|
140
|
+
let walk = root;
|
|
141
|
+
for (const s of segments) {
|
|
142
|
+
walk = join(walk, s);
|
|
143
|
+
if (existsSync(walk) && lstatSync(walk).isSymbolicLink()) {
|
|
144
|
+
throw httpError(400, "path crosses a symbolic link");
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const col = collectionFor(config(), norm);
|
|
148
|
+
if (!col) throw httpError(400, "no collection claims this path");
|
|
149
|
+
return { full, rel: norm, col };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* Serve one API request whose path inside the API is sub ("/graph",
|
|
153
|
+
"/doc"). Returns true when the route was taken; errors throw httpError
|
|
154
|
+
for the caller's catch, so both hosts report them the same way. */
|
|
155
|
+
async function handle(req, res, url, sub, send) {
|
|
156
|
+
if (req.method === "GET" && sub === "/graph") {
|
|
157
|
+
requireLoopbackHost(req);
|
|
158
|
+
send(200, data());
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (sub === "/doc") {
|
|
162
|
+
if (req.method === "GET") {
|
|
163
|
+
requireLoopbackHost(req);
|
|
164
|
+
const { full, rel } = confine(url.searchParams.get("path"));
|
|
165
|
+
if (!existsSync(full)) throw httpError(404, "no such document");
|
|
166
|
+
send(200, { path: rel, raw: readFileSync(full, "utf8") });
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
if (req.method === "POST") {
|
|
170
|
+
guardMutation(req);
|
|
171
|
+
const body = await readBody(req);
|
|
172
|
+
const { full, rel } = confine(body.path);
|
|
173
|
+
if (existsSync(full)) throw httpError(409, "document already exists");
|
|
174
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
175
|
+
writeFileSync(full, String(body.content ?? ""), { flag: "wx" });
|
|
176
|
+
bust();
|
|
177
|
+
send(201, { path: rel });
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
if (req.method === "PUT") {
|
|
181
|
+
guardMutation(req);
|
|
182
|
+
const body = await readBody(req);
|
|
183
|
+
const { full, rel, col } = confine(body.path);
|
|
184
|
+
if (col.immutable) throw httpError(403, `${col.name} is immutable`);
|
|
185
|
+
if (!existsSync(full)) throw httpError(404, "no such document");
|
|
186
|
+
atomicWrite(full, String(body.content ?? ""));
|
|
187
|
+
bust();
|
|
188
|
+
send(200, { path: rel });
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
if (req.method === "DELETE") {
|
|
192
|
+
guardMutation(req);
|
|
193
|
+
const { full, rel, col } = confine(url.searchParams.get("path"));
|
|
194
|
+
if (col.immutable) throw httpError(403, `${col.name} is immutable`);
|
|
195
|
+
if (!existsSync(full)) throw httpError(404, "no such document");
|
|
196
|
+
unlinkSync(full);
|
|
197
|
+
bust();
|
|
198
|
+
send(200, { path: rel });
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { root, data, handle };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function serve(rootArg, port = 4747) {
|
|
209
|
+
const api = corpusApi(rootArg);
|
|
210
|
+
|
|
211
|
+
const server = createServer(async (req, res) => {
|
|
212
|
+
const url = new URL(req.url, "http://localhost");
|
|
213
|
+
const send = (status, payload, type = "application/json") => {
|
|
214
|
+
res.writeHead(status, { "Content-Type": type + "; charset=utf-8" });
|
|
215
|
+
res.end(type === "application/json" ? JSON.stringify(payload) : payload);
|
|
216
|
+
};
|
|
217
|
+
try {
|
|
218
|
+
if (req.method === "GET" && url.pathname === "/") {
|
|
219
|
+
requireLoopbackHost(req);
|
|
220
|
+
return send(200, renderPage(api.data()), "text/html");
|
|
221
|
+
}
|
|
222
|
+
if (url.pathname.startsWith("/api/")) {
|
|
223
|
+
if (await api.handle(req, res, url, url.pathname.slice(4), send)) return;
|
|
224
|
+
}
|
|
225
|
+
throw httpError(404, "not found");
|
|
226
|
+
} catch (e) {
|
|
227
|
+
send(e.status || 500, { error: e.message });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return new Promise((res, rej) => {
|
|
232
|
+
server.once("error", rej);
|
|
233
|
+
server.listen(port, "127.0.0.1", () => {
|
|
234
|
+
server.removeListener("error", rej);
|
|
235
|
+
res({ server, port: server.address().port, root: api.root });
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/* ── the app host and its workspaces ─────────────────────────────────────── */
|
|
241
|
+
|
|
242
|
+
/* The host remembers projects by absolute path, which the in-browser folder
|
|
243
|
+
picker never reveals. The registry is a plain JSON file in the user's
|
|
244
|
+
config directory; ids are content-free (a hash of the real path), so the
|
|
245
|
+
same folder always answers to the same id. */
|
|
246
|
+
function workspacesFileDefault() {
|
|
247
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
248
|
+
return join(base, "canon-atlas", "workspaces.json");
|
|
249
|
+
}
|
|
250
|
+
function workspaceId(realPath) {
|
|
251
|
+
return createHash("sha256").update(realPath).digest("hex").slice(0, 12);
|
|
252
|
+
}
|
|
253
|
+
function loadWorkspaces(file) {
|
|
254
|
+
try {
|
|
255
|
+
const rows = JSON.parse(readFileSync(file, "utf8")).workspaces;
|
|
256
|
+
return Array.isArray(rows) ? rows.filter((w) => w && w.id && typeof w.path === "string") : [];
|
|
257
|
+
} catch {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function saveWorkspaces(file, rows) {
|
|
262
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
263
|
+
atomicWrite(file, JSON.stringify({ workspaces: rows }, null, 2) + "\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/* Host the pure-client app: one static page, plus a workspace registry so a
|
|
267
|
+
project can be opened by its absolute path and remembered as one. Each
|
|
268
|
+
registered workspace gets the full corpus API under /w/<id>/api, the same
|
|
269
|
+
handlers serve() mounts, so editing by path carries every confinement and
|
|
270
|
+
mutability rule. Browsers block the writable folder picker on file://
|
|
271
|
+
pages; an http origin unblocks it, and this is the smallest one, so the
|
|
272
|
+
in-browser pickers keep working beside the path door.
|
|
273
|
+
|
|
274
|
+
Atlas tabs beat GET /ping while open. With opts.idleMs set, a stretch of
|
|
275
|
+
silence that long closes the server and calls opts.onIdle, so the launcher
|
|
276
|
+
flow can exit itself instead of lingering. Every response carries an
|
|
277
|
+
x-canon-atlas header so a relaunch can tell this host from a stranger on
|
|
278
|
+
the same port. */
|
|
279
|
+
export function serveApp(port = 4700, opts = {}) {
|
|
280
|
+
const page = renderAppPage();
|
|
281
|
+
const wsFile = opts.workspacesFile || workspacesFileDefault();
|
|
282
|
+
let workspaces = loadWorkspaces(wsFile);
|
|
283
|
+
const apis = new Map(); // workspace id -> corpusApi, built on first use
|
|
284
|
+
|
|
285
|
+
function registerWorkspace(rawPath) {
|
|
286
|
+
if (typeof rawPath !== "string" || !rawPath.trim()) throw httpError(400, "path required");
|
|
287
|
+
const p = rawPath.trim();
|
|
288
|
+
if (!isAbsolute(p)) throw httpError(400, "path must be absolute");
|
|
289
|
+
if (!existsSync(p)) throw httpError(400, `no such directory: ${p}`);
|
|
290
|
+
if (!statSync(p).isDirectory()) throw httpError(400, "path must be a directory");
|
|
291
|
+
const real = realpathSync(p);
|
|
292
|
+
const id = workspaceId(real);
|
|
293
|
+
const known = workspaces.find((w) => w.id === id);
|
|
294
|
+
if (known) return { row: known, created: false };
|
|
295
|
+
const row = { id, path: real };
|
|
296
|
+
workspaces.push(row);
|
|
297
|
+
saveWorkspaces(wsFile, workspaces);
|
|
298
|
+
return { row, created: true };
|
|
299
|
+
}
|
|
300
|
+
function forgetWorkspace(id) {
|
|
301
|
+
const i = workspaces.findIndex((w) => w.id === id);
|
|
302
|
+
if (i < 0) throw httpError(404, "no such workspace");
|
|
303
|
+
workspaces.splice(i, 1);
|
|
304
|
+
apis.delete(id);
|
|
305
|
+
saveWorkspaces(wsFile, workspaces);
|
|
306
|
+
}
|
|
307
|
+
function apiFor(id) {
|
|
308
|
+
const row = workspaces.find((w) => w.id === id);
|
|
309
|
+
if (!row) throw httpError(404, "no such workspace");
|
|
310
|
+
if (!existsSync(row.path)) throw httpError(404, `workspace folder is missing: ${row.path}`);
|
|
311
|
+
if (!apis.has(id)) apis.set(id, corpusApi(row.path));
|
|
312
|
+
return apis.get(id);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let lastSeen = Date.now();
|
|
316
|
+
const server = createServer(async (req, res) => {
|
|
317
|
+
lastSeen = Date.now();
|
|
318
|
+
const stamp = { "x-canon-atlas": "app" };
|
|
319
|
+
const url = new URL(req.url, "http://localhost");
|
|
320
|
+
const send = (status, payload, type = "application/json") => {
|
|
321
|
+
res.writeHead(status, { ...stamp, "Content-Type": type + "; charset=utf-8" });
|
|
322
|
+
res.end(type === "application/json" ? JSON.stringify(payload) : payload);
|
|
323
|
+
};
|
|
324
|
+
try {
|
|
325
|
+
requireLoopbackHost(req);
|
|
326
|
+
if (req.method === "GET" && url.pathname === "/ping") {
|
|
327
|
+
res.writeHead(204, stamp);
|
|
328
|
+
return res.end();
|
|
329
|
+
}
|
|
330
|
+
if (req.method === "GET" && url.pathname === "/api/workspaces") {
|
|
331
|
+
return send(200, {
|
|
332
|
+
workspaces: workspaces.map((w) => ({ id: w.id, path: w.path, exists: existsSync(w.path) })),
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (url.pathname === "/api/workspace") {
|
|
336
|
+
if (req.method === "POST") {
|
|
337
|
+
guardMutation(req);
|
|
338
|
+
const body = await readBody(req);
|
|
339
|
+
const { row, created } = registerWorkspace(body.path);
|
|
340
|
+
return send(created ? 201 : 200, { id: row.id, path: row.path });
|
|
341
|
+
}
|
|
342
|
+
if (req.method === "DELETE") {
|
|
343
|
+
guardMutation(req);
|
|
344
|
+
const id = url.searchParams.get("id") || "";
|
|
345
|
+
forgetWorkspace(id);
|
|
346
|
+
return send(200, { id });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
/* /w/<id>/api/... is the registered workspace's own corpus API. */
|
|
350
|
+
const m = url.pathname.match(/^\/w\/([0-9a-f]+)\/api(\/.*)$/);
|
|
351
|
+
if (m) {
|
|
352
|
+
if (await apiFor(m[1]).handle(req, res, url, m[2], send)) return;
|
|
353
|
+
throw httpError(404, "not found");
|
|
354
|
+
}
|
|
355
|
+
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/w/")) {
|
|
356
|
+
throw httpError(404, "not found");
|
|
357
|
+
}
|
|
358
|
+
if (req.method !== "GET") throw httpError(405, "the app host is static");
|
|
359
|
+
return send(200, page, "text/html");
|
|
360
|
+
} catch (e) {
|
|
361
|
+
send(e.status || 500, { error: e.message });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
if (opts.idleMs) {
|
|
365
|
+
const timer = setInterval(() => {
|
|
366
|
+
if (Date.now() - lastSeen > opts.idleMs) {
|
|
367
|
+
clearInterval(timer);
|
|
368
|
+
server.close();
|
|
369
|
+
if (opts.onIdle) opts.onIdle();
|
|
370
|
+
}
|
|
371
|
+
}, Math.min(opts.idleMs, 15000));
|
|
372
|
+
}
|
|
373
|
+
return new Promise((res, rej) => {
|
|
374
|
+
server.once("error", rej);
|
|
375
|
+
server.listen(port, "127.0.0.1", () => {
|
|
376
|
+
server.removeListener("error", rej);
|
|
377
|
+
res({ server, port: server.address().port });
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|