@telepath-computer/vault-server 0.4.2 → 0.5.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.
@@ -1,485 +1,637 @@
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";
1
+ import { lstat, readdir, realpath } from "node:fs/promises";
2
+ import { STATUS_CODES } from "node:http";
3
+ import { join } from "node:path";
4
+ import { TextDecoder } from "node:util";
5
+ import { FileServer, } from "@telepath-computer/file-server";
7
6
  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. */
7
+ import { InvalidReferenceError, InvalidUtf8Error, mergeFields, readRawRecord, serializeNote, storedBody, storedFields, VaultIndex, } from "./notes.js";
8
+ const recordContentType = "application/vnd.telepath.record+json";
9
+ const recordResponseContentType = `${recordContentType}; charset=utf-8`;
10
+ const jsonContentType = "application/json; charset=utf-8";
11
+ const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
12
+ const exposedResponseHeaders = "ETag, Accept-Ranges, Content-Range, Accept-Patch";
13
+ const bootstrapQuietMs = 100;
14
+ const bootstrapQuietLimitMs = 1_000;
15
+ /** Raised when a caller's validate hook refuses a record write. */
14
16
  class ValidationError extends Error {
15
17
  }
18
+ /** A file server with cached structured views for visible markdown files. */
16
19
  export class VaultServer {
17
20
  app;
18
21
  server;
19
- /** Resolves once the watcher has indexed the vault and the root is known good. */
20
22
  ready;
23
+ files;
24
+ validate;
25
+ maximumJsonBytes;
26
+ activeChanges = new Set();
27
+ changesByPath = new Map();
28
+ bootstrapPathGenerations = new Map();
29
+ directories = new Set();
30
+ pathView;
21
31
  root;
22
32
  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;
33
+ linkFormat;
34
+ bodyFormat;
35
+ resolveIndex;
36
+ indexAvailable;
37
+ changeGeneration = 0;
38
+ bootstrapComplete = false;
35
39
  constructor(options) {
36
40
  rejectUnknownOptions(options, ["root", "linkFormat", "bodyFormat", "validate", "jsonLimit", "pingIntervalMs"]);
37
- const { root, linkFormat = "wikilink", bodyFormat = "markdown", validate, jsonLimit = "32mb", pingIntervalMs = 30_000, } = options;
38
- // A symlinked root (e.g. ~/workspace/vault -> ~/Dropbox/Vault) must be
39
- // resolved up front: the watcher ignores symlinks, so an unresolved root
40
- // would index and watch nothing.
41
- this.root = realpathSync(root);
41
+ const { root, linkFormat = "wikilink", bodyFormat = "markdown", validate, jsonLimit = "32mb", pingIntervalMs, } = options;
42
+ this.linkFormat = linkFormat;
43
+ this.bodyFormat = bodyFormat;
42
44
  this.validate = validate;
43
- const resolveBodyFormat = typeof bodyFormat === "function" ? bodyFormat : () => bodyFormat;
44
- this.index = new VaultIndex(this.root, undefined, linkFormat, resolveBodyFormat);
45
- this.app = express();
46
- this.app.use((request, response, next) => {
47
- response.set("Access-Control-Allow-Origin", "*");
48
- if (request.method !== "OPTIONS") {
49
- next();
50
- return;
51
- }
52
- response.set({
53
- "Access-Control-Allow-Methods": "GET, PUT, PATCH, DELETE",
54
- "Access-Control-Allow-Headers": "content-type",
55
- "Access-Control-Max-Age": "86400",
56
- });
57
- if (request.get("Access-Control-Request-Private-Network") === "true") {
58
- response.set("Access-Control-Allow-Private-Network", "true");
59
- }
60
- response.status(204).end();
61
- });
62
- this.app.use(express.json({ type: (request) => shouldParseNoteBody(request), limit: jsonLimit }));
63
- this.app.use(async (request, response) => { await handleRequest(this.context(), request, response); });
64
- this.app.use((error, _request, response, _next) => {
65
- if (isPayloadTooLarge(error))
66
- sendError(response, 413, "request body too large");
67
- else if (error instanceof SyntaxError)
68
- sendError(response, 400, "malformed JSON");
69
- else
70
- sendError(response, 500, "internal server error");
71
- });
72
- this.server = createServer(this.app);
73
- this.watcher = chokidar.watch(this.root, {
74
- ignoreInitial: false,
75
- followSymlinks: false,
76
- ignored: (path, stats) => stats?.isSymbolicLink() === true || hasDotSegment(toVaultPath(relative(this.root, path))),
77
- });
78
- const watcherReady = new Promise((resolve, reject) => {
79
- this.watcher.once("ready", resolve);
80
- this.watcher.on("error", (error) => {
81
- if (!this.isReady)
82
- reject(error);
83
- else
84
- console.error("sv-vault watcher error", error);
85
- });
45
+ this.maximumJsonBytes = parseByteLimit(jsonLimit);
46
+ this.indexAvailable = new Promise((resolve) => { this.resolveIndex = resolve; });
47
+ this.pathView = {
48
+ [Symbol.iterator]: () => this.index?.[Symbol.iterator]() ?? [][Symbol.iterator](),
49
+ };
50
+ const extension = {
51
+ handle: (request, response, context) => this.handle(request, response, context),
52
+ listing: async (_request, listing) => {
53
+ await this.ready;
54
+ return decorateListing(listing, this.index);
55
+ },
56
+ change: (event) => this.change(event),
57
+ };
58
+ this.files = new FileServer({
59
+ root,
60
+ defaultPort: 4747,
61
+ ...(pingIntervalMs === undefined ? {} : { pingIntervalMs }),
62
+ extension,
86
63
  });
87
- const rootReady = stat(this.root).then((rootStat) => { if (!rootStat.isDirectory())
88
- throw new Error("vault root is not a directory"); });
89
- this.ready = Promise.all([watcherReady, rootReady]).then(async () => {
90
- while (this.pendingIndexRefreshes.size > 0)
91
- await Promise.all([...this.pendingIndexRefreshes]);
92
- if (this.startupRefreshFailed)
93
- throw this.startupRefreshError;
94
- this.isReady = true;
64
+ this.server = this.files.server;
65
+ const resolvedRoot = realpath(root);
66
+ this.ready = Promise.all([this.files.ready, resolvedRoot]).then(async ([, canonicalRoot]) => {
67
+ this.root = canonicalRoot;
68
+ const bodyFormat = this.bodyFormat;
69
+ this.index = new VaultIndex(canonicalRoot, undefined, this.linkFormat, typeof bodyFormat === "function" ? bodyFormat : () => bodyFormat);
70
+ this.resolveIndex();
71
+ await this.bootstrap(canonicalRoot, this.index);
72
+ await this.drainBootstrapChanges();
73
+ this.bootstrapComplete = true;
74
+ this.bootstrapPathGenerations.clear();
95
75
  });
96
- this.server.on("upgrade", async (request, socket, head) => {
97
- const path = decodeRequestPath(request.url ?? "/");
98
- if (path === null || hasHiddenUrlSegment(path) || !rawPathname(request.url ?? "/").endsWith("/")) {
99
- socket.destroy();
100
- return;
76
+ void this.ready.catch(() => { });
77
+ // FileServer deliberately validates methods and OPTIONS before invoking an
78
+ // extension hook. This thin mount wrapper makes the vault's additional
79
+ // index readiness gate apply to those requests too, without copying the
80
+ // base router or any of its resource dispatch.
81
+ this.app = express();
82
+ this.app.disable("x-powered-by");
83
+ this.app.use(async (request, response, next) => {
84
+ try {
85
+ await this.ready;
101
86
  }
102
- const absolutePath = resolveVaultPath(this.root, path);
103
- if (!absolutePath || !await isVaultPathConfined(this.root, absolutePath)) {
104
- socket.destroy();
87
+ catch (error) {
88
+ // A base readiness failure is still best mapped by FileServer itself.
89
+ // Only vault-index bootstrap failures need the local error boundary.
90
+ try {
91
+ await this.files.ready;
92
+ }
93
+ catch {
94
+ this.files.app(request, response, next);
95
+ return;
96
+ }
97
+ sendReadinessError(request, response, error);
105
98
  return;
106
99
  }
107
- this.webSockets.handleUpgrade(request, socket, head, (webSocket) => {
108
- const subscription = { isAlive: true };
109
- this.sockets.set(webSocket, subscription);
110
- webSocket.on("pong", () => { subscription.isAlive = true; });
111
- webSocket.once("close", () => this.sockets.delete(webSocket));
112
- this.webSockets.emit("connection", webSocket, request);
113
- });
100
+ this.files.app(request, response, next);
114
101
  });
115
- this.watcher.on("add", (path) => this.handleWatcherChange("created", path))
116
- .on("change", (path) => this.handleWatcherChange("updated", path))
117
- .on("unlink", (path) => {
118
- const publish = this.isReady && !this.isClosing;
119
- this.cancelPendingWatcherEvent(path);
120
- this.index.delete(path);
121
- if (publish)
122
- this.emitEvent("deleted", path);
123
- });
124
- this.pingTimer = setInterval(() => {
125
- for (const [socket, subscription] of this.sockets) {
126
- if (socket.readyState !== WebSocket.OPEN)
127
- continue;
128
- if (!subscription.isAlive) {
129
- socket.terminate();
130
- continue;
131
- }
132
- subscription.isAlive = false;
133
- socket.ping();
134
- }
135
- }, pingIntervalMs);
136
- }
137
- /** The address the server is bound to, or undefined before listening. */
138
- get url() {
139
- const port = this.port;
140
- return port === undefined || this.boundHost === undefined ? undefined : `http://${this.boundHost}:${port}`;
141
- }
142
- /** The port actually bound, which for port 0 is the one the OS chose. */
143
- get port() {
144
- const address = this.server.address();
145
- return address !== null && typeof address === "object" ? address.port : undefined;
146
102
  }
147
- /** Every visible disk path currently held by the live index. */
148
- get paths() { return this.index; }
149
- /** Replace runtime formatting settings, rebuilding data derived from bodies. */
103
+ /** The URL currently bound, or undefined before listening and after closing. */
104
+ get url() { return this.files.url; }
105
+ /** The port currently bound, including an operating-system-selected port. */
106
+ get port() { return this.files.port; }
107
+ /** Every visible regular-file disk path currently held by the live index. */
108
+ get paths() { return this.pathView; }
109
+ /** Replace runtime formatting settings, rebuilding data derived from bodies synchronously. */
150
110
  configure(options) {
151
111
  rejectUnknownOptions(options, ["linkFormat", "bodyFormat"]);
152
- this.index.configure(options);
112
+ if (options.linkFormat !== undefined)
113
+ this.linkFormat = options.linkFormat;
114
+ if (options.bodyFormat !== undefined)
115
+ this.bodyFormat = options.bodyFormat;
116
+ this.index?.configure(options);
153
117
  }
154
- /** Accept connections, once the vault is indexed. Resolves with the server. */
118
+ /** Start accepting connections after the base watcher and record index are ready. */
155
119
  async listen(options = {}) {
156
120
  await this.ready;
157
- const host = options.host ?? "127.0.0.1";
158
- const firstPort = options.port ?? 4747;
159
- const attempts = options.port === undefined ? 100 : 1;
160
- for (let offset = 0; offset < attempts; offset += 1) {
161
- const port = firstPort + offset;
121
+ await this.files.listen(options);
122
+ return this;
123
+ }
124
+ /** Release the embedded file server resources. */
125
+ close() { return this.files.close(); }
126
+ /** Delegate a mount-relative WebSocket upgrade after the record index is ready. */
127
+ async handleUpgrade(request, socket, head, requestTarget = request.url ?? "") {
128
+ try {
129
+ await this.ready;
130
+ }
131
+ catch (error) {
132
+ try {
133
+ await this.files.ready;
134
+ }
135
+ catch {
136
+ await this.files.handleUpgrade(request, socket, head, requestTarget);
137
+ return;
138
+ }
139
+ await sendUpgradeReadinessError(socket, error);
140
+ return;
141
+ }
142
+ await this.files.handleUpgrade(request, socket, head, requestTarget);
143
+ }
144
+ async bootstrap(root, index) {
145
+ await this.refreshTree(root, index);
146
+ }
147
+ async refreshTree(root, index) {
148
+ const walk = async (directory) => {
149
+ let entries;
162
150
  try {
163
- await new Promise((resolve, reject) => {
164
- const onError = (error) => { this.server.removeListener("listening", onListening); reject(error); };
165
- const onListening = () => { this.server.removeListener("error", onError); resolve(); };
166
- this.server.once("error", onError);
167
- this.server.once("listening", onListening);
168
- this.server.listen(port, host);
169
- });
170
- break;
151
+ entries = await readdir(directory, { withFileTypes: true, encoding: "buffer" });
171
152
  }
172
153
  catch (error) {
173
- if (!isObject(error) || error.code !== "EADDRINUSE" || offset === attempts - 1)
154
+ if (isMissing(error))
155
+ return;
156
+ throw error;
157
+ }
158
+ for (const entry of entries) {
159
+ const name = decodeDirectoryName(entry.name);
160
+ if (name === undefined || name.startsWith(".") || name.includes("\\"))
161
+ continue;
162
+ const path = join(directory, name);
163
+ const diskPath = index.pathFor(path);
164
+ const generation = this.bootstrapGenerationFor(diskPath);
165
+ let current;
166
+ try {
167
+ current = await lstat(path);
168
+ }
169
+ catch (error) {
170
+ if (isMissing(error))
171
+ continue;
174
172
  throw error;
175
- await new Promise((resolve) => setImmediate(resolve));
173
+ }
174
+ if (this.bootstrapGenerationFor(diskPath) !== generation)
175
+ continue;
176
+ if (current.isDirectory()) {
177
+ this.directories.add(diskPath);
178
+ await walk(path);
179
+ }
180
+ else if (current.isFile()) {
181
+ if (name.endsWith(".md"))
182
+ await index.refresh(path);
183
+ else
184
+ index.add(path);
185
+ }
186
+ }
187
+ };
188
+ await walk(root);
189
+ }
190
+ bootstrapGenerationFor(path) {
191
+ let generation = 0;
192
+ let prefix = "";
193
+ for (const segment of path.split("/")) {
194
+ prefix = prefix === "" ? segment : `${prefix}/${segment}`;
195
+ generation = Math.max(generation, this.bootstrapPathGenerations.get(prefix) ?? 0);
196
+ }
197
+ return generation;
198
+ }
199
+ async drainBootstrapChanges() {
200
+ const deadline = Date.now() + bootstrapQuietLimitMs;
201
+ while (true) {
202
+ while (this.activeChanges.size > 0)
203
+ await Promise.all([...this.activeChanges]);
204
+ const observedGeneration = this.changeGeneration;
205
+ const quietMs = Math.min(bootstrapQuietMs, Math.max(0, deadline - Date.now()));
206
+ if (quietMs === 0)
207
+ return;
208
+ await new Promise((resolve) => setTimeout(resolve, quietMs));
209
+ if (this.activeChanges.size === 0 && this.changeGeneration === observedGeneration)
210
+ return;
211
+ if (Date.now() >= deadline) {
212
+ while (this.activeChanges.size > 0)
213
+ await Promise.all([...this.activeChanges]);
214
+ return;
176
215
  }
177
216
  }
178
- this.boundHost = host;
179
- return this;
180
- }
181
- async close() {
182
- this.isClosing = true;
183
- for (const path of this.pendingWatcherEvents.keys())
184
- this.cancelPendingWatcherEvent(path);
185
- clearInterval(this.pingTimer);
186
- for (const socket of this.sockets.keys())
187
- socket.terminate();
188
- await this.watcher.close();
189
- this.webSockets.close();
190
- if (this.server.listening)
191
- await new Promise((resolve, reject) => this.server.close((error) => error ? reject(error) : resolve()));
192
- this.boundHost = undefined;
193
217
  }
194
- context() {
195
- return { root: this.root, index: this.index, validate: this.validate, runMutation: this.runMutation };
218
+ async handle(request, response, context) {
219
+ await this.ready;
220
+ const index = this.index;
221
+ const root = this.root;
222
+ try {
223
+ if (request.method === "DELETE") {
224
+ if (context.trailingSlash) {
225
+ if (context.resource.type === "dir")
226
+ this.directories.add(context.path);
227
+ return false;
228
+ }
229
+ if (context.resource.type === "file")
230
+ return false;
231
+ const sourcePath = `${context.path}.md`;
232
+ if ((await context.inspect(sourcePath)).type !== "file") {
233
+ if (context.resource.type === "dir")
234
+ this.directories.add(context.path);
235
+ return false;
236
+ }
237
+ await context.remove(sourcePath);
238
+ response.status(204).end();
239
+ return true;
240
+ }
241
+ const recordMutation = (request.method === "PUT" || request.method === "PATCH")
242
+ && isRecordContentType(request.get("Content-Type"))
243
+ && !context.trailingSlash
244
+ && !context.path.endsWith(".md");
245
+ if (recordMutation) {
246
+ if (context.resource.type === "file") {
247
+ sendError(request, response, 409, "literal file occupies record URL");
248
+ return true;
249
+ }
250
+ await this.mutateRecord(request, response, context, root, index);
251
+ return true;
252
+ }
253
+ if (request.method !== "GET" && request.method !== "HEAD")
254
+ return false;
255
+ if (!context.trailingSlash && context.resource.type !== "file") {
256
+ const sourcePath = `${context.path}.md`;
257
+ if ((await context.inspect(sourcePath)).type === "file") {
258
+ try {
259
+ sendRecord(request, response, 200, index.read(join(root, ...sourcePath.split("/"))));
260
+ }
261
+ catch (error) {
262
+ if (error instanceof InvalidUtf8Error)
263
+ sendError(request, response, 422, error.message);
264
+ else
265
+ throw error;
266
+ }
267
+ return true;
268
+ }
269
+ }
270
+ return false;
271
+ }
272
+ catch (error) {
273
+ if (error instanceof InvalidReferenceError) {
274
+ sendError(request, response, 400, error.message);
275
+ return true;
276
+ }
277
+ if (error instanceof ValidationError) {
278
+ sendError(request, response, 422, error.message);
279
+ return true;
280
+ }
281
+ if (error instanceof InvalidUtf8Error) {
282
+ sendError(request, response, 422, error.message);
283
+ return true;
284
+ }
285
+ throw error;
286
+ }
196
287
  }
197
- runMutation = async (type, absolutePath, action) => {
198
- const result = await action();
199
- this.cancelPendingWatcherEvent(absolutePath);
200
- this.emitEvent(type, absolutePath);
201
- return result;
202
- };
203
- emitEvent(type, absolutePath) {
204
- const diskPath = toVaultPath(relative(this.root, absolutePath));
205
- const path = diskPath.endsWith(".md") ? diskPath.slice(0, -3) : diskPath;
206
- if (hasDotSegment(path))
288
+ async mutateRecord(request, response, context, root, index) {
289
+ const document = await readRecordDocument(request, this.maximumJsonBytes, response);
290
+ if (document === undefined)
207
291
  return;
208
- for (const socket of this.sockets.keys())
209
- if (socket.readyState === WebSocket.OPEN)
210
- socket.send(JSON.stringify({ type, path }));
211
- }
212
- handleWatcherChange(type, path) {
213
- if (this.isClosing)
292
+ if (Object.hasOwn(document, "fields") && !isObject(document.fields)) {
293
+ sendError(request, response, 400, "fields must be an object");
214
294
  return;
215
- if (!this.isReady) {
216
- void this.refreshIndex(path);
295
+ }
296
+ if (Object.hasOwn(document, "body") && document.body !== null && typeof document.body !== "string") {
297
+ sendError(request, response, 400, "body must be a string or null");
217
298
  return;
218
299
  }
219
- const existing = this.pendingWatcherEvents.get(path);
220
- if (existing?.timer)
221
- clearTimeout(existing.timer);
222
- const pending = {
223
- type: existing?.type === "created" || type === "created" ? "created" : "updated",
224
- };
225
- pending.timer = setTimeout(() => { delete pending.timer; void this.flushWatcherEvent(path, pending); }, watcherDebounceMs);
226
- pending.timer.unref();
227
- this.pendingWatcherEvents.set(path, pending);
228
- }
229
- async flushWatcherEvent(path, pending) {
230
- const outcome = await this.refreshIndex(path);
231
- if (this.pendingWatcherEvents.get(path) !== pending)
300
+ const sourcePath = `${context.path}.md`;
301
+ const source = await context.inspect(sourcePath);
302
+ if (source.type === "dir") {
303
+ sendError(request, response, 409, "cannot replace a directory");
232
304
  return;
233
- this.pendingWatcherEvents.delete(path);
234
- if (outcome === "committed" && !this.isClosing)
235
- this.emitEvent(pending.type, path);
236
- }
237
- cancelPendingWatcherEvent(path) {
238
- const pending = this.pendingWatcherEvents.get(path);
239
- if (pending?.timer)
240
- clearTimeout(pending.timer);
241
- this.pendingWatcherEvents.delete(path);
242
- }
243
- refreshIndex(path) {
244
- const startup = !this.isReady;
245
- const refresh = this.index.refresh(path).catch((error) => {
246
- if (startup) {
247
- if (!this.startupRefreshFailed)
248
- this.startupRefreshError = error;
249
- this.startupRefreshFailed = true;
305
+ }
306
+ const exists = source.type === "file";
307
+ const absolutePath = join(root, ...sourcePath.split("/"));
308
+ const linkingPath = sourcePath;
309
+ if (request.method === "PUT") {
310
+ let previous;
311
+ if (exists) {
312
+ try {
313
+ previous = await readRawRecord(absolutePath);
314
+ }
315
+ catch (error) {
316
+ if (!(error instanceof InvalidUtf8Error))
317
+ throw error;
318
+ }
250
319
  }
251
- else
252
- console.error("sv-vault index refresh error", error);
253
- return "failed";
254
- });
255
- this.pendingIndexRefreshes.add(refresh);
256
- void refresh.finally(() => this.pendingIndexRefreshes.delete(refresh));
257
- return refresh;
258
- }
259
- }
260
- async function handleRequest(context, request, response) {
261
- const path = decodeRequestPath(request.originalUrl);
262
- if (path === null) {
263
- sendError(response, 400, "invalid path");
264
- return;
265
- }
266
- if (hasHiddenUrlSegment(path)) {
267
- sendError(response, 404, "not found");
268
- return;
269
- }
270
- const absolutePath = resolveVaultPath(context.root, path);
271
- if (!absolutePath) {
272
- sendError(response, 400, "path escapes vault");
273
- return;
274
- }
275
- if (!await isVaultPathConfined(context.root, absolutePath)) {
276
- sendError(response, 400, "path escapes vault");
277
- return;
278
- }
279
- try {
280
- if (request.method === "GET") {
281
- await handleGet(context, absolutePath, request, response);
320
+ const fields = storedFields((document.fields ?? {}), previous?.fields, index, linkingPath);
321
+ const body = typeof document.body === "string"
322
+ ? storedBody(document.body, previous?.body, index, linkingPath)
323
+ : undefined;
324
+ const record = { fields, ...(body === undefined ? {} : { body }) };
325
+ await this.approve(context.path, record);
326
+ const bytes = Buffer.from(await serializeNote(absolutePath, record));
327
+ const result = await context.replace(sourcePath, bytes);
328
+ sendRecord(request, response, result === "created" ? 201 : 200, index.read(absolutePath));
282
329
  return;
283
330
  }
284
- if (["PUT", "PATCH", "DELETE"].includes(request.method)) {
285
- await handleWrite(context, absolutePath, request, response);
331
+ if (!exists) {
332
+ sendError(request, response, 404, "record not found");
286
333
  return;
287
334
  }
288
- sendError(response, 405, "method not allowed");
289
- }
290
- catch (error) {
291
- if (error instanceof ValidationError)
292
- sendError(response, 422, error.message);
293
- else if (error instanceof InvalidReferenceError)
294
- sendError(response, 400, error.message);
295
- else if (error instanceof InvalidUtf8Error)
296
- sendError(response, 422, error.message);
297
- else if (isMissing(error))
298
- sendError(response, 404, "not found");
299
- else if (error instanceof SyntaxError)
300
- sendError(response, 400, "malformed JSON");
301
- else {
302
- console.error(error);
303
- sendError(response, 500, "internal server error");
304
- }
305
- }
306
- }
307
- async function handleGet(context, absolutePath, request, response) {
308
- if (rawPathname(request.originalUrl).endsWith("/")) {
309
- if (!(await isDirectory(absolutePath))) {
310
- sendError(response, 404, "directory not found");
335
+ const current = index.read(absolutePath);
336
+ const fieldPatch = document.fields;
337
+ const bodyTouched = Object.hasOwn(document, "body");
338
+ if ((fieldPatch === undefined || Object.keys(fieldPatch).length === 0) && !bodyTouched) {
339
+ sendRecord(request, response, 200, current);
311
340
  return;
312
341
  }
313
- response.json(listNotes(context.index, canonicalPath(context.root, absolutePath)));
314
- return;
315
- }
316
- if (await isFile(absolutePath)) {
317
- response.sendFile(absolutePath);
318
- return;
342
+ const previous = await readRawRecord(absolutePath);
343
+ const fields = fieldPatch === undefined
344
+ ? previous.fields
345
+ : storedFields(mergeFields(current.fields, fieldPatch), previous.fields, index, linkingPath, fieldPatch);
346
+ const body = bodyTouched
347
+ ? typeof document.body === "string"
348
+ ? storedBody(document.body, previous.body, index, linkingPath)
349
+ : undefined
350
+ : previous.body;
351
+ const record = { fields, ...(body === undefined ? {} : { body }) };
352
+ await this.approve(context.path, record);
353
+ const bytes = Buffer.from(await serializeNote(absolutePath, record, fieldPatch ?? {}, bodyTouched, previous.rawBody));
354
+ await context.replace(sourcePath, bytes);
355
+ sendRecord(request, response, 200, index.read(absolutePath));
319
356
  }
320
- if (await isFile(`${absolutePath}.md`)) {
321
- if (!await isVaultPathConfined(context.root, `${absolutePath}.md`)) {
322
- sendError(response, 400, "path escapes vault");
357
+ async approve(path, record) {
358
+ if (this.validate === undefined)
323
359
  return;
360
+ try {
361
+ await this.validate({ path, ...record });
324
362
  }
325
- response.json(await readNote(context.root, `${absolutePath}.md`, context.index));
326
- return;
327
- }
328
- if (await isDirectory(absolutePath)) {
329
- response.redirect(308, `${request.path}/`);
330
- return;
331
- }
332
- sendError(response, 404, "not found");
333
- }
334
- async function handleWrite(context, absolutePath, request, response) {
335
- const path = decodeRequestPath(request.originalUrl) ?? "";
336
- if (rawPathname(request.originalUrl).endsWith("/") || path.endsWith(".md")) {
337
- sendError(response, 405, "path is read-only");
338
- return;
339
- }
340
- if (request.method !== "DELETE" && !request.is(noteContentType)) {
341
- sendError(response, 415, "unsupported content type");
342
- return;
343
- }
344
- if (await isFile(absolutePath)) {
345
- sendError(response, 409, "literal file occupies record URL");
346
- return;
347
- }
348
- const notePath = `${absolutePath}.md`;
349
- if (!await isVaultPathConfined(context.root, notePath)) {
350
- sendError(response, 400, "path escapes vault");
351
- return;
352
- }
353
- const exists = await isFile(notePath);
354
- if (request.method === "DELETE") {
355
- if (!exists) {
356
- sendError(response, 404, "record not found");
357
- return;
363
+ catch (error) {
364
+ throw new ValidationError(error instanceof Error ? error.message : String(error));
358
365
  }
359
- await context.runMutation("deleted", notePath, () => deleteNote(notePath, context.index));
360
- response.status(204).end();
361
- return;
362
- }
363
- if (!isObject(request.body)) {
364
- sendError(response, 400, "body must be an object");
365
- return;
366
- }
367
- if ("fields" in request.body && !isObject(request.body.fields)) {
368
- sendError(response, 400, "fields must be an object");
369
- return;
370
366
  }
371
- if ("body" in request.body && request.body.body !== null && typeof request.body.body !== "string") {
372
- sendError(response, 400, "body must be a string");
373
- return;
374
- }
375
- const linkingNotePath = canonicalPath(context.root, notePath);
376
- if (request.method === "PUT") {
377
- let previous;
378
- if (exists) {
367
+ change(event) {
368
+ this.changeGeneration += 1;
369
+ if (!this.bootstrapComplete)
370
+ this.bootstrapPathGenerations.set(event.path, this.changeGeneration);
371
+ const previous = this.changesByPath.get(event.path);
372
+ const change = (previous === undefined ? Promise.resolve() : previous.then(() => undefined, () => undefined))
373
+ .then(() => this.refreshChange(event));
374
+ this.changesByPath.set(event.path, change);
375
+ this.activeChanges.add(change);
376
+ const cleanup = () => {
377
+ this.activeChanges.delete(change);
378
+ if (this.changesByPath.get(event.path) === change)
379
+ this.changesByPath.delete(event.path);
380
+ };
381
+ void change.then(cleanup, cleanup);
382
+ return change;
383
+ }
384
+ async refreshChange(event) {
385
+ await this.indexAvailable;
386
+ const index = this.index;
387
+ const root = this.root;
388
+ const absolutePath = join(root, ...event.path.split("/"));
389
+ try {
390
+ let current;
379
391
  try {
380
- previous = await readRawRecord(notePath);
392
+ current = await lstat(absolutePath);
381
393
  }
382
394
  catch (error) {
383
- if (!(error instanceof InvalidUtf8Error))
395
+ if (!isMissing(error))
384
396
  throw error;
397
+ const wasDirectory = this.directories.has(event.path) || index.hasDescendant(event.path);
398
+ this.forgetDirectoryTree(event.path);
399
+ index.deleteTree(absolutePath);
400
+ if (wasDirectory)
401
+ return event;
402
+ }
403
+ if (current?.isDirectory()) {
404
+ this.forgetDirectoryTree(event.path);
405
+ this.directories.add(event.path);
406
+ index.deleteTree(absolutePath);
407
+ await this.refreshTree(absolutePath, index);
408
+ return event;
409
+ }
410
+ if (current?.isFile()) {
411
+ const replacedDirectory = this.directories.has(event.path) || index.hasDescendant(event.path);
412
+ this.forgetDirectoryTree(event.path);
413
+ if (replacedDirectory)
414
+ index.deleteTree(absolutePath);
415
+ if (event.path.endsWith(".md"))
416
+ await index.refresh(absolutePath);
417
+ else
418
+ index.add(absolutePath);
419
+ for (let separator = event.path.lastIndexOf("/"); separator >= 0; separator = event.path.lastIndexOf("/", separator - 1)) {
420
+ this.directories.add(event.path.slice(0, separator));
421
+ }
385
422
  }
386
423
  }
387
- const fields = storedFields((request.body.fields ?? {}), previous?.fields, context.index, linkingNotePath);
388
- const body = typeof request.body.body === "string"
389
- ? storedBody(request.body.body, previous?.body, context.index, linkingNotePath)
390
- : undefined;
391
- const record = { fields, ...(body === undefined ? {} : { body }) };
392
- await approve(context, notePath, record);
393
- const note = await context.runMutation(exists ? "updated" : "created", notePath, () => writeNote(context.root, notePath, context.index, record));
394
- response.status(exists ? 200 : 201).json(note);
395
- return;
396
- }
397
- if (!exists) {
398
- sendError(response, 404, "record not found");
399
- return;
424
+ catch (error) {
425
+ if (isDirectoryRead(error)) {
426
+ this.directories.add(event.path);
427
+ index.delete(absolutePath);
428
+ return event;
429
+ }
430
+ if (isMissing(error))
431
+ index.deleteTree(absolutePath);
432
+ else {
433
+ console.error("sv-vault index refresh error", error);
434
+ // A cache failure must not erase the base protocol's dirty signal.
435
+ // Publishing the literal event lets clients refetch and preserves the
436
+ // extension seam's fail-open guarantee for change hooks.
437
+ return event;
438
+ }
439
+ }
440
+ if (!event.path.endsWith(".md"))
441
+ return event;
442
+ const recordPath = event.path.slice(0, -3);
443
+ try {
444
+ const exact = await lstat(join(root, ...recordPath.split("/")));
445
+ if (exact.isFile())
446
+ return event;
447
+ }
448
+ catch (error) {
449
+ if (!isMissing(error))
450
+ console.error("sv-vault shadow inspection error", error);
451
+ }
452
+ return { ...event, path: recordPath };
400
453
  }
401
- const current = await readNote(context.root, notePath, context.index);
402
- const fieldPatch = request.body.fields;
403
- const bodyTouched = Object.hasOwn(request.body, "body");
404
- if ((fieldPatch === undefined || Object.keys(fieldPatch).length === 0) && !bodyTouched) {
405
- response.json(current);
406
- return;
454
+ forgetDirectoryTree(path) {
455
+ const prefix = `${path}/`;
456
+ for (const directory of this.directories) {
457
+ if (directory === path || directory.startsWith(prefix))
458
+ this.directories.delete(directory);
459
+ }
407
460
  }
408
- const previous = await readRawRecord(notePath);
409
- const fields = fieldPatch === undefined
410
- ? previous.fields
411
- : storedFields(mergeFields(current.fields, fieldPatch), previous.fields, context.index, linkingNotePath, fieldPatch);
412
- const body = bodyTouched
413
- ? typeof request.body.body === "string"
414
- ? storedBody(request.body.body, previous.body, context.index, linkingNotePath)
415
- : undefined
416
- : previous.body;
417
- const record = { fields, ...(body === undefined ? {} : { body }) };
418
- await approve(context, notePath, record);
419
- response.json(await context.runMutation("updated", notePath, () => writeNote(context.root, notePath, context.index, record, fieldPatch ?? {}, bodyTouched, previous.rawBody)));
420
461
  }
421
- /**
422
- * Offer the record a write would produce to the caller's `validate`, before any
423
- * of it reaches disk — so a refusal leaves nothing behind, not even a temp file.
424
- * The fields are the ones that will be stored, not the ones a read would give
425
- * back: a rule is judged against what the write actually persists.
426
- */
427
- async function approve(context, notePath, content) {
428
- if (!context.validate)
429
- return;
430
- const diskPath = canonicalPath(context.root, notePath);
431
- const record = { path: diskPath.slice(0, -3), ...content };
462
+ function decorateListing(listing, index) {
463
+ const exactFiles = new Set(listing.entries.filter(({ type }) => type === "file").map(({ name }) => name));
464
+ const entries = listing.entries.map((entry) => {
465
+ if (entry.type !== "file" || !entry.name.endsWith(".md"))
466
+ return entry;
467
+ const name = entry.name.slice(0, -3);
468
+ if (exactFiles.has(name))
469
+ return entry;
470
+ const diskPath = listing.path === "" ? entry.name : `${listing.path}/${entry.name}`;
471
+ const record = index.listingEntry(diskPath);
472
+ if (record === undefined)
473
+ return entry;
474
+ return {
475
+ name,
476
+ type: "record",
477
+ modified: record.updated,
478
+ fields: record.fields,
479
+ ...(record.body === undefined ? {} : { body: record.body }),
480
+ ...(record.error === undefined ? {} : { error: record.error }),
481
+ };
482
+ });
483
+ entries.sort(compareDirectoryEntries);
484
+ return { path: listing.path, entries };
485
+ }
486
+ function compareDirectoryEntries(left, right) {
487
+ return left.name < right.name ? -1
488
+ : left.name > right.name ? 1
489
+ : left.type < right.type ? -1
490
+ : left.type > right.type ? 1
491
+ : 0;
492
+ }
493
+ async function readRecordDocument(request, maximumBytes, response) {
494
+ const declaredLength = request.headers["content-length"];
495
+ if (typeof declaredLength === "string" && Number(declaredLength) > maximumBytes) {
496
+ rejectOversizedBody(request, response);
497
+ return undefined;
498
+ }
499
+ const chunks = [];
500
+ let size = 0;
501
+ for await (const value of request) {
502
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
503
+ size += chunk.length;
504
+ if (size > maximumBytes) {
505
+ rejectOversizedBody(request, response);
506
+ return undefined;
507
+ }
508
+ chunks.push(chunk);
509
+ }
510
+ let value;
432
511
  try {
433
- await context.validate(record);
512
+ value = JSON.parse(utf8Decoder.decode(Buffer.concat(chunks, size)));
434
513
  }
435
- catch (error) {
436
- throw new ValidationError(error instanceof Error ? error.message : String(error));
514
+ catch {
515
+ sendError(request, response, 400, "malformed JSON");
516
+ return undefined;
517
+ }
518
+ if (!isObject(value)) {
519
+ sendError(request, response, 400, "body must be an object");
520
+ return undefined;
437
521
  }
522
+ return value;
438
523
  }
439
- function isObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value); }
440
- function rejectUnknownOptions(options, knownOptions) {
441
- const unknownOption = Object.keys(options).find((key) => !knownOptions.includes(key));
442
- if (unknownOption)
443
- throw new Error(`unknown VaultServer option "${unknownOption}"`);
524
+ function rejectOversizedBody(request, response) {
525
+ request.pause();
526
+ response.shouldKeepAlive = false;
527
+ response.set("Connection", "close");
528
+ response.once("finish", () => setImmediate(() => request.destroy()));
529
+ sendError(request, response, 413, "request body is too large");
530
+ }
531
+ function sendRecord(request, response, status, value) {
532
+ sendSerialized(request, response, status, value, recordResponseContentType);
533
+ }
534
+ function sendSerialized(request, response, status, value, contentType) {
535
+ const body = Buffer.from(JSON.stringify(value));
536
+ response.set({ "Content-Type": contentType, "Content-Length": String(body.length) });
537
+ response.status(status);
538
+ if (request.method === "HEAD")
539
+ response.end();
540
+ else
541
+ response.end(body);
542
+ }
543
+ function sendError(request, response, status, message) {
544
+ sendSerialized(request, response, status, { error: message }, jsonContentType);
545
+ }
546
+ function sendReadinessError(request, response, error) {
547
+ const { status, message } = mapReadinessError(error);
548
+ response.set({
549
+ "Access-Control-Allow-Origin": "*",
550
+ "Access-Control-Expose-Headers": exposedResponseHeaders,
551
+ });
552
+ sendError(request, response, status, message);
444
553
  }
445
- async function isFile(path) { try {
446
- return (await stat(path)).isFile();
554
+ async function sendUpgradeReadinessError(socket, error) {
555
+ if (socket.closed)
556
+ return;
557
+ const { status, message } = mapReadinessError(error);
558
+ const body = Buffer.from(JSON.stringify({ error: message }));
559
+ const response = Buffer.from([
560
+ `HTTP/1.1 ${status} ${STATUS_CODES[status] ?? "Error"}`,
561
+ `Content-Type: ${jsonContentType}`,
562
+ `Content-Length: ${body.length}`,
563
+ "Connection: close",
564
+ "",
565
+ "",
566
+ ].join("\r\n"));
567
+ await new Promise((resolve) => {
568
+ let settled = false;
569
+ const close = () => {
570
+ if (settled)
571
+ return;
572
+ settled = true;
573
+ socket.removeListener("finish", finish);
574
+ socket.removeListener("close", close);
575
+ socket.removeListener("error", fail);
576
+ resolve();
577
+ };
578
+ const finish = () => { socket.destroy(); };
579
+ const fail = () => { socket.destroy(); };
580
+ socket.once("finish", finish);
581
+ socket.once("close", close);
582
+ socket.once("error", fail);
583
+ if (!socket.destroyed)
584
+ socket.end(Buffer.concat([response, body]));
585
+ if (socket.closed)
586
+ close();
587
+ else if (socket.writableFinished)
588
+ finish();
589
+ });
447
590
  }
448
- catch {
449
- return false;
450
- } }
451
- async function isDirectory(path) { try {
452
- return (await stat(path)).isDirectory();
591
+ function mapReadinessError(error) {
592
+ const code = error !== null && typeof error === "object" && "code" in error ? error.code : undefined;
593
+ if (code === "ENOENT" || code === "ENOTDIR")
594
+ return { status: 404, message: "not found" };
595
+ if (code === "EACCES" || code === "EPERM")
596
+ return { status: 403, message: "forbidden" };
597
+ return { status: 500, message: "internal server error" };
453
598
  }
454
- catch {
455
- return false;
456
- } }
457
- function isMissing(error) { return isObject(error) && "code" in error && error.code === "ENOENT"; }
458
- function isPayloadTooLarge(error) {
459
- return isObject(error) && (("status" in error && error.status === 413) || ("type" in error && error.type === "entity.too.large"));
599
+ function isRecordContentType(contentType) {
600
+ return contentType?.split(";", 1)[0]?.trim().toLowerCase() === recordContentType;
460
601
  }
461
- function rawPathname(url) { return url.split("?", 1)[0] ?? ""; }
462
- function decodeRequestPath(url) {
602
+ function parseByteLimit(limit) {
603
+ if (typeof limit === "number") {
604
+ if (!Number.isFinite(limit) || limit < 0)
605
+ throw new Error("jsonLimit must be a non-negative byte count");
606
+ return Math.floor(limit);
607
+ }
608
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?\s*$/i.exec(limit);
609
+ if (match === null)
610
+ throw new Error("jsonLimit must be a byte count such as 32mb");
611
+ const units = { b: 1, kb: 1024, mb: 1024 ** 2, gb: 1024 ** 3 };
612
+ return Math.floor(Number(match[1]) * units[(match[2] ?? "b").toLowerCase()]);
613
+ }
614
+ function decodeDirectoryName(name) {
463
615
  try {
464
- const path = decodeURIComponent(rawPathname(url)).replace(/^\/+/, "");
465
- return path.includes("\0") ? null : path;
616
+ return utf8Decoder.decode(name);
466
617
  }
467
618
  catch {
468
- return null;
619
+ return undefined;
469
620
  }
470
621
  }
471
- function hasHiddenUrlSegment(path) {
472
- return path.split("/").some((segment) => segment !== "." && segment !== ".." && segment.startsWith("."));
622
+ function rejectUnknownOptions(options, knownOptions) {
623
+ const unknownOption = Object.keys(options).find((key) => !knownOptions.includes(key));
624
+ if (unknownOption)
625
+ throw new Error(`unknown VaultServer option "${unknownOption}"`);
626
+ }
627
+ function isObject(value) {
628
+ return value !== null && typeof value === "object" && !Array.isArray(value);
629
+ }
630
+ function isMissing(error) {
631
+ return error !== null && typeof error === "object" && "code" in error
632
+ && (error.code === "ENOENT" || error.code === "ENOTDIR");
473
633
  }
474
- function shouldParseNoteBody(request) {
475
- if (request.method !== "PUT" && request.method !== "PATCH")
476
- return false;
477
- if (request.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== noteContentType)
478
- return false;
479
- const url = request.url ?? "/";
480
- const path = decodeRequestPath(url);
481
- return path !== null && !hasHiddenUrlSegment(path) && !rawPathname(url).endsWith("/") && !path.endsWith(".md");
634
+ function isDirectoryRead(error) {
635
+ return error !== null && typeof error === "object" && "code" in error && error.code === "EISDIR";
482
636
  }
483
- function canonicalPath(root, absolutePath) { return toVaultPath(relative(root, absolutePath)); }
484
- function sendError(response, status, message) { response.status(status).json({ error: message }); }
485
637
  //# sourceMappingURL=server.js.map