@hotrepl/sdk 2.0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Johann Glock
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # @hotrepl/sdk
2
+
3
+ [![npm](https://img.shields.io/npm/v/@hotrepl/sdk.svg)](https://www.npmjs.com/package/@hotrepl/sdk)
4
+ [![license](https://img.shields.io/npm/l/@hotrepl/sdk.svg)](https://github.com/glockyco/HotRepl/blob/main/LICENSE)
5
+
6
+ The official TypeScript SDK for [HotRepl](https://github.com/glockyco/HotRepl) — a runtime C# REPL
7
+ and typed command bridge for Unity games. Use it to inspect, automate, or export data from any Unity
8
+ game running the HotRepl plugin (BepInEx/Mono) or mod (MelonLoader/IL2CPP).
9
+
10
+ ## Requirements
11
+
12
+ - A Unity game running the HotRepl plugin (`HotRepl.BepInEx.dll`) or mod
13
+ (`HotRepl.Host.MelonLoader.dll`). The plugin opens `ws://127.0.0.1:18590` by default.
14
+ - A modern JavaScript runtime (Bun or Node) with WebSocket support.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ bun add @hotrepl/sdk # or: npm install @hotrepl/sdk
20
+ ```
21
+
22
+ ## Quickstart
23
+
24
+ ```ts
25
+ import { connect } from "@hotrepl/sdk";
26
+
27
+ const session = await connect(); // ws://127.0.0.1:18590 by default
28
+
29
+ // Raw eval — any C# expression, on the game's main thread:
30
+ const product = await session.eval<string>("UnityEngine.Application.productName");
31
+ // → { hasValue: true, value: "Ardenfall", valueType: "System.String", durationMs: 7 }
32
+
33
+ // Typed, schema-validated game command:
34
+ const preflight = await session.run<{ writable: boolean; freeMb: number }>(
35
+ "archive.preflight",
36
+ {},
37
+ );
38
+ // → { output: { writable: true, freeMb: 41213 }, artifacts: {} }
39
+
40
+ session.close();
41
+ ```
42
+
43
+ Point at a non-default backend with `connect({ url: "ws://host:port" })` or by setting `HOTREPL_URL`
44
+ in the environment.
45
+
46
+ ## What you get
47
+
48
+ - `connect(options?)` — open a WebSocket session against the game.
49
+ - `Session.eval(code, timeoutMs?)` — evaluate C# on the main thread.
50
+ - `Session.run(name, args, options?)` — invoke a typed, schema-validated command registered by the
51
+ host or a game mod.
52
+ - `Session.complete`, `Session.reset`, `Session.journal`, `Session.watch` — completion, evaluator
53
+ reset, evaluation/command history, and frame-by-frame value streaming.
54
+ - `Session.close()` — release the underlying WebSocket. Long-running consumers (servers, daemons)
55
+ should call this on shutdown.
56
+ - Typed errors: `HotReplError`, `HotReplSessionEvicted`, `HotReplArtifactCorrupted`.
57
+
58
+ ## Reference
59
+
60
+ - Repository, full docs, and examples:
61
+ [github.com/glockyco/HotRepl](https://github.com/glockyco/HotRepl)
62
+ - Protocol reference:
63
+ [`docs/control-plane-protocol.md`](https://github.com/glockyco/HotRepl/blob/main/docs/control-plane-protocol.md)
64
+ - Sibling packages: [`@hotrepl/cli`](https://www.npmjs.com/package/@hotrepl/cli) for shell usage,
65
+ [`@hotrepl/mcp`](https://www.npmjs.com/package/@hotrepl/mcp) for agent tooling.
66
+ - Issues: [github.com/glockyco/HotRepl/issues](https://github.com/glockyco/HotRepl/issues)
67
+
68
+ ## License
69
+
70
+ MIT
@@ -0,0 +1,211 @@
1
+ import { ArtifactRef, CommandDescriptor, HandshakeMessage, ServerMessage, SubscribeResultMessage, SubscribeErrorMessage, SessionEvictedMessage, JournalEntry, JobCancelResultMessage, ErrorKind, HotReplErrorEnvelope } from '@hotrepl/protocol';
2
+
3
+ interface ArtifactReader {
4
+ readArtifact(ref: ArtifactRef): Promise<Uint8Array>;
5
+ }
6
+ declare class Artifact {
7
+ readonly ref: ArtifactRef;
8
+ private readonly reader;
9
+ constructor(ref: ArtifactRef, reader: ArtifactReader);
10
+ bytes(): Promise<Uint8Array>;
11
+ text(): Promise<string>;
12
+ json<T = unknown>(): Promise<T>;
13
+ open(): Promise<{
14
+ path?: string;
15
+ uri: string;
16
+ }>;
17
+ }
18
+ declare function sha256Hex(bytes: Uint8Array): Promise<string>;
19
+
20
+ interface Result<T = unknown> {
21
+ output: T;
22
+ artifacts: Record<string, Artifact>;
23
+ }
24
+ declare function toResult<T>(output: unknown, artifacts: Record<string, ArtifactRef> | undefined, reader: ArtifactReader): Result<T>;
25
+ type DescriptorCache = Map<string, CommandDescriptor>;
26
+
27
+ type RuntimeRequest = {
28
+ type: "eval";
29
+ id: string;
30
+ code: string;
31
+ timeoutMs?: number;
32
+ } | {
33
+ type: "complete";
34
+ id: string;
35
+ code: string;
36
+ cursor?: number;
37
+ } | {
38
+ type: "reset";
39
+ id: string;
40
+ } | {
41
+ type: "subscribe";
42
+ id: string;
43
+ code: string;
44
+ intervalFrames?: number;
45
+ limit?: number;
46
+ } | {
47
+ type: "commands_list";
48
+ id: string;
49
+ since?: string;
50
+ } | {
51
+ type: "command_describe";
52
+ id: string;
53
+ name: string;
54
+ } | {
55
+ type: "command_call";
56
+ id: string;
57
+ name: string;
58
+ args: unknown;
59
+ timeoutMs?: number;
60
+ } | {
61
+ type: "job_status";
62
+ id: string;
63
+ jobId: string;
64
+ } | {
65
+ type: "job_cancel";
66
+ id: string;
67
+ jobId: string;
68
+ } | {
69
+ type: "journal_query";
70
+ id: string;
71
+ kind?: "eval" | "command";
72
+ limit?: number;
73
+ };
74
+ interface RuntimeTransport {
75
+ handshake(): Promise<HandshakeMessage>;
76
+ request(request: RuntimeRequest): Promise<ServerMessage>;
77
+ watch(request: Extract<RuntimeRequest, {
78
+ type: "subscribe";
79
+ }>): AsyncIterable<WatchWireMessage>;
80
+ readArtifact(ref: ArtifactRef): Promise<Uint8Array>;
81
+ onSessionEvicted(listener: (event: SessionEvictedMessage) => void): () => void;
82
+ close(): void;
83
+ }
84
+ type WatchWireMessage = SubscribeResultMessage | SubscribeErrorMessage;
85
+ interface RunOptions {
86
+ timeoutMs?: number;
87
+ pollIntervalMs?: number;
88
+ wait?: boolean;
89
+ }
90
+ interface EvalResponse<T = unknown> {
91
+ hasValue: boolean;
92
+ value?: T;
93
+ valueType?: string;
94
+ stdout?: string;
95
+ durationMs: number;
96
+ }
97
+ interface JobStatus {
98
+ jobId: string;
99
+ state: "running" | "done" | "failed" | "cancelled";
100
+ progress?: unknown;
101
+ }
102
+ interface WatchTick<T = unknown> {
103
+ seq: number;
104
+ hasValue: boolean;
105
+ value?: T;
106
+ valueType?: string;
107
+ final: boolean;
108
+ durationMs: number;
109
+ }
110
+ declare class JobHandle<T = unknown> {
111
+ readonly jobId: string;
112
+ private readonly session;
113
+ private readonly pollIntervalMs;
114
+ constructor(session: Session, jobId: string, pollIntervalMs: number);
115
+ status(): Promise<JobStatus>;
116
+ result<TOutput = T>(): Promise<Result<TOutput>>;
117
+ cancel(): Promise<JobCancelResultMessage>;
118
+ }
119
+ declare class Session {
120
+ readonly handshake: HandshakeMessage;
121
+ private readonly transport;
122
+ private readonly descriptors;
123
+ private readonly evictionListeners;
124
+ private sequence;
125
+ private evicted;
126
+ private closed;
127
+ constructor(transport: RuntimeTransport, handshake: HandshakeMessage);
128
+ onSessionEvicted(listener: (event: SessionEvictedMessage) => void): () => void;
129
+ run<T = unknown>(name: string, args: unknown, options: RunOptions & {
130
+ wait: false;
131
+ }): Promise<JobHandle<T>>;
132
+ run<T = unknown>(name: string, args: unknown, options?: RunOptions): Promise<Result<T>>;
133
+ eval<T = unknown>(code: string, timeoutMs?: number): Promise<EvalResponse<T>>;
134
+ reset(): Promise<void>;
135
+ complete(code: string, cursor?: number): Promise<string[]>;
136
+ watch<T = unknown>(code: string): AsyncIterable<WatchTick<T>>;
137
+ journal(query?: {
138
+ kind?: "eval" | "command";
139
+ limit?: number;
140
+ }): Promise<JournalEntry[]>;
141
+ artifact(ref: ArtifactRef): Artifact;
142
+ describeCommand(name: string): Promise<CommandDescriptor>;
143
+ pollJob<T>(jobId: string, pollIntervalMs: number): Promise<Result<T>>;
144
+ nextId(prefix: string): string;
145
+ request<T extends ServerMessage>(request: RuntimeRequest): Promise<T>;
146
+ private commandResult;
147
+ private jobResult;
148
+ /**
149
+ * Close the underlying transport. Safe to call multiple times;
150
+ * subsequent calls after the first are no-ops.
151
+ */
152
+ close(): void;
153
+ private ensureActive;
154
+ }
155
+
156
+ interface ConnectOptions {
157
+ runtime?: RuntimeTransport;
158
+ url?: string;
159
+ env?: Record<string, string | undefined>;
160
+ }
161
+ declare function resolveHotReplUrl(options?: ConnectOptions): string;
162
+ declare function connect(options?: ConnectOptions): Promise<Session>;
163
+
164
+ interface HotReplErrorInput {
165
+ kind: ErrorKind;
166
+ code: string;
167
+ message: string;
168
+ retryable: boolean;
169
+ details?: unknown;
170
+ }
171
+ declare class HotReplError extends Error {
172
+ readonly kind: ErrorKind;
173
+ readonly code: string;
174
+ readonly retryable: boolean;
175
+ readonly details: unknown;
176
+ constructor(input: HotReplErrorInput);
177
+ static fromEnvelope(error: HotReplErrorEnvelope): HotReplError;
178
+ }
179
+ declare class HotReplArtifactCorrupted extends HotReplError {
180
+ constructor(message: string);
181
+ }
182
+ declare class HotReplSessionEvicted extends HotReplError {
183
+ readonly event: SessionEvictedMessage;
184
+ constructor(event: SessionEvictedMessage);
185
+ }
186
+
187
+ declare class WebSocketTransport implements RuntimeTransport {
188
+ private readonly socket;
189
+ private readonly pending;
190
+ private readonly subscriptions;
191
+ private readonly evictionListeners;
192
+ private handshakeMessage;
193
+ private evicted;
194
+ private constructor();
195
+ static connect(url: string): Promise<WebSocketTransport>;
196
+ handshake(): Promise<HandshakeMessage>;
197
+ request(request: RuntimeRequest): Promise<ServerMessage>;
198
+ watch(request: Extract<RuntimeRequest, {
199
+ type: "subscribe";
200
+ }>): AsyncIterable<WatchWireMessage>;
201
+ readArtifact(ref: ArtifactRef): Promise<Uint8Array>;
202
+ onSessionEvicted(listener: (event: SessionEvictedMessage) => void): () => void;
203
+ close(): void;
204
+ private open;
205
+ private handleSocketMessage;
206
+ private dispatch;
207
+ private ensureAvailable;
208
+ private failAll;
209
+ }
210
+
211
+ export { Artifact, type ArtifactReader, type ConnectOptions, type DescriptorCache, type EvalResponse, HotReplArtifactCorrupted, HotReplError, type HotReplErrorInput, HotReplSessionEvicted, JobHandle, type JobStatus, type Result, type RunOptions, type RuntimeRequest, type RuntimeTransport, Session, type WatchTick, type WatchWireMessage, WebSocketTransport, connect, resolveHotReplUrl, sha256Hex, toResult };
package/dist/index.js ADDED
@@ -0,0 +1,563 @@
1
+ // src/errors.ts
2
+ var HotReplError = class _HotReplError extends Error {
3
+ kind;
4
+ code;
5
+ retryable;
6
+ details;
7
+ constructor(input) {
8
+ super(input.message);
9
+ this.name = "HotReplError";
10
+ this.kind = input.kind;
11
+ this.code = input.code;
12
+ this.retryable = input.retryable;
13
+ this.details = input.details;
14
+ }
15
+ static fromEnvelope(error) {
16
+ return new _HotReplError(error);
17
+ }
18
+ };
19
+ var HotReplArtifactCorrupted = class extends HotReplError {
20
+ constructor(message) {
21
+ super({
22
+ kind: "artifact_missing",
23
+ code: "artifactHashMismatch",
24
+ message,
25
+ retryable: false
26
+ });
27
+ this.name = "HotReplArtifactCorrupted";
28
+ }
29
+ };
30
+ var HotReplSessionEvicted = class extends HotReplError {
31
+ event;
32
+ constructor(event) {
33
+ super({
34
+ kind: "conflict",
35
+ code: "sessionEvicted",
36
+ message: `Session was evicted: ${event.reason}.`,
37
+ retryable: false
38
+ });
39
+ this.name = "HotReplSessionEvicted";
40
+ this.event = event;
41
+ }
42
+ };
43
+
44
+ // src/artifact.ts
45
+ var Artifact = class {
46
+ ref;
47
+ reader;
48
+ constructor(ref, reader) {
49
+ this.ref = ref;
50
+ this.reader = reader;
51
+ }
52
+ async bytes() {
53
+ const bytes = await this.reader.readArtifact(this.ref);
54
+ const actual = await sha256Hex(bytes);
55
+ if (actual !== this.ref.sha256) {
56
+ throw new HotReplArtifactCorrupted(
57
+ `Artifact ${this.ref.uri} hash mismatch: expected ${this.ref.sha256}, got ${actual}.`
58
+ );
59
+ }
60
+ return bytes;
61
+ }
62
+ async text() {
63
+ return new TextDecoder().decode(await this.bytes());
64
+ }
65
+ async json() {
66
+ return JSON.parse(await this.text());
67
+ }
68
+ async open() {
69
+ await this.bytes();
70
+ return this.ref.path === void 0 ? { uri: this.ref.uri } : { path: this.ref.path, uri: this.ref.uri };
71
+ }
72
+ };
73
+ async function sha256Hex(bytes) {
74
+ const digest = await crypto.subtle.digest("SHA-256", webCryptoSource(bytes));
75
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
76
+ }
77
+ function webCryptoSource(bytes) {
78
+ if (bytes.buffer instanceof ArrayBuffer) {
79
+ return bytes;
80
+ }
81
+ return new Uint8Array(bytes);
82
+ }
83
+
84
+ // src/commands.ts
85
+ function toResult(output, artifacts, reader) {
86
+ const wrapped = {};
87
+ for (const [name, ref] of Object.entries(artifacts ?? {})) {
88
+ wrapped[name] = new Artifact(ref, reader);
89
+ }
90
+ return { output, artifacts: wrapped };
91
+ }
92
+
93
+ // src/connect.ts
94
+ import { PROTOCOL_VERSION as PROTOCOL_VERSION2 } from "@hotrepl/protocol";
95
+
96
+ // src/session.ts
97
+ import { MESSAGE_TYPES } from "@hotrepl/protocol";
98
+ var JobHandle = class {
99
+ jobId;
100
+ session;
101
+ pollIntervalMs;
102
+ constructor(session, jobId, pollIntervalMs) {
103
+ this.session = session;
104
+ this.jobId = jobId;
105
+ this.pollIntervalMs = pollIntervalMs;
106
+ }
107
+ async status() {
108
+ const response = await this.session.request({
109
+ type: "job_status",
110
+ id: this.session.nextId("status"),
111
+ jobId: this.jobId
112
+ });
113
+ if (response.type === MESSAGE_TYPES.jobResult) {
114
+ return { jobId: response.jobId, state: response.state };
115
+ }
116
+ return { jobId: response.jobId, state: response.state, progress: response.progress };
117
+ }
118
+ async result() {
119
+ return this.session.pollJob(this.jobId, this.pollIntervalMs);
120
+ }
121
+ async cancel() {
122
+ return this.session.request({
123
+ type: "job_cancel",
124
+ id: this.session.nextId("cancel"),
125
+ jobId: this.jobId
126
+ });
127
+ }
128
+ };
129
+ var Session = class {
130
+ handshake;
131
+ transport;
132
+ descriptors = /* @__PURE__ */ new Map();
133
+ evictionListeners = /* @__PURE__ */ new Set();
134
+ sequence = 0;
135
+ evicted;
136
+ closed = false;
137
+ constructor(transport, handshake) {
138
+ this.transport = transport;
139
+ this.handshake = handshake;
140
+ this.transport.onSessionEvicted((event) => {
141
+ this.evicted = event;
142
+ for (const listener of this.evictionListeners) listener(event);
143
+ });
144
+ }
145
+ onSessionEvicted(listener) {
146
+ this.evictionListeners.add(listener);
147
+ return () => this.evictionListeners.delete(listener);
148
+ }
149
+ async run(name, args, options = {}) {
150
+ this.ensureActive();
151
+ const descriptor = await this.describeCommand(name);
152
+ const request = {
153
+ type: "command_call",
154
+ id: this.nextId("cmd"),
155
+ name,
156
+ args
157
+ };
158
+ if (options.timeoutMs !== void 0) request.timeoutMs = options.timeoutMs;
159
+ const response = await this.request(request);
160
+ if (descriptor.kind === "sync") {
161
+ if (response.type !== MESSAGE_TYPES.commandResult) {
162
+ throw protocolError("unexpectedResponse", "Expected command_result.");
163
+ }
164
+ return this.commandResult(response);
165
+ }
166
+ if (response.type === MESSAGE_TYPES.commandResult) {
167
+ if (response.status === "failed") this.commandResult(response);
168
+ throw protocolError("unexpectedResponse", "Expected job_accepted.");
169
+ }
170
+ if (response.type !== MESSAGE_TYPES.jobAccepted) {
171
+ throw protocolError("unexpectedResponse", "Expected job_accepted.");
172
+ }
173
+ const handle = new JobHandle(this, response.jobId, options.pollIntervalMs ?? 250);
174
+ return options.wait === false ? handle : handle.result();
175
+ }
176
+ async eval(code, timeoutMs) {
177
+ this.ensureActive();
178
+ const request = { type: "eval", id: this.nextId("eval"), code };
179
+ if (timeoutMs !== void 0) request.timeoutMs = timeoutMs;
180
+ const response = await this.request(request);
181
+ if (response.type === MESSAGE_TYPES.evalError) throw HotReplError.fromEnvelope(response.error);
182
+ const result = {
183
+ hasValue: response.hasValue,
184
+ value: response.value,
185
+ durationMs: response.durationMs
186
+ };
187
+ if (response.valueType !== void 0) result.valueType = response.valueType;
188
+ if (response.stdout !== void 0) result.stdout = response.stdout;
189
+ return result;
190
+ }
191
+ async reset() {
192
+ this.ensureActive();
193
+ const response = await this.request({
194
+ type: "reset",
195
+ id: this.nextId("reset")
196
+ });
197
+ if (!response.success) throw protocolError("resetFailed", "Runtime reset failed.");
198
+ }
199
+ async complete(code, cursor) {
200
+ this.ensureActive();
201
+ if (!this.handshake.evaluator.supportsCompletion) {
202
+ throw new HotReplError({
203
+ kind: "unsupported_operation",
204
+ code: "completionUnsupported",
205
+ message: "The active evaluator does not support completion.",
206
+ retryable: false
207
+ });
208
+ }
209
+ const request = { type: "complete", id: this.nextId("complete"), code };
210
+ if (cursor !== void 0) request.cursor = cursor;
211
+ const response = await this.request(request);
212
+ return response.completions;
213
+ }
214
+ async *watch(code) {
215
+ this.ensureActive();
216
+ const request = {
217
+ type: "subscribe",
218
+ id: this.nextId("watch"),
219
+ code
220
+ };
221
+ for await (const event of this.transport.watch(request)) {
222
+ if (event.type === MESSAGE_TYPES.subscribeError) {
223
+ throw HotReplError.fromEnvelope(event.error);
224
+ }
225
+ const tick = {
226
+ seq: event.seq,
227
+ hasValue: event.hasValue,
228
+ final: event.final,
229
+ durationMs: event.durationMs
230
+ };
231
+ if (event.hasValue && event.value !== void 0) tick.value = event.value;
232
+ if (event.valueType !== void 0) tick.valueType = event.valueType;
233
+ yield tick;
234
+ if (event.final) return;
235
+ }
236
+ }
237
+ async journal(query = {}) {
238
+ this.ensureActive();
239
+ const request = { type: "journal_query", id: this.nextId("journal") };
240
+ if (query.kind !== void 0) request.kind = query.kind;
241
+ if (query.limit !== void 0) request.limit = query.limit;
242
+ const response = await this.request(request);
243
+ return response.entries;
244
+ }
245
+ artifact(ref) {
246
+ return new Artifact(ref, this.transport);
247
+ }
248
+ async describeCommand(name) {
249
+ const cached = this.descriptors.get(name);
250
+ if (cached !== void 0) return cached;
251
+ const response = await this.request({
252
+ type: "command_describe",
253
+ id: this.nextId("describe"),
254
+ name
255
+ });
256
+ this.descriptors.set(name, response.descriptor);
257
+ return response.descriptor;
258
+ }
259
+ async pollJob(jobId, pollIntervalMs) {
260
+ while (true) {
261
+ const response = await this.request({
262
+ type: "job_status",
263
+ id: this.nextId("status"),
264
+ jobId
265
+ });
266
+ if (response.type === MESSAGE_TYPES.jobResult) return this.jobResult(response);
267
+ if (pollIntervalMs > 0) await sleep(pollIntervalMs);
268
+ }
269
+ }
270
+ nextId(prefix) {
271
+ this.sequence += 1;
272
+ return `${prefix}-${this.sequence}`;
273
+ }
274
+ async request(request) {
275
+ this.ensureActive();
276
+ try {
277
+ const response = await this.transport.request(request);
278
+ if (response.type === MESSAGE_TYPES.error) throw HotReplError.fromEnvelope(response.error);
279
+ return response;
280
+ } catch (error) {
281
+ if (error instanceof HotReplError) throw error;
282
+ throw error;
283
+ }
284
+ }
285
+ commandResult(response) {
286
+ if (response.status === "failed") {
287
+ throw HotReplError.fromEnvelope(response.error ?? internalError("commandFailed"));
288
+ }
289
+ return toResult(response.output, response.artifacts, this.transport);
290
+ }
291
+ jobResult(response) {
292
+ if (response.status === "failed") {
293
+ throw HotReplError.fromEnvelope(response.error ?? internalError("jobFailed"));
294
+ }
295
+ return toResult(response.output, response.artifacts, this.transport);
296
+ }
297
+ /**
298
+ * Close the underlying transport. Safe to call multiple times;
299
+ * subsequent calls after the first are no-ops.
300
+ */
301
+ close() {
302
+ if (this.closed) return;
303
+ this.closed = true;
304
+ this.transport.close();
305
+ }
306
+ ensureActive() {
307
+ if (this.evicted === void 0) return;
308
+ throw new HotReplSessionEvicted(this.evicted);
309
+ }
310
+ };
311
+ function protocolError(code, message) {
312
+ return new HotReplError({ kind: "invalid_request", code, message, retryable: false });
313
+ }
314
+ function internalError(code) {
315
+ return {
316
+ kind: "internal",
317
+ code,
318
+ message: "Runtime returned an error without details.",
319
+ retryable: false
320
+ };
321
+ }
322
+ async function sleep(ms) {
323
+ await new Promise((resolve) => setTimeout(resolve, ms));
324
+ }
325
+
326
+ // src/websocket-transport.ts
327
+ import { MESSAGE_TYPES as MESSAGE_TYPES2 } from "@hotrepl/protocol";
328
+ var AsyncMessageQueue = class {
329
+ values = [];
330
+ waiters = [];
331
+ failure;
332
+ isClosed = false;
333
+ push(value) {
334
+ const waiter = this.waiters.shift();
335
+ if (waiter !== void 0) {
336
+ waiter.resolve({ done: false, value });
337
+ return;
338
+ }
339
+ this.values.push(value);
340
+ }
341
+ close() {
342
+ this.isClosed = true;
343
+ while (this.waiters.length > 0) {
344
+ this.waiters.shift()?.resolve({ done: true, value: void 0 });
345
+ }
346
+ }
347
+ fail(error) {
348
+ this.failure = error;
349
+ this.isClosed = true;
350
+ while (this.waiters.length > 0) {
351
+ this.waiters.shift()?.reject(error);
352
+ }
353
+ }
354
+ async *[Symbol.asyncIterator]() {
355
+ while (true) {
356
+ if (this.values.length > 0) {
357
+ yield this.values.shift();
358
+ continue;
359
+ }
360
+ if (this.failure !== void 0) throw this.failure;
361
+ if (this.isClosed) return;
362
+ const result = await new Promise((resolve, reject) => {
363
+ this.waiters.push({ reject, resolve });
364
+ });
365
+ if (result.done === true) return;
366
+ yield result.value;
367
+ }
368
+ }
369
+ };
370
+ var WebSocketTransport = class _WebSocketTransport {
371
+ constructor(socket) {
372
+ this.socket = socket;
373
+ }
374
+ pending = /* @__PURE__ */ new Map();
375
+ subscriptions = /* @__PURE__ */ new Map();
376
+ evictionListeners = /* @__PURE__ */ new Set();
377
+ handshakeMessage;
378
+ evicted;
379
+ static connect(url) {
380
+ const transport = new _WebSocketTransport(new WebSocket(url));
381
+ return transport.open();
382
+ }
383
+ handshake() {
384
+ if (this.handshakeMessage === void 0) {
385
+ throw new HotReplError({
386
+ kind: "precondition_failed",
387
+ code: "handshakeMissing",
388
+ message: "Runtime handshake has not been received.",
389
+ retryable: false
390
+ });
391
+ }
392
+ return Promise.resolve(this.handshakeMessage);
393
+ }
394
+ request(request) {
395
+ this.ensureAvailable();
396
+ return new Promise((resolve, reject) => {
397
+ this.pending.set(request.id, { reject, resolve });
398
+ try {
399
+ this.socket.send(JSON.stringify(request));
400
+ } catch (error) {
401
+ this.pending.delete(request.id);
402
+ reject(error);
403
+ }
404
+ });
405
+ }
406
+ async *watch(request) {
407
+ this.ensureAvailable();
408
+ const queue = new AsyncMessageQueue();
409
+ this.subscriptions.set(request.id, queue);
410
+ try {
411
+ this.socket.send(JSON.stringify(request));
412
+ for await (const message of queue) {
413
+ yield message;
414
+ if (message.final) return;
415
+ }
416
+ } finally {
417
+ this.subscriptions.delete(request.id);
418
+ }
419
+ }
420
+ async readArtifact(ref) {
421
+ if (ref.path !== void 0) {
422
+ return new Uint8Array(await Bun.file(ref.path).arrayBuffer());
423
+ }
424
+ const response = await fetch(ref.uri);
425
+ if (!response.ok) {
426
+ throw new HotReplError({
427
+ kind: "artifact_missing",
428
+ code: "artifactReadFailed",
429
+ message: `Artifact ${ref.uri} could not be read.`,
430
+ retryable: false
431
+ });
432
+ }
433
+ return new Uint8Array(await response.arrayBuffer());
434
+ }
435
+ onSessionEvicted(listener) {
436
+ this.evictionListeners.add(listener);
437
+ return () => this.evictionListeners.delete(listener);
438
+ }
439
+ close() {
440
+ this.socket.close();
441
+ }
442
+ open() {
443
+ return new Promise((resolve, reject) => {
444
+ const failOpen = (error) => {
445
+ reject(error);
446
+ this.failAll(error);
447
+ };
448
+ this.socket.addEventListener("message", (event) => {
449
+ void this.handleSocketMessage(event.data).then(resolve, failOpen);
450
+ });
451
+ this.socket.addEventListener("error", () => {
452
+ failOpen(new Error("HotRepl WebSocket connection failed."));
453
+ });
454
+ this.socket.on?.("error", () => {
455
+ });
456
+ this.socket.addEventListener("close", () => {
457
+ this.failAll(new Error("HotRepl WebSocket connection closed."));
458
+ });
459
+ });
460
+ }
461
+ async handleSocketMessage(data) {
462
+ const message = JSON.parse(await messageText(data));
463
+ if (message.type === MESSAGE_TYPES2.handshake) {
464
+ this.handshakeMessage = message;
465
+ return this;
466
+ }
467
+ this.dispatch(message);
468
+ return this;
469
+ }
470
+ dispatch(message) {
471
+ if (message.type === MESSAGE_TYPES2.sessionEvicted) {
472
+ this.evicted = message;
473
+ const error = new HotReplSessionEvicted(message);
474
+ for (const listener of this.evictionListeners) listener(message);
475
+ this.failAll(error);
476
+ return;
477
+ }
478
+ if (message.type === MESSAGE_TYPES2.error && message.id !== void 0) {
479
+ const queue = this.subscriptions.get(message.id);
480
+ if (queue !== void 0) {
481
+ this.subscriptions.delete(message.id);
482
+ queue.fail(HotReplError.fromEnvelope(message.error));
483
+ return;
484
+ }
485
+ const pending2 = this.pending.get(message.id);
486
+ if (pending2 !== void 0) {
487
+ this.pending.delete(message.id);
488
+ pending2.reject(HotReplError.fromEnvelope(message.error));
489
+ return;
490
+ }
491
+ }
492
+ if (message.type === MESSAGE_TYPES2.subscribeResult || message.type === MESSAGE_TYPES2.subscribeError) {
493
+ const queue = this.subscriptions.get(message.id);
494
+ if (queue === void 0) return;
495
+ queue.push(message);
496
+ if (message.final) {
497
+ queue.close();
498
+ this.subscriptions.delete(message.id);
499
+ }
500
+ return;
501
+ }
502
+ if (!("id" in message)) return;
503
+ const pending = this.pending.get(message.id);
504
+ if (pending === void 0) return;
505
+ this.pending.delete(message.id);
506
+ pending.resolve(message);
507
+ }
508
+ ensureAvailable() {
509
+ if (this.evicted !== void 0) throw new HotReplSessionEvicted(this.evicted);
510
+ if (this.socket.readyState !== WebSocket.OPEN) {
511
+ throw new HotReplError({
512
+ kind: "precondition_failed",
513
+ code: "webSocketNotOpen",
514
+ message: "HotRepl WebSocket is not open.",
515
+ retryable: true
516
+ });
517
+ }
518
+ }
519
+ failAll(error) {
520
+ for (const pending of this.pending.values()) pending.reject(error);
521
+ this.pending.clear();
522
+ for (const queue of this.subscriptions.values()) queue.fail(error);
523
+ this.subscriptions.clear();
524
+ }
525
+ };
526
+ async function messageText(data) {
527
+ if (typeof data === "string") return data;
528
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(data);
529
+ if (ArrayBuffer.isView(data)) return new TextDecoder().decode(data);
530
+ if (data instanceof Blob) return data.text();
531
+ return String(data);
532
+ }
533
+
534
+ // src/connect.ts
535
+ function resolveHotReplUrl(options = {}) {
536
+ return options.url ?? options.env?.HOTREPL_URL ?? "ws://127.0.0.1:18590";
537
+ }
538
+ async function connect(options = {}) {
539
+ const runtime = options.runtime ?? await WebSocketTransport.connect(resolveHotReplUrl(options));
540
+ const handshake = await runtime.handshake();
541
+ if (handshake.protocolVersion !== PROTOCOL_VERSION2) {
542
+ throw new HotReplError({
543
+ kind: "unsupported_operation",
544
+ code: "protocolVersionMismatch",
545
+ message: `Expected protocol ${PROTOCOL_VERSION2}, got ${handshake.protocolVersion}.`,
546
+ retryable: false
547
+ });
548
+ }
549
+ return new Session(runtime, handshake);
550
+ }
551
+ export {
552
+ Artifact,
553
+ HotReplArtifactCorrupted,
554
+ HotReplError,
555
+ HotReplSessionEvicted,
556
+ JobHandle,
557
+ Session,
558
+ WebSocketTransport,
559
+ connect,
560
+ resolveHotReplUrl,
561
+ sha256Hex,
562
+ toResult
563
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@hotrepl/sdk",
3
+ "version": "2.0.0",
4
+ "description": "Canonical TypeScript SDK for HotRepl v2 runtimes.",
5
+ "keywords": [
6
+ "hotrepl",
7
+ "unity",
8
+ "csharp",
9
+ "repl",
10
+ "websocket",
11
+ "sdk",
12
+ "client"
13
+ ],
14
+ "homepage": "https://hotrepl.glockyco.com",
15
+ "bugs": "https://github.com/glockyco/HotRepl/issues",
16
+ "license": "MIT",
17
+ "author": "Johann Glock",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/glockyco/HotRepl.git",
21
+ "directory": "packages/sdk"
22
+ },
23
+ "type": "module",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "LICENSE",
29
+ "dist"
30
+ ],
31
+ "sideEffects": false,
32
+ "exports": {
33
+ ".": {
34
+ "bun": "./src/index.ts",
35
+ "import": "./dist/index.js",
36
+ "types": "./dist/index.d.ts"
37
+ }
38
+ },
39
+ "scripts": {
40
+ "build": "tsup",
41
+ "typecheck": "tsc -p tsconfig.json"
42
+ },
43
+ "dependencies": {
44
+ "@hotrepl/protocol": "2.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@hotrepl/testing": "2.0.0"
48
+ }
49
+ }