@hachej/boring-execution 0.0.0-stage → 0.2.1

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +97 -2
  3. package/dist/aws-code-interpreter.d.ts +149 -0
  4. package/dist/aws-code-interpreter.js +453 -0
  5. package/dist/bounded-error.d.ts +8 -0
  6. package/dist/bounded-error.js +12 -0
  7. package/dist/contracts.d.ts +46 -0
  8. package/dist/contracts.js +1 -0
  9. package/dist/fs-readers.d.ts +7 -0
  10. package/dist/fs-readers.js +72 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.js +1 -0
  13. package/dist/native.d.ts +8 -0
  14. package/dist/native.js +1 -0
  15. package/dist/remote-files-handler.d.ts +21 -0
  16. package/dist/remote-files-handler.js +215 -0
  17. package/dist/remote-files-io.d.ts +8 -0
  18. package/dist/remote-files-io.js +46 -0
  19. package/dist/remote-files-positional.d.ts +12 -0
  20. package/dist/remote-files-positional.js +208 -0
  21. package/dist/remote-files-protocol.d.ts +342 -0
  22. package/dist/remote-files-protocol.js +113 -0
  23. package/dist/remote-files-watch.d.ts +29 -0
  24. package/dist/remote-files-watch.js +154 -0
  25. package/dist/remote-files.d.ts +17 -0
  26. package/dist/remote-files.js +238 -0
  27. package/dist/remote-shell-handler.d.ts +25 -0
  28. package/dist/remote-shell-handler.js +131 -0
  29. package/dist/remote-shell-protocol.d.ts +50 -0
  30. package/dist/remote-shell-protocol.js +163 -0
  31. package/dist/remote-shell-window.d.ts +14 -0
  32. package/dist/remote-shell-window.js +82 -0
  33. package/dist/remote-shell.d.ts +15 -0
  34. package/dist/remote-shell.js +127 -0
  35. package/dist/virtual-filesystem.d.ts +67 -0
  36. package/dist/virtual-filesystem.js +218 -0
  37. package/dist/virtual-git.d.ts +60 -0
  38. package/dist/virtual-git.js +116 -0
  39. package/dist/virtual-sqlite.d.ts +42 -0
  40. package/dist/virtual-sqlite.js +50 -0
  41. package/dist/virtual.d.ts +34 -0
  42. package/dist/virtual.js +277 -0
  43. package/package.json +94 -4
@@ -0,0 +1,215 @@
1
+ import { awaitWithContext, withAbortSignal, withoutAbortSignal } from '@earendil-works/chord/context';
2
+ import { FileError, err } from '@earendil-works/pi-durable/env';
3
+ import { identity, sameIdentity, positiveLimit, nativeVersion, schema, version, streamType, requestInput, wireResult } from './remote-files-protocol.js';
4
+ import { servePositional } from './remote-files-positional.js';
5
+ import { serveWatch } from './remote-files-watch.js';
6
+ import { guardStatus, hasJsonContentType, readJsonBody } from '@hachej/boring-files/request-guard';
7
+ const refused = (status) => new Response(null, { status, headers: { 'cache-control': 'no-store' } });
8
+ function invoke(fs, call, context) {
9
+ switch (call.method) {
10
+ case 'absolutePath': return fs.absolutePath(...call.args, context);
11
+ case 'joinPath': return fs.joinPath(...call.args, context);
12
+ case 'readTextFile': return fs.readTextFile(...call.args, context);
13
+ case 'openTextLineReader': return fs.openTextLineReader(...call.args, context);
14
+ case 'readTextLines': return fs.readTextLines(...call.args, context);
15
+ case 'readBinaryFile': return fs.readBinaryFile(...call.args, context);
16
+ case 'writeFile': return fs.writeFile(...call.args, context);
17
+ case 'appendFile': return fs.appendFile(...call.args, context);
18
+ case 'truncateFile': return fs.truncateFile(...call.args, context);
19
+ case 'flushFile': return fs.flushFile(...call.args, context);
20
+ case 'renameFile': return fs.renameFile(...call.args, context);
21
+ case 'fileInfo': return fs.fileInfo(...call.args, context);
22
+ case 'listDir': return fs.listDir(...call.args, context);
23
+ case 'canonicalPath': return fs.canonicalPath(...call.args, context);
24
+ case 'exists': return fs.exists(...call.args, context);
25
+ case 'createDir': return fs.createDir(...call.args, context);
26
+ case 'remove': return fs.remove(...call.args, context);
27
+ case 'createTempDir': return fs.createTempDir(...call.args, context);
28
+ case 'createTempFile': return fs.createTempFile(...call.args, context);
29
+ case 'binaryInfo':
30
+ case 'readRange':
31
+ case 'scanLines':
32
+ case 'readDirPage':
33
+ case 'watch': throw new TypeError('Not a plain native call');
34
+ }
35
+ }
36
+ export function createRemoteFileSystemHandler(options) {
37
+ const maxRequestBytes = positiveLimit(options.maxRequestBytes ?? 1_048_576), maxResponseBytes = positiveLimit(options.maxResponseBytes ?? 4_194_304);
38
+ const maxFrameBytes = positiveLimit(options.maxFrameBytes ?? 1_048_576);
39
+ return async (request) => {
40
+ if (request.method !== 'POST')
41
+ return refused(405);
42
+ if (!hasJsonContentType(request.headers))
43
+ return refused(415);
44
+ let access;
45
+ try {
46
+ access = await options.authenticate(request);
47
+ }
48
+ catch {
49
+ return refused(503);
50
+ }
51
+ if (!access)
52
+ return refused(401);
53
+ const selected = identity(access.identity), { filesystemId, bindFileSystem, authorize, revoked } = access;
54
+ const stop = new AbortController(), context = withAbortSignal(AbortSignal.any([request.signal, revoked, stop.signal]), access.context);
55
+ const signal = context.abortSignal, cleanupContext = withoutAbortSignal(access.context);
56
+ let input;
57
+ try {
58
+ input = requestInput(await readJsonBody(request, maxRequestBytes, signal));
59
+ }
60
+ catch (error) {
61
+ return refused(guardStatus(error));
62
+ }
63
+ if (!sameIdentity(input.identity, selected) || input.filesystemId !== filesystemId)
64
+ return refused(409);
65
+ const binding = { schema, version, nativeVersion, requestId: input.requestId, identity: selected, filesystemId };
66
+ const reply = (result) => {
67
+ const body = new TextEncoder().encode(JSON.stringify({ ...binding, result: wireResult(result) }));
68
+ if (body.length > maxResponseBytes)
69
+ return refused(503);
70
+ return new Response(body, { headers: { 'content-type': 'application/json', 'cache-control': 'no-store' } });
71
+ };
72
+ const permitted = async () => !signal?.aborted
73
+ && await awaitWithContext(Promise.resolve(authorize(structuredClone(input.call), input.cwd)), context) === true && !signal?.aborted;
74
+ let lease, released = false, transferred = false;
75
+ let closeReader;
76
+ const release = async () => { if (!lease || released)
77
+ return; released = true; await lease.release(cleanupContext); };
78
+ const matches = () => !!lease && sameIdentity(lease.identity, selected) && lease.environment.id === filesystemId && lease.environment.cwd === input.cwd;
79
+ try {
80
+ if (!await permitted())
81
+ return reply(err(new FileError('permission_denied', 'Filesystem operation is not permitted')));
82
+ const pending = Promise.resolve(bindFileSystem(input.cwd, context)).then(value => {
83
+ lease = value;
84
+ if (signal?.aborted)
85
+ void release().catch(error => options.onCleanupError?.(error));
86
+ return value;
87
+ });
88
+ lease = await awaitWithContext(pending, context);
89
+ if (!matches() || signal?.aborted)
90
+ return refused(409);
91
+ if (!await permitted() || !matches())
92
+ return reply(err(new FileError('permission_denied', 'Filesystem binding is no longer permitted')));
93
+ const call = input.call;
94
+ if (call.method === 'binaryInfo' || call.method === 'readRange' || call.method === 'scanLines' || call.method === 'readDirPage') {
95
+ const result = await servePositional(lease.environment, call, context, cleanupContext, maxResponseBytes);
96
+ if (!await permitted() || !matches())
97
+ return refused(403);
98
+ return reply(result);
99
+ }
100
+ if (call.method === 'watch') {
101
+ const started = await serveWatch({
102
+ fs: lease.environment, targets: call.args[0], binding, requestId: input.requestId, context, cleanup: cleanupContext, maxFrameBytes,
103
+ permitted: async () => await permitted() && matches(), release, onCleanupError: options.onCleanupError, stop: () => stop.abort(),
104
+ });
105
+ if (!started.ok) {
106
+ if (!await permitted() || !matches())
107
+ return refused(403);
108
+ return reply(started);
109
+ }
110
+ transferred = true;
111
+ return started.value;
112
+ }
113
+ if (call.method !== 'openTextLineReader') {
114
+ const result = await invoke(lease.environment, call, context);
115
+ if (!await permitted() || !matches())
116
+ return refused(403);
117
+ return reply(result);
118
+ }
119
+ const opened = await lease.environment.openTextLineReader(...call.args, context);
120
+ if (!opened.ok) {
121
+ if (!await permitted() || !matches())
122
+ return refused(403);
123
+ return reply(opened);
124
+ }
125
+ let reader = opened.value;
126
+ let closing;
127
+ const close = () => closing ??= Promise.resolve().then(async () => {
128
+ const current = reader;
129
+ reader = undefined;
130
+ try {
131
+ if (current)
132
+ await current.close(cleanupContext);
133
+ }
134
+ finally {
135
+ await release();
136
+ }
137
+ });
138
+ closeReader = close;
139
+ if (!await permitted() || !matches()) {
140
+ await close();
141
+ return refused(403);
142
+ }
143
+ let ended = false, sequence = 1;
144
+ let controller;
145
+ const frame = (value) => {
146
+ const bytes = new TextEncoder().encode(JSON.stringify(value) + '\n');
147
+ if (bytes.length > maxFrameBytes)
148
+ throw new TypeError('Filesystem frame exceeds its byte limit');
149
+ return bytes;
150
+ };
151
+ const interrupt = () => {
152
+ if (ended)
153
+ return;
154
+ ended = true;
155
+ signal?.removeEventListener('abort', interrupt);
156
+ controller.error(new Error('Remote file stream interrupted'));
157
+ void close().catch(error => options.onCleanupError?.(error));
158
+ };
159
+ const stream = new ReadableStream({
160
+ start(value) {
161
+ controller = value;
162
+ controller.enqueue(frame({ ...binding, type: 'opened', sequence: 0 }));
163
+ signal?.addEventListener('abort', interrupt, { once: true });
164
+ if (signal?.aborted)
165
+ interrupt();
166
+ },
167
+ async pull() {
168
+ try {
169
+ if (ended)
170
+ return;
171
+ if (!await permitted() || !reader || !matches()) {
172
+ interrupt();
173
+ return;
174
+ }
175
+ const result = await reader.readLine(context);
176
+ if (ended)
177
+ return;
178
+ if (!await permitted() || !matches()) {
179
+ interrupt();
180
+ return;
181
+ }
182
+ if (result.ok && result.value === undefined) {
183
+ await close();
184
+ if (ended || signal?.aborted)
185
+ return;
186
+ controller.enqueue(frame({ requestId: input.requestId, sequence: sequence++, type: 'end' }));
187
+ ended = true;
188
+ signal?.removeEventListener('abort', interrupt);
189
+ controller.close();
190
+ }
191
+ else
192
+ controller.enqueue(frame({ requestId: input.requestId, sequence: sequence++, type: 'line', result: wireResult(result) }));
193
+ }
194
+ catch {
195
+ interrupt();
196
+ }
197
+ },
198
+ async cancel() { ended = true; signal?.removeEventListener('abort', interrupt); stop.abort(); await close(); },
199
+ }, { highWaterMark: 1 });
200
+ transferred = true;
201
+ return new Response(stream, { headers: { 'content-type': streamType, 'cache-control': 'no-store' } });
202
+ }
203
+ catch {
204
+ return refused(503);
205
+ }
206
+ finally {
207
+ if (!transferred) {
208
+ if (closeReader)
209
+ await closeReader();
210
+ else
211
+ await release();
212
+ }
213
+ }
214
+ };
215
+ }
@@ -0,0 +1,8 @@
1
+ import type { Context } from '@earendil-works/chord';
2
+ /** Read a response body as JSON under a byte cap (the shared guard of `@hachej/boring-files/request-guard`). */
3
+ export declare function readJson(body: ReadableStream<Uint8Array> | null, context: Context, limit: number): Promise<unknown>;
4
+ export declare function frameReader(body: ReadableStream<Uint8Array>, limit: number): {
5
+ next: () => Promise<unknown>;
6
+ close: () => Promise<void>;
7
+ };
8
+ //# sourceMappingURL=remote-files-io.d.ts.map
@@ -0,0 +1,46 @@
1
+ import { readBoundedJson } from '@hachej/boring-files/request-guard';
2
+ /** Read a response body as JSON under a byte cap (the shared guard of `@hachej/boring-files/request-guard`). */
3
+ export function readJson(body, context, limit) {
4
+ return readBoundedJson(body, limit, context.abortSignal);
5
+ }
6
+ export function frameReader(body, limit) {
7
+ const reader = body.getReader();
8
+ let chunk = new Uint8Array(), offset = 0;
9
+ let buffer = new Uint8Array(Math.min(4096, limit)), length = 0;
10
+ let closed = false;
11
+ const close = async () => { if (closed)
12
+ return; closed = true; void reader.cancel().catch(() => { }); reader.releaseLock(); };
13
+ const next = async () => {
14
+ while (!closed) {
15
+ if (offset === chunk.length) {
16
+ const item = await reader.read();
17
+ if (item.done) {
18
+ if (length)
19
+ throw new TypeError('Truncated frame');
20
+ return undefined;
21
+ }
22
+ chunk = item.value;
23
+ offset = 0;
24
+ }
25
+ const newline = chunk.indexOf(10, offset), end = newline === -1 ? chunk.length : newline;
26
+ const part = chunk.subarray(offset, end);
27
+ if (length + part.length > limit)
28
+ throw new TypeError('Frame byte limit exceeded');
29
+ if (length + part.length > buffer.length) {
30
+ const larger = new Uint8Array(Math.min(limit, Math.max(length + part.length, buffer.length * 2)));
31
+ larger.set(buffer.subarray(0, length));
32
+ buffer = larger;
33
+ }
34
+ buffer.set(part, length);
35
+ length += part.length;
36
+ offset = end + (newline === -1 ? 0 : 1);
37
+ if (newline !== -1) {
38
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, length));
39
+ length = 0;
40
+ return JSON.parse(text);
41
+ }
42
+ }
43
+ throw new TypeError('Reader is closed');
44
+ };
45
+ return { next, close };
46
+ }
@@ -0,0 +1,12 @@
1
+ import { FileError } from '@earendil-works/pi-durable/env';
2
+ import type { FileSystem, Result } from '@earendil-works/pi-durable/env';
3
+ import type { Context } from '@earendil-works/chord';
4
+ import { z } from 'zod';
5
+ import type { PositionalCall, RemoteFileSystemCall } from './remote-files-protocol.js';
6
+ /** Worker side: serve one positional call over the worker's native `FileSystem`. `cleanup` closes readers after the request context ends. */
7
+ export declare function servePositional(fs: FileSystem, call: PositionalCall, context: Context, cleanup: Context, maxResponseBytes: number): Promise<Result<unknown, FileError>>;
8
+ type Invoke = <Value>(call: RemoteFileSystemCall, output: z.ZodType<Value>, context: Context) => Promise<Result<Value, FileError>>;
9
+ /** Client side: Pi's `openBinaryReader` and `openDirReader` over `invoke`, one bounded request per call. */
10
+ export declare function remoteReaders(invoke: Invoke, maxResponseBytes: number): Pick<FileSystem, 'openBinaryReader' | 'openDirReader'>;
11
+ export {};
12
+ //# sourceMappingURL=remote-files-positional.d.ts.map
@@ -0,0 +1,208 @@
1
+ // Pi's `BinaryReader` and `DirReader` keep an open handle; a stateless request cannot. Over the remote-files wire each reader call
2
+ // is one request that opens the worker's own native reader, answers a bounded range, page or scan, and closes it again. The client
3
+ // remembers what the file looked like when it was opened and the worker refuses a request when the file no longer matches, which is
4
+ // the stateless counterpart of Pi's "all calls see the same file".
5
+ import { FileError, err, ok } from '@earendil-works/pi-durable/env';
6
+ import { z } from 'zod';
7
+ import { bytes, dirPage, fileInfo, lineScan, maxDirPageEntries, maxRangeBytes } from './remote-files-protocol.js';
8
+ const changed = (left, right) => left.size !== right.size || left.mtimeMs !== right.mtimeMs;
9
+ const changedFile = (path) => new FileError('invalid', 'The file changed since it was opened', path);
10
+ const closedReader = (what, path) => new FileError('invalid', `${what} is closed`, path);
11
+ const aborted = (context, path) => context.abortSignal?.aborted ? new FileError('aborted', 'Operation aborted', path) : undefined;
12
+ /** Worker side: serve one positional call over the worker's native `FileSystem`. `cleanup` closes readers after the request context ends. */
13
+ export async function servePositional(fs, call, context, cleanup, maxResponseBytes) {
14
+ if (call.method === 'readDirPage')
15
+ return serveDirPage(fs, call.args, context, cleanup);
16
+ const [path, options] = call.args;
17
+ const opened = await fs.openBinaryReader(path, options, context);
18
+ if (!opened.ok)
19
+ return opened;
20
+ const reader = opened.value;
21
+ try {
22
+ const before = await reader.info(context);
23
+ if (!before.ok)
24
+ return before;
25
+ if (call.method === 'binaryInfo')
26
+ return before;
27
+ const expected = (call.method === 'readRange' ? call.args[4] : call.args[3]) ?? before.value;
28
+ if (changed(before.value, expected))
29
+ return err(changedFile(path));
30
+ let produced;
31
+ if (call.method === 'readRange') {
32
+ const [, , offset, length] = call.args;
33
+ if (length > maxRangeBytes(maxResponseBytes))
34
+ return err(new FileError('invalid', 'The range exceeds the remote response limit', path));
35
+ produced = await reader.read(offset, length, context);
36
+ }
37
+ else {
38
+ const { startLine, endLine } = call.args[2];
39
+ if (endLine !== undefined && endLine <= startLine)
40
+ return err(new FileError('invalid', 'Invalid line range', path));
41
+ produced = await reader.scanLines(call.args[2], context);
42
+ }
43
+ if (!produced.ok)
44
+ return produced;
45
+ // A change while the range was read or the file scanned would mix two versions of it.
46
+ const after = await reader.info(context);
47
+ if (!after.ok)
48
+ return after;
49
+ return changed(after.value, expected) ? err(changedFile(path)) : produced;
50
+ }
51
+ finally {
52
+ await reader.close(cleanup);
53
+ }
54
+ }
55
+ async function serveDirPage(fs, [path, offset, maxEntries, expectedMtimeMs], context, cleanup) {
56
+ const stamp = async () => {
57
+ // A symbolic link's own timestamps say nothing about the directory behind it.
58
+ const canonical = await fs.canonicalPath(path, context);
59
+ if (!canonical.ok)
60
+ return canonical;
61
+ const info = await fs.fileInfo(canonical.value, context);
62
+ return info.ok ? ok(info.value.mtimeMs) : info;
63
+ };
64
+ const opened = await fs.openDirReader(path, context);
65
+ if (!opened.ok)
66
+ return opened;
67
+ const reader = opened.value;
68
+ try {
69
+ const before = await stamp();
70
+ if (!before.ok)
71
+ return before;
72
+ if (expectedMtimeMs !== undefined && before.value !== expectedMtimeMs)
73
+ return err(new FileError('invalid', 'The directory changed since it was opened', path));
74
+ const entries = [];
75
+ let done = false, skip = offset;
76
+ while (skip > 0 && !done) {
77
+ const page = await reader.next(Math.min(skip, maxDirPageEntries), context);
78
+ if (!page.ok)
79
+ return page;
80
+ skip -= page.value.entries.length;
81
+ done = page.value.done;
82
+ if (page.value.entries.length === 0 && !page.value.done)
83
+ return err(new FileError('unknown', 'The native directory reader made no progress', path));
84
+ }
85
+ if (maxEntries > 0 && !done) {
86
+ const page = await reader.next(maxEntries, context);
87
+ if (!page.ok)
88
+ return page;
89
+ entries.push(...page.value.entries);
90
+ done = page.value.done;
91
+ if (entries.length === 0 && !done)
92
+ return err(new FileError('unknown', 'The native directory reader made no progress', path));
93
+ }
94
+ const after = await stamp();
95
+ if (!after.ok)
96
+ return after;
97
+ if (after.value !== before.value)
98
+ return err(new FileError('invalid', 'The directory changed while it was being read', path));
99
+ return ok({ entries, done, mtimeMs: before.value });
100
+ }
101
+ finally {
102
+ await reader.close(cleanup);
103
+ }
104
+ }
105
+ /** Client side: Pi's `openBinaryReader` and `openDirReader` over `invoke`, one bounded request per call. */
106
+ export function remoteReaders(invoke, maxResponseBytes) {
107
+ const chunk = maxRangeBytes(maxResponseBytes);
108
+ return {
109
+ openBinaryReader: async (requested, options, context) => {
110
+ // Snapshot the caller's options before any await: a caller may mutate them while the open is in flight.
111
+ const wire = options?.noFollow === undefined ? undefined : { noFollow: options.noFollow };
112
+ // Resolve once: the lease's cwd can change later, and every request of this reader must name the file it opened.
113
+ const resolved = await invoke({ method: 'absolutePath', args: [requested] }, z.string(), context);
114
+ if (!resolved.ok)
115
+ return resolved;
116
+ const path = resolved.value;
117
+ const info = await invoke({ method: 'binaryInfo', args: [path, wire] }, fileInfo, context);
118
+ if (!info.ok)
119
+ return info;
120
+ const opened = info.value, expected = { size: opened.size, mtimeMs: opened.mtimeMs };
121
+ let closed = false;
122
+ const refuse = (inner) => aborted(inner, path) ?? (closed ? closedReader('Binary reader', path) : undefined);
123
+ return ok({
124
+ info: async (inner) => { const failed = refuse(inner); return failed ? err(failed) : ok({ ...opened }); },
125
+ read: async (offset, length, inner) => {
126
+ const failed = refuse(inner);
127
+ if (failed)
128
+ return err(failed);
129
+ if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isSafeInteger(length) || length < 0)
130
+ return err(new FileError('invalid', 'Offset and length must be non-negative safe integers', path));
131
+ const parts = [];
132
+ let total = 0;
133
+ while (total < length) {
134
+ const want = Math.min(length - total, chunk);
135
+ const piece = await invoke({ method: 'readRange', args: [path, wire, offset + total, want, expected] }, bytes, inner);
136
+ if (!piece.ok)
137
+ return piece;
138
+ parts.push(piece.value);
139
+ total += piece.value.length;
140
+ if (piece.value.length < want)
141
+ break;
142
+ }
143
+ if (parts.length === 1)
144
+ return ok(parts[0]);
145
+ const joined = new Uint8Array(total);
146
+ let position = 0;
147
+ for (const part of parts) {
148
+ joined.set(part, position);
149
+ position += part.length;
150
+ }
151
+ return ok(joined);
152
+ },
153
+ // The worker's native reader scans in place; moving the file to count its newlines here would defeat a bounded read.
154
+ scanLines: async (range, inner) => {
155
+ const failed = refuse(inner);
156
+ if (failed)
157
+ return err(failed);
158
+ const { startLine, endLine } = range;
159
+ if (!Number.isSafeInteger(startLine) || startLine < 0 || (endLine !== undefined && (!Number.isSafeInteger(endLine) || endLine <= startLine)))
160
+ return err(new FileError('invalid', 'Invalid line range', path));
161
+ return invoke({ method: 'scanLines', args: [path, wire, endLine === undefined ? { startLine } : { startLine, endLine }, expected] }, lineScan, inner);
162
+ },
163
+ close: async () => { closed = true; },
164
+ });
165
+ },
166
+ openDirReader: async (requested, context) => {
167
+ const resolved = await invoke({ method: 'absolutePath', args: [requested] }, z.string(), context);
168
+ if (!resolved.ok)
169
+ return resolved;
170
+ const path = resolved.value;
171
+ const probe = await invoke({ method: 'readDirPage', args: [path, 0, 0, undefined] }, dirPage, context);
172
+ if (!probe.ok)
173
+ return probe;
174
+ const mtimeMs = probe.value.mtimeMs;
175
+ let offset = 0, done = false, closed = false, busy = false;
176
+ return ok({
177
+ next: async (maxEntries, inner) => {
178
+ const failed = aborted(inner, path) ?? (closed ? closedReader('Directory reader', path) : undefined);
179
+ if (failed)
180
+ return err(failed);
181
+ if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0)
182
+ return err(new FileError('invalid', 'maxEntries must be a positive safe integer', path));
183
+ if (busy)
184
+ return err(new FileError('invalid', 'A directory read is already pending', path));
185
+ busy = true;
186
+ try {
187
+ const entries = [];
188
+ while (!done && entries.length < maxEntries) {
189
+ const page = await invoke({ method: 'readDirPage', args: [path, offset, Math.min(maxEntries - entries.length, maxDirPageEntries), mtimeMs] }, dirPage, inner);
190
+ if (!page.ok)
191
+ return page;
192
+ entries.push(...page.value.entries);
193
+ offset += page.value.entries.length;
194
+ done = page.value.done;
195
+ if (page.value.entries.length === 0 && !done)
196
+ return err(new FileError('unknown', 'The remote directory reader made no progress', path));
197
+ }
198
+ return ok({ entries, done });
199
+ }
200
+ finally {
201
+ busy = false;
202
+ }
203
+ },
204
+ close: async () => { closed = true; },
205
+ });
206
+ },
207
+ };
208
+ }