@inhuman.tools/harness-client 0.0.0-stage → 0.2.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.md +7 -0
- package/README.md +40 -2
- package/dist/client.d.ts +33 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +14 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +28 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +18 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +107 -0
- package/dist/link.js.map +1 -0
- package/dist/node.d.ts +12 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +12 -0
- package/dist/node.js.map +1 -0
- package/dist/transport.d.ts +35 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +16 -0
- package/dist/transport.js.map +1 -0
- package/dist/websocket.d.ts +28 -0
- package/dist/websocket.d.ts.map +1 -0
- package/dist/websocket.js +66 -0
- package/dist/websocket.js.map +1 -0
- package/package.json +69 -3
- package/src/client.ts +40 -0
- package/src/errors.ts +33 -0
- package/src/index.ts +5 -0
- package/src/link.ts +125 -0
- package/src/node.ts +19 -0
- package/src/transport.ts +36 -0
- package/src/websocket.ts +79 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Merkle Bonsai
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# `@inhuman.tools/harness-client`
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Typed client for the inhuman harness daemon, generated from
|
|
4
|
+
[`@inhuman.tools/harness-contract`](https://www.npmjs.com/package/@inhuman.tools/harness-contract).
|
|
5
|
+
It works over any transport that implements the four-member `Transport` interface.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @inhuman.tools/harness-client
|
|
9
|
+
npm install ws # only for the Node entry point
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Node, over the daemon's unix socket
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { createHarnessClient } from "@inhuman.tools/harness-client";
|
|
16
|
+
import { connectUnixSocket } from "@inhuman.tools/harness-client/node";
|
|
17
|
+
|
|
18
|
+
const transport = await connectUnixSocket({
|
|
19
|
+
socketPath: "<kernel>/daemon.sock",
|
|
20
|
+
target: { host: "session", sessionId },
|
|
21
|
+
});
|
|
22
|
+
const client = createHarnessClient({ transport, onPark: (form) => ui.answer(form) });
|
|
23
|
+
await client.session.kv.set({ key: "model", value: "m", scope: "session" });
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Browser or React Native
|
|
27
|
+
|
|
28
|
+
Use `connectWebSocket(url)` from the main entry. `harnessUpgradePath(target)`
|
|
29
|
+
builds the path part of the URL.
|
|
30
|
+
|
|
31
|
+
## Notes
|
|
32
|
+
|
|
33
|
+
- A method that can park returns a UI form instead of its result. `onPark`
|
|
34
|
+
receives it; see the contract's spec for `ui/resolve`.
|
|
35
|
+
- Aborting a call with an `AbortSignal` rejects locally. There is no wire
|
|
36
|
+
cancel, so the daemon keeps working on it.
|
|
37
|
+
|
|
38
|
+
The full wire description is in the
|
|
39
|
+
[contract spec](https://github.com/here-build/inhuman-harness/tree/main/packages/harness-contract/spec).
|
|
40
|
+
|
|
41
|
+
MIT
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HarnessContract, UiForm } from "@inhuman.tools/harness-contract";
|
|
2
|
+
import { type Client } from "@orpc/client";
|
|
3
|
+
import type { ContractRouterClient } from "@orpc/contract";
|
|
4
|
+
import { type ParkHandler } from "./link.js";
|
|
5
|
+
import type { Transport } from "./transport.js";
|
|
6
|
+
/**
|
|
7
|
+
* The typed client. A parking method's declared output is `result | UiForm`;
|
|
8
|
+
* the client either resolves the park (`onPark`) or rejects with
|
|
9
|
+
* `HarnessParkError`, so a call never returns the form and the type drops it.
|
|
10
|
+
*/
|
|
11
|
+
type WithoutPark<R> = R extends Client<infer C, infer I, infer O, infer E> ? Client<C, I, Exclude<O, UiForm>, E> : {
|
|
12
|
+
[K in keyof R]: WithoutPark<R[K]>;
|
|
13
|
+
};
|
|
14
|
+
export type HarnessClient = WithoutPark<ContractRouterClient<HarnessContract>>;
|
|
15
|
+
export interface HarnessClientOptions {
|
|
16
|
+
readonly transport: Transport;
|
|
17
|
+
/**
|
|
18
|
+
* Called when a method parks for UI. Return the answer to resume the call
|
|
19
|
+
* (its result is what the original call resolves with). Without it a park
|
|
20
|
+
* rejects the call with `HarnessParkError`.
|
|
21
|
+
*/
|
|
22
|
+
readonly onPark?: ParkHandler;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Typed client over one connection. The connection decides the host: a session
|
|
26
|
+
* socket also reaches kernel methods (the daemon falls through), a kernel socket
|
|
27
|
+
* does not reach session methods. Close the transport to dispose the client.
|
|
28
|
+
*
|
|
29
|
+
* Inputs and outputs are not re-validated here; the daemon validates both.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createHarnessClient(options: HarnessClientOptions): HarnessClient;
|
|
32
|
+
export {};
|
|
33
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAC;AAC/E,OAAO,EAAoB,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAE3D,OAAO,EAAe,KAAK,WAAW,EAAE,MAAM,WAAW,CAAC;AAC1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD;;;;GAIG;AACH,KAAK,WAAW,CAAC,CAAC,IAChB,CAAC,SAAS,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,CAAC,GAChD,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,GACnC;KAAG,CAAC,IAAI,MAAM,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;CAAE,CAAC;AAE5C,MAAM,MAAM,aAAa,GAAG,WAAW,CAAC,oBAAoB,CAAC,eAAe,CAAC,CAAC,CAAC;AAE/E,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;CAC/B;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CAGhF"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createORPCClient } from "@orpc/client";
|
|
2
|
+
import { JsonRpcLink } from "./link.js";
|
|
3
|
+
/**
|
|
4
|
+
* Typed client over one connection. The connection decides the host: a session
|
|
5
|
+
* socket also reaches kernel methods (the daemon falls through), a kernel socket
|
|
6
|
+
* does not reach session methods. Close the transport to dispose the client.
|
|
7
|
+
*
|
|
8
|
+
* Inputs and outputs are not re-validated here; the daemon validates both.
|
|
9
|
+
*/
|
|
10
|
+
export function createHarnessClient(options) {
|
|
11
|
+
const link = new JsonRpcLink(options.transport, options.onPark);
|
|
12
|
+
return createORPCClient(link);
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAe,MAAM,cAAc,CAAC;AAG7D,OAAO,EAAE,WAAW,EAAoB,MAAM,WAAW,CAAC;AAyB1D;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,OAA6B;IAC/D,MAAM,IAAI,GAAG,IAAI,WAAW,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC;IAChE,OAAO,gBAAgB,CAAC,IAAI,CAAkB,CAAC;AACjD,CAAC"}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { UiForm } from "@inhuman.tools/harness-contract";
|
|
2
|
+
/** A JSON-RPC error reply. `code` is the JSON-RPC code (-32602 invalid params, -32603 internal, ...). */
|
|
3
|
+
export declare class HarnessRpcError extends Error {
|
|
4
|
+
readonly code: number;
|
|
5
|
+
readonly data: unknown;
|
|
6
|
+
constructor(code: number, message: string, data?: unknown);
|
|
7
|
+
}
|
|
8
|
+
/** The method parked for UI and the client has no `onPark` handler. The park stays open on the daemon until the socket closes. */
|
|
9
|
+
export declare class HarnessParkError extends Error {
|
|
10
|
+
readonly form: UiForm;
|
|
11
|
+
constructor(form: UiForm);
|
|
12
|
+
}
|
|
13
|
+
/** The connection ended (or was closed) before the call was answered. */
|
|
14
|
+
export declare class HarnessClosedError extends Error {
|
|
15
|
+
constructor(message?: string, options?: {
|
|
16
|
+
cause?: unknown;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,iCAAiC,CAAC;AAE9D,yGAAyG;AACzG,qBAAa,eAAgB,SAAQ,KAAK;IACxC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;gBAEX,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO;CAM1D;AAED,kIAAkI;AAClI,qBAAa,gBAAiB,SAAQ,KAAK;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBAEV,IAAI,EAAE,MAAM;CAKzB;AAED,yEAAyE;AACzE,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,OAAO,SAAsB,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CAIzE"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** A JSON-RPC error reply. `code` is the JSON-RPC code (-32602 invalid params, -32603 internal, ...). */
|
|
2
|
+
export class HarnessRpcError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
data;
|
|
5
|
+
constructor(code, message, data) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "HarnessRpcError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.data = data;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** The method parked for UI and the client has no `onPark` handler. The park stays open on the daemon until the socket closes. */
|
|
13
|
+
export class HarnessParkError extends Error {
|
|
14
|
+
form;
|
|
15
|
+
constructor(form) {
|
|
16
|
+
super("the call parked for UI and no onPark handler is set");
|
|
17
|
+
this.name = "HarnessParkError";
|
|
18
|
+
this.form = form;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** The connection ended (or was closed) before the call was answered. */
|
|
22
|
+
export class HarnessClosedError extends Error {
|
|
23
|
+
constructor(message = "connection closed", options) {
|
|
24
|
+
super(message, options);
|
|
25
|
+
this.name = "HarnessClosedError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAEA,yGAAyG;AACzG,MAAM,OAAO,eAAgB,SAAQ,KAAK;IAC/B,IAAI,CAAS;IACb,IAAI,CAAU;IAEvB,YAAY,IAAY,EAAE,OAAe,EAAE,IAAc;QACvD,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;QAC9B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,kIAAkI;AAClI,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAChC,IAAI,CAAS;IAEtB,YAAY,IAAY;QACtB,KAAK,CAAC,qDAAqD,CAAC,CAAC;QAC7D,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;QAC/B,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,yEAAyE;AACzE,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,OAAO,GAAG,mBAAmB,EAAE,OAA6B;QACtE,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from "./client.js";
|
|
2
|
+
export * from "./errors.js";
|
|
3
|
+
export type { ParkHandler } from "./link.js";
|
|
4
|
+
export * from "./transport.js";
|
|
5
|
+
export { connectWebSocket, webSocketTransport, whenOpen, type WebSocketLike } from "./websocket.js";
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAC5B,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7C,cAAc,gBAAgB,CAAC;AAC/B,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC;AAC5B,cAAc,aAAa,CAAC;AAE5B,cAAc,gBAAgB,CAAC;AAC/B,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,QAAQ,EAAsB,MAAM,gBAAgB,CAAC"}
|
package/dist/link.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type UiAnswer, type UiForm } from "@inhuman.tools/harness-contract";
|
|
2
|
+
import type { ClientLink, ClientOptions } from "@orpc/client";
|
|
3
|
+
import type { Transport } from "./transport.js";
|
|
4
|
+
/**
|
|
5
|
+
* Answers a parked UI form. Returning resumes the parked call with the answer
|
|
6
|
+
* (`ui/resolve`); throwing cancels the park and rejects the original call.
|
|
7
|
+
*/
|
|
8
|
+
export type ParkHandler = (form: UiForm) => UiAnswer | Promise<UiAnswer>;
|
|
9
|
+
/**
|
|
10
|
+
* oRPC `ClientLink` that speaks the daemon's JSON-RPC wire. A procedure's tree
|
|
11
|
+
* path is mapped to its wire name through the contract (`meta.method`).
|
|
12
|
+
*/
|
|
13
|
+
export declare class JsonRpcLink implements ClientLink<Record<never, never>> {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(transport: Transport, onPark?: ParkHandler);
|
|
16
|
+
call(path: readonly string[], input: unknown, options: ClientOptions<Record<never, never>>): Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=link.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link.d.ts","sourceRoot":"","sources":["../src/link.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,QAAQ,EACb,KAAK,MAAM,EACZ,MAAM,iCAAiC,CAAC;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAG9D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,IAAI,EAAE,MAAM,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;AAOzE;;;GAGG;AACH,qBAAa,WAAY,YAAW,UAAU,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;;gBAOtD,SAAS,EAAE,SAAS,EAAE,MAAM,CAAC,EAAE,WAAW;IAOhD,IAAI,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,aAAa,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC,GAAG,OAAO,CAAC,OAAO,CAAC;CA8EpH"}
|
package/dist/link.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { encodeJsonRpcRequest, isUiForm, jsonRpcResponse, methodForPath, } from "@inhuman.tools/harness-contract";
|
|
2
|
+
import { HarnessClosedError, HarnessParkError, HarnessRpcError } from "./errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* oRPC `ClientLink` that speaks the daemon's JSON-RPC wire. A procedure's tree
|
|
5
|
+
* path is mapped to its wire name through the contract (`meta.method`).
|
|
6
|
+
*/
|
|
7
|
+
export class JsonRpcLink {
|
|
8
|
+
#transport;
|
|
9
|
+
#onPark;
|
|
10
|
+
#waiters = new Map();
|
|
11
|
+
#nextId = 1;
|
|
12
|
+
#closed;
|
|
13
|
+
constructor(transport, onPark) {
|
|
14
|
+
this.#transport = transport;
|
|
15
|
+
this.#onPark = onPark;
|
|
16
|
+
transport.onMessage((text) => this.#deliver(text));
|
|
17
|
+
transport.onClose((cause) => this.#fail(new HarnessClosedError("connection closed", { cause })));
|
|
18
|
+
}
|
|
19
|
+
async call(path, input, options) {
|
|
20
|
+
const method = methodForPath(path);
|
|
21
|
+
if (method === undefined)
|
|
22
|
+
throw new Error(`not a procedure of the harness contract: ${path.join(".")}`);
|
|
23
|
+
let result = await this.#request(method, input ?? {}, options.signal);
|
|
24
|
+
while (isUiForm(result)) {
|
|
25
|
+
const form = result;
|
|
26
|
+
if (this.#onPark === undefined)
|
|
27
|
+
throw new HarnessParkError(form);
|
|
28
|
+
let answer;
|
|
29
|
+
try {
|
|
30
|
+
answer = await this.#onPark(form);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// Free the parked call on the daemon rather than leave it until the socket closes.
|
|
34
|
+
await this.#request("ui/resolve", { pending: form.pending, kind: "cancel" }, options.signal).catch(() => undefined);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
result = await this.#request("ui/resolve", { pending: form.pending, ...answer }, options.signal);
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
#request(method, params, signal) {
|
|
42
|
+
if (this.#closed !== undefined)
|
|
43
|
+
return Promise.reject(this.#closed);
|
|
44
|
+
if (signal?.aborted === true)
|
|
45
|
+
return Promise.reject(abortReason(signal));
|
|
46
|
+
const id = this.#nextId++;
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const onAbort = () => {
|
|
49
|
+
this.#waiters.delete(id);
|
|
50
|
+
reject(abortReason(signal));
|
|
51
|
+
};
|
|
52
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
53
|
+
this.#waiters.set(id, {
|
|
54
|
+
resolve: (value) => {
|
|
55
|
+
signal?.removeEventListener("abort", onAbort);
|
|
56
|
+
resolve(value);
|
|
57
|
+
},
|
|
58
|
+
reject: (error) => {
|
|
59
|
+
signal?.removeEventListener("abort", onAbort);
|
|
60
|
+
reject(error);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
try {
|
|
64
|
+
this.#transport.send(encodeJsonRpcRequest(id, method, params));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
this.#waiters.delete(id);
|
|
68
|
+
signal?.removeEventListener("abort", onAbort);
|
|
69
|
+
reject(error);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
#deliver(text) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = JSON.parse(text);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const parsed = jsonRpcResponse.safeParse(raw);
|
|
82
|
+
if (!parsed.success || typeof parsed.data.id !== "number")
|
|
83
|
+
return;
|
|
84
|
+
const waiter = this.#waiters.get(parsed.data.id);
|
|
85
|
+
if (waiter === undefined)
|
|
86
|
+
return;
|
|
87
|
+
this.#waiters.delete(parsed.data.id);
|
|
88
|
+
if ("error" in parsed.data) {
|
|
89
|
+
const { code, message, data } = parsed.data.error;
|
|
90
|
+
waiter.reject(new HarnessRpcError(code, message, data));
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
waiter.resolve(parsed.data.result);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
#fail(error) {
|
|
97
|
+
this.#closed ??= error;
|
|
98
|
+
const waiters = [...this.#waiters.values()];
|
|
99
|
+
this.#waiters.clear();
|
|
100
|
+
for (const waiter of waiters)
|
|
101
|
+
waiter.reject(error);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function abortReason(signal) {
|
|
105
|
+
return signal?.reason ?? new DOMException("This operation was aborted", "AbortError");
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=link.js.map
|
package/dist/link.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link.js","sourceRoot":"","sources":["../src/link.ts"],"names":[],"mappings":"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"}
|
package/dist/node.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type HarnessTarget, type Transport } from "./transport.js";
|
|
2
|
+
export interface UnixSocketOptions {
|
|
3
|
+
/** Absolute path of the daemon socket, `<root>/daemon.sock`. */
|
|
4
|
+
readonly socketPath: string;
|
|
5
|
+
readonly target: HarnessTarget;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Connect to the daemon's unix socket from Node, using the `ws` package
|
|
9
|
+
* (an optional peer dependency, loaded only from this entry point).
|
|
10
|
+
*/
|
|
11
|
+
export declare function connectUnixSocket(options: UnixSocketOptions): Promise<Transport>;
|
|
12
|
+
//# sourceMappingURL=node.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.d.ts","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsB,KAAK,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAGxF,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;CAChC;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,SAAS,CAAC,CAGhF"}
|
package/dist/node.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import { harnessUpgradePath } from "./transport.js";
|
|
3
|
+
import { whenOpen } from "./websocket.js";
|
|
4
|
+
/**
|
|
5
|
+
* Connect to the daemon's unix socket from Node, using the `ws` package
|
|
6
|
+
* (an optional peer dependency, loaded only from this entry point).
|
|
7
|
+
*/
|
|
8
|
+
export function connectUnixSocket(options) {
|
|
9
|
+
const socket = new WebSocket(`ws+unix://${options.socketPath}:${harnessUpgradePath(options.target)}`);
|
|
10
|
+
return whenOpen(socket);
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=node.js.map
|
package/dist/node.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"node.js","sourceRoot":"","sources":["../src/node.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,IAAI,CAAC;AAE/B,OAAO,EAAE,kBAAkB,EAAsC,MAAM,gBAAgB,CAAC;AACxF,OAAO,EAAE,QAAQ,EAAsB,MAAM,gBAAgB,CAAC;AAQ9D;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,OAA0B;IAC1D,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,aAAa,OAAO,CAAC,UAAU,IAAI,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACtG,OAAO,QAAQ,CAAC,MAAkC,CAAC,CAAC;AACtD,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the client needs from a connection: JSON-RPC text frames in and out.
|
|
3
|
+
* Binary frames (Yjs sync on the same socket) are not the client's business;
|
|
4
|
+
* a transport must not deliver them to `onMessage`.
|
|
5
|
+
*/
|
|
6
|
+
export interface Transport {
|
|
7
|
+
/** Send one JSON-RPC text frame. Throws if the connection is not open. */
|
|
8
|
+
send(text: string): void;
|
|
9
|
+
/** Subscribe to text frames. Returns the unsubscribe function. */
|
|
10
|
+
onMessage(listener: (text: string) => void): () => void;
|
|
11
|
+
/** Subscribe to the connection ending, with the cause when known. Returns the unsubscribe function. */
|
|
12
|
+
onClose(listener: (cause?: Error) => void): () => void;
|
|
13
|
+
/** Close the connection. Calls in flight reject with {@link HarnessClosedError}. */
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
/** Which daemon an upgrade reaches. See `packages/harness-contract/spec/README.md` for routing and fall-through. */
|
|
17
|
+
export type HarnessTarget = {
|
|
18
|
+
readonly host: "kernel";
|
|
19
|
+
} | {
|
|
20
|
+
readonly host: "project";
|
|
21
|
+
readonly path: string;
|
|
22
|
+
} | {
|
|
23
|
+
readonly host: "session";
|
|
24
|
+
readonly sessionId: string;
|
|
25
|
+
readonly path?: string;
|
|
26
|
+
} | {
|
|
27
|
+
readonly host: "session";
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly sessionId?: string;
|
|
30
|
+
};
|
|
31
|
+
/** The WebSocket upgrade path (with query) for a target, e.g. `/session?sessionId=s1`. */
|
|
32
|
+
export declare function harnessUpgradePath(target: HarnessTarget): string;
|
|
33
|
+
/** Full upgrade URL on a base such as `ws://localhost:8080`, for bridges that expose the daemon over TCP. */
|
|
34
|
+
export declare function harnessUpgradeUrl(base: string, target: HarnessTarget): string;
|
|
35
|
+
//# sourceMappingURL=transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,0EAA0E;IAC1E,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,kEAAkE;IAClE,SAAS,CAAC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACxD,uGAAuG;IACvG,OAAO,CAAC,QAAQ,EAAE,CAAC,KAAK,CAAC,EAAE,KAAK,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACvD,oFAAoF;IACpF,KAAK,IAAI,IAAI,CAAC;CACf;AAED,oHAAoH;AACpH,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC3B;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAChF;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAErF,0FAA0F;AAC1F,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAMhE;AAED,6GAA6G;AAC7G,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,GAAG,MAAM,CAE7E"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The WebSocket upgrade path (with query) for a target, e.g. `/session?sessionId=s1`. */
|
|
2
|
+
export function harnessUpgradePath(target) {
|
|
3
|
+
if (target.host === "kernel")
|
|
4
|
+
return "/kernel";
|
|
5
|
+
const query = new URLSearchParams();
|
|
6
|
+
if (target.path !== undefined)
|
|
7
|
+
query.set("path", target.path);
|
|
8
|
+
if (target.host === "session" && target.sessionId !== undefined)
|
|
9
|
+
query.set("sessionId", target.sessionId);
|
|
10
|
+
return `/${target.host}?${query.toString()}`;
|
|
11
|
+
}
|
|
12
|
+
/** Full upgrade URL on a base such as `ws://localhost:8080`, for bridges that expose the daemon over TCP. */
|
|
13
|
+
export function harnessUpgradeUrl(base, target) {
|
|
14
|
+
return `${base.replace(/\/+$/, "")}${harnessUpgradePath(target)}`;
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=transport.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAuBA,0FAA0F;AAC1F,MAAM,UAAU,kBAAkB,CAAC,MAAqB;IACtD,IAAI,MAAM,CAAC,IAAI,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAC;IAC/C,MAAM,KAAK,GAAG,IAAI,eAAe,EAAE,CAAC;IACpC,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS;QAAE,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC;IAC9D,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS;QAAE,KAAK,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC;IAC1G,OAAO,IAAI,MAAM,CAAC,IAAI,IAAI,KAAK,CAAC,QAAQ,EAAE,EAAE,CAAC;AAC/C,CAAC;AAED,6GAA6G;AAC7G,MAAM,UAAU,iBAAiB,CAAC,IAAY,EAAE,MAAqB;IACnE,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,GAAG,kBAAkB,CAAC,MAAM,CAAC,EAAE,CAAC;AACpE,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Transport } from "./transport.js";
|
|
2
|
+
/**
|
|
3
|
+
* The slice of the WHATWG `WebSocket` the transport uses. Browsers, React
|
|
4
|
+
* Native, Node's global `WebSocket` and the `ws` package all satisfy it.
|
|
5
|
+
*/
|
|
6
|
+
export interface WebSocketLike {
|
|
7
|
+
readonly readyState: number;
|
|
8
|
+
send(data: string): void;
|
|
9
|
+
close(): void;
|
|
10
|
+
addEventListener(type: "message", listener: (event: {
|
|
11
|
+
readonly data: unknown;
|
|
12
|
+
}) => void): void;
|
|
13
|
+
addEventListener(type: "close", listener: (event: {
|
|
14
|
+
readonly reason?: string;
|
|
15
|
+
}) => void): void;
|
|
16
|
+
addEventListener(type: "error", listener: (event: unknown) => void): void;
|
|
17
|
+
addEventListener(type: "open", listener: () => void): void;
|
|
18
|
+
}
|
|
19
|
+
/** Wrap an already open socket. Only string (text) frames are delivered; binary frames are ignored. */
|
|
20
|
+
export declare function webSocketTransport(socket: WebSocketLike): Transport;
|
|
21
|
+
/** Resolve once an open socket is ready, reject if it fails to open. */
|
|
22
|
+
export declare function whenOpen(socket: WebSocketLike): Promise<Transport>;
|
|
23
|
+
/**
|
|
24
|
+
* Connect with the platform `WebSocket` (browser, React Native, Node 22+).
|
|
25
|
+
* `url` is the full upgrade URL, see `harnessUpgradeUrl`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function connectWebSocket(url: string): Promise<Transport>;
|
|
28
|
+
//# sourceMappingURL=websocket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,IAAI,IAAI,CAAC;IACd,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IAC/F,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IAC/F,gBAAgB,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI,CAAC;IAC1E,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI,CAAC;CAC5D;AAID,uGAAuG;AACvG,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,GAAG,SAAS,CAqCnE;AAED,wEAAwE;AACxE,wBAAgB,QAAQ,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,SAAS,CAAC,CAUlE;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAEhE"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { HarnessClosedError } from "./errors.js";
|
|
2
|
+
const OPEN = 1;
|
|
3
|
+
/** Wrap an already open socket. Only string (text) frames are delivered; binary frames are ignored. */
|
|
4
|
+
export function webSocketTransport(socket) {
|
|
5
|
+
const messages = new Set();
|
|
6
|
+
const closes = new Set();
|
|
7
|
+
let ended = false;
|
|
8
|
+
const end = (cause) => {
|
|
9
|
+
if (ended)
|
|
10
|
+
return;
|
|
11
|
+
ended = true;
|
|
12
|
+
for (const listener of closes)
|
|
13
|
+
listener(cause);
|
|
14
|
+
};
|
|
15
|
+
socket.addEventListener("message", (event) => {
|
|
16
|
+
if (typeof event.data !== "string")
|
|
17
|
+
return;
|
|
18
|
+
for (const listener of messages)
|
|
19
|
+
listener(event.data);
|
|
20
|
+
});
|
|
21
|
+
socket.addEventListener("close", (event) => {
|
|
22
|
+
end(event.reason === undefined || event.reason === "" ? undefined : new Error(event.reason));
|
|
23
|
+
});
|
|
24
|
+
socket.addEventListener("error", () => {
|
|
25
|
+
end(new Error("websocket error"));
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
send(text) {
|
|
29
|
+
if (ended || socket.readyState !== OPEN)
|
|
30
|
+
throw new HarnessClosedError("websocket is not open");
|
|
31
|
+
socket.send(text);
|
|
32
|
+
},
|
|
33
|
+
onMessage(listener) {
|
|
34
|
+
messages.add(listener);
|
|
35
|
+
return () => messages.delete(listener);
|
|
36
|
+
},
|
|
37
|
+
onClose(listener) {
|
|
38
|
+
closes.add(listener);
|
|
39
|
+
return () => closes.delete(listener);
|
|
40
|
+
},
|
|
41
|
+
close() {
|
|
42
|
+
socket.close();
|
|
43
|
+
end();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Resolve once an open socket is ready, reject if it fails to open. */
|
|
48
|
+
export function whenOpen(socket) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
if (socket.readyState === OPEN) {
|
|
51
|
+
resolve(webSocketTransport(socket));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
socket.addEventListener("open", () => resolve(webSocketTransport(socket)));
|
|
55
|
+
socket.addEventListener("error", () => reject(new HarnessClosedError("websocket failed to open")));
|
|
56
|
+
socket.addEventListener("close", () => reject(new HarnessClosedError("websocket closed before it opened")));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Connect with the platform `WebSocket` (browser, React Native, Node 22+).
|
|
61
|
+
* `url` is the full upgrade URL, see `harnessUpgradeUrl`.
|
|
62
|
+
*/
|
|
63
|
+
export function connectWebSocket(url) {
|
|
64
|
+
return whenOpen(new WebSocket(url));
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=websocket.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"websocket.js","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,72 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inhuman.tools/harness-client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Transport-agnostic typed client for the harness daemon, built on the harness-contract.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./node": {
|
|
15
|
+
"types": "./dist/node.d.ts",
|
|
16
|
+
"import": "./dist/node.js",
|
|
17
|
+
"default": "./dist/node.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src",
|
|
23
|
+
"!**/__tests__"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"browserslist": [
|
|
27
|
+
"last 2 chrome versions",
|
|
28
|
+
"last 2 firefox versions",
|
|
29
|
+
"last 2 safari versions",
|
|
30
|
+
"ios_saf >= 16.4",
|
|
31
|
+
"node >= 22"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@orpc/client": "1.15.7",
|
|
35
|
+
"@orpc/contract": "1.15.7",
|
|
36
|
+
"@inhuman.tools/harness-contract": "^0.2.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"ws": "^8.18.3"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"ws": {
|
|
43
|
+
"optional": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^24.0.0",
|
|
48
|
+
"@types/ws": "8.18.1",
|
|
49
|
+
"eslint": "9.39.2",
|
|
50
|
+
"typescript": "6.0.2",
|
|
51
|
+
"vitest": "4.1.0",
|
|
52
|
+
"ws": "8.18.3",
|
|
53
|
+
"@-internal-/eslint-configs": "0.0.0"
|
|
54
|
+
},
|
|
55
|
+
"license": "MIT",
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/here-build/inhuman-harness.git",
|
|
59
|
+
"directory": "packages/harness-client"
|
|
60
|
+
},
|
|
61
|
+
"author": "Merkle Bonsai",
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "tsc -p tsconfig.build.json",
|
|
67
|
+
"lint": "eslint .",
|
|
68
|
+
"lint:fix": "eslint . --fix",
|
|
69
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
70
|
+
"test": "tsc -p tsconfig.json --noEmit && vitest run"
|
|
71
|
+
}
|
|
6
72
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HarnessContract, UiForm } from "@inhuman.tools/harness-contract";
|
|
2
|
+
import { createORPCClient, type Client } from "@orpc/client";
|
|
3
|
+
import type { ContractRouterClient } from "@orpc/contract";
|
|
4
|
+
|
|
5
|
+
import { JsonRpcLink, type ParkHandler } from "./link.js";
|
|
6
|
+
import type { Transport } from "./transport.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The typed client. A parking method's declared output is `result | UiForm`;
|
|
10
|
+
* the client either resolves the park (`onPark`) or rejects with
|
|
11
|
+
* `HarnessParkError`, so a call never returns the form and the type drops it.
|
|
12
|
+
*/
|
|
13
|
+
type WithoutPark<R> =
|
|
14
|
+
R extends Client<infer C, infer I, infer O, infer E>
|
|
15
|
+
? Client<C, I, Exclude<O, UiForm>, E>
|
|
16
|
+
: { [K in keyof R]: WithoutPark<R[K]> };
|
|
17
|
+
|
|
18
|
+
export type HarnessClient = WithoutPark<ContractRouterClient<HarnessContract>>;
|
|
19
|
+
|
|
20
|
+
export interface HarnessClientOptions {
|
|
21
|
+
readonly transport: Transport;
|
|
22
|
+
/**
|
|
23
|
+
* Called when a method parks for UI. Return the answer to resume the call
|
|
24
|
+
* (its result is what the original call resolves with). Without it a park
|
|
25
|
+
* rejects the call with `HarnessParkError`.
|
|
26
|
+
*/
|
|
27
|
+
readonly onPark?: ParkHandler;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Typed client over one connection. The connection decides the host: a session
|
|
32
|
+
* socket also reaches kernel methods (the daemon falls through), a kernel socket
|
|
33
|
+
* does not reach session methods. Close the transport to dispose the client.
|
|
34
|
+
*
|
|
35
|
+
* Inputs and outputs are not re-validated here; the daemon validates both.
|
|
36
|
+
*/
|
|
37
|
+
export function createHarnessClient(options: HarnessClientOptions): HarnessClient {
|
|
38
|
+
const link = new JsonRpcLink(options.transport, options.onPark);
|
|
39
|
+
return createORPCClient(link) as HarnessClient;
|
|
40
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { UiForm } from "@inhuman.tools/harness-contract";
|
|
2
|
+
|
|
3
|
+
/** A JSON-RPC error reply. `code` is the JSON-RPC code (-32602 invalid params, -32603 internal, ...). */
|
|
4
|
+
export class HarnessRpcError extends Error {
|
|
5
|
+
readonly code: number;
|
|
6
|
+
readonly data: unknown;
|
|
7
|
+
|
|
8
|
+
constructor(code: number, message: string, data?: unknown) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "HarnessRpcError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.data = data;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The method parked for UI and the client has no `onPark` handler. The park stays open on the daemon until the socket closes. */
|
|
17
|
+
export class HarnessParkError extends Error {
|
|
18
|
+
readonly form: UiForm;
|
|
19
|
+
|
|
20
|
+
constructor(form: UiForm) {
|
|
21
|
+
super("the call parked for UI and no onPark handler is set");
|
|
22
|
+
this.name = "HarnessParkError";
|
|
23
|
+
this.form = form;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The connection ended (or was closed) before the call was answered. */
|
|
28
|
+
export class HarnessClosedError extends Error {
|
|
29
|
+
constructor(message = "connection closed", options?: { cause?: unknown }) {
|
|
30
|
+
super(message, options);
|
|
31
|
+
this.name = "HarnessClosedError";
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/index.ts
ADDED
package/src/link.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
encodeJsonRpcRequest,
|
|
3
|
+
isUiForm,
|
|
4
|
+
jsonRpcResponse,
|
|
5
|
+
methodForPath,
|
|
6
|
+
type UiAnswer,
|
|
7
|
+
type UiForm,
|
|
8
|
+
} from "@inhuman.tools/harness-contract";
|
|
9
|
+
import type { ClientLink, ClientOptions } from "@orpc/client";
|
|
10
|
+
|
|
11
|
+
import { HarnessClosedError, HarnessParkError, HarnessRpcError } from "./errors.js";
|
|
12
|
+
import type { Transport } from "./transport.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Answers a parked UI form. Returning resumes the parked call with the answer
|
|
16
|
+
* (`ui/resolve`); throwing cancels the park and rejects the original call.
|
|
17
|
+
*/
|
|
18
|
+
export type ParkHandler = (form: UiForm) => UiAnswer | Promise<UiAnswer>;
|
|
19
|
+
|
|
20
|
+
interface Waiter {
|
|
21
|
+
readonly resolve: (value: unknown) => void;
|
|
22
|
+
readonly reject: (error: unknown) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* oRPC `ClientLink` that speaks the daemon's JSON-RPC wire. A procedure's tree
|
|
27
|
+
* path is mapped to its wire name through the contract (`meta.method`).
|
|
28
|
+
*/
|
|
29
|
+
export class JsonRpcLink implements ClientLink<Record<never, never>> {
|
|
30
|
+
readonly #transport: Transport;
|
|
31
|
+
readonly #onPark: ParkHandler | undefined;
|
|
32
|
+
readonly #waiters = new Map<number, Waiter>();
|
|
33
|
+
#nextId = 1;
|
|
34
|
+
#closed: HarnessClosedError | undefined;
|
|
35
|
+
|
|
36
|
+
constructor(transport: Transport, onPark?: ParkHandler) {
|
|
37
|
+
this.#transport = transport;
|
|
38
|
+
this.#onPark = onPark;
|
|
39
|
+
transport.onMessage((text) => this.#deliver(text));
|
|
40
|
+
transport.onClose((cause) => this.#fail(new HarnessClosedError("connection closed", { cause })));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async call(path: readonly string[], input: unknown, options: ClientOptions<Record<never, never>>): Promise<unknown> {
|
|
44
|
+
const method = methodForPath(path);
|
|
45
|
+
if (method === undefined) throw new Error(`not a procedure of the harness contract: ${path.join(".")}`);
|
|
46
|
+
let result = await this.#request(method, input ?? {}, options.signal);
|
|
47
|
+
while (isUiForm(result)) {
|
|
48
|
+
const form = result;
|
|
49
|
+
if (this.#onPark === undefined) throw new HarnessParkError(form);
|
|
50
|
+
let answer: UiAnswer;
|
|
51
|
+
try {
|
|
52
|
+
answer = await this.#onPark(form);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Free the parked call on the daemon rather than leave it until the socket closes.
|
|
55
|
+
await this.#request("ui/resolve", { pending: form.pending, kind: "cancel" }, options.signal).catch(
|
|
56
|
+
() => undefined,
|
|
57
|
+
);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
result = await this.#request("ui/resolve", { pending: form.pending, ...answer }, options.signal);
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#request(method: string, params: unknown, signal: AbortSignal | undefined): Promise<unknown> {
|
|
66
|
+
if (this.#closed !== undefined) return Promise.reject(this.#closed);
|
|
67
|
+
if (signal?.aborted === true) return Promise.reject(abortReason(signal));
|
|
68
|
+
const id = this.#nextId++;
|
|
69
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
70
|
+
const onAbort = (): void => {
|
|
71
|
+
this.#waiters.delete(id);
|
|
72
|
+
reject(abortReason(signal));
|
|
73
|
+
};
|
|
74
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
75
|
+
this.#waiters.set(id, {
|
|
76
|
+
resolve: (value) => {
|
|
77
|
+
signal?.removeEventListener("abort", onAbort);
|
|
78
|
+
resolve(value);
|
|
79
|
+
},
|
|
80
|
+
reject: (error) => {
|
|
81
|
+
signal?.removeEventListener("abort", onAbort);
|
|
82
|
+
reject(error);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
try {
|
|
86
|
+
this.#transport.send(encodeJsonRpcRequest(id, method, params));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.#waiters.delete(id);
|
|
89
|
+
signal?.removeEventListener("abort", onAbort);
|
|
90
|
+
reject(error);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
#deliver(text: string): void {
|
|
96
|
+
let raw: unknown;
|
|
97
|
+
try {
|
|
98
|
+
raw = JSON.parse(text);
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const parsed = jsonRpcResponse.safeParse(raw);
|
|
103
|
+
if (!parsed.success || typeof parsed.data.id !== "number") return;
|
|
104
|
+
const waiter = this.#waiters.get(parsed.data.id);
|
|
105
|
+
if (waiter === undefined) return;
|
|
106
|
+
this.#waiters.delete(parsed.data.id);
|
|
107
|
+
if ("error" in parsed.data) {
|
|
108
|
+
const { code, message, data } = parsed.data.error;
|
|
109
|
+
waiter.reject(new HarnessRpcError(code, message, data));
|
|
110
|
+
} else {
|
|
111
|
+
waiter.resolve(parsed.data.result);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#fail(error: HarnessClosedError): void {
|
|
116
|
+
this.#closed ??= error;
|
|
117
|
+
const waiters = [...this.#waiters.values()];
|
|
118
|
+
this.#waiters.clear();
|
|
119
|
+
for (const waiter of waiters) waiter.reject(error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function abortReason(signal: AbortSignal | undefined): unknown {
|
|
124
|
+
return signal?.reason ?? new DOMException("This operation was aborted", "AbortError");
|
|
125
|
+
}
|
package/src/node.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
|
|
3
|
+
import { harnessUpgradePath, type HarnessTarget, type Transport } from "./transport.js";
|
|
4
|
+
import { whenOpen, type WebSocketLike } from "./websocket.js";
|
|
5
|
+
|
|
6
|
+
export interface UnixSocketOptions {
|
|
7
|
+
/** Absolute path of the daemon socket, `<root>/daemon.sock`. */
|
|
8
|
+
readonly socketPath: string;
|
|
9
|
+
readonly target: HarnessTarget;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Connect to the daemon's unix socket from Node, using the `ws` package
|
|
14
|
+
* (an optional peer dependency, loaded only from this entry point).
|
|
15
|
+
*/
|
|
16
|
+
export function connectUnixSocket(options: UnixSocketOptions): Promise<Transport> {
|
|
17
|
+
const socket = new WebSocket(`ws+unix://${options.socketPath}:${harnessUpgradePath(options.target)}`);
|
|
18
|
+
return whenOpen(socket as unknown as WebSocketLike);
|
|
19
|
+
}
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the client needs from a connection: JSON-RPC text frames in and out.
|
|
3
|
+
* Binary frames (Yjs sync on the same socket) are not the client's business;
|
|
4
|
+
* a transport must not deliver them to `onMessage`.
|
|
5
|
+
*/
|
|
6
|
+
export interface Transport {
|
|
7
|
+
/** Send one JSON-RPC text frame. Throws if the connection is not open. */
|
|
8
|
+
send(text: string): void;
|
|
9
|
+
/** Subscribe to text frames. Returns the unsubscribe function. */
|
|
10
|
+
onMessage(listener: (text: string) => void): () => void;
|
|
11
|
+
/** Subscribe to the connection ending, with the cause when known. Returns the unsubscribe function. */
|
|
12
|
+
onClose(listener: (cause?: Error) => void): () => void;
|
|
13
|
+
/** Close the connection. Calls in flight reject with {@link HarnessClosedError}. */
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Which daemon an upgrade reaches. See `packages/harness-contract/spec/README.md` for routing and fall-through. */
|
|
18
|
+
export type HarnessTarget =
|
|
19
|
+
| { readonly host: "kernel" }
|
|
20
|
+
| { readonly host: "project"; readonly path: string }
|
|
21
|
+
| { readonly host: "session"; readonly sessionId: string; readonly path?: string }
|
|
22
|
+
| { readonly host: "session"; readonly path: string; readonly sessionId?: string };
|
|
23
|
+
|
|
24
|
+
/** The WebSocket upgrade path (with query) for a target, e.g. `/session?sessionId=s1`. */
|
|
25
|
+
export function harnessUpgradePath(target: HarnessTarget): string {
|
|
26
|
+
if (target.host === "kernel") return "/kernel";
|
|
27
|
+
const query = new URLSearchParams();
|
|
28
|
+
if (target.path !== undefined) query.set("path", target.path);
|
|
29
|
+
if (target.host === "session" && target.sessionId !== undefined) query.set("sessionId", target.sessionId);
|
|
30
|
+
return `/${target.host}?${query.toString()}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Full upgrade URL on a base such as `ws://localhost:8080`, for bridges that expose the daemon over TCP. */
|
|
34
|
+
export function harnessUpgradeUrl(base: string, target: HarnessTarget): string {
|
|
35
|
+
return `${base.replace(/\/+$/, "")}${harnessUpgradePath(target)}`;
|
|
36
|
+
}
|
package/src/websocket.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { HarnessClosedError } from "./errors.js";
|
|
2
|
+
import type { Transport } from "./transport.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The slice of the WHATWG `WebSocket` the transport uses. Browsers, React
|
|
6
|
+
* Native, Node's global `WebSocket` and the `ws` package all satisfy it.
|
|
7
|
+
*/
|
|
8
|
+
export interface WebSocketLike {
|
|
9
|
+
readonly readyState: number;
|
|
10
|
+
send(data: string): void;
|
|
11
|
+
close(): void;
|
|
12
|
+
addEventListener(type: "message", listener: (event: { readonly data: unknown }) => void): void;
|
|
13
|
+
addEventListener(type: "close", listener: (event: { readonly reason?: string }) => void): void;
|
|
14
|
+
addEventListener(type: "error", listener: (event: unknown) => void): void;
|
|
15
|
+
addEventListener(type: "open", listener: () => void): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const OPEN = 1;
|
|
19
|
+
|
|
20
|
+
/** Wrap an already open socket. Only string (text) frames are delivered; binary frames are ignored. */
|
|
21
|
+
export function webSocketTransport(socket: WebSocketLike): Transport {
|
|
22
|
+
const messages = new Set<(text: string) => void>();
|
|
23
|
+
const closes = new Set<(cause?: Error) => void>();
|
|
24
|
+
let ended = false;
|
|
25
|
+
const end = (cause?: Error): void => {
|
|
26
|
+
if (ended) return;
|
|
27
|
+
ended = true;
|
|
28
|
+
for (const listener of closes) listener(cause);
|
|
29
|
+
};
|
|
30
|
+
socket.addEventListener("message", (event) => {
|
|
31
|
+
if (typeof event.data !== "string") return;
|
|
32
|
+
for (const listener of messages) listener(event.data);
|
|
33
|
+
});
|
|
34
|
+
socket.addEventListener("close", (event) => {
|
|
35
|
+
end(event.reason === undefined || event.reason === "" ? undefined : new Error(event.reason));
|
|
36
|
+
});
|
|
37
|
+
socket.addEventListener("error", () => {
|
|
38
|
+
end(new Error("websocket error"));
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
send(text) {
|
|
42
|
+
if (ended || socket.readyState !== OPEN) throw new HarnessClosedError("websocket is not open");
|
|
43
|
+
socket.send(text);
|
|
44
|
+
},
|
|
45
|
+
onMessage(listener) {
|
|
46
|
+
messages.add(listener);
|
|
47
|
+
return () => messages.delete(listener);
|
|
48
|
+
},
|
|
49
|
+
onClose(listener) {
|
|
50
|
+
closes.add(listener);
|
|
51
|
+
return () => closes.delete(listener);
|
|
52
|
+
},
|
|
53
|
+
close() {
|
|
54
|
+
socket.close();
|
|
55
|
+
end();
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Resolve once an open socket is ready, reject if it fails to open. */
|
|
61
|
+
export function whenOpen(socket: WebSocketLike): Promise<Transport> {
|
|
62
|
+
return new Promise<Transport>((resolve, reject) => {
|
|
63
|
+
if (socket.readyState === OPEN) {
|
|
64
|
+
resolve(webSocketTransport(socket));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
socket.addEventListener("open", () => resolve(webSocketTransport(socket)));
|
|
68
|
+
socket.addEventListener("error", () => reject(new HarnessClosedError("websocket failed to open")));
|
|
69
|
+
socket.addEventListener("close", () => reject(new HarnessClosedError("websocket closed before it opened")));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Connect with the platform `WebSocket` (browser, React Native, Node 22+).
|
|
75
|
+
* `url` is the full upgrade URL, see `harnessUpgradeUrl`.
|
|
76
|
+
*/
|
|
77
|
+
export function connectWebSocket(url: string): Promise<Transport> {
|
|
78
|
+
return whenOpen(new WebSocket(url) as unknown as WebSocketLike);
|
|
79
|
+
}
|