@ahpd/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.
Files changed (163) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +129 -0
  3. package/dist/automations.d.ts +4 -0
  4. package/dist/automations.d.ts.map +1 -0
  5. package/dist/automations.js +191 -0
  6. package/dist/automations.js.map +1 -0
  7. package/dist/catalog.d.ts +48 -0
  8. package/dist/catalog.d.ts.map +1 -0
  9. package/dist/catalog.js +51 -0
  10. package/dist/catalog.js.map +1 -0
  11. package/dist/changes.d.ts +4 -0
  12. package/dist/changes.d.ts.map +1 -0
  13. package/dist/changes.js +601 -0
  14. package/dist/changes.js.map +1 -0
  15. package/dist/cron.d.ts +43 -0
  16. package/dist/cron.d.ts.map +1 -0
  17. package/dist/cron.js +211 -0
  18. package/dist/cron.js.map +1 -0
  19. package/dist/git.d.ts +16 -0
  20. package/dist/git.d.ts.map +1 -0
  21. package/dist/git.js +126 -0
  22. package/dist/git.js.map +1 -0
  23. package/dist/host.d.ts +24 -0
  24. package/dist/host.d.ts.map +1 -0
  25. package/dist/host.js +5850 -0
  26. package/dist/host.js.map +1 -0
  27. package/dist/index.d.ts +36 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +34 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/listen.d.ts +3 -0
  32. package/dist/listen.d.ts.map +1 -0
  33. package/dist/listen.js +183 -0
  34. package/dist/listen.js.map +1 -0
  35. package/dist/paging.d.ts +38 -0
  36. package/dist/paging.d.ts.map +1 -0
  37. package/dist/paging.js +53 -0
  38. package/dist/paging.js.map +1 -0
  39. package/dist/paths.d.ts +15 -0
  40. package/dist/paths.d.ts.map +1 -0
  41. package/dist/paths.js +19 -0
  42. package/dist/paths.js.map +1 -0
  43. package/dist/resources.d.ts +122 -0
  44. package/dist/resources.d.ts.map +1 -0
  45. package/dist/resources.js +494 -0
  46. package/dist/resources.js.map +1 -0
  47. package/dist/rpc.d.ts +63 -0
  48. package/dist/rpc.d.ts.map +1 -0
  49. package/dist/rpc.js +208 -0
  50. package/dist/rpc.js.map +1 -0
  51. package/dist/scheduled.d.ts +48 -0
  52. package/dist/scheduled.d.ts.map +1 -0
  53. package/dist/scheduled.js +325 -0
  54. package/dist/scheduled.js.map +1 -0
  55. package/dist/terminals.d.ts +29 -0
  56. package/dist/terminals.d.ts.map +1 -0
  57. package/dist/terminals.js +346 -0
  58. package/dist/terminals.js.map +1 -0
  59. package/dist/tools.d.ts +18 -0
  60. package/dist/tools.d.ts.map +1 -0
  61. package/dist/tools.js +65 -0
  62. package/dist/tools.js.map +1 -0
  63. package/dist/types/agent.d.ts +245 -0
  64. package/dist/types/agent.d.ts.map +1 -0
  65. package/dist/types/agent.js +3 -0
  66. package/dist/types/agent.js.map +1 -0
  67. package/dist/types/automations.d.ts +164 -0
  68. package/dist/types/automations.d.ts.map +1 -0
  69. package/dist/types/automations.js +3 -0
  70. package/dist/types/automations.js.map +1 -0
  71. package/dist/types/catalog.d.ts +30 -0
  72. package/dist/types/catalog.d.ts.map +1 -0
  73. package/dist/types/catalog.js +3 -0
  74. package/dist/types/catalog.js.map +1 -0
  75. package/dist/types/changes.d.ts +272 -0
  76. package/dist/types/changes.d.ts.map +1 -0
  77. package/dist/types/changes.js +3 -0
  78. package/dist/types/changes.js.map +1 -0
  79. package/dist/types/common.d.ts +9 -0
  80. package/dist/types/common.d.ts.map +1 -0
  81. package/dist/types/common.js +3 -0
  82. package/dist/types/common.js.map +1 -0
  83. package/dist/types/host.d.ts +372 -0
  84. package/dist/types/host.d.ts.map +1 -0
  85. package/dist/types/host.js +3 -0
  86. package/dist/types/host.js.map +1 -0
  87. package/dist/types/index.d.ts +20 -0
  88. package/dist/types/index.d.ts.map +1 -0
  89. package/dist/types/index.js +8 -0
  90. package/dist/types/index.js.map +1 -0
  91. package/dist/types/listen.d.ts +48 -0
  92. package/dist/types/listen.d.ts.map +1 -0
  93. package/dist/types/listen.js +3 -0
  94. package/dist/types/listen.js.map +1 -0
  95. package/dist/types/paging.d.ts +10 -0
  96. package/dist/types/paging.d.ts.map +1 -0
  97. package/dist/types/paging.js +3 -0
  98. package/dist/types/paging.js.map +1 -0
  99. package/dist/types/probe.d.ts +46 -0
  100. package/dist/types/probe.d.ts.map +1 -0
  101. package/dist/types/probe.js +3 -0
  102. package/dist/types/probe.js.map +1 -0
  103. package/dist/types/resources.d.ts +83 -0
  104. package/dist/types/resources.d.ts.map +1 -0
  105. package/dist/types/resources.js +3 -0
  106. package/dist/types/resources.js.map +1 -0
  107. package/dist/types/rpc.d.ts +65 -0
  108. package/dist/types/rpc.d.ts.map +1 -0
  109. package/dist/types/rpc.js +3 -0
  110. package/dist/types/rpc.js.map +1 -0
  111. package/dist/types/session.d.ts +395 -0
  112. package/dist/types/session.d.ts.map +1 -0
  113. package/dist/types/session.js +3 -0
  114. package/dist/types/session.js.map +1 -0
  115. package/dist/types/terminals.d.ts +131 -0
  116. package/dist/types/terminals.d.ts.map +1 -0
  117. package/dist/types/terminals.js +3 -0
  118. package/dist/types/terminals.js.map +1 -0
  119. package/dist/types/wire.d.ts +62 -0
  120. package/dist/types/wire.d.ts.map +1 -0
  121. package/dist/types/wire.js +13 -0
  122. package/dist/types/wire.js.map +1 -0
  123. package/dist/types/worktrees.d.ts +86 -0
  124. package/dist/types/worktrees.d.ts.map +1 -0
  125. package/dist/types/worktrees.js +3 -0
  126. package/dist/types/worktrees.js.map +1 -0
  127. package/dist/worktrees.d.ts +27 -0
  128. package/dist/worktrees.d.ts.map +1 -0
  129. package/dist/worktrees.js +152 -0
  130. package/dist/worktrees.js.map +1 -0
  131. package/package.json +61 -0
  132. package/src/automations.ts +204 -0
  133. package/src/catalog.ts +53 -0
  134. package/src/changes.ts +629 -0
  135. package/src/cron.ts +251 -0
  136. package/src/git.ts +132 -0
  137. package/src/host.ts +5947 -0
  138. package/src/index.ts +40 -0
  139. package/src/listen.ts +240 -0
  140. package/src/paging.ts +56 -0
  141. package/src/paths.ts +19 -0
  142. package/src/resources.ts +513 -0
  143. package/src/rpc.ts +222 -0
  144. package/src/scheduled.ts +369 -0
  145. package/src/terminals.ts +353 -0
  146. package/src/tools.ts +64 -0
  147. package/src/types/agent.ts +255 -0
  148. package/src/types/automations.ts +164 -0
  149. package/src/types/catalog.ts +26 -0
  150. package/src/types/changes.ts +266 -0
  151. package/src/types/common.ts +9 -0
  152. package/src/types/host.ts +386 -0
  153. package/src/types/index.ts +27 -0
  154. package/src/types/listen.ts +53 -0
  155. package/src/types/paging.ts +11 -0
  156. package/src/types/probe.ts +42 -0
  157. package/src/types/resources.ts +107 -0
  158. package/src/types/rpc.ts +68 -0
  159. package/src/types/session.ts +415 -0
  160. package/src/types/terminals.ts +133 -0
  161. package/src/types/wire.ts +69 -0
  162. package/src/types/worktrees.ts +91 -0
  163. package/src/worktrees.ts +162 -0
@@ -0,0 +1,513 @@
1
+ import { watch as watchPath } from 'node:fs';
2
+ import { copyFile, cp, mkdir as makeDir, readdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { dirname, isAbsolute, join, relative, sep } from 'node:path';
4
+ import { RpcError } from './rpc.js';
5
+ import { within } from './paths.js';
6
+ import type { Entry, Metadata, Read, ResourceChange, WatchOptions, Watcher, Write } from './types/resources.js';
7
+ import type { ResourceStore } from './types/host.js';
8
+
9
+ /**
10
+ * The host's filesystem, as far as a client is allowed to see it.
11
+ *
12
+ * Every path is checked against the directories the host was told to serve
13
+ * before anything is opened. A host that answered for any path is one that
14
+ * anybody who can reach the port can read `~/.ssh/id_ed25519` through - and
15
+ * this daemon is meant to be reachable, with a token, from another machine.
16
+ *
17
+ * `node:fs/promises` and `node:path` are used here because all three
18
+ * supported runtimes provide them.
19
+ */
20
+
21
+ /** `-32008`, which the protocol has for a resource that is not there. */
22
+ const NOT_FOUND = -32008;
23
+ /** `-32009`, for one this client may not see. */
24
+ const REFUSED = -32009;
25
+ /** `-32010`, for a `createOnly` write onto something already there. */
26
+ const ALREADY = -32010;
27
+ /** `-32011`, for a write whose `ifMatch` no longer matches. */
28
+ const CONFLICT = -32011;
29
+
30
+ /** `file:///a/b` and `/a/b` both mean the same path here. */
31
+ export const pathOf = (uri: string): string => {
32
+ const bare = uri.startsWith('file://') ? uri.slice('file://'.length) : uri;
33
+ return decodeURIComponent(bare);
34
+ };
35
+
36
+ /** A path, back as the URI a client sends and receives. */
37
+ export const uriOf = (path: string): string => `file://${path}`;
38
+
39
+ /** Whether `path` is `root` or is under it. */
40
+ /**
41
+ * Why this store cannot answer for a URI, in the terms of what it is.
42
+ *
43
+ * This one serves a filesystem, so anything that is not a `file:` URI is not
44
+ * a path it got wrong - it is a URI somebody else was meant to answer. A
45
+ * client publishes its own under `<scheme>://<clientId>/`, and a request for
46
+ * one whose client has hung up arrives here having been routed nowhere. Told
47
+ * as what it is, because `virtual://ahpc-6ec6cf49/hello.txt is not an
48
+ * absolute path` sends whoever reads it looking at their path.
49
+ */
50
+ const why = (uri: string): string => {
51
+ const scheme = /^([a-zA-Z][\w+.-]*):/.exec(uri)?.[1];
52
+ if (scheme !== undefined && scheme !== 'file') {
53
+ return `${uri} is not this host's to read: nothing here serves ${scheme}:,`
54
+ + ' and no connected client publishes it';
55
+ }
56
+ return `${uri} is not an absolute path`;
57
+ };
58
+
59
+ /**
60
+ * The real path, if the client may see it.
61
+ *
62
+ * Resolved *before* the check, not after: `served/link` pointing at `/etc`
63
+ * passes a textual test and opens something else entirely. A path that does
64
+ * not exist yet is checked as written, since there is nothing to resolve.
65
+ */
66
+ export async function allowed(uri: string, roots: string[]): Promise<string> {
67
+ const asked = pathOf(uri);
68
+ if (!isAbsolute(asked)) {
69
+ throw new RpcError(REFUSED, why(uri));
70
+ }
71
+ let real = asked;
72
+ try {
73
+ real = await realpath(asked);
74
+ }
75
+ catch { /* not there yet; the written path is what will be created */ }
76
+ const roots_ = await Promise.all(roots.map((root) => realpath(root).catch(() => root)));
77
+ if (!roots_.some((root) => within(root, real))) {
78
+ throw new RpcError(REFUSED, `This host does not serve ${asked}. It serves ${roots.join(', ')}.`);
79
+ }
80
+ return real;
81
+ }
82
+
83
+ /**
84
+ * The path to write, if the client may write it.
85
+ *
86
+ * Not `allowed`, and the difference is the whole security of the write half.
87
+ * `allowed` resolves the path itself and falls back to the written text when
88
+ * there is nothing there yet - which is right for reading, where a file that
89
+ * does not exist has nothing to hand back either way. For a write it is a
90
+ * hole: `served/link` pointing at `/etc` resolves to nothing for
91
+ * `served/link/passwd`, so the textual test passes and the file is created
92
+ * in `/etc`.
93
+ *
94
+ * So the *parent* is resolved, and the check is on that. A directory that is
95
+ * not there yet is walked up until one is, because `mkdir -p` may be creating
96
+ * several at once and none of them can escape a real ancestor.
97
+ */
98
+ export async function writable(uri: string, roots: string[]): Promise<string> {
99
+ const asked = pathOf(uri);
100
+ if (!isAbsolute(asked)) throw new RpcError(REFUSED, why(uri));
101
+ /** The nearest ancestor that exists, and how far up it was. */
102
+ let up = dirname(asked);
103
+ const climbed: string[] = [];
104
+ for (;;) {
105
+ const real = await realpath(up).catch(() => undefined);
106
+ if (real !== undefined) {
107
+ const roots_ = await Promise.all(roots.map((root) => realpath(root).catch(() => root)));
108
+ if (!roots_.some((root) => within(root, real))) {
109
+ throw new RpcError(REFUSED, `This host does not serve ${asked}. It serves ${roots.join(', ')}.`);
110
+ }
111
+ // Rebuilt from the resolved ancestor down, so what is opened is what was
112
+ // checked rather than the text that was sent.
113
+ return join(real, ...climbed.reverse(), asked.slice(asked.lastIndexOf(sep) + 1));
114
+ }
115
+ const next = dirname(up);
116
+ // `/` resolving to nothing means the filesystem is gone, not that the
117
+ // client found a way out.
118
+ if (next === up) throw new RpcError(REFUSED, `This host does not serve ${asked}.`);
119
+ climbed.push(up.slice(up.lastIndexOf(sep) + 1));
120
+ up = next;
121
+ }
122
+ }
123
+
124
+ /** The validator `resourceWrite`'s `ifMatch` compares against. */
125
+ const tagOf = (size: number, mtimeMs: number): string => `W/"${size.toString(16)}-${Math.trunc(mtimeMs).toString(16)}"`;
126
+
127
+ /** One directory's entries, names only. */
128
+ export async function list(uri: string, roots: string[]): Promise<Entry[]> {
129
+ const path = await allowed(uri, roots);
130
+ const found = await readdir(path, { withFileTypes: true }).catch(() => {
131
+ throw new RpcError(NOT_FOUND, `No directory at ${uri}`);
132
+ });
133
+ return found
134
+ .map((entry) => ({ name: entry.name, type: entry.isDirectory() ? 'directory' as const : 'file' as const }))
135
+ // Directories first, then by name: a listing sorted by the order the
136
+ // filesystem happens to return is a listing nobody can scan.
137
+ .sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'directory' ? -1 : 1));
138
+ }
139
+
140
+ /**
141
+ * What a path is, without opening it.
142
+ *
143
+ * `followSymlinks` decides which of the two questions is being asked - what
144
+ * the link points at, or what the link is - and they have different answers
145
+ * for size and type.
146
+ */
147
+ export async function resolve(uri: string, roots: string[], followSymlinks = true): Promise<Metadata> {
148
+ const path = await allowed(uri, roots);
149
+ const asked = followSymlinks ? path : pathOf(uri);
150
+ const found = await stat(asked, { bigint: false }).catch(() => {
151
+ throw new RpcError(NOT_FOUND, `Nothing at ${uri}`);
152
+ });
153
+ return {
154
+ uri: uriOf(followSymlinks ? path : asked),
155
+ type: found.isDirectory() ? 'directory' : found.isSymbolicLink() ? 'symlink' : 'file',
156
+ ...(found.isDirectory() ? {} : { size: found.size }),
157
+ mtime: found.mtime.toISOString(),
158
+ ctime: found.birthtime.toISOString(),
159
+ // Only for a file. A directory has no bytes to have been changed under
160
+ // somebody, which is the only thing this is compared for.
161
+ ...(found.isDirectory() ? {} : { etag: tagOf(found.size, found.mtimeMs) }),
162
+ };
163
+ }
164
+
165
+ /** Text by extension, and bytes for anything this does not recognise. */
166
+ const TEXTUAL = new Set([
167
+ 'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'json', 'md', 'txt', 'css', 'html',
168
+ 'yml', 'yaml', 'toml', 'sh', 'c', 'h', 'cc', 'cpp', 'hpp', 'py', 'rb', 'go',
169
+ 'rs', 'java', 'sql', 'xml', 'svg', 'ini', 'conf', 'env', 'gitignore',
170
+ ]);
171
+
172
+ /**
173
+ * One file's bytes, as text where that is meaningful.
174
+ *
175
+ * The encoding is *reported* rather than assumed by the caller: a client that
176
+ * asked for utf-8 and got a PNG would render the replacement character four
177
+ * hundred thousand times, so anything not recognisably textual comes back
178
+ * base64 whatever was asked for - which the protocol allows for exactly this.
179
+ */
180
+ export async function read(uri: string, roots: string[], wanted?: string): Promise<Read> {
181
+ const path = await allowed(uri, roots);
182
+ const bytes = await readFile(path).catch(() => {
183
+ throw new RpcError(NOT_FOUND, `No file at ${uri}`);
184
+ });
185
+ const dot = path.lastIndexOf('.');
186
+ const extension = dot === -1 ? '' : path.slice(dot + 1).toLowerCase();
187
+ const textual = TEXTUAL.has(extension) || path.slice(path.lastIndexOf(sep) + 1).startsWith('.');
188
+ if (wanted === 'base64' || !textual) {
189
+ return { data: bytes.toString('base64'), encoding: 'base64' };
190
+ }
191
+ return { data: bytes.toString('utf8'), encoding: 'utf-8', contentType: 'text/plain' };
192
+ }
193
+
194
+ /**
195
+ * Paths under `base` that start with what was typed.
196
+ *
197
+ * The typed text is a path fragment, so its last segment is the prefix and
198
+ * everything before it is the directory to look in - which is what makes
199
+ * `@src/ho` complete to `@src/host.ts` rather than looking for a file called
200
+ * `src/ho`.
201
+ */
202
+ export async function complete(typed: string, base: string, roots: string[], limit = 50): Promise<string[]> {
203
+ const cut = typed.lastIndexOf('/');
204
+ const inside = cut === -1 ? '' : typed.slice(0, cut + 1);
205
+ const prefix = cut === -1 ? typed : typed.slice(cut + 1);
206
+ const where = join(base, inside);
207
+ const found = await list(uriOf(where), roots).catch(() => [] as Entry[]);
208
+ return found
209
+ .filter((entry) => entry.name.toLowerCase().startsWith(prefix.toLowerCase()))
210
+ // A directory keeps its slash, so the next keystroke goes into it rather
211
+ // than starting again.
212
+ .map((entry) => `${inside}${entry.name}${entry.type === 'directory' ? '/' : ''}`)
213
+ .slice(0, limit);
214
+ }
215
+
216
+ /**
217
+ * Write, create or splice one file.
218
+ *
219
+ * The three modes differ in where `position` is rooted, which the protocol is
220
+ * explicit about and which is easy to get subtly wrong: `truncate` and
221
+ * `insert` count from the start of the file and `append` counts *backwards
222
+ * from EOF*, so `append` with `position: 0` is a POSIX append and with
223
+ * `position: 5` splices five bytes before the end.
224
+ *
225
+ * Everything is done on a buffer and written once. A file is not a stream
226
+ * here - the protocol has no chunked write - so reading it, splicing, and
227
+ * putting it back is both the simplest form and the only one that can honour
228
+ * `insert` at all.
229
+ */
230
+ export async function write(uri: string, roots: string[], content: Write): Promise<void> {
231
+ const path = await writable(uri, roots);
232
+ const incoming = Buffer.from(content.data, content.encoding === 'base64' ? 'base64' : 'utf8');
233
+
234
+ const found = await stat(path).catch(() => undefined);
235
+ if (found?.isDirectory()) throw new RpcError(REFUSED, `${uri} is a directory`);
236
+ if (content.createOnly === true && found !== undefined) {
237
+ throw new RpcError(ALREADY, `${uri} already exists`);
238
+ }
239
+ if (content.ifMatch !== undefined) {
240
+ // A file that is gone cannot match, and one that is there must. Both are
241
+ // the same failure from the client's side: what it read is not what is
242
+ // there now.
243
+ const now = found === undefined ? undefined : tagOf(found.size, found.mtimeMs);
244
+ if (now !== content.ifMatch) {
245
+ throw new RpcError(CONFLICT, `${uri} has changed since ${content.ifMatch}`);
246
+ }
247
+ }
248
+
249
+ const mode = content.mode ?? 'truncate';
250
+ const at = content.position ?? 0;
251
+ // Only read the existing bytes where a mode actually keeps some. A truncate
252
+ // from zero - the ordinary save - reads nothing.
253
+ const held = mode === 'truncate' && at === 0
254
+ ? Buffer.alloc(0)
255
+ : await readFile(path).catch(() => Buffer.alloc(0));
256
+
257
+ let out: Buffer;
258
+ if (mode === 'append') {
259
+ // Backwards from EOF, and clamped: a position past the start of the file
260
+ // is a client asking to insert before the beginning.
261
+ const cut = Math.max(0, held.length - Math.max(0, at));
262
+ out = Buffer.concat([held.subarray(0, cut), incoming, held.subarray(cut)]);
263
+ }
264
+ else if (mode === 'insert') {
265
+ const cut = Math.min(Math.max(0, at), held.length);
266
+ out = Buffer.concat([held.subarray(0, cut), incoming, held.subarray(cut)]);
267
+ }
268
+ else {
269
+ // Truncate: everything from `position` on is replaced, so what survives is
270
+ // the head. A short file padded out to `position` would be inventing
271
+ // bytes, so the head is however much of it there is.
272
+ const cut = Math.min(Math.max(0, at), held.length);
273
+ out = Buffer.concat([held.subarray(0, cut), incoming]);
274
+ }
275
+ await writeFile(path, out).catch((error: NodeJS.ErrnoException) => {
276
+ // A missing parent is the protocol's `NotFound`, said about the directory
277
+ // rather than about the file the client asked to create.
278
+ if (error.code === 'ENOENT') throw new RpcError(NOT_FOUND, `No directory for ${uri}`);
279
+ throw new RpcError(REFUSED, `Could not write ${uri}: ${error.message}`);
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Remove one.
285
+ *
286
+ * A directory needs `recursive`, and a directory without it is refused rather
287
+ * than emptied: the protocol has the flag so that deleting a tree is
288
+ * something a client asked for in as many words.
289
+ */
290
+ export async function remove(uri: string, roots: string[], recursive = false): Promise<void> {
291
+ const path = await writable(uri, roots);
292
+ const found = await stat(path).catch(() => {
293
+ throw new RpcError(NOT_FOUND, `Nothing at ${uri}`);
294
+ });
295
+ if (found.isDirectory() && !recursive) {
296
+ throw new RpcError(REFUSED, `${uri} is a directory. Pass recursive to remove it.`);
297
+ }
298
+ await rm(path, { recursive, force: false }).catch((error: NodeJS.ErrnoException) => {
299
+ throw new RpcError(REFUSED, `Could not remove ${uri}: ${error.message}`);
300
+ });
301
+ }
302
+
303
+ /** Make a directory, and the parents it needs. */
304
+ export async function mkdir(uri: string, roots: string[]): Promise<void> {
305
+ const path = await writable(uri, roots);
306
+ await makeDir(path, { recursive: true }).catch((error: NodeJS.ErrnoException) => {
307
+ // `recursive` already tolerates an existing directory, so this is a *file*
308
+ // in the way - which is a different thing to say.
309
+ if (error.code === 'EEXIST' || error.code === 'ENOTDIR') {
310
+ throw new RpcError(ALREADY, `${uri} is already a file`);
311
+ }
312
+ throw new RpcError(REFUSED, `Could not create ${uri}: ${error.message}`);
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Both ends of a two-path operation, checked.
318
+ *
319
+ * The source has to exist and the destination has to be somewhere this host
320
+ * serves - so they are different questions and neither implies the other. A
321
+ * move out of the served set is the interesting one to refuse: it would carry
322
+ * a file somewhere the host can no longer see, which is a deletion nobody
323
+ * asked for.
324
+ */
325
+ async function pair(source: string, destination: string, roots: string[], failIfExists: boolean) {
326
+ const from = await allowed(source, roots);
327
+ const to = await writable(destination, roots);
328
+ await stat(from).catch(() => {
329
+ throw new RpcError(NOT_FOUND, `Nothing at ${source}`);
330
+ });
331
+ if (failIfExists && await stat(to).then(() => true, () => false)) {
332
+ throw new RpcError(ALREADY, `${destination} already exists`);
333
+ }
334
+ return { from, to };
335
+ }
336
+
337
+ /** Rename, within the served directories on both ends. */
338
+ export async function move(source: string, destination: string, roots: string[], failIfExists = false): Promise<void> {
339
+ const { from, to } = await pair(source, destination, roots, failIfExists);
340
+ await rename(from, to).catch((error: NodeJS.ErrnoException) => {
341
+ throw new RpcError(REFUSED, `Could not move ${source}: ${error.message}`);
342
+ });
343
+ }
344
+
345
+ /** Copy, within the served directories on both ends. */
346
+ export async function copy(source: string, destination: string, roots: string[], failIfExists = false): Promise<void> {
347
+ const { from, to } = await pair(source, destination, roots, failIfExists);
348
+ const found = await stat(from);
349
+ // A directory copy is a tree walk and a file copy is one syscall. `cp` does
350
+ // both, but only `copyFile` reports the ordinary case honestly.
351
+ const run = found.isDirectory()
352
+ ? cp(from, to, { recursive: true, force: !failIfExists, errorOnExist: failIfExists })
353
+ : copyFile(from, to);
354
+ await run.catch((error: NodeJS.ErrnoException) => {
355
+ throw new RpcError(REFUSED, `Could not copy ${source}: ${error.message}`);
356
+ });
357
+ }
358
+
359
+ /**
360
+ * One glob, as a regular expression.
361
+ *
362
+ * Enough of the syntax to read what the protocol's own example sends -
363
+ * `**\/.git/**`, `**\/node_modules/**` - and no more. `**` crosses
364
+ * separators and `*` does not, which is the distinction the whole notation
365
+ * exists for; everything else is escaped, so a pattern with a dot in it means
366
+ * a dot.
367
+ */
368
+ const globbed = (pattern: string): RegExp => {
369
+ let out = '';
370
+ for (let i = 0; i < pattern.length; i++) {
371
+ const c = pattern[i] as string;
372
+ if (c === '*') {
373
+ if (pattern[i + 1] === '*') {
374
+ i += 1;
375
+ // `**/` also matches nothing at all, so `**\/.git/**` finds `.git/config`
376
+ // at the root and not only in a subdirectory.
377
+ if (pattern[i + 1] === '/') { i += 1; out += '(?:.*/)?'; }
378
+ else out += '.*';
379
+ }
380
+ else out += '[^/]*';
381
+ }
382
+ else if (c === '?') out += '[^/]';
383
+ else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
384
+ }
385
+ return new RegExp(`^${out}$`);
386
+ };
387
+
388
+ /** How long to gather events before saying anything. */
389
+ const COALESCE = 50;
390
+
391
+ /**
392
+ * How far before a watch started a file may have been created and still count
393
+ * as new.
394
+ *
395
+ * `Date.now()` and the filesystem's own clock are not the same clock: a file
396
+ * written immediately after a watch begins stats with a `birthtime` a
397
+ * millisecond or two *before* the moment the watch recorded. Without an
398
+ * allowance the first file created under a fresh watch is reported as an edit.
399
+ */
400
+ const SKEW = 50;
401
+
402
+ /**
403
+ * Tell me when that changes.
404
+ *
405
+ * `node:fs.watch`, and the two things about it worth stating. It reports one
406
+ * event per file, so a save that rewrites four files is four events and a
407
+ * `git checkout` is hundreds - hence the window: events are gathered and sent
408
+ * as one batch, which is what the protocol asks a server to do and what keeps
409
+ * a client from redrawing per file.
410
+ *
411
+ * And its event names are not the protocol's, nor even the same across
412
+ * runtimes: creating a file is `rename` on Node and `change` on Deno, which is
413
+ * the sort of thing only running it on both ever tells you. So the name is
414
+ * ignored and the file is *looked at*. Gone is `deleted`. There and created
415
+ * since this watch started is `added`. Anything else is `updated`.
416
+ *
417
+ * `birthtime` rather than an inventory of the tree, because the tree may be a
418
+ * hundred thousand files and the question is only ever asked about the handful
419
+ * that moved. A path already reported as `added` is `updated` from then on, so
420
+ * a file created and then written twice is one appearance and two edits rather
421
+ * than three appearances.
422
+ */
423
+ export async function watch(
424
+ uri: string,
425
+ roots: string[],
426
+ options: WatchOptions,
427
+ onChange: (changes: ResourceChange[]) => void,
428
+ ): Promise<Watcher> {
429
+ const path = await allowed(uri, roots);
430
+ await stat(path).catch(() => {
431
+ throw new RpcError(NOT_FOUND, `Nothing at ${uri}`);
432
+ });
433
+ const excludes = (options.excludes ?? []).map(globbed);
434
+ const includes = (options.includes ?? []).map(globbed);
435
+ /** Whether a path relative to the root is one the caller asked about. */
436
+ const wanted = (relative_: string): boolean => {
437
+ if (excludes.some((one) => one.test(relative_))) return false;
438
+ return includes.length === 0 || includes.some((one) => one.test(relative_));
439
+ };
440
+
441
+ /** Paths that have moved since the last batch went out. */
442
+ const pending = new Set<string>();
443
+ /** Paths already reported as having appeared, so they are edits from then on. */
444
+ const announced = new Set<string>();
445
+ const since = Date.now() - SKEW;
446
+ let timer: ReturnType<typeof setTimeout> | undefined;
447
+ let closed = false;
448
+
449
+ const flush = (): void => {
450
+ timer = undefined;
451
+ const held = [...pending];
452
+ pending.clear();
453
+ void (async () => {
454
+ const items: ResourceChange[] = [];
455
+ for (const full of held) {
456
+ const found = await stat(full).catch(() => undefined);
457
+ if (found === undefined) {
458
+ announced.delete(full);
459
+ items.push({ uri: uriOf(full), type: 'deleted' });
460
+ continue;
461
+ }
462
+ const fresh = found.birthtimeMs >= since && !announced.has(full);
463
+ if (fresh) announced.add(full);
464
+ items.push({ uri: uriOf(full), type: fresh ? 'added' : 'updated' });
465
+ }
466
+ // "An empty `changes.items` list MUST NOT be dispatched" - and the whole
467
+ // batch can be empty once every event in it was filtered out.
468
+ if (items.length > 0 && !closed) onChange(items);
469
+ })().catch(() => {});
470
+ };
471
+
472
+ const held = watchPath(path, { recursive: options.recursive === true }, (kind, name) => {
473
+ // A watcher can fire with no filename - the platform knows something moved
474
+ // and not what. Nothing useful can be said about that, and saying the
475
+ // directory changed would send a client to re-read the wrong thing.
476
+ if (name === null || name === undefined) return;
477
+ const relative_ = String(name).split(sep).join('/');
478
+ if (!wanted(relative_)) return;
479
+ // `kind` is deliberately unread: see above.
480
+ void kind;
481
+ pending.add(join(path, String(name)));
482
+ if (timer === undefined) timer = setTimeout(flush, COALESCE);
483
+ });
484
+ // Never the reason a daemon stays up: a watch is something a client asked
485
+ // for, and the process should still exit when everything else is done.
486
+ held.unref?.();
487
+ held.on('error', () => { /* the directory went; the watch is simply over */ });
488
+
489
+ return {
490
+ close() {
491
+ closed = true;
492
+ if (timer !== undefined) clearTimeout(timer);
493
+ held.close();
494
+ },
495
+ };
496
+ }
497
+
498
+ /**
499
+ * The filesystem this process is on, as a host's `ResourceStore`.
500
+ *
501
+ * Kept out of `createHost` so the protocol imports no runtime: this file is
502
+ * the one that touches `node:fs`, and a host that never opens a file never
503
+ * loads it.
504
+ *
505
+ * ```ts
506
+ * createHost({ path, agents, resources: fileResources() });
507
+ * ```
508
+ */
509
+ export const fileResources = (): ResourceStore => ({
510
+ list, read, resolve, complete,
511
+ write, remove, mkdir, move, copy,
512
+ watch,
513
+ });