@telepath-computer/vault-server 0.4.1 → 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.
- package/README.md +136 -83
- package/dist/src/cli.js +39 -21
- package/dist/src/cli.js.map +1 -1
- package/dist/src/notes.d.ts +12 -8
- package/dist/src/notes.js +76 -85
- package/dist/src/notes.js.map +1 -1
- package/dist/src/server.d.ts +42 -38
- package/dist/src/server.js +563 -411
- package/dist/src/server.js.map +1 -1
- package/package.json +6 -6
- package/skill/SKILL.md +116 -0
- package/dist/src/paths.d.ts +0 -8
- package/dist/src/paths.js +0 -44
- package/dist/src/paths.js.map +0 -1
package/dist/src/server.js
CHANGED
|
@@ -1,485 +1,637 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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 {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
44
|
-
this.
|
|
45
|
-
this.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
this.ready = Promise.all([
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
this.
|
|
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.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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
|
-
/**
|
|
148
|
-
get
|
|
149
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
118
|
+
/** Start accepting connections after the base watcher and record index are ready. */
|
|
155
119
|
async listen(options = {}) {
|
|
156
120
|
await this.ready;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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 (
|
|
285
|
-
|
|
331
|
+
if (!exists) {
|
|
332
|
+
sendError(request, response, 404, "record not found");
|
|
286
333
|
return;
|
|
287
334
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
if (
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
392
|
+
current = await lstat(absolutePath);
|
|
381
393
|
}
|
|
382
394
|
catch (error) {
|
|
383
|
-
if (!(error
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
512
|
+
value = JSON.parse(utf8Decoder.decode(Buffer.concat(chunks, size)));
|
|
434
513
|
}
|
|
435
|
-
catch
|
|
436
|
-
|
|
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
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
446
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
455
|
-
return
|
|
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
|
|
462
|
-
|
|
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
|
-
|
|
465
|
-
return path.includes("\0") ? null : path;
|
|
616
|
+
return utf8Decoder.decode(name);
|
|
466
617
|
}
|
|
467
618
|
catch {
|
|
468
|
-
return
|
|
619
|
+
return undefined;
|
|
469
620
|
}
|
|
470
621
|
}
|
|
471
|
-
function
|
|
472
|
-
|
|
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
|
|
475
|
-
|
|
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
|