@devicerail/client 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.
- package/LICENSE +201 -0
- package/README.md +79 -0
- package/dist/errors.d.ts +76 -0
- package/dist/errors.js +132 -0
- package/dist/event-stream.d.ts +66 -0
- package/dist/event-stream.js +1153 -0
- package/dist/generated/response-schemas.d.ts +6 -0
- package/dist/generated/response-schemas.js +7621 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/ndjson.d.ts +13 -0
- package/dist/ndjson.js +134 -0
- package/dist/rpc-client.d.ts +69 -0
- package/dist/rpc-client.js +928 -0
- package/dist/rpc-response-validator.d.ts +5 -0
- package/dist/rpc-response-validator.js +190 -0
- package/dist/stderr-tail.d.ts +10 -0
- package/dist/stderr-tail.js +42 -0
- package/dist/write-queue.d.ts +26 -0
- package/dist/write-queue.js +273 -0
- package/package.json +56 -0
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RpcMethod, RpcResponseFor, RpcResultFor } from "@devicerail/protocol";
|
|
2
|
+
export declare function assertPureJsonValue(value: unknown, location?: string): void;
|
|
3
|
+
export declare function validateRpcResponse<M extends RpcMethod>(method: M, response: unknown): asserts response is RpcResponseFor<M>;
|
|
4
|
+
/** Validates a method result for adapters that inject a client-compatible transport. */
|
|
5
|
+
export declare function validateRpcResult<M extends RpcMethod>(method: M, result: unknown): asserts result is RpcResultFor<M>;
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { types as utilTypes } from "node:util";
|
|
2
|
+
import { Ajv2020, } from "ajv/dist/2020.js";
|
|
3
|
+
import { ProtocolViolationError } from "./errors.js";
|
|
4
|
+
import { RPC_RESPONSE_SCHEMAS } from "./generated/response-schemas.js";
|
|
5
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
6
|
+
const MAX_PUBLIC_JSON_DEPTH = 256;
|
|
7
|
+
const MAX_PUBLIC_JSON_NODES = 100_000;
|
|
8
|
+
const compiler = new Ajv2020({
|
|
9
|
+
allErrors: false,
|
|
10
|
+
allowUnionTypes: true,
|
|
11
|
+
strict: true,
|
|
12
|
+
validateFormats: true,
|
|
13
|
+
});
|
|
14
|
+
compiler.addFormat("uuid", { type: "string", validate: (value) => UUID_PATTERN.test(value) });
|
|
15
|
+
compiler.addFormat("int32", {
|
|
16
|
+
type: "number",
|
|
17
|
+
validate: (value) => Number.isInteger(value) && value >= -2_147_483_648 && value <= 2_147_483_647,
|
|
18
|
+
});
|
|
19
|
+
compiler.addFormat("uint16", {
|
|
20
|
+
type: "number",
|
|
21
|
+
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 65_535,
|
|
22
|
+
});
|
|
23
|
+
compiler.addFormat("uint32", {
|
|
24
|
+
type: "number",
|
|
25
|
+
validate: (value) => Number.isInteger(value) && value >= 0 && value <= 4_294_967_295,
|
|
26
|
+
});
|
|
27
|
+
compiler.addFormat("uint64", {
|
|
28
|
+
type: "number",
|
|
29
|
+
validate: (value) => Number.isSafeInteger(value) && value >= 0,
|
|
30
|
+
});
|
|
31
|
+
compiler.addFormat("double", {
|
|
32
|
+
type: "number",
|
|
33
|
+
validate: (value) => Number.isFinite(value),
|
|
34
|
+
});
|
|
35
|
+
const responseValidators = new Map();
|
|
36
|
+
function responseValidator(method) {
|
|
37
|
+
const cached = responseValidators.get(method);
|
|
38
|
+
if (cached) {
|
|
39
|
+
return cached;
|
|
40
|
+
}
|
|
41
|
+
if (typeof method !== "string" || !Object.hasOwn(RPC_RESPONSE_SCHEMAS, method)) {
|
|
42
|
+
throw new ProtocolViolationError("no runtime response Schema is registered for the method");
|
|
43
|
+
}
|
|
44
|
+
const schema = RPC_RESPONSE_SCHEMAS[method];
|
|
45
|
+
try {
|
|
46
|
+
const validator = compiler.compile(schema);
|
|
47
|
+
responseValidators.set(method, validator);
|
|
48
|
+
return validator;
|
|
49
|
+
}
|
|
50
|
+
catch (cause) {
|
|
51
|
+
throw new Error(`failed to compile the packaged ${method} response Schema`, { cause });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function boundedDiagnosticLocation(value) {
|
|
55
|
+
const safe = value.replace(/[^\x20-\x7e]/gu, "?");
|
|
56
|
+
return safe.length <= 256 ? safe : `${safe.slice(0, 253)}...`;
|
|
57
|
+
}
|
|
58
|
+
export function assertPureJsonValue(value, location = "$") {
|
|
59
|
+
const pending = [
|
|
60
|
+
{ depth: 0, location, value },
|
|
61
|
+
];
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
let nodes = 0;
|
|
64
|
+
while (pending.length > 0) {
|
|
65
|
+
const current = pending.pop();
|
|
66
|
+
if (!current) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
nodes += 1;
|
|
70
|
+
if (nodes > MAX_PUBLIC_JSON_NODES || current.depth > MAX_PUBLIC_JSON_DEPTH) {
|
|
71
|
+
throw new ProtocolViolationError("response exceeds the pure-JSON validation budget");
|
|
72
|
+
}
|
|
73
|
+
if (current.value === null ||
|
|
74
|
+
typeof current.value === "string" ||
|
|
75
|
+
typeof current.value === "boolean") {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (typeof current.value === "number") {
|
|
79
|
+
if (!Number.isFinite(current.value) ||
|
|
80
|
+
(Number.isInteger(current.value) && !Number.isSafeInteger(current.value))) {
|
|
81
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains an unsafe JSON number`);
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (typeof current.value !== "object") {
|
|
86
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains a non-JSON value`);
|
|
87
|
+
}
|
|
88
|
+
if (utilTypes.isProxy(current.value)) {
|
|
89
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains a non-JSON proxy`);
|
|
90
|
+
}
|
|
91
|
+
if (seen.has(current.value)) {
|
|
92
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains a repeated or cyclic value`);
|
|
93
|
+
}
|
|
94
|
+
seen.add(current.value);
|
|
95
|
+
if (Array.isArray(current.value)) {
|
|
96
|
+
const array = current.value;
|
|
97
|
+
if (Object.getPrototypeOf(array) !== Array.prototype) {
|
|
98
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains a non-JSON array`);
|
|
99
|
+
}
|
|
100
|
+
const ownKeys = Reflect.ownKeys(array);
|
|
101
|
+
if (array.length > MAX_PUBLIC_JSON_NODES ||
|
|
102
|
+
ownKeys.length > MAX_PUBLIC_JSON_NODES + 1) {
|
|
103
|
+
throw new ProtocolViolationError("response exceeds the pure-JSON validation budget");
|
|
104
|
+
}
|
|
105
|
+
if (ownKeys.some((key) => typeof key !== "string" ||
|
|
106
|
+
(key !== "length" &&
|
|
107
|
+
(!/^(0|[1-9][0-9]*)$/u.test(key) ||
|
|
108
|
+
!Number.isSafeInteger(Number(key)) ||
|
|
109
|
+
Number(key) >= array.length)))) {
|
|
110
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains a non-JSON array property`);
|
|
111
|
+
}
|
|
112
|
+
const descriptors = Object.getOwnPropertyDescriptors(array);
|
|
113
|
+
for (let index = array.length - 1; index >= 0; index -= 1) {
|
|
114
|
+
const descriptor = descriptors[String(index)];
|
|
115
|
+
if (!descriptor?.enumerable || !("value" in descriptor)) {
|
|
116
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains a sparse or accessor array slot`);
|
|
117
|
+
}
|
|
118
|
+
pending.push({
|
|
119
|
+
depth: current.depth + 1,
|
|
120
|
+
location: boundedDiagnosticLocation(`${current.location}[${index}]`),
|
|
121
|
+
value: descriptor.value,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const prototype = Object.getPrototypeOf(current.value);
|
|
127
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
128
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains a non-JSON object`);
|
|
129
|
+
}
|
|
130
|
+
const ownKeys = Reflect.ownKeys(current.value);
|
|
131
|
+
if (ownKeys.length > MAX_PUBLIC_JSON_NODES) {
|
|
132
|
+
throw new ProtocolViolationError("response exceeds the pure-JSON validation budget");
|
|
133
|
+
}
|
|
134
|
+
const descriptors = Object.getOwnPropertyDescriptors(current.value);
|
|
135
|
+
for (const key of ownKeys) {
|
|
136
|
+
if (typeof key !== "string") {
|
|
137
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains a symbol property`);
|
|
138
|
+
}
|
|
139
|
+
const descriptor = descriptors[key];
|
|
140
|
+
if (!descriptor?.enumerable || !("value" in descriptor)) {
|
|
141
|
+
throw new ProtocolViolationError(`${boundedDiagnosticLocation(current.location)} contains a non-JSON property`);
|
|
142
|
+
}
|
|
143
|
+
pending.push({
|
|
144
|
+
depth: current.depth + 1,
|
|
145
|
+
location: boundedDiagnosticLocation(`${current.location}.*`),
|
|
146
|
+
value: descriptor.value,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function redactedInstanceLocation(instancePath) {
|
|
153
|
+
const segments = instancePath.split("/").slice(1);
|
|
154
|
+
let location = "$";
|
|
155
|
+
for (const segment of segments) {
|
|
156
|
+
location += /^(0|[1-9][0-9]{0,11})$/u.test(segment) ? `[${segment}]` : ".*";
|
|
157
|
+
if (location.length > 256) {
|
|
158
|
+
return `${location.slice(0, 253)}...`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return location;
|
|
162
|
+
}
|
|
163
|
+
function validationError(method, errors) {
|
|
164
|
+
const candidates = [...(errors ?? [])].sort((left, right) => {
|
|
165
|
+
const pathDifference = right.instancePath.length - left.instancePath.length;
|
|
166
|
+
if (pathDifference !== 0) {
|
|
167
|
+
return pathDifference;
|
|
168
|
+
}
|
|
169
|
+
const leftAggregate = left.keyword === "anyOf" || left.keyword === "oneOf";
|
|
170
|
+
const rightAggregate = right.keyword === "anyOf" || right.keyword === "oneOf";
|
|
171
|
+
return Number(leftAggregate) - Number(rightAggregate);
|
|
172
|
+
});
|
|
173
|
+
const error = candidates[0];
|
|
174
|
+
if (!error) {
|
|
175
|
+
return new ProtocolViolationError(`${method} response was rejected by its JSON Schema`);
|
|
176
|
+
}
|
|
177
|
+
const location = redactedInstanceLocation(error.instancePath);
|
|
178
|
+
return new ProtocolViolationError(`${method} response was rejected at ${location}: ${error.message ?? error.keyword}`);
|
|
179
|
+
}
|
|
180
|
+
export function validateRpcResponse(method, response) {
|
|
181
|
+
const validator = responseValidator(method);
|
|
182
|
+
assertPureJsonValue(response);
|
|
183
|
+
if (!validator(response)) {
|
|
184
|
+
throw validationError(method, validator.errors);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/** Validates a method result for adapters that inject a client-compatible transport. */
|
|
188
|
+
export function validateRpcResult(method, result) {
|
|
189
|
+
validateRpcResponse(method, { id: 0, jsonrpc: "2.0", result });
|
|
190
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Readable } from "node:stream";
|
|
2
|
+
export declare const DEFAULT_STDERR_TAIL_BYTES: number;
|
|
3
|
+
export declare class BoundedStderrTail {
|
|
4
|
+
#private;
|
|
5
|
+
readonly maxBytes: number;
|
|
6
|
+
constructor(stream?: Readable, maxBytes?: number);
|
|
7
|
+
get byteLength(): number;
|
|
8
|
+
get text(): string;
|
|
9
|
+
stop(): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const DEFAULT_STDERR_TAIL_BYTES = 64 * 1024;
|
|
2
|
+
export class BoundedStderrTail {
|
|
3
|
+
maxBytes;
|
|
4
|
+
#bytes = Buffer.alloc(0);
|
|
5
|
+
#stopped = false;
|
|
6
|
+
#stream;
|
|
7
|
+
#onData = (chunk) => {
|
|
8
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk);
|
|
9
|
+
if (bytes.length >= this.maxBytes) {
|
|
10
|
+
this.#bytes = Buffer.from(bytes.subarray(bytes.length - this.maxBytes));
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const overflow = Math.max(0, this.#bytes.length + bytes.length - this.maxBytes);
|
|
14
|
+
this.#bytes = Buffer.concat([this.#bytes.subarray(overflow), bytes]);
|
|
15
|
+
};
|
|
16
|
+
#onError = () => {
|
|
17
|
+
// stderr is diagnostic only; transport health is determined by stdout/stdin.
|
|
18
|
+
};
|
|
19
|
+
constructor(stream, maxBytes = DEFAULT_STDERR_TAIL_BYTES) {
|
|
20
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
21
|
+
throw new RangeError("maxBytes must be a positive safe integer");
|
|
22
|
+
}
|
|
23
|
+
this.maxBytes = maxBytes;
|
|
24
|
+
this.#stream = stream;
|
|
25
|
+
stream?.on("data", this.#onData);
|
|
26
|
+
stream?.on("error", this.#onError);
|
|
27
|
+
}
|
|
28
|
+
get byteLength() {
|
|
29
|
+
return this.#bytes.length;
|
|
30
|
+
}
|
|
31
|
+
get text() {
|
|
32
|
+
return this.#bytes.toString("utf8");
|
|
33
|
+
}
|
|
34
|
+
stop() {
|
|
35
|
+
if (this.#stopped) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.#stopped = true;
|
|
39
|
+
this.#stream?.off("data", this.#onData);
|
|
40
|
+
this.#stream?.off("error", this.#onError);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Writable } from "node:stream";
|
|
2
|
+
export interface NdjsonWriteQueueOptions {
|
|
3
|
+
readonly maxFrameBytes?: number;
|
|
4
|
+
readonly maxQueuedBytes?: number;
|
|
5
|
+
readonly maxQueuedFrames?: number;
|
|
6
|
+
}
|
|
7
|
+
/** Serializes outbound NDJSON writes and makes stream backpressure observable. */
|
|
8
|
+
export declare class NdjsonWriteQueue {
|
|
9
|
+
#private;
|
|
10
|
+
readonly maxFrameBytes: number;
|
|
11
|
+
readonly maxQueuedBytes: number;
|
|
12
|
+
readonly maxQueuedFrames: number;
|
|
13
|
+
constructor(writable: Writable, options?: NdjsonWriteQueueOptions);
|
|
14
|
+
get backpressured(): boolean;
|
|
15
|
+
/** Resolves exactly once when the underlying stream or queue fails. */
|
|
16
|
+
get failure(): Promise<Error>;
|
|
17
|
+
get queuedBytes(): number;
|
|
18
|
+
get queuedFrames(): number;
|
|
19
|
+
get progressVersion(): bigint;
|
|
20
|
+
enqueue(frame: string): Promise<void>;
|
|
21
|
+
idle(): Promise<void>;
|
|
22
|
+
/** Resolves after the next admitted frame leaves the bounded queue. */
|
|
23
|
+
waitForProgress(since?: bigint): Promise<void>;
|
|
24
|
+
fail(error: Error): void;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { TransportClosedError, WriteFrameTooLargeError, WriteQueueOverflowError, } from "./errors.js";
|
|
2
|
+
import { DEFAULT_MAX_FRAME_BYTES } from "./ndjson.js";
|
|
3
|
+
const NEWLINE = Buffer.from("\n");
|
|
4
|
+
function positiveSafeInteger(value, name) {
|
|
5
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
6
|
+
throw new RangeError(`${name} must be a positive safe integer`);
|
|
7
|
+
}
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
10
|
+
/** Serializes outbound NDJSON writes and makes stream backpressure observable. */
|
|
11
|
+
export class NdjsonWriteQueue {
|
|
12
|
+
maxFrameBytes;
|
|
13
|
+
maxQueuedBytes;
|
|
14
|
+
maxQueuedFrames;
|
|
15
|
+
#backpressured = false;
|
|
16
|
+
#accepting = true;
|
|
17
|
+
#activeAbort;
|
|
18
|
+
#closePromise;
|
|
19
|
+
#failure;
|
|
20
|
+
#failurePromise;
|
|
21
|
+
#resolveFailure;
|
|
22
|
+
#idleWaiters = [];
|
|
23
|
+
#progressWaiters = [];
|
|
24
|
+
#pumping = false;
|
|
25
|
+
#progressVersion = 0n;
|
|
26
|
+
#queue = [];
|
|
27
|
+
#queuedBytes = 0;
|
|
28
|
+
#streamErrorsGuarded = false;
|
|
29
|
+
#writable;
|
|
30
|
+
#ignoreLateStreamError = () => { };
|
|
31
|
+
#onGuardedStreamClose = () => {
|
|
32
|
+
this.#writable.off("error", this.#ignoreLateStreamError);
|
|
33
|
+
this.#writable.off("close", this.#onGuardedStreamClose);
|
|
34
|
+
this.#streamErrorsGuarded = false;
|
|
35
|
+
};
|
|
36
|
+
#onStreamClose = () => {
|
|
37
|
+
this.fail(new TransportClosedError("outbound stream closed"));
|
|
38
|
+
};
|
|
39
|
+
#onStreamError = (cause) => {
|
|
40
|
+
this.fail(new TransportClosedError("outbound stream failed", { cause }));
|
|
41
|
+
};
|
|
42
|
+
#onStreamFinish = () => {
|
|
43
|
+
this.fail(new TransportClosedError("outbound stream finished"));
|
|
44
|
+
};
|
|
45
|
+
constructor(writable, options = {}) {
|
|
46
|
+
this.#writable = writable;
|
|
47
|
+
this.maxFrameBytes = positiveSafeInteger(options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES, "maxFrameBytes");
|
|
48
|
+
this.maxQueuedBytes = positiveSafeInteger(options.maxQueuedBytes ?? this.maxFrameBytes * 4, "maxQueuedBytes");
|
|
49
|
+
this.maxQueuedFrames = positiveSafeInteger(options.maxQueuedFrames ?? 256, "maxQueuedFrames");
|
|
50
|
+
this.#failurePromise = new Promise((resolve) => {
|
|
51
|
+
this.#resolveFailure = resolve;
|
|
52
|
+
});
|
|
53
|
+
this.#writable.on("close", this.#onStreamClose);
|
|
54
|
+
this.#writable.on("error", this.#onStreamError);
|
|
55
|
+
this.#writable.on("finish", this.#onStreamFinish);
|
|
56
|
+
}
|
|
57
|
+
get backpressured() {
|
|
58
|
+
return this.#backpressured;
|
|
59
|
+
}
|
|
60
|
+
/** Resolves exactly once when the underlying stream or queue fails. */
|
|
61
|
+
get failure() {
|
|
62
|
+
return this.#failurePromise;
|
|
63
|
+
}
|
|
64
|
+
get queuedBytes() {
|
|
65
|
+
return this.#queuedBytes;
|
|
66
|
+
}
|
|
67
|
+
get queuedFrames() {
|
|
68
|
+
return this.#queue.length;
|
|
69
|
+
}
|
|
70
|
+
get progressVersion() {
|
|
71
|
+
return this.#progressVersion;
|
|
72
|
+
}
|
|
73
|
+
enqueue(frame) {
|
|
74
|
+
if (this.#failure) {
|
|
75
|
+
return Promise.reject(this.#failure);
|
|
76
|
+
}
|
|
77
|
+
if (!this.#accepting) {
|
|
78
|
+
return Promise.reject(new TransportClosedError("outbound queue is closing"));
|
|
79
|
+
}
|
|
80
|
+
const payload = Buffer.from(frame, "utf8");
|
|
81
|
+
if (payload.length > this.maxFrameBytes) {
|
|
82
|
+
return Promise.reject(new WriteFrameTooLargeError(this.maxFrameBytes, payload.length));
|
|
83
|
+
}
|
|
84
|
+
const bytes = Buffer.concat([payload, NEWLINE]);
|
|
85
|
+
if (this.#queue.length >= this.maxQueuedFrames) {
|
|
86
|
+
return Promise.reject(new WriteQueueOverflowError(`outbound queue already contains ${this.#queue.length} frames; the limit is ${this.maxQueuedFrames}`));
|
|
87
|
+
}
|
|
88
|
+
if (this.#queuedBytes + bytes.length > this.maxQueuedBytes) {
|
|
89
|
+
return Promise.reject(new WriteQueueOverflowError(`outbound queue would contain ${this.#queuedBytes + bytes.length} bytes; the limit is ${this.maxQueuedBytes}`));
|
|
90
|
+
}
|
|
91
|
+
const completion = new Promise((resolve, reject) => {
|
|
92
|
+
this.#queue.push({ bytes, reject, resolve });
|
|
93
|
+
this.#queuedBytes += bytes.length;
|
|
94
|
+
});
|
|
95
|
+
void this.#pump();
|
|
96
|
+
return completion;
|
|
97
|
+
}
|
|
98
|
+
idle() {
|
|
99
|
+
if (this.#failure) {
|
|
100
|
+
return Promise.reject(this.#failure);
|
|
101
|
+
}
|
|
102
|
+
if (this.#queue.length === 0) {
|
|
103
|
+
return Promise.resolve();
|
|
104
|
+
}
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
this.#idleWaiters.push({ reject, resolve });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Resolves after the next admitted frame leaves the bounded queue. */
|
|
110
|
+
waitForProgress(since = this.#progressVersion) {
|
|
111
|
+
if (this.#failure) {
|
|
112
|
+
return Promise.reject(this.#failure);
|
|
113
|
+
}
|
|
114
|
+
if (since !== this.#progressVersion) {
|
|
115
|
+
return Promise.resolve();
|
|
116
|
+
}
|
|
117
|
+
return new Promise((resolve, reject) => {
|
|
118
|
+
this.#progressWaiters.push({ reject, resolve });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
fail(error) {
|
|
122
|
+
if (this.#failure) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.#accepting = false;
|
|
126
|
+
this.#failure = error;
|
|
127
|
+
this.#resolveFailure(error);
|
|
128
|
+
this.#backpressured = false;
|
|
129
|
+
this.#activeAbort?.(error);
|
|
130
|
+
const queued = this.#queue.splice(0);
|
|
131
|
+
this.#queuedBytes = 0;
|
|
132
|
+
for (const frame of queued) {
|
|
133
|
+
frame.reject(error);
|
|
134
|
+
}
|
|
135
|
+
for (const waiter of this.#idleWaiters.splice(0)) {
|
|
136
|
+
waiter.reject(error);
|
|
137
|
+
}
|
|
138
|
+
for (const waiter of this.#progressWaiters.splice(0)) {
|
|
139
|
+
waiter.reject(error);
|
|
140
|
+
}
|
|
141
|
+
this.#detachStreamListeners();
|
|
142
|
+
}
|
|
143
|
+
close() {
|
|
144
|
+
if (this.#closePromise) {
|
|
145
|
+
return this.#closePromise;
|
|
146
|
+
}
|
|
147
|
+
if (this.#failure) {
|
|
148
|
+
this.#closePromise = Promise.reject(this.#failure);
|
|
149
|
+
return this.#closePromise;
|
|
150
|
+
}
|
|
151
|
+
this.#accepting = false;
|
|
152
|
+
this.#closePromise = this.#finishClose();
|
|
153
|
+
return this.#closePromise;
|
|
154
|
+
}
|
|
155
|
+
async #finishClose() {
|
|
156
|
+
await this.idle();
|
|
157
|
+
if (this.#failure) {
|
|
158
|
+
throw this.#failure;
|
|
159
|
+
}
|
|
160
|
+
this.#failure = new TransportClosedError();
|
|
161
|
+
for (const waiter of this.#progressWaiters.splice(0)) {
|
|
162
|
+
waiter.reject(this.#failure);
|
|
163
|
+
}
|
|
164
|
+
this.#detachStreamListeners();
|
|
165
|
+
}
|
|
166
|
+
async #pump() {
|
|
167
|
+
if (this.#pumping || this.#failure) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
this.#pumping = true;
|
|
171
|
+
try {
|
|
172
|
+
while (!this.#failure) {
|
|
173
|
+
const frame = this.#queue[0];
|
|
174
|
+
if (!frame) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
try {
|
|
178
|
+
await this.#write(frame.bytes);
|
|
179
|
+
}
|
|
180
|
+
catch (cause) {
|
|
181
|
+
const error = cause instanceof Error
|
|
182
|
+
? new TransportClosedError("outbound stream failed", { cause })
|
|
183
|
+
: new TransportClosedError("outbound stream failed");
|
|
184
|
+
this.fail(error);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (this.#failure) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
this.#queue.shift();
|
|
191
|
+
this.#queuedBytes -= frame.bytes.length;
|
|
192
|
+
this.#progressVersion += 1n;
|
|
193
|
+
for (const waiter of this.#progressWaiters.splice(0)) {
|
|
194
|
+
waiter.resolve();
|
|
195
|
+
}
|
|
196
|
+
frame.resolve();
|
|
197
|
+
if (this.#queue.length === 0) {
|
|
198
|
+
for (const waiter of this.#idleWaiters.splice(0)) {
|
|
199
|
+
waiter.resolve();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
this.#pumping = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
#write(bytes) {
|
|
209
|
+
return new Promise((resolve, reject) => {
|
|
210
|
+
let callbackComplete = false;
|
|
211
|
+
let drainComplete = false;
|
|
212
|
+
let settled = false;
|
|
213
|
+
let writeReturned = false;
|
|
214
|
+
const cleanup = () => {
|
|
215
|
+
this.#writable.off("drain", onDrain);
|
|
216
|
+
if (this.#activeAbort === abort) {
|
|
217
|
+
this.#activeAbort = undefined;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const abort = (error) => {
|
|
221
|
+
if (settled) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
settled = true;
|
|
225
|
+
cleanup();
|
|
226
|
+
reject(error);
|
|
227
|
+
};
|
|
228
|
+
const completeIfReady = () => {
|
|
229
|
+
if (!settled && writeReturned && callbackComplete && drainComplete) {
|
|
230
|
+
settled = true;
|
|
231
|
+
cleanup();
|
|
232
|
+
this.#backpressured = false;
|
|
233
|
+
resolve();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
const onDrain = () => {
|
|
237
|
+
drainComplete = true;
|
|
238
|
+
completeIfReady();
|
|
239
|
+
};
|
|
240
|
+
this.#activeAbort = abort;
|
|
241
|
+
try {
|
|
242
|
+
const accepted = this.#writable.write(bytes, (error) => {
|
|
243
|
+
if (error) {
|
|
244
|
+
abort(error);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
callbackComplete = true;
|
|
248
|
+
completeIfReady();
|
|
249
|
+
});
|
|
250
|
+
writeReturned = true;
|
|
251
|
+
drainComplete = accepted;
|
|
252
|
+
this.#backpressured = !accepted;
|
|
253
|
+
if (!accepted) {
|
|
254
|
+
this.#writable.once("drain", onDrain);
|
|
255
|
+
}
|
|
256
|
+
completeIfReady();
|
|
257
|
+
}
|
|
258
|
+
catch (cause) {
|
|
259
|
+
abort(cause instanceof Error ? cause : new Error("outbound write threw"));
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
#detachStreamListeners() {
|
|
264
|
+
this.#writable.off("close", this.#onStreamClose);
|
|
265
|
+
this.#writable.off("error", this.#onStreamError);
|
|
266
|
+
this.#writable.off("finish", this.#onStreamFinish);
|
|
267
|
+
if (!this.#streamErrorsGuarded) {
|
|
268
|
+
this.#streamErrorsGuarded = true;
|
|
269
|
+
this.#writable.on("error", this.#ignoreLateStreamError);
|
|
270
|
+
this.#writable.once("close", this.#onGuardedStreamClose);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@devicerail/client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Typed Node.js client for the DeviceRail device automation protocol",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"device-automation",
|
|
8
|
+
"test-automation",
|
|
9
|
+
"json-rpc",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@types/node": "26.1.1",
|
|
33
|
+
"ajv": "8.20.0",
|
|
34
|
+
"@devicerail/protocol": "0.1.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"typescript": "7.0.2"
|
|
38
|
+
},
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "git+https://github.com/wangweiwei/device-rail.git",
|
|
42
|
+
"directory": "packages/client"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/wangweiwei/device-rail#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/wangweiwei/device-rail/issues"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"runtime-schemas:generate": "node scripts/generate-response-schemas.mjs",
|
|
50
|
+
"runtime-schemas:check": "node scripts/generate-response-schemas.mjs --check",
|
|
51
|
+
"runtime-schemas:test": "node --test scripts/generate-response-schemas.test.mjs",
|
|
52
|
+
"typecheck": "pnpm run runtime-schemas:check && tsc -p tsconfig.json --noEmit",
|
|
53
|
+
"build": "pnpm run runtime-schemas:check && node scripts/clean.mjs dist && tsc -p tsconfig.build.json && tsc -p tsconfig.package-exports.json && node scripts/check-package-exports.mjs",
|
|
54
|
+
"test": "pnpm run runtime-schemas:check && pnpm run runtime-schemas:test && node scripts/clean.mjs .test-dist && tsc -p tsconfig.test.json && node scripts/run-tests.mjs"
|
|
55
|
+
}
|
|
56
|
+
}
|