@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.
@@ -0,0 +1,7 @@
1
+ export * from "./errors.js";
2
+ export { DeviceRailEventStream, type EventStreamItem, type EventStreamOptions, type EventStreamWebSocket, type EventStreamWebSocketEvent, type EventStreamWebSocketEventType, type EventStreamWebSocketFactory, } from "./event-stream.js";
3
+ export * from "./ndjson.js";
4
+ export * from "./rpc-client.js";
5
+ export { validateRpcResult } from "./rpc-response-validator.js";
6
+ export * from "./stderr-tail.js";
7
+ export * from "./write-queue.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./errors.js";
2
+ export { DeviceRailEventStream, } from "./event-stream.js";
3
+ export * from "./ndjson.js";
4
+ export * from "./rpc-client.js";
5
+ export { validateRpcResult } from "./rpc-response-validator.js";
6
+ export * from "./stderr-tail.js";
7
+ export * from "./write-queue.js";
@@ -0,0 +1,13 @@
1
+ export declare const DEFAULT_MAX_FRAME_BYTES: number;
2
+ export interface NdjsonDecoderOptions {
3
+ readonly maxFrameBytes?: number;
4
+ }
5
+ /** A byte-bounded, fatal-UTF-8 NDJSON decoder. */
6
+ export declare class NdjsonDecoder {
7
+ #private;
8
+ readonly maxFrameBytes: number;
9
+ constructor(options?: NdjsonDecoderOptions);
10
+ get bufferedBytes(): number;
11
+ push(chunk: Uint8Array): string[];
12
+ end(): void;
13
+ }
package/dist/ndjson.js ADDED
@@ -0,0 +1,134 @@
1
+ import { NdjsonFrameTooLargeError, NdjsonIncompleteFrameError, NdjsonInvalidUtf8Error, } from "./errors.js";
2
+ export const DEFAULT_MAX_FRAME_BYTES = 1024 * 1024;
3
+ function positiveSafeInteger(value, name) {
4
+ if (!Number.isSafeInteger(value) || value <= 0) {
5
+ throw new RangeError(`${name} must be a positive safe integer`);
6
+ }
7
+ return value;
8
+ }
9
+ /** A byte-bounded, fatal-UTF-8 NDJSON decoder. */
10
+ export class NdjsonDecoder {
11
+ maxFrameBytes;
12
+ #buffer = Buffer.alloc(0);
13
+ #bufferedBytes = 0;
14
+ #ended = false;
15
+ #failure;
16
+ constructor(options = {}) {
17
+ this.maxFrameBytes = positiveSafeInteger(options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES, "maxFrameBytes");
18
+ }
19
+ get bufferedBytes() {
20
+ return this.#bufferedBytes;
21
+ }
22
+ push(chunk) {
23
+ this.#assertOpen();
24
+ if (chunk.byteLength === 0) {
25
+ return [];
26
+ }
27
+ const incoming = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
28
+ const frames = [];
29
+ let start = 0;
30
+ for (let index = 0; index < incoming.length; index += 1) {
31
+ if (incoming[index] !== 0x0a) {
32
+ continue;
33
+ }
34
+ const segment = incoming.subarray(start, index);
35
+ this.#checkCompletedBound(segment);
36
+ const frame = this.#bufferedBytes === 0
37
+ ? Buffer.from(segment)
38
+ : this.#appendAndView(segment);
39
+ frames.push(this.#decodeFrame(frame));
40
+ this.#bufferedBytes = 0;
41
+ start = index + 1;
42
+ }
43
+ if (start < incoming.length) {
44
+ const remainder = incoming.subarray(start);
45
+ this.#checkPendingSegmentBound(remainder);
46
+ this.#append(remainder);
47
+ }
48
+ return frames;
49
+ }
50
+ end() {
51
+ this.#assertOpen();
52
+ this.#ended = true;
53
+ if (this.#bufferedBytes !== 0) {
54
+ const error = new NdjsonIncompleteFrameError(this.#bufferedBytes);
55
+ this.#failure = error;
56
+ throw error;
57
+ }
58
+ }
59
+ #decodeFrame(rawFrame) {
60
+ const frame = rawFrame.at(-1) === 0x0d ? rawFrame.subarray(0, -1) : rawFrame;
61
+ if (frame.length > this.maxFrameBytes) {
62
+ return this.#fail(new NdjsonFrameTooLargeError(this.maxFrameBytes, frame.length));
63
+ }
64
+ try {
65
+ return new TextDecoder("utf-8", { fatal: true }).decode(frame);
66
+ }
67
+ catch (cause) {
68
+ return this.#fail(new NdjsonInvalidUtf8Error(cause));
69
+ }
70
+ }
71
+ #checkPendingSegmentBound(segment) {
72
+ const length = this.#bufferedBytes + segment.length;
73
+ const finalByte = segment.at(-1) ?? this.#buffer.at(this.#bufferedBytes - 1);
74
+ const mayBeTerminalCarriageReturn = length === this.maxFrameBytes + 1 && finalByte === 0x0d;
75
+ if (length > this.maxFrameBytes && !mayBeTerminalCarriageReturn) {
76
+ this.#fail(new NdjsonFrameTooLargeError(this.maxFrameBytes, length));
77
+ }
78
+ }
79
+ #checkCompletedBound(segment) {
80
+ const rawLength = this.#bufferedBytes + segment.length;
81
+ const finalByte = segment.at(-1) ?? this.#buffer.at(this.#bufferedBytes - 1);
82
+ const frameLength = finalByte === 0x0d ? rawLength - 1 : rawLength;
83
+ if (frameLength > this.maxFrameBytes) {
84
+ this.#fail(new NdjsonFrameTooLargeError(this.maxFrameBytes, frameLength));
85
+ }
86
+ }
87
+ #append(segment) {
88
+ const required = this.#bufferedBytes + segment.length;
89
+ this.#ensureCapacity(required);
90
+ segment.copy(this.#buffer, this.#bufferedBytes);
91
+ this.#bufferedBytes = required;
92
+ }
93
+ #appendAndView(segment) {
94
+ this.#append(segment);
95
+ return this.#buffer.subarray(0, this.#bufferedBytes);
96
+ }
97
+ #ensureCapacity(required) {
98
+ if (required <= this.#buffer.length) {
99
+ return;
100
+ }
101
+ const maximum = this.maxFrameBytes + 1;
102
+ let capacity = Math.max(64, this.#buffer.length);
103
+ while (capacity < required) {
104
+ capacity = Math.min(maximum, capacity * 2);
105
+ if (capacity < required && capacity === maximum) {
106
+ capacity = required;
107
+ break;
108
+ }
109
+ }
110
+ const next = Buffer.allocUnsafe(capacity);
111
+ if (this.#bufferedBytes > 0) {
112
+ this.#buffer.copy(next, 0, 0, this.#bufferedBytes);
113
+ }
114
+ this.#buffer = next;
115
+ }
116
+ #assertOpen() {
117
+ if (this.#failure) {
118
+ throw this.#failure;
119
+ }
120
+ if (this.#ended) {
121
+ throw new TransportClosedDecoderError();
122
+ }
123
+ }
124
+ #fail(error) {
125
+ this.#failure = error;
126
+ throw error;
127
+ }
128
+ }
129
+ class TransportClosedDecoderError extends Error {
130
+ constructor() {
131
+ super("NDJSON decoder has already ended");
132
+ this.name = "TransportClosedDecoderError";
133
+ }
134
+ }
@@ -0,0 +1,69 @@
1
+ import type { SpawnOptionsWithoutStdio } from "node:child_process";
2
+ import type { Readable, Writable } from "node:stream";
3
+ import type { HelloParams, HelloResult, EventsSubscribeParams, RequestCancelResult, RpcId, RpcMethod, RpcParamsFor, RpcResultFor, RpcSupportsTimeout } from "@devicerail/protocol";
4
+ import { DeviceRailEventStream, type EventStreamOptions } from "./event-stream.js";
5
+ export type ClientRpcMethod = Exclude<RpcMethod, "events.subscribe" | "system.hello">;
6
+ export type ClientState = "awaitingHello" | "helloInFlight" | "ready" | "closing" | "closed" | "failed";
7
+ export interface TransportClosure {
8
+ readonly code: number | null;
9
+ readonly error?: Error;
10
+ readonly signal: NodeJS.Signals | null;
11
+ }
12
+ export interface ClientTransport {
13
+ readonly closed: Promise<TransportClosure>;
14
+ readonly readable: Readable;
15
+ readonly stderr?: Readable;
16
+ readonly writable: Writable;
17
+ closeInput(): void;
18
+ terminate?(): void;
19
+ }
20
+ export interface SpawnClientOptions {
21
+ readonly args?: readonly string[];
22
+ readonly closeGraceMs?: number;
23
+ readonly command: string;
24
+ readonly hello: HelloParams;
25
+ readonly maxFrameBytes?: number;
26
+ readonly maxPendingRequests?: number;
27
+ readonly maxQueuedBytes?: number;
28
+ readonly maxQueuedFrames?: number;
29
+ readonly spawn?: Omit<SpawnOptionsWithoutStdio, "shell" | "stdio">;
30
+ readonly stderrTailBytes?: number;
31
+ }
32
+ export interface AttachedClientOptions {
33
+ readonly closeGraceMs?: number;
34
+ readonly maxFrameBytes?: number;
35
+ readonly maxPendingRequests?: number;
36
+ readonly maxQueuedBytes?: number;
37
+ readonly maxQueuedFrames?: number;
38
+ readonly stderrTailBytes?: number;
39
+ }
40
+ type TimeoutCallOptions<M extends ClientRpcMethod> = RpcSupportsTimeout<M> extends true ? {
41
+ readonly signal?: AbortSignal;
42
+ readonly timeoutMs?: number;
43
+ } : {
44
+ readonly signal?: never;
45
+ readonly timeoutMs?: never;
46
+ };
47
+ export type CallOptions<M extends ClientRpcMethod> = TimeoutCallOptions<M>;
48
+ export type CallArguments<M extends ClientRpcMethod> = undefined extends RpcParamsFor<M> ? [params?: RpcParamsFor<M>, options?: CallOptions<M>] : [params: RpcParamsFor<M>, options?: CallOptions<M>];
49
+ export interface RequestHandle<Result> {
50
+ readonly id: RpcId;
51
+ readonly result: Promise<Result>;
52
+ cancel(): Promise<RequestCancelResult>;
53
+ }
54
+ export declare class DeviceRailClient {
55
+ #private;
56
+ constructor(transport: ClientTransport, options?: AttachedClientOptions);
57
+ static spawn(options: SpawnClientOptions): Promise<DeviceRailClient>;
58
+ get enabledFeatures(): ReadonlySet<string>;
59
+ get pendingRequests(): number;
60
+ get state(): ClientState;
61
+ get stderrTail(): string;
62
+ hello(params: HelloParams): Promise<HelloResult>;
63
+ call<M extends ClientRpcMethod>(method: M, ...args: CallArguments<M>): Promise<RpcResultFor<M>>;
64
+ openEventStream(params: EventsSubscribeParams, options?: EventStreamOptions): Promise<DeviceRailEventStream>;
65
+ beginCall<M extends ClientRpcMethod>(method: M, ...args: CallArguments<M>): RequestHandle<RpcResultFor<M>>;
66
+ cancel(requestId: RpcId): Promise<RequestCancelResult>;
67
+ close(): Promise<void>;
68
+ }
69
+ export {};