@compforge/harness-toolbox 0.4.1 → 0.4.3
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 +51 -27
- package/dist/{chunk-c6z919qb.js → chunk-5tbzf9wd.js} +1 -1
- package/dist/chunk-grpygk6f.js +157 -0
- package/dist/chunk-kkts16w9.js +131 -0
- package/dist/chunk-sqbew04a.js +65 -0
- package/dist/chunk-yp6tcw22.js +366 -0
- package/dist/file/client.d.ts +12 -7
- package/dist/file/client.js +4 -2
- package/dist/file/index.d.ts +1 -1
- package/dist/file/index.js +3 -3
- package/dist/file/local.d.ts +5 -5
- package/dist/file/local.js +2 -2
- package/dist/file/pod.d.ts +7 -6
- package/dist/file/pod.js +2 -2
- package/dist/redis/client.d.ts +27 -8
- package/dist/redis/client.js +9 -1
- package/dist/redis/index.js +11 -4
- package/dist/redis/topology.d.ts +7 -4
- package/dist/redis/topology.js +1 -1
- package/package.json +2 -2
- package/dist/chunk-ajhdd4hp.js +0 -267
- package/dist/chunk-ba883sdp.js +0 -60
- package/dist/chunk-mxyvpy79.js +0 -62
- package/dist/chunk-mzdz6wwc.js +0 -91
package/README.md
CHANGED
|
@@ -18,45 +18,69 @@ not load database or Kubernetes drivers.
|
|
|
18
18
|
|
|
19
19
|
## File sources
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
namespace/Pod/container. Both extend Common's
|
|
23
|
-
|
|
24
|
-
`id` or `kind` wrapper is required; consumer-owned selection names remain separate from client identity.
|
|
21
|
+
A FileDataSource is a caller-declared acquisition group. Use `LocalFileDataSource` for local paths
|
|
22
|
+
or `PodFileDataSource` for paths in one explicit namespace/Pod/container. Both extend Common's
|
|
23
|
+
`FileDataSource`; no additional id/kind wrapper is required.
|
|
25
24
|
|
|
26
25
|
```ts
|
|
27
|
-
import { ClientManager
|
|
26
|
+
import { ClientManager } from "@compforge/harness-common";
|
|
28
27
|
import { LocalFileDataSource, PodFileDataSource } from "@compforge/harness-toolbox/file";
|
|
29
28
|
|
|
30
|
-
const source = new LocalFileDataSource("./config.yaml", { concurrency: 2 });
|
|
29
|
+
const source = new LocalFileDataSource(["./config.yaml", "./secret/config.yaml"], { concurrency: 2 });
|
|
31
30
|
const clients = new ClientManager();
|
|
32
31
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
const file = await clients.get(source);
|
|
33
|
+
const results = await file.read({ maxBytes: 1024 * 1024, maxTotalBytes: 2 * 1024 * 1024, timeoutMs: 15_000 });
|
|
34
|
+
for (const result of results) {
|
|
35
|
+
if (result.status === "error") console.error(result.path, result.error.message);
|
|
36
|
+
else console.log(result.path, new TextDecoder("utf-8", { fatal: true }).decode(result.bytes));
|
|
37
37
|
}
|
|
38
|
-
} finally {
|
|
39
|
-
await clients.dispose();
|
|
40
|
-
}
|
|
38
|
+
} finally { await clients.dispose(); }
|
|
41
39
|
|
|
42
|
-
//
|
|
43
|
-
// Resolve Workloads first and create one source per chosen Pod/container.
|
|
40
|
+
// Resolve Workloads first. This entire group is fetched through one exec session.
|
|
44
41
|
const podSource = new PodFileDataSource(environment,
|
|
45
42
|
{ namespace: "app", pod: "worker-abc", container: "worker" },
|
|
46
|
-
"/etc/app/config.yaml", { concurrency: 2 });
|
|
43
|
+
["/etc/app/config.yaml", "/etc/app/secret/config.yaml"], { concurrency: 2 });
|
|
47
44
|
```
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
Every read reopens all declared paths and returns one ordered result per path. Empty files succeed;
|
|
47
|
+
missing, denied, nonregular and oversized files return individual errors with no partial bytes.
|
|
48
|
+
An error in one file does not discard sibling results. Per-file limits reset for each path; the total
|
|
49
|
+
budget bounds retained decoded bytes, including incomplete files. Queueing and the whole group
|
|
50
|
+
share one deadline. Cancellation, total-budget exhaustion, deadline and transport/protocol failure
|
|
51
|
+
reject the operation. Sources share clients, never previous file contents.
|
|
52
|
+
|
|
53
|
+
Pod groups use one exec and require `sh`, `head -c`, `base64` and `tr`. Paths remain positional
|
|
54
|
+
arguments even when they contain shell syntax or newlines. Indexed base64 records carry raw binary
|
|
55
|
+
bytes plus each file's exit status; truncated or malformed records reject without replay. No remote
|
|
56
|
+
temporary files are written. Local reads allow symlinks to regular files and reject nonregular files.
|
|
57
|
+
Keep the path and Pod target on evidence; grouping guarantees neither an atomic filesystem snapshot
|
|
58
|
+
nor application configuration precedence. A Pod name is not a UID fence; callers needing incarnation
|
|
59
|
+
guarantees must check discovery around collection. File clients borrow root Kubernetes access and
|
|
60
|
+
never close it themselves.
|
|
61
|
+
|
|
62
|
+
Version 0.4.2 requires Common 0.5.4. Replace single path arguments with arrays, supply `maxTotalBytes`,
|
|
63
|
+
and handle per-file results. Publish Common before Toolbox; this change has no single-file legacy adapter.
|
|
64
|
+
|
|
65
|
+
## Redis sources
|
|
66
|
+
|
|
67
|
+
`RedisClient` owns resolved configuration and reusable node connections. Use `clusterType` to
|
|
68
|
+
select `single`, `cluster` or `sentinel`; `endpoints` contains that mode's seed addresses. Sentinel
|
|
69
|
+
configuration additionally provides `sentinelMasterName` and optional Sentinel credentials,
|
|
70
|
+
separate from data-node credentials. Cluster connections require database zero.
|
|
71
|
+
|
|
72
|
+
Supply ordered TCP transports through `ConnectionSource`. Only mapping or handshake network
|
|
73
|
+
failures advance to the next transport; authentication and TLS verification failures stop immediately.
|
|
74
|
+
`connectTimeoutMs` bounds each route (mapping and handshake); it defaults to the command
|
|
75
|
+
`timeoutMs`. `acquireTimeoutMs` bounds all candidates together and defaults to the per-route budget
|
|
76
|
+
times the number of routes. A spent total budget does not start another route.
|
|
77
|
+
`onRoute` reports the selected route and the preceding failure code without credentials.
|
|
78
|
+
|
|
79
|
+
`client.topology()` refreshes topology using the same connections and route policy as
|
|
80
|
+
`client.access.connection(endpoint, database)`. A root signal cancels connection preparation and
|
|
81
|
+
active commands. Commands and pipelines are sent once, with no offline queue or automatic replay;
|
|
82
|
+
a subsequent acquisition replaces an unhealthy connection. The caller owns `dispose()` and transport
|
|
83
|
+
cleanup, and diagnostics own sampling policy and collected observations.
|
|
60
84
|
|
|
61
85
|
## Kubernetes resources
|
|
62
86
|
|
|
@@ -26,7 +26,7 @@ function sentinelRows(value) {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
async function discoverSentinel(access, config) {
|
|
29
|
-
const sentinels = config.
|
|
29
|
+
const sentinels = config.endpoints;
|
|
30
30
|
let lastError;
|
|
31
31
|
for (const sentinel of sentinels) {
|
|
32
32
|
try {
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoundedFileClient,
|
|
3
|
+
fileClientOptions,
|
|
4
|
+
filePaths
|
|
5
|
+
} from "./chunk-kkts16w9.js";
|
|
6
|
+
import {
|
|
7
|
+
FileError
|
|
8
|
+
} from "./chunk-20kv4wc3.js";
|
|
9
|
+
|
|
10
|
+
// src/file/pod.ts
|
|
11
|
+
import { posix } from "node:path";
|
|
12
|
+
import { clientKey, FileDataSource } from "@compforge/harness-common";
|
|
13
|
+
var READ_FILES = String.raw`limit=$1
|
|
14
|
+
shift
|
|
15
|
+
index=0
|
|
16
|
+
for path do
|
|
17
|
+
printf '%s ' "$index"
|
|
18
|
+
{ if [ -f "$path" ]; then head -c "$limit" "$path" 2>/dev/null; status=$?; else status=1; fi
|
|
19
|
+
printf '\000%03d' "$status"
|
|
20
|
+
} | base64 | tr -d '\r\n'
|
|
21
|
+
printf '\n'
|
|
22
|
+
index=$((index + 1))
|
|
23
|
+
done`;
|
|
24
|
+
function fileRecords(paths, maxBytes, target, sink) {
|
|
25
|
+
let index = 0, header = "", started = false, carry = "", encodedSize = 0, padded = false;
|
|
26
|
+
let trailer = Buffer.alloc(0);
|
|
27
|
+
const maximumEncoded = Math.ceil((maxBytes + 5) / 3) * 4;
|
|
28
|
+
const invalid = () => new FileError("Invalid Pod file group response", { kind: "invalid_response" });
|
|
29
|
+
const body = (part) => {
|
|
30
|
+
if (padded && part)
|
|
31
|
+
throw invalid();
|
|
32
|
+
encodedSize += part.length;
|
|
33
|
+
if (encodedSize > maximumEncoded)
|
|
34
|
+
throw invalid();
|
|
35
|
+
const text = carry + part, end = text.length - text.length % 4;
|
|
36
|
+
carry = text.slice(end);
|
|
37
|
+
if (!end)
|
|
38
|
+
return;
|
|
39
|
+
const encoded = text.slice(0, end), decoded = Buffer.from(encoded, "base64");
|
|
40
|
+
if (decoded.toString("base64") !== encoded)
|
|
41
|
+
throw invalid();
|
|
42
|
+
padded = encoded.endsWith("=");
|
|
43
|
+
if (padded && carry)
|
|
44
|
+
throw invalid();
|
|
45
|
+
const bytes = Buffer.concat([trailer, decoded]);
|
|
46
|
+
if (bytes.length > 4)
|
|
47
|
+
sink.receive(index, bytes.subarray(0, -4));
|
|
48
|
+
trailer = Buffer.from(bytes.subarray(Math.max(0, bytes.length - 4)));
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
receive(bytes) {
|
|
52
|
+
if (bytes.some((byte) => byte > 127))
|
|
53
|
+
throw invalid();
|
|
54
|
+
const text = Buffer.from(bytes).toString("ascii");
|
|
55
|
+
for (let offset = 0;offset < text.length; ) {
|
|
56
|
+
if (!started) {
|
|
57
|
+
const char = text[offset++];
|
|
58
|
+
if (char !== " ") {
|
|
59
|
+
if (!/\d/.test(char) || header.length >= String(paths.length).length)
|
|
60
|
+
throw invalid();
|
|
61
|
+
header += char;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (index >= paths.length || header !== String(index))
|
|
65
|
+
throw invalid();
|
|
66
|
+
started = true;
|
|
67
|
+
}
|
|
68
|
+
const newline = text.indexOf(`
|
|
69
|
+
`, offset);
|
|
70
|
+
body(text.slice(offset, newline < 0 ? text.length : newline));
|
|
71
|
+
if (newline < 0)
|
|
72
|
+
return;
|
|
73
|
+
if (carry || trailer.length !== 4 || trailer[0] !== 0)
|
|
74
|
+
throw invalid();
|
|
75
|
+
const status = trailer.subarray(1).toString("ascii");
|
|
76
|
+
if (!/^\d{3}$/.test(status) || Number(status) > 255)
|
|
77
|
+
throw invalid();
|
|
78
|
+
sink.complete(index, Number(status) === 0 ? undefined : new FileError(`Cannot read Pod file: ${target.namespace}/${target.pod}/${target.container} ${paths[index]}`, { kind: "operation_failed", code: Number(status) }));
|
|
79
|
+
index++;
|
|
80
|
+
header = "";
|
|
81
|
+
started = false;
|
|
82
|
+
encodedSize = 0;
|
|
83
|
+
padded = false;
|
|
84
|
+
trailer = Buffer.alloc(0);
|
|
85
|
+
offset = newline + 1;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
finish() {
|
|
89
|
+
if (started || header || index !== paths.length)
|
|
90
|
+
throw invalid();
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
class PodFileClient extends BoundedFileClient {
|
|
96
|
+
target;
|
|
97
|
+
acquire;
|
|
98
|
+
#kubernetes;
|
|
99
|
+
constructor(paths, target, options, signal, acquire) {
|
|
100
|
+
super(paths, options, signal);
|
|
101
|
+
this.target = target;
|
|
102
|
+
this.acquire = acquire;
|
|
103
|
+
}
|
|
104
|
+
async initializeAccess() {
|
|
105
|
+
this.#kubernetes = await this.acquire();
|
|
106
|
+
}
|
|
107
|
+
async capture(signal, limits, sink) {
|
|
108
|
+
const abort = new AbortController;
|
|
109
|
+
const combined = AbortSignal.any([signal, abort.signal]);
|
|
110
|
+
const records = fileRecords(this.paths, limits.maxBytes, this.target, sink);
|
|
111
|
+
const result = await this.#kubernetes.exec(this.target.namespace, this.target, ["sh", "-c", READ_FILES, "file-source", String(limits.maxBytes + 1), ...this.paths], {
|
|
112
|
+
signal: combined,
|
|
113
|
+
timeoutMs: limits.timeoutMs,
|
|
114
|
+
collectStdout: false,
|
|
115
|
+
onStdoutBytes: (bytes) => {
|
|
116
|
+
if (combined.aborted)
|
|
117
|
+
return;
|
|
118
|
+
try {
|
|
119
|
+
records.receive(bytes);
|
|
120
|
+
combined.throwIfAborted();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
abort.abort(error);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
combined.throwIfAborted();
|
|
127
|
+
if (!result.ok)
|
|
128
|
+
throw new FileError(`Cannot acquire Pod file group: ${this.target.namespace}/${this.target.pod}/${this.target.container}`, { kind: result.timedOut ? "timeout" : "operation_failed", code: result.exitCode ?? undefined });
|
|
129
|
+
records.finish();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class PodFileDataSource extends FileDataSource {
|
|
134
|
+
kubernetes;
|
|
135
|
+
paths;
|
|
136
|
+
target;
|
|
137
|
+
clientKey;
|
|
138
|
+
#options;
|
|
139
|
+
constructor(kubernetes, target, paths, options) {
|
|
140
|
+
super();
|
|
141
|
+
this.kubernetes = kubernetes;
|
|
142
|
+
this.paths = filePaths(paths);
|
|
143
|
+
if (this.paths.some((path) => !posix.isAbsolute(path)))
|
|
144
|
+
throw new FileError("Pod file paths must be absolute", { kind: "invalid_argument" });
|
|
145
|
+
if ([target.namespace, target.pod, target.container].some((value) => !value.trim())) {
|
|
146
|
+
throw new FileError("Pod file namespace, Pod and Container are required", { kind: "invalid_argument" });
|
|
147
|
+
}
|
|
148
|
+
this.target = Object.freeze({ namespace: target.namespace, pod: target.pod, container: target.container });
|
|
149
|
+
this.#options = fileClientOptions(options);
|
|
150
|
+
this.clientKey = clientKey("pod-file", { access: kubernetes.clientKey, target: this.target, paths: this.paths, options: this.#options });
|
|
151
|
+
}
|
|
152
|
+
createClient(clients, signal) {
|
|
153
|
+
return new PodFileClient(this.paths, this.target, this.#options, signal, () => clients.get(this.kubernetes));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { PodFileClient, PodFileDataSource };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FileError,
|
|
3
|
+
ToolboxError
|
|
4
|
+
} from "./chunk-20kv4wc3.js";
|
|
5
|
+
import {
|
|
6
|
+
ConcurrencyPool
|
|
7
|
+
} from "./chunk-v3hxvnrc.js";
|
|
8
|
+
|
|
9
|
+
// src/file/client.ts
|
|
10
|
+
function fileClientOptions(options) {
|
|
11
|
+
if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
|
|
12
|
+
throw new FileError("File concurrency must be a positive integer", { kind: "invalid_argument" });
|
|
13
|
+
}
|
|
14
|
+
return Object.freeze({ concurrency: options.concurrency });
|
|
15
|
+
}
|
|
16
|
+
function filePaths(paths) {
|
|
17
|
+
if (!paths.length || paths.some((path) => typeof path !== "string" || !path || path.includes("\x00")) || new Set(paths).size !== paths.length) {
|
|
18
|
+
throw new FileError("File paths must be nonempty, distinct and contain no NUL", { kind: "invalid_argument" });
|
|
19
|
+
}
|
|
20
|
+
return Object.freeze([...paths]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class BoundedFileClient {
|
|
24
|
+
paths;
|
|
25
|
+
#controller = new AbortController;
|
|
26
|
+
#signal;
|
|
27
|
+
#pool;
|
|
28
|
+
#operations = new Set;
|
|
29
|
+
#ready = false;
|
|
30
|
+
#disposal;
|
|
31
|
+
constructor(paths, options, signal) {
|
|
32
|
+
this.paths = filePaths(paths);
|
|
33
|
+
this.#pool = new ConcurrencyPool(fileClientOptions(options).concurrency);
|
|
34
|
+
this.#signal = AbortSignal.any([signal, this.#controller.signal]);
|
|
35
|
+
}
|
|
36
|
+
async initialize() {
|
|
37
|
+
this.#signal.throwIfAborted();
|
|
38
|
+
await this.initializeAccess();
|
|
39
|
+
this.#signal.throwIfAborted();
|
|
40
|
+
this.#ready = true;
|
|
41
|
+
}
|
|
42
|
+
async initializeAccess() {}
|
|
43
|
+
async read(options) {
|
|
44
|
+
this.#signal.throwIfAborted();
|
|
45
|
+
if (!this.#ready)
|
|
46
|
+
throw new FileError("File client is not initialized", { kind: "operation_failed" });
|
|
47
|
+
if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0 || options.maxBytes >= Number.MAX_SAFE_INTEGER - 5 || !Number.isSafeInteger(options.maxTotalBytes) || options.maxTotalBytes < 0 || !Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 2147483647) {
|
|
48
|
+
throw new FileError("Invalid file group read limits", { kind: "invalid_argument" });
|
|
49
|
+
}
|
|
50
|
+
const controller = new AbortController;
|
|
51
|
+
const signal = AbortSignal.any([this.#signal, controller.signal, ...options.signal ? [options.signal] : []]);
|
|
52
|
+
const timer = setTimeout(() => controller.abort(new FileError("File group read timed out", { kind: "timeout" })), options.timeoutMs);
|
|
53
|
+
const operation = this.#pool.run(async () => {
|
|
54
|
+
const files = this.paths.map((path) => ({ path, chunks: [], size: 0, done: false, error: undefined }));
|
|
55
|
+
let retained = 0;
|
|
56
|
+
const discard = (file, error) => {
|
|
57
|
+
retained -= file.size;
|
|
58
|
+
file.size = 0;
|
|
59
|
+
file.chunks = [];
|
|
60
|
+
file.error ??= error;
|
|
61
|
+
};
|
|
62
|
+
const entry = (index) => {
|
|
63
|
+
const file = files[index];
|
|
64
|
+
if (!file || file.done)
|
|
65
|
+
throw new FileError("Invalid file group response", { kind: "invalid_response" });
|
|
66
|
+
return file;
|
|
67
|
+
};
|
|
68
|
+
const sink = {
|
|
69
|
+
receive: (index, bytes) => {
|
|
70
|
+
if (signal.aborted)
|
|
71
|
+
return;
|
|
72
|
+
const file = entry(index);
|
|
73
|
+
if (file.error)
|
|
74
|
+
return;
|
|
75
|
+
if (bytes.byteLength > options.maxBytes - file.size) {
|
|
76
|
+
discard(file, new FileError(`File exceeds ${options.maxBytes} bytes: ${file.path}`, { kind: "limit_exceeded" }));
|
|
77
|
+
} else if (bytes.byteLength > options.maxTotalBytes - retained) {
|
|
78
|
+
controller.abort(new FileError(`File group exceeds ${options.maxTotalBytes} bytes`, { kind: "limit_exceeded" }));
|
|
79
|
+
} else {
|
|
80
|
+
file.chunks.push(Uint8Array.from(bytes));
|
|
81
|
+
file.size += bytes.byteLength;
|
|
82
|
+
retained += bytes.byteLength;
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
complete: (index, error) => {
|
|
86
|
+
if (signal.aborted)
|
|
87
|
+
return;
|
|
88
|
+
const file = entry(index);
|
|
89
|
+
if (error)
|
|
90
|
+
discard(file, error);
|
|
91
|
+
file.done = true;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
await this.capture(signal, options, sink);
|
|
95
|
+
signal.throwIfAborted();
|
|
96
|
+
if (files.some((file) => !file.done))
|
|
97
|
+
throw new FileError("Incomplete file group response", { kind: "invalid_response" });
|
|
98
|
+
return files.map((file) => {
|
|
99
|
+
if (file.error)
|
|
100
|
+
return { path: file.path, status: "error", error: file.error };
|
|
101
|
+
const bytes = new Uint8Array(file.size);
|
|
102
|
+
let offset = 0;
|
|
103
|
+
for (const chunk of file.chunks) {
|
|
104
|
+
bytes.set(chunk, offset);
|
|
105
|
+
offset += chunk.byteLength;
|
|
106
|
+
}
|
|
107
|
+
return { path: file.path, status: "ok", bytes };
|
|
108
|
+
});
|
|
109
|
+
}, signal);
|
|
110
|
+
this.#operations.add(operation);
|
|
111
|
+
try {
|
|
112
|
+
return await operation;
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
signal.throwIfAborted();
|
|
115
|
+
if (cause instanceof ToolboxError)
|
|
116
|
+
throw cause;
|
|
117
|
+
throw new FileError("Cannot acquire file group", { kind: "operation_failed", cause });
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
this.#operations.delete(operation);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
dispose() {
|
|
124
|
+
return this.#disposal ??= (async () => {
|
|
125
|
+
this.#controller.abort(new Error("File client disposed"));
|
|
126
|
+
await Promise.allSettled(this.#operations);
|
|
127
|
+
})();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { fileClientOptions, filePaths, BoundedFileClient };
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoundedFileClient,
|
|
3
|
+
fileClientOptions,
|
|
4
|
+
filePaths
|
|
5
|
+
} from "./chunk-kkts16w9.js";
|
|
6
|
+
import {
|
|
7
|
+
FileError
|
|
8
|
+
} from "./chunk-20kv4wc3.js";
|
|
9
|
+
|
|
10
|
+
// src/file/local.ts
|
|
11
|
+
import { constants } from "node:fs";
|
|
12
|
+
import { open } from "node:fs/promises";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { clientKey, FileDataSource } from "@compforge/harness-common";
|
|
15
|
+
class LocalFileClient extends BoundedFileClient {
|
|
16
|
+
async capture(signal, limits, sink) {
|
|
17
|
+
for (const [index, path] of this.paths.entries()) {
|
|
18
|
+
try {
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
21
|
+
try {
|
|
22
|
+
signal.throwIfAborted();
|
|
23
|
+
if (!(await file.stat()).isFile())
|
|
24
|
+
throw new FileError(`Not a regular file: ${path}`, { kind: "unsupported_operation" });
|
|
25
|
+
const stream = file.createReadStream({
|
|
26
|
+
autoClose: false,
|
|
27
|
+
signal,
|
|
28
|
+
end: limits.maxBytes,
|
|
29
|
+
highWaterMark: Math.min(limits.maxBytes + 1, 64 * 1024)
|
|
30
|
+
});
|
|
31
|
+
for await (const bytes of stream)
|
|
32
|
+
sink.receive(index, bytes);
|
|
33
|
+
} finally {
|
|
34
|
+
await file.close();
|
|
35
|
+
}
|
|
36
|
+
sink.complete(index);
|
|
37
|
+
} catch (cause) {
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
const code = cause?.code;
|
|
40
|
+
sink.complete(index, cause instanceof FileError ? cause : new FileError(`Cannot read local file: ${path}`, {
|
|
41
|
+
kind: code === "ENOENT" || code === "ENOTDIR" ? "resource_not_found" : code === "EACCES" || code === "EPERM" ? "permission_denied" : "operation_failed",
|
|
42
|
+
code,
|
|
43
|
+
cause
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class LocalFileDataSource extends FileDataSource {
|
|
51
|
+
paths;
|
|
52
|
+
clientKey;
|
|
53
|
+
#options;
|
|
54
|
+
constructor(paths, options) {
|
|
55
|
+
super();
|
|
56
|
+
this.paths = filePaths(filePaths(paths).map((path) => resolve(path)));
|
|
57
|
+
this.#options = fileClientOptions(options);
|
|
58
|
+
this.clientKey = clientKey("local-file", { paths: this.paths, options: this.#options });
|
|
59
|
+
}
|
|
60
|
+
createClient(_clients, signal) {
|
|
61
|
+
return new LocalFileClient(this.paths, this.#options, signal);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export { LocalFileClient, LocalFileDataSource };
|