@ahpd/server 0.1.0 → 0.3.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 +88 -81
- package/dist/config.d.ts +72 -0
- package/dist/config.js +75 -0
- package/dist/daemon.d.ts +31 -0
- package/dist/daemon.js +162 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +335 -0
- package/dist/pty.d.ts +12 -0
- package/dist/pty.js +56 -0
- package/package.json +15 -25
- package/dist/automations.d.ts +0 -4
- package/dist/automations.d.ts.map +0 -1
- package/dist/automations.js +0 -191
- package/dist/automations.js.map +0 -1
- package/dist/catalog.d.ts +0 -48
- package/dist/catalog.d.ts.map +0 -1
- package/dist/catalog.js +0 -51
- package/dist/catalog.js.map +0 -1
- package/dist/changes.d.ts +0 -4
- package/dist/changes.d.ts.map +0 -1
- package/dist/changes.js +0 -601
- package/dist/changes.js.map +0 -1
- package/dist/cron.d.ts +0 -43
- package/dist/cron.d.ts.map +0 -1
- package/dist/cron.js +0 -211
- package/dist/cron.js.map +0 -1
- package/dist/git.d.ts +0 -16
- package/dist/git.d.ts.map +0 -1
- package/dist/git.js +0 -126
- package/dist/git.js.map +0 -1
- package/dist/host.d.ts +0 -24
- package/dist/host.d.ts.map +0 -1
- package/dist/host.js +0 -5850
- package/dist/host.js.map +0 -1
- package/dist/index.d.ts +0 -36
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -34
- package/dist/index.js.map +0 -1
- package/dist/listen.d.ts +0 -3
- package/dist/listen.d.ts.map +0 -1
- package/dist/listen.js +0 -183
- package/dist/listen.js.map +0 -1
- package/dist/paging.d.ts +0 -38
- package/dist/paging.d.ts.map +0 -1
- package/dist/paging.js +0 -53
- package/dist/paging.js.map +0 -1
- package/dist/paths.d.ts +0 -15
- package/dist/paths.d.ts.map +0 -1
- package/dist/paths.js +0 -19
- package/dist/paths.js.map +0 -1
- package/dist/resources.d.ts +0 -122
- package/dist/resources.d.ts.map +0 -1
- package/dist/resources.js +0 -494
- package/dist/resources.js.map +0 -1
- package/dist/rpc.d.ts +0 -63
- package/dist/rpc.d.ts.map +0 -1
- package/dist/rpc.js +0 -208
- package/dist/rpc.js.map +0 -1
- package/dist/scheduled.d.ts +0 -48
- package/dist/scheduled.d.ts.map +0 -1
- package/dist/scheduled.js +0 -325
- package/dist/scheduled.js.map +0 -1
- package/dist/terminals.d.ts +0 -29
- package/dist/terminals.d.ts.map +0 -1
- package/dist/terminals.js +0 -346
- package/dist/terminals.js.map +0 -1
- package/dist/tools.d.ts +0 -18
- package/dist/tools.d.ts.map +0 -1
- package/dist/tools.js +0 -65
- package/dist/tools.js.map +0 -1
- package/dist/types/agent.d.ts +0 -245
- package/dist/types/agent.d.ts.map +0 -1
- package/dist/types/agent.js +0 -3
- package/dist/types/agent.js.map +0 -1
- package/dist/types/automations.d.ts +0 -164
- package/dist/types/automations.d.ts.map +0 -1
- package/dist/types/automations.js +0 -3
- package/dist/types/automations.js.map +0 -1
- package/dist/types/catalog.d.ts +0 -30
- package/dist/types/catalog.d.ts.map +0 -1
- package/dist/types/catalog.js +0 -3
- package/dist/types/catalog.js.map +0 -1
- package/dist/types/changes.d.ts +0 -272
- package/dist/types/changes.d.ts.map +0 -1
- package/dist/types/changes.js +0 -3
- package/dist/types/changes.js.map +0 -1
- package/dist/types/common.d.ts +0 -9
- package/dist/types/common.d.ts.map +0 -1
- package/dist/types/common.js +0 -3
- package/dist/types/common.js.map +0 -1
- package/dist/types/host.d.ts +0 -372
- package/dist/types/host.d.ts.map +0 -1
- package/dist/types/host.js +0 -3
- package/dist/types/host.js.map +0 -1
- package/dist/types/index.d.ts +0 -20
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -8
- package/dist/types/index.js.map +0 -1
- package/dist/types/listen.d.ts +0 -48
- package/dist/types/listen.d.ts.map +0 -1
- package/dist/types/listen.js +0 -3
- package/dist/types/listen.js.map +0 -1
- package/dist/types/paging.d.ts +0 -10
- package/dist/types/paging.d.ts.map +0 -1
- package/dist/types/paging.js +0 -3
- package/dist/types/paging.js.map +0 -1
- package/dist/types/probe.d.ts +0 -46
- package/dist/types/probe.d.ts.map +0 -1
- package/dist/types/probe.js +0 -3
- package/dist/types/probe.js.map +0 -1
- package/dist/types/resources.d.ts +0 -83
- package/dist/types/resources.d.ts.map +0 -1
- package/dist/types/resources.js +0 -3
- package/dist/types/resources.js.map +0 -1
- package/dist/types/rpc.d.ts +0 -65
- package/dist/types/rpc.d.ts.map +0 -1
- package/dist/types/rpc.js +0 -3
- package/dist/types/rpc.js.map +0 -1
- package/dist/types/session.d.ts +0 -395
- package/dist/types/session.d.ts.map +0 -1
- package/dist/types/session.js +0 -3
- package/dist/types/session.js.map +0 -1
- package/dist/types/terminals.d.ts +0 -131
- package/dist/types/terminals.d.ts.map +0 -1
- package/dist/types/terminals.js +0 -3
- package/dist/types/terminals.js.map +0 -1
- package/dist/types/wire.d.ts +0 -62
- package/dist/types/wire.d.ts.map +0 -1
- package/dist/types/wire.js +0 -13
- package/dist/types/wire.js.map +0 -1
- package/dist/types/worktrees.d.ts +0 -86
- package/dist/types/worktrees.d.ts.map +0 -1
- package/dist/types/worktrees.js +0 -3
- package/dist/types/worktrees.js.map +0 -1
- package/dist/worktrees.d.ts +0 -27
- package/dist/worktrees.d.ts.map +0 -1
- package/dist/worktrees.js +0 -152
- package/dist/worktrees.js.map +0 -1
- package/src/automations.ts +0 -204
- package/src/catalog.ts +0 -53
- package/src/changes.ts +0 -629
- package/src/cron.ts +0 -251
- package/src/git.ts +0 -132
- package/src/host.ts +0 -5947
- package/src/index.ts +0 -40
- package/src/listen.ts +0 -240
- package/src/paging.ts +0 -56
- package/src/paths.ts +0 -19
- package/src/resources.ts +0 -513
- package/src/rpc.ts +0 -222
- package/src/scheduled.ts +0 -369
- package/src/terminals.ts +0 -353
- package/src/tools.ts +0 -64
- package/src/types/agent.ts +0 -255
- package/src/types/automations.ts +0 -164
- package/src/types/catalog.ts +0 -26
- package/src/types/changes.ts +0 -266
- package/src/types/common.ts +0 -9
- package/src/types/host.ts +0 -386
- package/src/types/index.ts +0 -27
- package/src/types/listen.ts +0 -53
- package/src/types/paging.ts +0 -11
- package/src/types/probe.ts +0 -42
- package/src/types/resources.ts +0 -107
- package/src/types/rpc.ts +0 -68
- package/src/types/session.ts +0 -415
- package/src/types/terminals.ts +0 -133
- package/src/types/wire.ts +0 -69
- package/src/types/worktrees.ts +0 -91
- package/src/worktrees.ts +0 -162
package/src/resources.ts
DELETED
|
@@ -1,513 +0,0 @@
|
|
|
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
|
-
});
|