@bian-womp/spark-remote 0.2.40 → 0.2.41

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/lib/cjs/index.cjs CHANGED
@@ -1,16 +1,1139 @@
1
1
  'use strict';
2
2
 
3
- var index = require('./index-BS8Eessm.js');
4
- require('@bian-womp/spark-graph');
3
+ var sparkGraph = require('@bian-womp/spark-graph');
5
4
 
5
+ class SeqGenerator {
6
+ constructor() {
7
+ this.lastMs = 0;
8
+ this.seqInMs = 0;
9
+ }
10
+ next() {
11
+ const now = Date.now();
12
+ if (now === this.lastMs) {
13
+ this.seqInMs = (this.seqInMs + 1) % 1000;
14
+ }
15
+ else {
16
+ this.lastMs = now;
17
+ this.seqInMs = 0;
18
+ }
19
+ return now * 1000 + this.seqInMs;
20
+ }
21
+ }
6
22
 
23
+ const OPEN = 1;
24
+ class WebSocketTransport {
25
+ constructor(url) {
26
+ this.listeners = new Set();
27
+ this.seq = new SeqGenerator();
28
+ this.baseUrl = url;
29
+ }
30
+ async connect(options) {
31
+ if (this.ws && this.ws.readyState === OPEN)
32
+ return;
33
+ // Build URL with connection params
34
+ // Handle both ws:///wss:// URLs and http:///https:// URLs (convert to ws)
35
+ let url;
36
+ if (this.baseUrl.startsWith("ws://") || this.baseUrl.startsWith("wss://")) {
37
+ url = new URL(this.baseUrl);
38
+ }
39
+ else {
40
+ // Convert http/https to ws/wss
41
+ const wsBaseUrl = this.baseUrl.replace(/^http/, "ws");
42
+ url = new URL(wsBaseUrl);
43
+ }
44
+ if (options?.params) {
45
+ for (const [key, value] of Object.entries(options.params)) {
46
+ if (value !== undefined && value !== null) {
47
+ url.searchParams.set(key, String(value));
48
+ }
49
+ }
50
+ }
51
+ const wsUrl = url.toString();
52
+ this.ws = window
53
+ ? new window.WebSocket(wsUrl)
54
+ : new (await import('ws')).default(wsUrl);
55
+ await new Promise((resolve, reject) => {
56
+ if (!this.ws)
57
+ return reject(new Error("ws init failed"));
58
+ this.ws.onopen = () => resolve();
59
+ this.ws.onerror = (e) => reject(e);
60
+ this.ws.onmessage = (ev) => {
61
+ try {
62
+ const env = JSON.parse(String(ev.data));
63
+ for (const l of Array.from(this.listeners))
64
+ l(env);
65
+ }
66
+ catch { }
67
+ };
68
+ });
69
+ }
70
+ async request(msg) {
71
+ // For now, just send and wait for the next message with matching seq (simple demo)
72
+ const seq = this.seq.next();
73
+ const env = { ...msg, seq };
74
+ const p = new Promise((resolve) => {
75
+ const off = this.subscribe((incoming) => {
76
+ if (incoming.seq === seq) {
77
+ off();
78
+ resolve(incoming);
79
+ }
80
+ });
81
+ });
82
+ this.send(env);
83
+ return p;
84
+ }
85
+ send(msg) {
86
+ if (!this.ws || this.ws.readyState !== OPEN)
87
+ return;
88
+ this.ws.send(JSON.stringify(msg));
89
+ }
90
+ subscribe(cb) {
91
+ this.listeners.add(cb);
92
+ return () => this.listeners.delete(cb);
93
+ }
94
+ async close() {
95
+ if (!this.ws)
96
+ return;
97
+ const ws = this.ws;
98
+ this.ws = undefined;
99
+ await new Promise((resolve) => {
100
+ ws.onclose = () => resolve();
101
+ try {
102
+ ws.close();
103
+ }
104
+ catch {
105
+ resolve();
106
+ }
107
+ });
108
+ }
109
+ }
7
110
 
8
- exports.HttpPollingTransport = index.HttpPollingTransport;
9
- exports.RemoteEngine = index.RemoteEngine;
10
- exports.RuntimeApiClient = index.RuntimeApiClient;
11
- exports.RuntimeApiServer = index.RuntimeApiServer;
12
- exports.WebSocketTransport = index.WebSocketTransport;
13
- exports.createRuntimeAdapter = index.createRuntimeAdapter;
14
- exports.serializeError = index.serializeError;
15
- exports.summarize = index.summarize;
111
+ class HttpPollingTransport {
112
+ constructor(baseUrl) {
113
+ this.listeners = new Set();
114
+ this.baseUrl = baseUrl.replace(/\/$/, "");
115
+ }
116
+ async connect(options) {
117
+ // Store connection params for use in requests
118
+ this.connectParams = options?.params;
119
+ // Start polling loop
120
+ if (this.polling)
121
+ return;
122
+ const tick = async () => {
123
+ try {
124
+ const url = new URL(this.baseUrl + "/events");
125
+ if (this.cursor)
126
+ url.searchParams.set("cursor", this.cursor);
127
+ // Add connection params to all requests
128
+ if (this.connectParams) {
129
+ for (const [key, value] of Object.entries(this.connectParams)) {
130
+ if (value !== undefined && value !== null) {
131
+ url.searchParams.set(key, String(value));
132
+ }
133
+ }
134
+ }
135
+ const res = await fetch(url.toString());
136
+ if (!res.ok)
137
+ return;
138
+ const batch = (await res.json());
139
+ for (const env of batch) {
140
+ this.cursor = String(env.seq ?? Date.now());
141
+ for (const l of Array.from(this.listeners))
142
+ l(env);
143
+ }
144
+ }
145
+ catch { }
146
+ };
147
+ this.polling = setInterval(tick, 200);
148
+ }
149
+ async request(msg) {
150
+ const url = new URL(this.baseUrl + "/command");
151
+ // Add connection params to command requests
152
+ if (this.connectParams) {
153
+ for (const [key, value] of Object.entries(this.connectParams)) {
154
+ if (value !== undefined && value !== null) {
155
+ url.searchParams.set(key, String(value));
156
+ }
157
+ }
158
+ }
159
+ const res = await fetch(url.toString(), {
160
+ method: "POST",
161
+ headers: { "content-type": "application/json" },
162
+ body: JSON.stringify(msg),
163
+ });
164
+ const data = (await res.json());
165
+ return data;
166
+ }
167
+ send(msg) {
168
+ const url = new URL(this.baseUrl + "/command");
169
+ // Add connection params to send requests
170
+ if (this.connectParams) {
171
+ for (const [key, value] of Object.entries(this.connectParams)) {
172
+ if (value !== undefined && value !== null) {
173
+ url.searchParams.set(key, String(value));
174
+ }
175
+ }
176
+ }
177
+ fetch(url.toString(), {
178
+ method: "POST",
179
+ headers: { "content-type": "application/json" },
180
+ body: JSON.stringify(msg),
181
+ });
182
+ }
183
+ subscribe(cb) {
184
+ this.listeners.add(cb);
185
+ return () => this.listeners.delete(cb);
186
+ }
187
+ async close() {
188
+ if (this.polling)
189
+ clearInterval(this.polling);
190
+ this.polling = undefined;
191
+ }
192
+ }
193
+
194
+ /* eslint-disable @typescript-eslint/no-explicit-any */
195
+ function summarize(value, maxLen = 120) {
196
+ try {
197
+ const s = typeof value === "string" ? value : JSON.stringify(value);
198
+ return s.length > maxLen ? s.slice(0, maxLen) + "…" : s;
199
+ }
200
+ catch {
201
+ return String(value);
202
+ }
203
+ }
204
+ function serializeError(err) {
205
+ try {
206
+ if (!err)
207
+ return { message: String(err) };
208
+ const errAny = err;
209
+ if (err instanceof Error) {
210
+ const base = {
211
+ name: err.name,
212
+ message: err.message,
213
+ stack: err.stack,
214
+ };
215
+ for (const k of Object.keys(errAny))
216
+ base[k] = errAny[k];
217
+ return base;
218
+ }
219
+ if (typeof err === "object") {
220
+ const maybeMsg = errAny?.message;
221
+ const maybeName = errAny?.name;
222
+ const maybeStack = errAny?.stack;
223
+ const out = { ...errAny };
224
+ if (maybeMsg && !out.message)
225
+ out.message = String(maybeMsg);
226
+ if (maybeName && !out.name)
227
+ out.name = String(maybeName);
228
+ if (maybeStack && !out.stack)
229
+ out.stack = String(maybeStack);
230
+ return out;
231
+ }
232
+ return { message: String(err) };
233
+ }
234
+ catch {
235
+ return { message: String(err) };
236
+ }
237
+ }
238
+
239
+ async function createRuntimeAdapter(createRegistry, send, extensions) {
240
+ const registry = await createRegistry();
241
+ const builder = new sparkGraph.GraphBuilder(registry);
242
+ let graphRuntime;
243
+ let extData = {};
244
+ // Helper to get current context
245
+ const getContext = () => ({
246
+ registry,
247
+ builder,
248
+ graphRuntime,
249
+ extData,
250
+ });
251
+ // Original implementations - define as separate functions first to allow cross-references
252
+ const originalApi = {
253
+ coerce: async (from, to, value) => {
254
+ const resolved = registry.resolveCoercion(from, to);
255
+ if (!resolved)
256
+ return value;
257
+ if (resolved.kind === "sync")
258
+ return resolved.convert(value);
259
+ const ac = new AbortController();
260
+ return await resolved.convertAsync(value, ac.signal);
261
+ },
262
+ getEnvironment: () => {
263
+ return graphRuntime?.getEnvironment?.() ?? {};
264
+ },
265
+ applyRegistry: async (deltas) => {
266
+ // Pause runtime if exists
267
+ // Apply each delta to the live registry
268
+ for (const d of deltas || []) {
269
+ if (!d || typeof d !== "object")
270
+ continue;
271
+ if (d.kind === "register-enum") {
272
+ registry.registerEnum({
273
+ id: d.id,
274
+ displayName: d.displayName,
275
+ options: d.options || [],
276
+ bakeTarget: d.bakeTarget,
277
+ opts: d.opts,
278
+ });
279
+ }
280
+ else if (d.kind === "register-type") {
281
+ registry.registerType({
282
+ id: d.id,
283
+ displayName: d.displayName,
284
+ bakeTarget: d.bakeTarget,
285
+ validate: (_v) => true,
286
+ });
287
+ }
288
+ else if (d.kind === "register-node") {
289
+ const desc = d.desc || {};
290
+ registry.registerNode({
291
+ id: String(desc.id || ""),
292
+ categoryId: String(desc.categoryId || "compute"),
293
+ displayName: desc.displayName,
294
+ inputs: desc.inputs || {},
295
+ outputs: desc.outputs || {},
296
+ // impl must be empty per frontend registration contract
297
+ impl: () => { },
298
+ });
299
+ }
300
+ }
301
+ // Notify clients (include deltas in invalidate payload)
302
+ send({
303
+ message: {
304
+ type: "invalidate",
305
+ payload: { reason: "registry-changed", deltas },
306
+ },
307
+ });
308
+ },
309
+ build: async (def, opts) => {
310
+ const env = opts || {};
311
+ graphRuntime = builder.build(def, { environment: env });
312
+ graphRuntime.on("value", (p) => send({ message: { type: "value", payload: p } }));
313
+ graphRuntime.on("invalidate", (p) => send({ message: { type: "invalidate", payload: p } }));
314
+ graphRuntime.on("error", (p) => send({ message: { type: "error", payload: p } }));
315
+ graphRuntime.on("stats", (p) => send({ message: { type: "stats", payload: p } }));
316
+ },
317
+ setExtData: (data) => {
318
+ if (!data || typeof data !== "object") {
319
+ extData = {};
320
+ return;
321
+ }
322
+ // Replace to keep semantics deterministic
323
+ extData = { ...data };
324
+ },
325
+ getExtData: () => {
326
+ return extData;
327
+ },
328
+ snapshot: () => {
329
+ const inputs = {};
330
+ const outputs = {};
331
+ if (!graphRuntime)
332
+ return { inputs, outputs };
333
+ const nodes = graphRuntime.getNodeIds();
334
+ for (const nodeId of nodes) {
335
+ const data = graphRuntime.getNodeData(nodeId);
336
+ if (data?.inputs && Object.keys(data.inputs).length > 0) {
337
+ inputs[nodeId] = { ...data.inputs };
338
+ }
339
+ if (data?.outputs && Object.keys(data.outputs).length > 0) {
340
+ outputs[nodeId] = { ...data.outputs };
341
+ }
342
+ }
343
+ return { inputs, outputs };
344
+ },
345
+ snapshotFull: () => {
346
+ const snap = originalApi.snapshot();
347
+ const env = graphRuntime?.getEnvironment?.() ?? {};
348
+ const def = graphRuntime?.getGraphDef();
349
+ return {
350
+ def,
351
+ environment: env,
352
+ inputs: snap.inputs,
353
+ outputs: snap.outputs,
354
+ };
355
+ },
356
+ applySnapshotFull: async (payload) => {
357
+ const def = payload.def;
358
+ if (!def)
359
+ return;
360
+ await originalApi.build(def, payload.environment);
361
+ // Hydrate inputs/outputs exactly, then re-emit outputs without scheduling runs
362
+ graphRuntime?.hydrate({
363
+ inputs: payload.inputs,
364
+ outputs: payload.outputs,
365
+ });
366
+ },
367
+ describeRegistry: () => {
368
+ // types (include enum options when available)
369
+ const types = Array.from(registry.types.entries()).map(([id, d]) => {
370
+ const en = registry.enums.get(id);
371
+ return {
372
+ id,
373
+ displayName: d.displayName,
374
+ bakeTarget: d.bakeTarget,
375
+ ...(en ? { options: en.options } : {}),
376
+ };
377
+ });
378
+ // categories: not directly enumerable; derive from node descriptors
379
+ const nodeDescs = Array.from(registry.nodes.values());
380
+ const catIds = new Set(nodeDescs.map((n) => n.categoryId));
381
+ const categories = Array.from(catIds).map((id) => {
382
+ const cat = registry.categories.get?.(id);
383
+ return { id, displayName: cat?.displayName };
384
+ });
385
+ const nodes = nodeDescs.map((n) => ({
386
+ id: n.id,
387
+ categoryId: n.categoryId,
388
+ displayName: n.displayName,
389
+ inputs: n.inputs || {},
390
+ outputs: n.outputs || {},
391
+ inputDefaults: n.inputDefaults || {},
392
+ }));
393
+ const coercions = registry.listCoercions();
394
+ return { types, categories, nodes, coercions, schemaVersion: 4 };
395
+ },
396
+ update: async (def) => {
397
+ if (!graphRuntime)
398
+ return;
399
+ graphRuntime.update(def, registry);
400
+ send({
401
+ message: {
402
+ type: "invalidate",
403
+ payload: { reason: "graph-updated" },
404
+ },
405
+ });
406
+ },
407
+ setEnvironment: (env, opts) => {
408
+ if (!graphRuntime)
409
+ return;
410
+ if (opts?.merge) {
411
+ const current = graphRuntime.getEnvironment();
412
+ const next = { ...(current || {}), ...(env || {}) };
413
+ graphRuntime.setEnvironment(next);
414
+ return;
415
+ }
416
+ graphRuntime.setEnvironment(env);
417
+ },
418
+ setInput: (nodeId, handle, value) => {
419
+ graphRuntime?.setInput(nodeId, handle, value);
420
+ },
421
+ setInputs: (nodeId, inputs) => {
422
+ graphRuntime?.setInputs(nodeId, inputs);
423
+ },
424
+ triggerExternal: (nodeId, event) => {
425
+ graphRuntime?.triggerExternal(nodeId, event);
426
+ },
427
+ launch: (invalidate) => {
428
+ graphRuntime?.launch(invalidate);
429
+ },
430
+ whenIdle: () => {
431
+ return graphRuntime?.whenIdle?.() ?? Promise.resolve();
432
+ },
433
+ dispose: () => {
434
+ graphRuntime?.dispose?.();
435
+ graphRuntime = undefined;
436
+ },
437
+ };
438
+ // Helper to wrap a method with extension support
439
+ const wrapMethod = (key, original) => {
440
+ const extension = extensions?.[key];
441
+ if (!extension) {
442
+ return original;
443
+ }
444
+ return ((...args) => {
445
+ return extension(original, getContext(), ...args);
446
+ });
447
+ };
448
+ // Create API with extensions applied
449
+ const extendedApi = {
450
+ coerce: wrapMethod("coerce", originalApi.coerce),
451
+ getEnvironment: wrapMethod("getEnvironment", originalApi.getEnvironment),
452
+ applyRegistry: wrapMethod("applyRegistry", originalApi.applyRegistry),
453
+ build: wrapMethod("build", originalApi.build),
454
+ setExtData: wrapMethod("setExtData", originalApi.setExtData),
455
+ getExtData: wrapMethod("getExtData", originalApi.getExtData),
456
+ snapshot: wrapMethod("snapshot", originalApi.snapshot),
457
+ snapshotFull: wrapMethod("snapshotFull", originalApi.snapshotFull),
458
+ applySnapshotFull: wrapMethod("applySnapshotFull", originalApi.applySnapshotFull),
459
+ describeRegistry: wrapMethod("describeRegistry", originalApi.describeRegistry),
460
+ update: wrapMethod("update", originalApi.update),
461
+ setEnvironment: wrapMethod("setEnvironment", originalApi.setEnvironment),
462
+ setInput: wrapMethod("setInput", originalApi.setInput),
463
+ setInputs: wrapMethod("setInputs", originalApi.setInputs),
464
+ triggerExternal: wrapMethod("triggerExternal", originalApi.triggerExternal),
465
+ launch: wrapMethod("launch", originalApi.launch),
466
+ whenIdle: wrapMethod("whenIdle", originalApi.whenIdle),
467
+ dispose: wrapMethod("dispose", originalApi.dispose),
468
+ };
469
+ return extendedApi;
470
+ }
471
+
472
+ class RemoteEngine {
473
+ constructor(transport) {
474
+ this.transport = transport;
475
+ this.listeners = new Map();
476
+ this.cache = new Map();
477
+ this.transport.subscribe((env) => this.onEnvelope(env));
478
+ }
479
+ onEnvelope(env) {
480
+ const msg = env.message;
481
+ if (!msg)
482
+ return;
483
+ if (msg.type === "value") {
484
+ const key = `${msg.payload.nodeId}.${msg.payload.handle}`;
485
+ this.cache.set(key, {
486
+ value: msg.payload.value,
487
+ runtimeTypeId: msg.payload.runtimeTypeId,
488
+ });
489
+ this.emit("value", msg.payload);
490
+ }
491
+ else if (msg.type === "error") {
492
+ this.emit("error", msg.payload);
493
+ }
494
+ else if (msg.type === "invalidate") {
495
+ this.emit("invalidate", msg.payload);
496
+ }
497
+ else if (msg.type === "stats") {
498
+ this.emit("stats", msg.payload);
499
+ }
500
+ }
501
+ launch(invalidate) {
502
+ this.transport.send({
503
+ message: { type: "Launch", payload: { invalidate } },
504
+ });
505
+ }
506
+ setInput(nodeId, handle, value) {
507
+ this.transport.send({
508
+ message: { type: "SetInput", payload: { nodeId, handle, value } },
509
+ });
510
+ }
511
+ // Batch inputs for a single network round-trip
512
+ setInputs(nodeId, inputs) {
513
+ this.transport.send({
514
+ message: { type: "SetInputs", payload: { nodeId, inputs } },
515
+ });
516
+ }
517
+ triggerExternal(nodeId, event) {
518
+ this.transport.send({
519
+ message: { type: "TriggerExternal", payload: { nodeId, event } },
520
+ });
521
+ }
522
+ on(event, handler) {
523
+ if (!this.listeners.has(event))
524
+ this.listeners.set(event, new Set());
525
+ const set = this.listeners.get(event);
526
+ set.add(handler);
527
+ return () => set.delete(handler);
528
+ }
529
+ emit(event, payload) {
530
+ const set = this.listeners.get(event);
531
+ if (set)
532
+ for (const h of Array.from(set))
533
+ h(payload);
534
+ }
535
+ getOutput(nodeId, output) {
536
+ return this.cache.get(`${nodeId}.${output}`)?.value;
537
+ }
538
+ async whenIdle() {
539
+ await this.transport.request({ message: { type: "WhenIdle" } });
540
+ }
541
+ dispose() {
542
+ this.transport.send({ message: { type: "Dispose" } });
543
+ }
544
+ }
545
+
546
+ // Node-only transport using a UNIX domain socket.
547
+ // Import directly from path: "@bian-womp/spark-remote/transport/UnixSocketTransport" in Node/Electron code.
548
+ // Do not re-export from the browser index to avoid bundling in web builds.
549
+ class UnixSocketTransport {
550
+ constructor(socketPath) {
551
+ this.socketPath = socketPath;
552
+ this.listeners = new Set();
553
+ this.buffer = "";
554
+ this.waiters = new Map();
555
+ this.seq = new SeqGenerator();
556
+ }
557
+ async connect() {
558
+ if (typeof process === "undefined") {
559
+ throw new Error("UnixSocketTransport requires a Node environment");
560
+ }
561
+ if (this.socket)
562
+ return;
563
+ const { createConnection } = await import('node:net');
564
+ const sock = createConnection(this.socketPath);
565
+ this.socket = sock;
566
+ await new Promise((resolve, reject) => {
567
+ sock.once("connect", () => resolve());
568
+ sock.once("error", (e) => reject(e));
569
+ });
570
+ sock.on("data", (chunk) => this.onData(chunk));
571
+ sock.on("error", () => {
572
+ /* noop, surface via request waiters if needed */
573
+ });
574
+ }
575
+ onData(chunk) {
576
+ this.buffer += chunk.toString("utf8");
577
+ let idx = this.buffer.indexOf("\n");
578
+ while (idx >= 0) {
579
+ const raw = this.buffer.slice(0, idx);
580
+ this.buffer = this.buffer.slice(idx + 1);
581
+ idx = this.buffer.indexOf("\n");
582
+ if (!raw.trim())
583
+ continue;
584
+ try {
585
+ const env = JSON.parse(raw);
586
+ if (env && typeof env.seq === "number" && this.waiters.has(env.seq)) {
587
+ const fn = this.waiters.get(env.seq);
588
+ this.waiters.delete(env.seq);
589
+ fn(env);
590
+ }
591
+ else {
592
+ for (const l of Array.from(this.listeners))
593
+ l(env);
594
+ }
595
+ }
596
+ catch {
597
+ // ignore malformed frames
598
+ }
599
+ }
600
+ }
601
+ async request(msg) {
602
+ const seq = typeof msg.seq === "number" ? msg.seq : this.seq.next();
603
+ const env = { ...msg, seq };
604
+ const p = new Promise((resolve) => {
605
+ this.waiters.set(seq, (incoming) => resolve(incoming));
606
+ });
607
+ this.send(env);
608
+ return p;
609
+ }
610
+ send(msg) {
611
+ const s = this.socket;
612
+ if (!s)
613
+ return;
614
+ try {
615
+ s.write(JSON.stringify(msg) + "\n");
616
+ }
617
+ catch {
618
+ // ignore
619
+ }
620
+ }
621
+ subscribe(cb) {
622
+ this.listeners.add(cb);
623
+ return () => this.listeners.delete(cb);
624
+ }
625
+ async close() {
626
+ const s = this.socket;
627
+ this.socket = undefined;
628
+ if (!s)
629
+ return;
630
+ await new Promise((resolve) => {
631
+ try {
632
+ s.end(() => resolve());
633
+ }
634
+ catch {
635
+ resolve();
636
+ }
637
+ });
638
+ }
639
+ }
640
+
641
+ class RuntimeApiClient {
642
+ constructor(config, options) {
643
+ this.customEventListeners = new Set();
644
+ this.transportEventListeners = new Set();
645
+ this.disposed = false;
646
+ this.config = config;
647
+ this.onCustomEvent = options?.onCustomEvent;
648
+ // Engine will be created after transport is connected
649
+ }
650
+ /**
651
+ * Create transport instance based on config.
652
+ * Extracted to a separate method for better testability and clarity.
653
+ */
654
+ async createTransport() {
655
+ const kind = this.config.kind;
656
+ if (kind === "remote-http") {
657
+ if (!HttpPollingTransport) {
658
+ throw new Error("HttpPollingTransport not available");
659
+ }
660
+ return new HttpPollingTransport(this.config.baseUrl);
661
+ }
662
+ else if (kind === "remote-ws") {
663
+ if (!WebSocketTransport) {
664
+ throw new Error("WebSocketTransport not available");
665
+ }
666
+ return new WebSocketTransport(this.config.url);
667
+ }
668
+ else if (kind === "remote-unix") {
669
+ // Dynamic import to avoid bundling in browser builds
670
+ return new UnixSocketTransport(this.config.socketPath);
671
+ }
672
+ else {
673
+ throw new Error(`Invalid transport kind: ${kind}`);
674
+ }
675
+ }
676
+ /**
677
+ * Connect to the remote runtime API.
678
+ * Creates and connects the transport, then initializes the engine.
679
+ * Safe to call multiple times - concurrent calls will wait for the same connection.
680
+ */
681
+ async connect() {
682
+ if (this.disposed) {
683
+ throw new Error("Cannot connect: RuntimeApiClient has been disposed");
684
+ }
685
+ if (this.transport) {
686
+ // Already connected
687
+ return;
688
+ }
689
+ // If already connecting, wait for that connection to complete
690
+ if (this.connectingPromise) {
691
+ return this.connectingPromise;
692
+ }
693
+ const kind = this.config.kind;
694
+ this.emitTransportStatus({ state: "connecting", kind });
695
+ // Create connection promise to prevent concurrent connections
696
+ this.connectingPromise = (async () => {
697
+ try {
698
+ const transport = await this.createTransport();
699
+ await transport.connect(this.config.connectOptions);
700
+ this.transport = transport;
701
+ // Subscribe to all transport events
702
+ this.transportUnsubscribe = transport.subscribe((event) => {
703
+ this.handleTransportEvent(event);
704
+ });
705
+ // Create engine with connected transport
706
+ this.engine = new RemoteEngine(transport);
707
+ this.emitTransportStatus({ state: "connected", kind });
708
+ }
709
+ catch (error) {
710
+ // Clear connecting promise on error so retry is possible
711
+ this.connectingPromise = undefined;
712
+ this.emitTransportStatus({ state: "disconnected", kind });
713
+ throw error;
714
+ }
715
+ finally {
716
+ // Clear connecting promise on success
717
+ this.connectingPromise = undefined;
718
+ }
719
+ })();
720
+ return this.connectingPromise;
721
+ }
722
+ /**
723
+ * Handle events from transport.
724
+ * Routes standard runtime events to engine, custom events to custom handlers.
725
+ */
726
+ handleTransportEvent(event) {
727
+ const msg = event.message;
728
+ if (!msg || typeof msg !== "object" || !("type" in msg))
729
+ return;
730
+ const type = msg.type;
731
+ // Standard runtime events: stats, value, error, invalidate
732
+ // These are handled by RemoteEngine via transport subscription
733
+ // Custom events are anything else (e.g., flow-opened, flow-latest)
734
+ if (!["stats", "value", "error", "invalidate"].includes(type)) {
735
+ // Emit to custom event listeners
736
+ for (const listener of this.customEventListeners) {
737
+ listener(event);
738
+ }
739
+ // Also call the constructor-provided handler if present
740
+ this.onCustomEvent?.(event);
741
+ }
742
+ }
743
+ /**
744
+ * Subscribe to custom events (non-standard runtime events like flow-opened, flow-latest).
745
+ * Returns an unsubscribe function.
746
+ */
747
+ subscribeCustomEvents(listener) {
748
+ this.customEventListeners.add(listener);
749
+ return () => {
750
+ this.customEventListeners.delete(listener);
751
+ };
752
+ }
753
+ /**
754
+ * Subscribe to transport status changes.
755
+ * Returns an unsubscribe function.
756
+ */
757
+ onTransportStatus(listener) {
758
+ this.transportEventListeners.add(listener);
759
+ // Immediately emit current status if connected
760
+ if (this.transport) {
761
+ const kind = this.config.kind;
762
+ listener({ state: "connected", kind });
763
+ }
764
+ return () => {
765
+ this.transportEventListeners.delete(listener);
766
+ };
767
+ }
768
+ emitTransportStatus(status) {
769
+ for (const listener of this.transportEventListeners) {
770
+ listener(status);
771
+ }
772
+ }
773
+ /**
774
+ * Ensure transport is connected. Called automatically by API methods.
775
+ * Returns the transport instance, throwing if connection fails or client is disposed.
776
+ */
777
+ async ensureConnected() {
778
+ if (this.disposed) {
779
+ throw new Error("Cannot ensure connection: RuntimeApiClient has been disposed");
780
+ }
781
+ if (!this.transport) {
782
+ await this.connect();
783
+ }
784
+ // After connect(), transport is guaranteed to be set
785
+ if (!this.transport) {
786
+ throw new Error("Failed to establish transport connection");
787
+ }
788
+ return this.transport;
789
+ }
790
+ async build(def, opts) {
791
+ const transport = await this.ensureConnected();
792
+ await transport.request({
793
+ message: {
794
+ type: "Build",
795
+ payload: { def, environment: opts?.environment },
796
+ },
797
+ });
798
+ }
799
+ async update(def) {
800
+ const transport = await this.ensureConnected();
801
+ await transport.request({
802
+ message: { type: "Update", payload: { def } },
803
+ });
804
+ }
805
+ async describeRegistry() {
806
+ const transport = await this.ensureConnected();
807
+ const res = await transport.request({
808
+ message: { type: "DescribeRegistry" },
809
+ });
810
+ const payload = res?.message || {};
811
+ return (payload.registry || {
812
+ types: [],
813
+ categories: [],
814
+ nodes: [],
815
+ coercions: [],
816
+ schemaVersion: 4,
817
+ });
818
+ }
819
+ async applyRegistry(deltas) {
820
+ const transport = await this.ensureConnected();
821
+ await transport.request({
822
+ message: { type: "RegistryApply", payload: { deltas } },
823
+ });
824
+ }
825
+ async snapshot() {
826
+ const transport = await this.ensureConnected();
827
+ const res = await transport.request({
828
+ message: { type: "Snapshot" },
829
+ });
830
+ const payload = res?.message || {};
831
+ return payload.snapshot || { inputs: {}, outputs: {} };
832
+ }
833
+ async snapshotFull() {
834
+ const transport = await this.ensureConnected();
835
+ const res = await transport.request({
836
+ message: { type: "SnapshotFull" },
837
+ });
838
+ const payload = res?.message || {};
839
+ return (payload.snapshot || {
840
+ def: undefined,
841
+ environment: {},
842
+ inputs: {},
843
+ outputs: {},
844
+ });
845
+ }
846
+ async applySnapshotFull(payload) {
847
+ const transport = await this.ensureConnected();
848
+ await transport.request({
849
+ message: { type: "ApplySnapshotFull", payload },
850
+ });
851
+ }
852
+ async setEnvironment(environment, opts) {
853
+ const transport = await this.ensureConnected();
854
+ await transport.request({
855
+ message: {
856
+ type: "SetEnvironment",
857
+ payload: { environment, merge: opts?.merge },
858
+ },
859
+ });
860
+ }
861
+ async getEnvironment() {
862
+ const transport = await this.ensureConnected();
863
+ const res = await transport.request({
864
+ message: { type: "GetEnvironment" },
865
+ });
866
+ const payload = res?.message || {};
867
+ return payload.environment || {};
868
+ }
869
+ async coerce(from, to, value) {
870
+ const transport = await this.ensureConnected();
871
+ const res = await transport.request({
872
+ message: { type: "Coerce", payload: { from, to, value } },
873
+ });
874
+ const payload = res?.message || {};
875
+ return payload.value;
876
+ }
877
+ getEngine() {
878
+ if (!this.engine) {
879
+ throw new Error("Engine not available: call connect() first");
880
+ }
881
+ return this.engine;
882
+ }
883
+ /**
884
+ * Dispose the client and close the transport connection.
885
+ * Idempotent: safe to call multiple times.
886
+ */
887
+ async dispose() {
888
+ if (this.disposed)
889
+ return;
890
+ this.disposed = true;
891
+ // Clear connecting promise if any
892
+ this.connectingPromise = undefined;
893
+ // Unsubscribe from transport events
894
+ if (this.transportUnsubscribe) {
895
+ this.transportUnsubscribe();
896
+ this.transportUnsubscribe = undefined;
897
+ }
898
+ // Close transport connection
899
+ if (this.transport) {
900
+ const transportToClose = this.transport;
901
+ this.transport = undefined;
902
+ this.engine = undefined; // Clear engine reference
903
+ await transportToClose.close().catch((err) => {
904
+ console.warn("[RuntimeApiClient] Error closing transport:", err);
905
+ });
906
+ }
907
+ // Clear listeners
908
+ this.customEventListeners.clear();
909
+ this.transportEventListeners.clear();
910
+ this.emitTransportStatus({ state: "disconnected", kind: this.config.kind });
911
+ }
912
+ }
913
+
914
+ class RuntimeApiServer {
915
+ constructor(runtimeApi, label, onError) {
916
+ this.runtimeApi = runtimeApi;
917
+ this.label = label;
918
+ this.onError = onError;
919
+ this.middlewares = [];
920
+ }
921
+ /**
922
+ * Register middleware to preprocess commands before handling.
923
+ * Middlewares are executed in registration order.
924
+ */
925
+ use(middleware) {
926
+ this.middlewares.push(middleware);
927
+ }
928
+ /**
929
+ * Handle command with error handling and middleware support.
930
+ */
931
+ async handleCommand(env, ack) {
932
+ try {
933
+ // Execute middlewares in sequence, then process command
934
+ await this.executeMiddlewares(env, async () => {
935
+ await this.processCommand(env, ack);
936
+ });
937
+ }
938
+ catch (err) {
939
+ this.handleError(env, err);
940
+ }
941
+ }
942
+ /**
943
+ * Execute middlewares in sequence, then call the final handler.
944
+ */
945
+ async executeMiddlewares(env, finalHandler) {
946
+ let index = 0;
947
+ const next = async () => {
948
+ if (index >= this.middlewares.length) {
949
+ await finalHandler();
950
+ return;
951
+ }
952
+ const middleware = this.middlewares[index++];
953
+ await middleware(env, next);
954
+ };
955
+ await next();
956
+ }
957
+ /**
958
+ * Process the command by routing to the appropriate handler.
959
+ */
960
+ async processCommand(env, ack) {
961
+ const msg = env.message;
962
+ switch (msg.type) {
963
+ case "Build": {
964
+ this.logCommand("Build", env, {
965
+ nodes: msg.payload.def.nodes?.length ?? 0,
966
+ edges: msg.payload.def.edges?.length ?? 0,
967
+ envKeys: Object.keys(msg.payload.environment ?? {}).join(","),
968
+ });
969
+ await this.runtimeApi.build(msg.payload.def, msg.payload.environment);
970
+ ack();
971
+ break;
972
+ }
973
+ case "Update": {
974
+ this.logCommand("Update", env, {
975
+ nodes: msg.payload.def.nodes?.length ?? 0,
976
+ edges: msg.payload.def.edges?.length ?? 0,
977
+ });
978
+ await this.runtimeApi.update(msg.payload.def);
979
+ ack();
980
+ break;
981
+ }
982
+ case "SetEnvironment": {
983
+ this.logCommand("SetEnvironment", env);
984
+ this.runtimeApi.setEnvironment(msg.payload.environment, {
985
+ merge: Boolean(msg.payload.merge),
986
+ });
987
+ ack();
988
+ break;
989
+ }
990
+ case "SetInput": {
991
+ this.logCommand("SetInput", env, {
992
+ nodeId: msg.payload.nodeId,
993
+ handle: msg.payload.handle,
994
+ value: summarize(msg.payload.value),
995
+ });
996
+ this.runtimeApi.setInput(msg.payload.nodeId, msg.payload.handle, msg.payload.value);
997
+ ack();
998
+ break;
999
+ }
1000
+ case "SetInputs": {
1001
+ this.logCommand("SetInputs", env, {
1002
+ nodeId: msg.payload.nodeId,
1003
+ keys: Object.keys(msg.payload.inputs || {}).join(","),
1004
+ });
1005
+ const nodeId = msg.payload.nodeId;
1006
+ const inputs = msg.payload.inputs;
1007
+ this.runtimeApi.setInputs(nodeId, inputs);
1008
+ ack();
1009
+ break;
1010
+ }
1011
+ case "TriggerExternal": {
1012
+ this.logCommand("TriggerExternal", env, {
1013
+ nodeId: msg.payload.nodeId,
1014
+ event: summarize(msg.payload.event),
1015
+ });
1016
+ this.runtimeApi.triggerExternal(msg.payload.nodeId, msg.payload.event);
1017
+ ack();
1018
+ break;
1019
+ }
1020
+ case "Launch": {
1021
+ this.logCommand("Launch", env);
1022
+ this.runtimeApi.launch(msg.payload.invalidate);
1023
+ ack();
1024
+ break;
1025
+ }
1026
+ case "WhenIdle": {
1027
+ this.logCommand("WhenIdle", env);
1028
+ await this.runtimeApi.whenIdle();
1029
+ ack();
1030
+ break;
1031
+ }
1032
+ case "Snapshot": {
1033
+ this.logCommand("Snapshot", env);
1034
+ const snap = this.runtimeApi.snapshot();
1035
+ ack({ snapshot: snap });
1036
+ break;
1037
+ }
1038
+ case "SnapshotFull": {
1039
+ this.logCommand("SnapshotFull", env);
1040
+ const snap = this.runtimeApi.snapshotFull();
1041
+ ack({ snapshot: snap });
1042
+ break;
1043
+ }
1044
+ case "GetEnvironment": {
1045
+ this.logCommand("GetEnvironment", env);
1046
+ const environment = this.runtimeApi.getEnvironment();
1047
+ ack({ environment });
1048
+ break;
1049
+ }
1050
+ case "ApplySnapshotFull": {
1051
+ this.logCommand("ApplySnapshotFull", env);
1052
+ await this.runtimeApi.applySnapshotFull(msg.payload);
1053
+ ack();
1054
+ break;
1055
+ }
1056
+ case "Coerce": {
1057
+ this.logCommand("Coerce", env, {
1058
+ from: msg.payload.from,
1059
+ to: msg.payload.to,
1060
+ });
1061
+ const value = await this.runtimeApi.coerce(msg.payload?.from, msg.payload?.to, msg.payload?.value);
1062
+ ack({ value });
1063
+ break;
1064
+ }
1065
+ case "DescribeRegistry": {
1066
+ this.logCommand("DescribeRegistry", env);
1067
+ const desc = this.runtimeApi.describeRegistry();
1068
+ ack({ registry: desc });
1069
+ break;
1070
+ }
1071
+ case "RegistryApply": {
1072
+ this.logCommand("RegistryApply", env);
1073
+ await this.runtimeApi.applyRegistry(msg.payload.deltas || []);
1074
+ ack();
1075
+ break;
1076
+ }
1077
+ case "Dispose": {
1078
+ this.logCommand("Dispose", env);
1079
+ this.runtimeApi.dispose();
1080
+ ack();
1081
+ break;
1082
+ }
1083
+ case "Pause":
1084
+ case "Resume": {
1085
+ this.logCommand(`${msg.type} (not-impl)`, env);
1086
+ ack();
1087
+ break;
1088
+ }
1089
+ default: {
1090
+ this.logCommand("Unknown type", env);
1091
+ ack();
1092
+ }
1093
+ }
1094
+ }
1095
+ /**
1096
+ * Helper method to log commands with consistent format.
1097
+ */
1098
+ logCommand(type, env, extra) {
1099
+ const extraStr = extra
1100
+ ? ` ${Object.entries(extra)
1101
+ .map(([k, v]) => `${k}=${v}`)
1102
+ .join(" ")}`
1103
+ : "";
1104
+ console.debug(`[${this.label}] rx ${type} seq=${env.seq}${extraStr}`);
1105
+ }
1106
+ /**
1107
+ * Handle errors and notify error handler if provided.
1108
+ * Formats errors as SystemError since these are infrastructure/command processing errors,
1109
+ * not node execution errors.
1110
+ */
1111
+ handleError(env, err) {
1112
+ console.error(`[${this.label}] error handling command:`, err);
1113
+ const serialized = serializeError(err);
1114
+ const systemError = {
1115
+ kind: "system",
1116
+ message: serialized?.message || String(err) || "Unknown error",
1117
+ code: serialized?.code || serialized?.statusCode || 500,
1118
+ err: serialized,
1119
+ };
1120
+ const errorEnv = {
1121
+ seq: env.seq ?? Date.now(),
1122
+ ts: Date.now(),
1123
+ message: { type: "error", payload: systemError },
1124
+ };
1125
+ if (this.onError) {
1126
+ this.onError(errorEnv);
1127
+ }
1128
+ }
1129
+ }
1130
+
1131
+ exports.HttpPollingTransport = HttpPollingTransport;
1132
+ exports.RemoteEngine = RemoteEngine;
1133
+ exports.RuntimeApiClient = RuntimeApiClient;
1134
+ exports.RuntimeApiServer = RuntimeApiServer;
1135
+ exports.WebSocketTransport = WebSocketTransport;
1136
+ exports.createRuntimeAdapter = createRuntimeAdapter;
1137
+ exports.serializeError = serializeError;
1138
+ exports.summarize = summarize;
16
1139
  //# sourceMappingURL=index.cjs.map