@telepath-computer/vault-server 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/README.md +123 -0
- package/dist/src/app.d.ts +63 -0
- package/dist/src/app.js +463 -0
- package/dist/src/app.js.map +1 -0
- package/dist/src/cli.d.ts +8 -0
- package/dist/src/cli.js +138 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/notes.d.ts +108 -0
- package/dist/src/notes.js +1287 -0
- package/dist/src/notes.js.map +1 -0
- package/dist/src/paths.d.ts +8 -0
- package/dist/src/paths.js +44 -0
- package/dist/src/paths.js.map +1 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# vault-server
|
|
2
|
+
|
|
3
|
+
`vault-server` makes an Obsidian vault — or any plain folder of markdown files —
|
|
4
|
+
accessible over HTTP. It's a local sidecar for web apps — dashboards, HTML
|
|
5
|
+
artifacts, anything that runs in a browser — that want to query, display,
|
|
6
|
+
and modify records and follow changes live, without touching the filesystem.
|
|
7
|
+
It follows Obsidian's conventions (YAML headers, wikilink references) but
|
|
8
|
+
never requires Obsidian itself: point it at any directory of markdown.
|
|
9
|
+
|
|
10
|
+
The URL space mirrors the vault: every file serves at its own path, and
|
|
11
|
+
dropping a markdown file's `.md` extension addresses it as structured JSON —
|
|
12
|
+
header parsed into fields, field references resolved. Directory URLs list records; a
|
|
13
|
+
WebSocket upgrade on `/` streams change events. `spec/` is the full contract;
|
|
14
|
+
this README shows the working surface.
|
|
15
|
+
|
|
16
|
+
## Install and run
|
|
17
|
+
|
|
18
|
+
Node.js 24 or later.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install -g @telepath-computer/vault-server
|
|
22
|
+
vault-server # serve the current directory
|
|
23
|
+
vault-server /path/to/vault # or an explicit vault root
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`--host` (default `127.0.0.1`) and `--port` (default `4747`) adjust binding.
|
|
27
|
+
|
|
28
|
+
The server binds localhost only by default and has no authentication —
|
|
29
|
+
expose it beyond the local machine at your own risk. CORS is wide open
|
|
30
|
+
(`Access-Control-Allow-Origin: *`), so browser pages from any origin can
|
|
31
|
+
call it directly.
|
|
32
|
+
|
|
33
|
+
## Read
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
curl http://127.0.0.1:4747/journal/today # record as JSON
|
|
37
|
+
curl http://127.0.0.1:4747/journal/today.md # same file, raw markdown
|
|
38
|
+
curl http://127.0.0.1:4747/photos/cat.jpg # any file, raw bytes
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A record's JSON view:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"path": "journal/today",
|
|
46
|
+
"fields": {
|
|
47
|
+
"status": "open",
|
|
48
|
+
"contact": { "$type": "ref", "path": "contacts/jane-doe" }
|
|
49
|
+
},
|
|
50
|
+
"body": "Raw markdown below the YAML header.\n",
|
|
51
|
+
"links": [{ "path": "contacts/jane-doe", "field": "contact" }],
|
|
52
|
+
"updated": "2026-08-07T09:15:00.000Z"
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`fields` is the markdown file's YAML header; `body`, its own key beside
|
|
57
|
+
them, is the markdown below it. A
|
|
58
|
+
field value that is entirely a wikilink (`contact: "[[Jane Doe]]"`) serves
|
|
59
|
+
as a reference resolved against the whole vault — `{ "$type": "ref",
|
|
60
|
+
"path": … }` — the target's API path, fetchable here — plus a `label` when
|
|
61
|
+
the link's rendered text differs. Prose inside
|
|
62
|
+
`body` serves as standard markdown — wikilinks come out as resolved
|
|
63
|
+
relative links, so any renderer navigates them against these same URLs.
|
|
64
|
+
`links` lists every resolved record link touching this record: outbound
|
|
65
|
+
entries name their target, while entries with `backlink: true` name their
|
|
66
|
+
source. `field` identifies the top-level header key; prose links omit it.
|
|
67
|
+
If the frontmatter is invalid, the whole file is returned as `body` with
|
|
68
|
+
empty `fields`, and the record carries `error: { "code":
|
|
69
|
+
"invalid_frontmatter", "message": "Frontmatter could not be parsed" }`.
|
|
70
|
+
|
|
71
|
+
## Query
|
|
72
|
+
|
|
73
|
+
A trailing slash lists a directory's records recursively as
|
|
74
|
+
`{ path, fields, body?, updated, error? }`; `body` remains beside `fields`,
|
|
75
|
+
while the single-record `links` list is omitted. There are no query parameters;
|
|
76
|
+
filter and sort in JS:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
const records = await (await fetch("http://127.0.0.1:4747/tasks/")).json();
|
|
80
|
+
const open = records
|
|
81
|
+
.filter((record) => record.fields.status === "open")
|
|
82
|
+
.sort((a, b) => b.updated.localeCompare(a.updated));
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Write
|
|
86
|
+
|
|
87
|
+
Record writes carry the record media type,
|
|
88
|
+
`application/vnd.telepath.record+json` — the content type is what
|
|
89
|
+
distinguishes a record write from an eventual raw-file upload at the
|
|
90
|
+
same kind of URL; the request body is the record's
|
|
91
|
+
`{ fields, body }` — the header object and the text below it, sent
|
|
92
|
+
separately as they are served. PATCH merge-patches `fields` (`null` deletes a
|
|
93
|
+
field) and replaces `body` only when that sibling key is present (`null`
|
|
94
|
+
removes it):
|
|
95
|
+
|
|
96
|
+
```js
|
|
97
|
+
await fetch("http://127.0.0.1:4747/tasks/fix-roof", {
|
|
98
|
+
method: "PATCH",
|
|
99
|
+
headers: { "content-type": "application/vnd.telepath.record+json" },
|
|
100
|
+
body: JSON.stringify({ fields: { status: "done", due: null } }),
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
PUT replaces the fields wholesale, creating the record if needed; DELETE
|
|
105
|
+
removes it. All
|
|
106
|
+
writes are atomic on disk, so a syncing vault never sees half a record.
|
|
107
|
+
|
|
108
|
+
## Subscribe
|
|
109
|
+
|
|
110
|
+
A WebSocket upgrade on `/` streams every change in the vault — external
|
|
111
|
+
edits included (Obsidian on another device, Dropbox sync), not just writes
|
|
112
|
+
made through the API. Filter by path prefix client-side:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
const socket = new WebSocket("ws://127.0.0.1:4747/");
|
|
116
|
+
socket.onmessage = ({ data }) => {
|
|
117
|
+
const { type, path } = JSON.parse(data); // created | updated | deleted
|
|
118
|
+
refresh(path);
|
|
119
|
+
};
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Events carry no content and there is no replay: list first, then apply
|
|
123
|
+
events, and re-list whenever the socket reconnects.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** Composes the vault HTTP API, filesystem watcher, and WebSocket subscriptions. */
|
|
2
|
+
import { type Server } from "node:http";
|
|
3
|
+
import express from "express";
|
|
4
|
+
import { type RecordContent } from "./notes.ts";
|
|
5
|
+
/** A record in the form it will be stored, offered for approval before it is written. */
|
|
6
|
+
export type ValidatedRecord = RecordContent & {
|
|
7
|
+
path: string;
|
|
8
|
+
};
|
|
9
|
+
export type Validate = (record: ValidatedRecord) => void | Promise<void>;
|
|
10
|
+
export type LinkFormat = "wikilink" | "markdown";
|
|
11
|
+
export type VaultOptions = {
|
|
12
|
+
linkFormat?: LinkFormat;
|
|
13
|
+
rawBodies?: string[];
|
|
14
|
+
};
|
|
15
|
+
export type ServerOptions = {
|
|
16
|
+
jsonLimit?: string | number;
|
|
17
|
+
pingIntervalMs?: number;
|
|
18
|
+
};
|
|
19
|
+
export type VaultServerOptions = {
|
|
20
|
+
root: string;
|
|
21
|
+
vault?: VaultOptions;
|
|
22
|
+
server?: ServerOptions;
|
|
23
|
+
validate?: Validate;
|
|
24
|
+
};
|
|
25
|
+
export declare class VaultServer {
|
|
26
|
+
readonly app: express.Express;
|
|
27
|
+
readonly server: Server;
|
|
28
|
+
/** Resolves once the watcher has indexed the vault and the root is known good. */
|
|
29
|
+
readonly ready: Promise<void>;
|
|
30
|
+
private readonly root;
|
|
31
|
+
private readonly index;
|
|
32
|
+
private readonly validate;
|
|
33
|
+
private readonly watcher;
|
|
34
|
+
private readonly webSockets;
|
|
35
|
+
private readonly sockets;
|
|
36
|
+
private readonly pendingIndexRefreshes;
|
|
37
|
+
private readonly pendingWatcherEvents;
|
|
38
|
+
private readonly pingTimer;
|
|
39
|
+
private isReady;
|
|
40
|
+
private isClosing;
|
|
41
|
+
private startupRefreshFailed;
|
|
42
|
+
private startupRefreshError;
|
|
43
|
+
private boundHost;
|
|
44
|
+
constructor(options: VaultServerOptions);
|
|
45
|
+
/** The address the server is bound to, or undefined before listening. */
|
|
46
|
+
get url(): string | undefined;
|
|
47
|
+
/** The port actually bound, which for port 0 is the one the OS chose. */
|
|
48
|
+
get port(): number | undefined;
|
|
49
|
+
get recordCount(): number;
|
|
50
|
+
/** Accept connections, once the vault is indexed. Resolves with the server. */
|
|
51
|
+
listen({ host, port }?: {
|
|
52
|
+
host?: string;
|
|
53
|
+
port?: number;
|
|
54
|
+
}): Promise<this>;
|
|
55
|
+
close(): Promise<void>;
|
|
56
|
+
private context;
|
|
57
|
+
private readonly runMutation;
|
|
58
|
+
private emitEvent;
|
|
59
|
+
private handleWatcherChange;
|
|
60
|
+
private flushWatcherEvent;
|
|
61
|
+
private cancelPendingWatcherEvent;
|
|
62
|
+
private refreshIndex;
|
|
63
|
+
}
|
package/dist/src/app.js
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/** Composes the vault HTTP API, filesystem watcher, and WebSocket subscriptions. */
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { stat } from "node:fs/promises";
|
|
5
|
+
import { relative } from "node:path";
|
|
6
|
+
import chokidar from "chokidar";
|
|
7
|
+
import express from "express";
|
|
8
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
9
|
+
import { deleteNote, InvalidReferenceError, InvalidUtf8Error, listNotes, mergeFields, readNote, readRawRecord, storedBody, storedFields, VaultIndex, writeNote } from "./notes.js";
|
|
10
|
+
import { hasDotSegment, isVaultPathConfined, resolveVaultPath, toVaultPath } from "./paths.js";
|
|
11
|
+
const noteContentType = "application/vnd.telepath.record+json";
|
|
12
|
+
const watcherDebounceMs = 50;
|
|
13
|
+
/** Raised when a caller's `validate` refuses a write. */
|
|
14
|
+
class ValidationError extends Error {
|
|
15
|
+
}
|
|
16
|
+
export class VaultServer {
|
|
17
|
+
app;
|
|
18
|
+
server;
|
|
19
|
+
/** Resolves once the watcher has indexed the vault and the root is known good. */
|
|
20
|
+
ready;
|
|
21
|
+
root;
|
|
22
|
+
index;
|
|
23
|
+
validate;
|
|
24
|
+
watcher;
|
|
25
|
+
webSockets = new WebSocketServer({ noServer: true });
|
|
26
|
+
sockets = new Map();
|
|
27
|
+
pendingIndexRefreshes = new Set();
|
|
28
|
+
pendingWatcherEvents = new Map();
|
|
29
|
+
pingTimer;
|
|
30
|
+
isReady = false;
|
|
31
|
+
isClosing = false;
|
|
32
|
+
startupRefreshFailed = false;
|
|
33
|
+
startupRefreshError;
|
|
34
|
+
boundHost;
|
|
35
|
+
constructor(options) {
|
|
36
|
+
const unknownOption = Object.keys(options).find((key) => !["root", "vault", "server", "validate"].includes(key));
|
|
37
|
+
if (unknownOption)
|
|
38
|
+
throw new Error(`unknown VaultServer option "${unknownOption}"; use the "vault" or "server" group`);
|
|
39
|
+
const { root, vault = {}, server = {}, validate } = options;
|
|
40
|
+
const { linkFormat = "wikilink", rawBodies = [] } = vault;
|
|
41
|
+
const { jsonLimit = "32mb", pingIntervalMs = 30_000 } = server;
|
|
42
|
+
// A symlinked root (e.g. ~/workspace/vault -> ~/Dropbox/Vault) must be
|
|
43
|
+
// resolved up front: the watcher ignores symlinks, so an unresolved root
|
|
44
|
+
// would index and watch nothing.
|
|
45
|
+
this.root = realpathSync(root);
|
|
46
|
+
this.validate = validate;
|
|
47
|
+
this.index = new VaultIndex(this.root, undefined, linkFormat, rawBodies);
|
|
48
|
+
this.app = express();
|
|
49
|
+
this.app.use((request, response, next) => {
|
|
50
|
+
response.set("Access-Control-Allow-Origin", "*");
|
|
51
|
+
if (request.method !== "OPTIONS") {
|
|
52
|
+
next();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
response.set({
|
|
56
|
+
"Access-Control-Allow-Methods": "GET, PUT, PATCH, DELETE",
|
|
57
|
+
"Access-Control-Allow-Headers": "content-type",
|
|
58
|
+
"Access-Control-Max-Age": "86400",
|
|
59
|
+
});
|
|
60
|
+
if (request.get("Access-Control-Request-Private-Network") === "true") {
|
|
61
|
+
response.set("Access-Control-Allow-Private-Network", "true");
|
|
62
|
+
}
|
|
63
|
+
response.status(204).end();
|
|
64
|
+
});
|
|
65
|
+
this.app.use(express.json({ type: (request) => shouldParseNoteBody(request), limit: jsonLimit }));
|
|
66
|
+
this.app.use(async (request, response) => { await handleRequest(this.context(), request, response); });
|
|
67
|
+
this.app.use((error, _request, response, _next) => {
|
|
68
|
+
if (isPayloadTooLarge(error))
|
|
69
|
+
sendError(response, 413, "request body too large");
|
|
70
|
+
else if (error instanceof SyntaxError)
|
|
71
|
+
sendError(response, 400, "malformed JSON");
|
|
72
|
+
else
|
|
73
|
+
sendError(response, 500, "internal server error");
|
|
74
|
+
});
|
|
75
|
+
this.server = createServer(this.app);
|
|
76
|
+
this.watcher = chokidar.watch(this.root, {
|
|
77
|
+
ignoreInitial: false,
|
|
78
|
+
followSymlinks: false,
|
|
79
|
+
ignored: (path, stats) => stats?.isSymbolicLink() === true || hasDotSegment(toVaultPath(relative(this.root, path))),
|
|
80
|
+
});
|
|
81
|
+
const watcherReady = new Promise((resolve, reject) => {
|
|
82
|
+
this.watcher.once("ready", resolve);
|
|
83
|
+
this.watcher.on("error", (error) => {
|
|
84
|
+
if (!this.isReady)
|
|
85
|
+
reject(error);
|
|
86
|
+
else
|
|
87
|
+
console.error("vault-server watcher error", error);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
const rootReady = stat(this.root).then((rootStat) => { if (!rootStat.isDirectory())
|
|
91
|
+
throw new Error("vault root is not a directory"); });
|
|
92
|
+
this.ready = Promise.all([watcherReady, rootReady]).then(async () => {
|
|
93
|
+
while (this.pendingIndexRefreshes.size > 0)
|
|
94
|
+
await Promise.all([...this.pendingIndexRefreshes]);
|
|
95
|
+
if (this.startupRefreshFailed)
|
|
96
|
+
throw this.startupRefreshError;
|
|
97
|
+
this.isReady = true;
|
|
98
|
+
});
|
|
99
|
+
this.server.on("upgrade", async (request, socket, head) => {
|
|
100
|
+
const path = decodeRequestPath(request.url ?? "/");
|
|
101
|
+
if (path === null || hasHiddenUrlSegment(path) || !rawPathname(request.url ?? "/").endsWith("/")) {
|
|
102
|
+
socket.destroy();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const absolutePath = resolveVaultPath(this.root, path);
|
|
106
|
+
if (!absolutePath || !await isVaultPathConfined(this.root, absolutePath)) {
|
|
107
|
+
socket.destroy();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.webSockets.handleUpgrade(request, socket, head, (webSocket) => {
|
|
111
|
+
const subscription = { isAlive: true };
|
|
112
|
+
this.sockets.set(webSocket, subscription);
|
|
113
|
+
webSocket.on("pong", () => { subscription.isAlive = true; });
|
|
114
|
+
webSocket.once("close", () => this.sockets.delete(webSocket));
|
|
115
|
+
this.webSockets.emit("connection", webSocket, request);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
this.watcher.on("add", (path) => this.handleWatcherChange("created", path))
|
|
119
|
+
.on("change", (path) => this.handleWatcherChange("updated", path))
|
|
120
|
+
.on("unlink", (path) => {
|
|
121
|
+
const publish = this.isReady && !this.isClosing;
|
|
122
|
+
this.cancelPendingWatcherEvent(path);
|
|
123
|
+
this.index.delete(path);
|
|
124
|
+
if (publish)
|
|
125
|
+
this.emitEvent("deleted", path);
|
|
126
|
+
});
|
|
127
|
+
this.pingTimer = setInterval(() => {
|
|
128
|
+
for (const [socket, subscription] of this.sockets) {
|
|
129
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
130
|
+
continue;
|
|
131
|
+
if (!subscription.isAlive) {
|
|
132
|
+
socket.terminate();
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
subscription.isAlive = false;
|
|
136
|
+
socket.ping();
|
|
137
|
+
}
|
|
138
|
+
}, pingIntervalMs);
|
|
139
|
+
}
|
|
140
|
+
/** The address the server is bound to, or undefined before listening. */
|
|
141
|
+
get url() {
|
|
142
|
+
const port = this.port;
|
|
143
|
+
return port === undefined || this.boundHost === undefined ? undefined : `http://${this.boundHost}:${port}`;
|
|
144
|
+
}
|
|
145
|
+
/** The port actually bound, which for port 0 is the one the OS chose. */
|
|
146
|
+
get port() {
|
|
147
|
+
const address = this.server.address();
|
|
148
|
+
return address !== null && typeof address === "object" ? address.port : undefined;
|
|
149
|
+
}
|
|
150
|
+
get recordCount() { return this.index.markdownNoteCount; }
|
|
151
|
+
/** Accept connections, once the vault is indexed. Resolves with the server. */
|
|
152
|
+
async listen({ host = "127.0.0.1", port = 4747 } = {}) {
|
|
153
|
+
await this.ready;
|
|
154
|
+
await new Promise((resolve, reject) => {
|
|
155
|
+
const onError = (error) => { this.server.removeListener("listening", onListening); reject(error); };
|
|
156
|
+
const onListening = () => { this.server.removeListener("error", onError); resolve(); };
|
|
157
|
+
this.server.once("error", onError);
|
|
158
|
+
this.server.once("listening", onListening);
|
|
159
|
+
this.server.listen(port, host);
|
|
160
|
+
});
|
|
161
|
+
this.boundHost = host;
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
async close() {
|
|
165
|
+
this.isClosing = true;
|
|
166
|
+
for (const path of this.pendingWatcherEvents.keys())
|
|
167
|
+
this.cancelPendingWatcherEvent(path);
|
|
168
|
+
clearInterval(this.pingTimer);
|
|
169
|
+
for (const socket of this.sockets.keys())
|
|
170
|
+
socket.terminate();
|
|
171
|
+
await this.watcher.close();
|
|
172
|
+
this.webSockets.close();
|
|
173
|
+
if (this.server.listening)
|
|
174
|
+
await new Promise((resolve, reject) => this.server.close((error) => error ? reject(error) : resolve()));
|
|
175
|
+
this.boundHost = undefined;
|
|
176
|
+
}
|
|
177
|
+
context() {
|
|
178
|
+
return { root: this.root, index: this.index, validate: this.validate, runMutation: this.runMutation };
|
|
179
|
+
}
|
|
180
|
+
runMutation = async (type, absolutePath, action) => {
|
|
181
|
+
const result = await action();
|
|
182
|
+
this.cancelPendingWatcherEvent(absolutePath);
|
|
183
|
+
this.emitEvent(type, absolutePath);
|
|
184
|
+
return result;
|
|
185
|
+
};
|
|
186
|
+
emitEvent(type, absolutePath) {
|
|
187
|
+
const diskPath = toVaultPath(relative(this.root, absolutePath));
|
|
188
|
+
const path = diskPath.endsWith(".md") ? diskPath.slice(0, -3) : diskPath;
|
|
189
|
+
if (hasDotSegment(path))
|
|
190
|
+
return;
|
|
191
|
+
for (const socket of this.sockets.keys())
|
|
192
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
193
|
+
socket.send(JSON.stringify({ type, path }));
|
|
194
|
+
}
|
|
195
|
+
handleWatcherChange(type, path) {
|
|
196
|
+
if (this.isClosing)
|
|
197
|
+
return;
|
|
198
|
+
if (!this.isReady) {
|
|
199
|
+
void this.refreshIndex(path);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const existing = this.pendingWatcherEvents.get(path);
|
|
203
|
+
if (existing?.timer)
|
|
204
|
+
clearTimeout(existing.timer);
|
|
205
|
+
const pending = {
|
|
206
|
+
type: existing?.type === "created" || type === "created" ? "created" : "updated",
|
|
207
|
+
};
|
|
208
|
+
pending.timer = setTimeout(() => { delete pending.timer; void this.flushWatcherEvent(path, pending); }, watcherDebounceMs);
|
|
209
|
+
pending.timer.unref();
|
|
210
|
+
this.pendingWatcherEvents.set(path, pending);
|
|
211
|
+
}
|
|
212
|
+
async flushWatcherEvent(path, pending) {
|
|
213
|
+
const outcome = await this.refreshIndex(path);
|
|
214
|
+
if (this.pendingWatcherEvents.get(path) !== pending)
|
|
215
|
+
return;
|
|
216
|
+
this.pendingWatcherEvents.delete(path);
|
|
217
|
+
if (outcome === "committed" && !this.isClosing)
|
|
218
|
+
this.emitEvent(pending.type, path);
|
|
219
|
+
}
|
|
220
|
+
cancelPendingWatcherEvent(path) {
|
|
221
|
+
const pending = this.pendingWatcherEvents.get(path);
|
|
222
|
+
if (pending?.timer)
|
|
223
|
+
clearTimeout(pending.timer);
|
|
224
|
+
this.pendingWatcherEvents.delete(path);
|
|
225
|
+
}
|
|
226
|
+
refreshIndex(path) {
|
|
227
|
+
const startup = !this.isReady;
|
|
228
|
+
const refresh = this.index.refresh(path).catch((error) => {
|
|
229
|
+
if (startup) {
|
|
230
|
+
if (!this.startupRefreshFailed)
|
|
231
|
+
this.startupRefreshError = error;
|
|
232
|
+
this.startupRefreshFailed = true;
|
|
233
|
+
}
|
|
234
|
+
else
|
|
235
|
+
console.error("vault-server index refresh error", error);
|
|
236
|
+
return "failed";
|
|
237
|
+
});
|
|
238
|
+
this.pendingIndexRefreshes.add(refresh);
|
|
239
|
+
void refresh.finally(() => this.pendingIndexRefreshes.delete(refresh));
|
|
240
|
+
return refresh;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function handleRequest(context, request, response) {
|
|
244
|
+
const path = decodeRequestPath(request.originalUrl);
|
|
245
|
+
if (path === null) {
|
|
246
|
+
sendError(response, 400, "invalid path");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (hasHiddenUrlSegment(path)) {
|
|
250
|
+
sendError(response, 404, "not found");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const absolutePath = resolveVaultPath(context.root, path);
|
|
254
|
+
if (!absolutePath) {
|
|
255
|
+
sendError(response, 400, "path escapes vault");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!await isVaultPathConfined(context.root, absolutePath)) {
|
|
259
|
+
sendError(response, 400, "path escapes vault");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
if (request.method === "GET") {
|
|
264
|
+
await handleGet(context, absolutePath, request, response);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (["PUT", "PATCH", "DELETE"].includes(request.method)) {
|
|
268
|
+
await handleWrite(context, absolutePath, request, response);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
sendError(response, 405, "method not allowed");
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (error instanceof ValidationError)
|
|
275
|
+
sendError(response, 422, error.message);
|
|
276
|
+
else if (error instanceof InvalidReferenceError)
|
|
277
|
+
sendError(response, 400, error.message);
|
|
278
|
+
else if (error instanceof InvalidUtf8Error)
|
|
279
|
+
sendError(response, 422, error.message);
|
|
280
|
+
else if (isMissing(error))
|
|
281
|
+
sendError(response, 404, "not found");
|
|
282
|
+
else if (error instanceof SyntaxError)
|
|
283
|
+
sendError(response, 400, "malformed JSON");
|
|
284
|
+
else {
|
|
285
|
+
console.error(error);
|
|
286
|
+
sendError(response, 500, "internal server error");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function handleGet(context, absolutePath, request, response) {
|
|
291
|
+
if (rawPathname(request.originalUrl).endsWith("/")) {
|
|
292
|
+
if (!(await isDirectory(absolutePath))) {
|
|
293
|
+
sendError(response, 404, "directory not found");
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
response.json(listNotes(context.index, canonicalPath(context.root, absolutePath)));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (await isFile(absolutePath)) {
|
|
300
|
+
response.sendFile(absolutePath);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (await isFile(`${absolutePath}.md`)) {
|
|
304
|
+
if (!await isVaultPathConfined(context.root, `${absolutePath}.md`)) {
|
|
305
|
+
sendError(response, 400, "path escapes vault");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
response.json(await readNote(context.root, `${absolutePath}.md`, context.index));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (await isDirectory(absolutePath)) {
|
|
312
|
+
response.redirect(308, `${request.path}/`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
sendError(response, 404, "not found");
|
|
316
|
+
}
|
|
317
|
+
async function handleWrite(context, absolutePath, request, response) {
|
|
318
|
+
const path = decodeRequestPath(request.originalUrl) ?? "";
|
|
319
|
+
if (rawPathname(request.originalUrl).endsWith("/") || path.endsWith(".md")) {
|
|
320
|
+
sendError(response, 405, "path is read-only");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (request.method !== "DELETE" && !request.is(noteContentType)) {
|
|
324
|
+
sendError(response, 415, "unsupported content type");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (await isFile(absolutePath)) {
|
|
328
|
+
sendError(response, 409, "literal file occupies record URL");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const notePath = `${absolutePath}.md`;
|
|
332
|
+
if (!await isVaultPathConfined(context.root, notePath)) {
|
|
333
|
+
sendError(response, 400, "path escapes vault");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const exists = await isFile(notePath);
|
|
337
|
+
if (request.method === "DELETE") {
|
|
338
|
+
if (!exists) {
|
|
339
|
+
sendError(response, 404, "record not found");
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
await context.runMutation("deleted", notePath, () => deleteNote(notePath, context.index));
|
|
343
|
+
response.status(204).end();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (!isObject(request.body)) {
|
|
347
|
+
sendError(response, 400, "body must be an object");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if ("fields" in request.body && !isObject(request.body.fields)) {
|
|
351
|
+
sendError(response, 400, "fields must be an object");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if ("body" in request.body && request.body.body !== null && typeof request.body.body !== "string") {
|
|
355
|
+
sendError(response, 400, "body must be a string");
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const linkingNotePath = canonicalPath(context.root, notePath);
|
|
359
|
+
if (request.method === "PUT") {
|
|
360
|
+
let previous;
|
|
361
|
+
if (exists) {
|
|
362
|
+
try {
|
|
363
|
+
previous = await readRawRecord(notePath);
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
if (!(error instanceof InvalidUtf8Error))
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const fields = storedFields((request.body.fields ?? {}), previous?.fields, context.index, linkingNotePath);
|
|
371
|
+
const body = typeof request.body.body === "string"
|
|
372
|
+
? storedBody(request.body.body, previous?.body, context.index, linkingNotePath)
|
|
373
|
+
: undefined;
|
|
374
|
+
const record = { fields, ...(body === undefined ? {} : { body }) };
|
|
375
|
+
await approve(context, notePath, record);
|
|
376
|
+
const note = await context.runMutation(exists ? "updated" : "created", notePath, () => writeNote(context.root, notePath, context.index, record));
|
|
377
|
+
response.status(exists ? 200 : 201).json(note);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (!exists) {
|
|
381
|
+
sendError(response, 404, "record not found");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const current = await readNote(context.root, notePath, context.index);
|
|
385
|
+
const fieldPatch = request.body.fields;
|
|
386
|
+
const bodyTouched = Object.hasOwn(request.body, "body");
|
|
387
|
+
if ((fieldPatch === undefined || Object.keys(fieldPatch).length === 0) && !bodyTouched) {
|
|
388
|
+
response.json(current);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const previous = await readRawRecord(notePath);
|
|
392
|
+
const fields = fieldPatch === undefined
|
|
393
|
+
? previous.fields
|
|
394
|
+
: storedFields(mergeFields(current.fields, fieldPatch), previous.fields, context.index, linkingNotePath, fieldPatch);
|
|
395
|
+
const body = bodyTouched
|
|
396
|
+
? typeof request.body.body === "string"
|
|
397
|
+
? storedBody(request.body.body, previous.body, context.index, linkingNotePath)
|
|
398
|
+
: undefined
|
|
399
|
+
: previous.body;
|
|
400
|
+
const record = { fields, ...(body === undefined ? {} : { body }) };
|
|
401
|
+
await approve(context, notePath, record);
|
|
402
|
+
response.json(await context.runMutation("updated", notePath, () => writeNote(context.root, notePath, context.index, record, fieldPatch ?? {}, bodyTouched, previous.rawBody)));
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Offer the record a write would produce to the caller's `validate`, before any
|
|
406
|
+
* of it reaches disk — so a refusal leaves nothing behind, not even a temp file.
|
|
407
|
+
* The fields are the ones that will be stored, not the ones a read would give
|
|
408
|
+
* back: a rule is judged against what the write actually persists.
|
|
409
|
+
*/
|
|
410
|
+
async function approve(context, notePath, content) {
|
|
411
|
+
if (!context.validate)
|
|
412
|
+
return;
|
|
413
|
+
const diskPath = canonicalPath(context.root, notePath);
|
|
414
|
+
const record = { path: diskPath.slice(0, -3), ...content };
|
|
415
|
+
try {
|
|
416
|
+
await context.validate(record);
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
throw new ValidationError(error instanceof Error ? error.message : String(error));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
423
|
+
async function isFile(path) { try {
|
|
424
|
+
return (await stat(path)).isFile();
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
return false;
|
|
428
|
+
} }
|
|
429
|
+
async function isDirectory(path) { try {
|
|
430
|
+
return (await stat(path)).isDirectory();
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
return false;
|
|
434
|
+
} }
|
|
435
|
+
function isMissing(error) { return isObject(error) && "code" in error && error.code === "ENOENT"; }
|
|
436
|
+
function isPayloadTooLarge(error) {
|
|
437
|
+
return isObject(error) && (("status" in error && error.status === 413) || ("type" in error && error.type === "entity.too.large"));
|
|
438
|
+
}
|
|
439
|
+
function rawPathname(url) { return url.split("?", 1)[0] ?? ""; }
|
|
440
|
+
function decodeRequestPath(url) {
|
|
441
|
+
try {
|
|
442
|
+
const path = decodeURIComponent(rawPathname(url)).replace(/^\/+/, "");
|
|
443
|
+
return path.includes("\0") ? null : path;
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function hasHiddenUrlSegment(path) {
|
|
450
|
+
return path.split("/").some((segment) => segment !== "." && segment !== ".." && segment.startsWith("."));
|
|
451
|
+
}
|
|
452
|
+
function shouldParseNoteBody(request) {
|
|
453
|
+
if (request.method !== "PUT" && request.method !== "PATCH")
|
|
454
|
+
return false;
|
|
455
|
+
if (request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== noteContentType)
|
|
456
|
+
return false;
|
|
457
|
+
const url = request.url ?? "/";
|
|
458
|
+
const path = decodeRequestPath(url);
|
|
459
|
+
return path !== null && !hasHiddenUrlSegment(path) && !rawPathname(url).endsWith("/") && !path.endsWith(".md");
|
|
460
|
+
}
|
|
461
|
+
function canonicalPath(root, absolutePath) { return toVaultPath(relative(root, absolutePath)); }
|
|
462
|
+
function sendError(response, status, message) { response.status(status).json({ error: message }); }
|
|
463
|
+
//# sourceMappingURL=app.js.map
|