@tangle-network/blueprint-ui 0.5.6 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,607 @@
1
+ import {
2
+ NO_WALLET_ADDRESS,
3
+ TANGLE_CLOUD_ORIGINS_DEFAULT,
4
+ TANGLE_IFRAME_PROTOCOL_VERSION,
5
+ detectTangleCloudParentOrigin,
6
+ makeCorrelationId
7
+ } from "../chunk-TM5ROMDV.js";
8
+
9
+ // src/iframe/TangleIframeProvider.tsx
10
+ import {
11
+ createContext,
12
+ useContext,
13
+ useEffect,
14
+ useMemo,
15
+ useRef,
16
+ useState
17
+ } from "react";
18
+
19
+ // src/iframe/tangleIframeClient.ts
20
+ var DEFAULT_REQUEST_TIMEOUT_MS = 6e4;
21
+ var CONNECT_REQUEST_TIMEOUT_MS = 3e5;
22
+ var HANDSHAKE_RETRY_MS = 250;
23
+ var HANDSHAKE_RETRY_BUDGET_MS = 1e4;
24
+ var NULL_WALLET = {
25
+ address: null,
26
+ chainId: null,
27
+ isConnected: false
28
+ };
29
+ var NULL_SERVICE = {
30
+ blueprintId: null,
31
+ serviceId: null,
32
+ operators: [],
33
+ jobs: [],
34
+ mode: null,
35
+ chain: null
36
+ };
37
+ var TangleIframeClient = class {
38
+ constructor(options) {
39
+ this.options = options;
40
+ }
41
+ options;
42
+ wallet = NULL_WALLET;
43
+ service = NULL_SERVICE;
44
+ handshakeAcked = false;
45
+ handshakeWaiters = [];
46
+ installed = false;
47
+ handshakeRetry = null;
48
+ listeners = {
49
+ wallet: /* @__PURE__ */ new Set(),
50
+ service: /* @__PURE__ */ new Set(),
51
+ job: /* @__PURE__ */ new Set()
52
+ };
53
+ pendingJobs = /* @__PURE__ */ new Map();
54
+ /** Wire the global message listener + initial handshake. Idempotent. */
55
+ install() {
56
+ if (this.installed || typeof window === "undefined") return;
57
+ this.installed = true;
58
+ window.addEventListener("message", this.handleParentMessage);
59
+ this.postHandshake();
60
+ if (this.handshakeRetry === null) {
61
+ let elapsed = 0;
62
+ this.handshakeRetry = setInterval(() => {
63
+ elapsed += HANDSHAKE_RETRY_MS;
64
+ if (this.handshakeAcked || elapsed >= HANDSHAKE_RETRY_BUDGET_MS) {
65
+ this.clearHandshakeRetry();
66
+ return;
67
+ }
68
+ this.postHandshake();
69
+ }, HANDSHAKE_RETRY_MS);
70
+ }
71
+ }
72
+ uninstall() {
73
+ if (!this.installed || typeof window === "undefined") return;
74
+ this.installed = false;
75
+ this.clearHandshakeRetry();
76
+ window.removeEventListener("message", this.handleParentMessage);
77
+ for (const [, pending] of this.pendingJobs) {
78
+ clearTimeout(pending.timer);
79
+ pending.reject(new Error("Tangle iframe client uninstalled"));
80
+ }
81
+ this.pendingJobs.clear();
82
+ }
83
+ // ── State accessors ─────────────────────────────────────────────────────
84
+ getWallet() {
85
+ return this.wallet;
86
+ }
87
+ getService() {
88
+ return this.service;
89
+ }
90
+ // ── Subscription API (used by React hooks) ──────────────────────────────
91
+ subscribe(event, listener) {
92
+ this.listeners[event].add(listener);
93
+ return () => {
94
+ this.listeners[event].delete(listener);
95
+ };
96
+ }
97
+ // ── Wallet operations ───────────────────────────────────────────────────
98
+ /**
99
+ * Ask the parent dapp to connect a wallet — opening its connect modal if
100
+ * none is connected. The iframe is sandboxed and cannot reach a wallet
101
+ * itself, so connection is always delegated to the parent. Resolves with the
102
+ * connected address (or `null` if the user dismissed without connecting).
103
+ *
104
+ * Uses a long timeout (the user is interacting with a modal). Already-
105
+ * connected parents resolve immediately.
106
+ */
107
+ async connect() {
108
+ await this.ensureBootstrapped();
109
+ const data = await this.dispatchWallet(
110
+ "tangle.app.requestConnect",
111
+ {},
112
+ CONNECT_REQUEST_TIMEOUT_MS
113
+ );
114
+ const { account, chainId } = data;
115
+ const address = account === NO_WALLET_ADDRESS ? null : account;
116
+ this.updateWallet({ address, chainId, isConnected: address !== null });
117
+ return address;
118
+ }
119
+ async signMessage(message) {
120
+ await this.ensureBootstrapped();
121
+ return this.dispatchWallet("tangle.app.signMessage", {
122
+ chainId: this.wallet.chainId ?? 0,
123
+ message
124
+ }).then((data) => data.signature);
125
+ }
126
+ async sendTransaction(tx) {
127
+ await this.ensureBootstrapped();
128
+ return this.dispatchWallet("tangle.app.signTransaction", {
129
+ chainId: this.wallet.chainId ?? 0,
130
+ to: tx.to,
131
+ data: tx.data,
132
+ ...tx.value !== void 0 ? { value: tx.value.toString(10) } : {}
133
+ }).then((data) => data.txHash);
134
+ }
135
+ async switchChain(chainId) {
136
+ await this.ensureBootstrapped();
137
+ return this.dispatchWallet("tangle.app.switchChain", { chainId }).then(
138
+ (data) => data.chainId
139
+ );
140
+ }
141
+ /**
142
+ * EIP-712 typed-data signing. The parent renders the typed-data fields in
143
+ * its approval modal; the user audits what they're signing. Use for
144
+ * operator envelopes, off-chain attestations, anything that needs a
145
+ * signature outside the standard blueprint-job RFQ flow.
146
+ *
147
+ * Shape mirrors viem's `signTypedData` argument. Do not include the
148
+ * EIP712Domain entry in `types` — the parent injects it from `domain`.
149
+ */
150
+ async signTypedData(args) {
151
+ await this.ensureBootstrapped();
152
+ return this.dispatchWallet("tangle.app.signTypedData", {
153
+ chainId: this.wallet.chainId ?? 0,
154
+ domain: args.domain,
155
+ types: args.types,
156
+ primaryType: args.primaryType,
157
+ message: args.message
158
+ }).then((data) => data.signature);
159
+ }
160
+ // ── Job invocation ──────────────────────────────────────────────────────
161
+ /**
162
+ * Invoke a blueprint job. Returns a Promise that resolves on terminal
163
+ * status (`success` or `error`); subscribe to the `job` event for
164
+ * intermediate streaming chunks.
165
+ *
166
+ * Streaming opt-in: pass `stream: true` if the publisher's job emits
167
+ * chunks (LLM generation, video encoding). One-shot jobs (embeddings,
168
+ * classifications) skip the streaming machinery.
169
+ */
170
+ async callJob(args) {
171
+ await this.ensureBootstrapped();
172
+ const correlationId = makeCorrelationId("tangle.app.callJob");
173
+ const timeout = this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
174
+ return new Promise((resolve, reject) => {
175
+ const invocation = {
176
+ correlationId,
177
+ status: "pending",
178
+ chunks: []
179
+ };
180
+ const timer = setTimeout(() => {
181
+ this.pendingJobs.delete(correlationId);
182
+ reject(
183
+ bridgeError(4900, `Job did not respond within ${timeout}ms`)
184
+ );
185
+ }, timeout);
186
+ this.pendingJobs.set(correlationId, {
187
+ resolve,
188
+ reject,
189
+ timer,
190
+ invocation
191
+ });
192
+ const message = {
193
+ kind: "tangle.app.callJob",
194
+ correlationId,
195
+ jobIndex: args.jobIndex,
196
+ inputs: args.inputs,
197
+ ...args.stream !== void 0 ? { stream: args.stream } : {}
198
+ };
199
+ this.postToParent(message);
200
+ this.emit("job", invocation);
201
+ });
202
+ }
203
+ // ── Internals ───────────────────────────────────────────────────────────
204
+ clearHandshakeRetry() {
205
+ if (this.handshakeRetry !== null) {
206
+ clearInterval(this.handshakeRetry);
207
+ this.handshakeRetry = null;
208
+ }
209
+ }
210
+ postHandshake() {
211
+ this.postToParent({
212
+ kind: "tangle.app.handshake",
213
+ appId: this.options.appId,
214
+ version: TANGLE_IFRAME_PROTOCOL_VERSION
215
+ });
216
+ }
217
+ postToParent(message) {
218
+ if (typeof window === "undefined") return;
219
+ try {
220
+ window.parent.postMessage(message, this.options.parentOrigin);
221
+ } catch {
222
+ }
223
+ }
224
+ handleParentMessage = (event) => {
225
+ if (event.origin !== this.options.parentOrigin) return;
226
+ const data = event.data;
227
+ if (typeof data !== "object" || data === null) return;
228
+ const message = data;
229
+ switch (message.kind) {
230
+ case "tangle.app.handshakeAck":
231
+ this.handshakeAcked = true;
232
+ this.clearHandshakeRetry();
233
+ for (const resolve of this.handshakeWaiters) resolve();
234
+ this.handshakeWaiters = [];
235
+ return;
236
+ case "tangle.app.readAccountResult":
237
+ if (message.ok) {
238
+ this.updateWallet({
239
+ address: message.data.account === NO_WALLET_ADDRESS ? null : message.data.account,
240
+ chainId: message.data.chainId,
241
+ isConnected: message.data.account !== NO_WALLET_ADDRESS
242
+ });
243
+ }
244
+ return;
245
+ case "tangle.app.accountChanged":
246
+ this.updateWallet({
247
+ address: message.account,
248
+ chainId: this.wallet.chainId,
249
+ isConnected: message.account !== null
250
+ });
251
+ return;
252
+ case "tangle.app.chainChanged":
253
+ this.updateWallet({
254
+ address: this.wallet.address,
255
+ chainId: message.chainId,
256
+ isConnected: this.wallet.isConnected
257
+ });
258
+ return;
259
+ case "tangle.app.serviceContext":
260
+ this.updateService(message);
261
+ return;
262
+ case "tangle.app.jobResult":
263
+ this.handleJobResult(message);
264
+ return;
265
+ // Wallet-shape responses (signMessageResult etc.) are routed by
266
+ // dispatchWallet's promise resolver, not here.
267
+ default:
268
+ return;
269
+ }
270
+ };
271
+ async dispatchWallet(kind, payload, timeoutMs) {
272
+ return new Promise((resolve, reject) => {
273
+ const correlationId = makeCorrelationId(kind);
274
+ const timeout = timeoutMs ?? this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
275
+ const expectedKind = {
276
+ "tangle.app.signMessage": "tangle.app.signMessageResult",
277
+ "tangle.app.signTransaction": "tangle.app.signTransactionResult",
278
+ "tangle.app.signTypedData": "tangle.app.signTypedDataResult",
279
+ "tangle.app.switchChain": "tangle.app.switchChainResult",
280
+ "tangle.app.requestConnect": "tangle.app.connectResult"
281
+ }[kind];
282
+ const timer = setTimeout(() => {
283
+ window.removeEventListener("message", listener);
284
+ reject(bridgeError(4900, `Parent did not respond to ${kind} in ${timeout}ms`));
285
+ }, timeout);
286
+ const listener = (event) => {
287
+ if (event.origin !== this.options.parentOrigin) return;
288
+ const data = event.data;
289
+ if (typeof data !== "object" || data === null) return;
290
+ const msg = data;
291
+ if (msg.kind !== expectedKind || !("correlationId" in msg) || msg.correlationId !== correlationId) {
292
+ return;
293
+ }
294
+ clearTimeout(timer);
295
+ window.removeEventListener("message", listener);
296
+ const env = msg;
297
+ if (env.ok) {
298
+ resolve(env.data);
299
+ } else {
300
+ reject(bridgeError(4001, env.error ?? "Parent rejected request"));
301
+ }
302
+ };
303
+ window.addEventListener("message", listener);
304
+ this.postToParent({ kind, correlationId, ...payload });
305
+ });
306
+ }
307
+ handleJobResult(message) {
308
+ const pending = this.pendingJobs.get(message.correlationId);
309
+ if (!pending) return;
310
+ const updated = {
311
+ correlationId: message.correlationId,
312
+ status: message.status,
313
+ chunks: message.chunk !== void 0 ? [...pending.invocation.chunks, message.chunk] : pending.invocation.chunks,
314
+ ...message.data !== void 0 ? { data: message.data } : {},
315
+ ...message.error !== void 0 ? { error: message.error } : {},
316
+ ...message.progress !== void 0 ? { progress: message.progress } : {}
317
+ };
318
+ pending.invocation = updated;
319
+ this.emit("job", updated);
320
+ if (message.status === "success" || message.status === "error") {
321
+ clearTimeout(pending.timer);
322
+ this.pendingJobs.delete(message.correlationId);
323
+ if (message.status === "success") {
324
+ pending.resolve(updated);
325
+ } else {
326
+ pending.reject(bridgeError(4001, message.error ?? "Job failed"));
327
+ }
328
+ }
329
+ }
330
+ updateWallet(next) {
331
+ if (this.wallet.address === next.address && this.wallet.chainId === next.chainId && this.wallet.isConnected === next.isConnected) {
332
+ return;
333
+ }
334
+ this.wallet = next;
335
+ this.emit("wallet", next);
336
+ }
337
+ updateService(broadcast) {
338
+ const next = {
339
+ blueprintId: broadcast.blueprintId,
340
+ serviceId: broadcast.serviceId,
341
+ operators: broadcast.operators,
342
+ jobs: broadcast.jobs,
343
+ mode: broadcast.mode,
344
+ chain: broadcast.chain ?? null
345
+ };
346
+ this.service = next;
347
+ this.emit("service", next);
348
+ }
349
+ emit(event, value) {
350
+ for (const listener of [...this.listeners[event]]) {
351
+ try {
352
+ listener(value);
353
+ } catch {
354
+ }
355
+ }
356
+ }
357
+ async ensureBootstrapped() {
358
+ if (this.handshakeAcked) return;
359
+ this.install();
360
+ await new Promise((resolve) => {
361
+ this.handshakeWaiters.push(resolve);
362
+ const retry = setInterval(() => {
363
+ if (this.handshakeAcked) {
364
+ clearInterval(retry);
365
+ return;
366
+ }
367
+ this.postHandshake();
368
+ }, 500);
369
+ setTimeout(() => clearInterval(retry), 1e4);
370
+ });
371
+ }
372
+ };
373
+ function bridgeError(code, message) {
374
+ const err = new Error(message);
375
+ err.code = code;
376
+ return err;
377
+ }
378
+
379
+ // src/iframe/TangleIframeProvider.tsx
380
+ import { jsx } from "react/jsx-runtime";
381
+ var TangleIframeContext = createContext(null);
382
+ var NULL_WALLET2 = {
383
+ address: null,
384
+ chainId: null,
385
+ isConnected: false
386
+ };
387
+ var NULL_SERVICE2 = {
388
+ blueprintId: null,
389
+ serviceId: null,
390
+ operators: [],
391
+ jobs: [],
392
+ mode: null,
393
+ chain: null
394
+ };
395
+ function TangleIframeProvider({
396
+ appId,
397
+ parentOrigin: explicitOrigin,
398
+ extraOrigins,
399
+ mode: requestedMode = "auto",
400
+ children
401
+ }) {
402
+ const resolution = useMemo(() => {
403
+ if (requestedMode === "dev") {
404
+ return { mode: "dev", parentOrigin: null };
405
+ }
406
+ const detected = explicitOrigin ?? detectTangleCloudParentOrigin({ extraOrigins });
407
+ if (requestedMode === "bridge") {
408
+ if (!detected) {
409
+ console.error(
410
+ '[TangleIframeProvider] mode="bridge" but no trusted parent was detected. Falling back to dev mode.'
411
+ );
412
+ return { mode: "dev", parentOrigin: null };
413
+ }
414
+ return { mode: "bridge", parentOrigin: detected };
415
+ }
416
+ return detected ? { mode: "bridge", parentOrigin: detected } : { mode: "dev", parentOrigin: null };
417
+ }, [requestedMode, explicitOrigin, extraOrigins]);
418
+ const clientRef = useRef(null);
419
+ const [wallet, setWallet] = useState(NULL_WALLET2);
420
+ const [service, setService] = useState(NULL_SERVICE2);
421
+ const [isReady, setIsReady] = useState(false);
422
+ useEffect(() => {
423
+ if (resolution.mode === "dev") {
424
+ setIsReady(true);
425
+ return void 0;
426
+ }
427
+ const options = {
428
+ parentOrigin: resolution.parentOrigin,
429
+ appId
430
+ };
431
+ const client = new TangleIframeClient(options);
432
+ clientRef.current = client;
433
+ const unsubWallet = client.subscribe("wallet", setWallet);
434
+ const unsubService = client.subscribe("service", setService);
435
+ client.install();
436
+ setIsReady(true);
437
+ return () => {
438
+ unsubWallet();
439
+ unsubService();
440
+ client.uninstall();
441
+ clientRef.current = null;
442
+ setIsReady(false);
443
+ };
444
+ }, [resolution, appId]);
445
+ const value = useMemo(
446
+ () => ({
447
+ client: clientRef.current,
448
+ wallet,
449
+ service,
450
+ mode: resolution.mode,
451
+ isReady
452
+ }),
453
+ [wallet, service, resolution.mode, isReady]
454
+ );
455
+ return /* @__PURE__ */ jsx(TangleIframeContext.Provider, { value, children });
456
+ }
457
+ function useTangleIframeContext() {
458
+ const ctx = useContext(TangleIframeContext);
459
+ if (!ctx) {
460
+ throw new Error(
461
+ "useTangleIframeContext must be used inside <TangleIframeProvider>."
462
+ );
463
+ }
464
+ return ctx;
465
+ }
466
+
467
+ // src/iframe/hooks.ts
468
+ import { useCallback, useEffect as useEffect2, useMemo as useMemo2, useState as useState2 } from "react";
469
+ import {
470
+ createPublicClient,
471
+ http
472
+ } from "viem";
473
+ function useTangleWallet() {
474
+ const { client, wallet } = useTangleIframeContext();
475
+ const connect = useCallback(() => {
476
+ if (!client) throw new Error("Wallet not available in dev mode.");
477
+ return client.connect();
478
+ }, [client]);
479
+ const signMessage = useCallback(
480
+ (message) => {
481
+ if (!client) throw new Error("Wallet not available in dev mode.");
482
+ return client.signMessage(message);
483
+ },
484
+ [client]
485
+ );
486
+ const sendTransaction = useCallback(
487
+ (tx) => {
488
+ if (!client) throw new Error("Wallet not available in dev mode.");
489
+ return client.sendTransaction(tx);
490
+ },
491
+ [client]
492
+ );
493
+ const signTypedData = useCallback(
494
+ (args) => {
495
+ if (!client) throw new Error("Wallet not available in dev mode.");
496
+ return client.signTypedData(args);
497
+ },
498
+ [client]
499
+ );
500
+ const switchChain = useCallback(
501
+ (chainId) => {
502
+ if (!client) throw new Error("Wallet not available in dev mode.");
503
+ return client.switchChain(chainId);
504
+ },
505
+ [client]
506
+ );
507
+ return {
508
+ ...wallet,
509
+ connect,
510
+ signMessage,
511
+ sendTransaction,
512
+ signTypedData,
513
+ switchChain
514
+ };
515
+ }
516
+ function useChainContext() {
517
+ return useTangleIframeContext().service.chain;
518
+ }
519
+ function useTanglePublicClient() {
520
+ const chain = useChainContext();
521
+ return useMemo2(() => {
522
+ if (!chain) return null;
523
+ const chainConfig = {
524
+ id: chain.id,
525
+ name: chain.name,
526
+ nativeCurrency: chain.nativeCurrency !== void 0 ? { ...chain.nativeCurrency } : { name: "Ether", symbol: "ETH", decimals: 18 },
527
+ rpcUrls: {
528
+ default: { http: [chain.rpcUrl] }
529
+ },
530
+ ...chain.blockExplorerUrl ? {
531
+ blockExplorers: {
532
+ default: { name: "Explorer", url: chain.blockExplorerUrl }
533
+ }
534
+ } : {}
535
+ };
536
+ return createPublicClient({
537
+ chain: chainConfig,
538
+ transport: http(chain.rpcUrl)
539
+ });
540
+ }, [chain]);
541
+ }
542
+ function useTangleService() {
543
+ return useTangleIframeContext().service;
544
+ }
545
+ function useCallJob() {
546
+ const { client } = useTangleIframeContext();
547
+ const [invocation, setInvocation] = useState2(null);
548
+ const [latestId, setLatestId] = useState2(null);
549
+ useEffect2(() => {
550
+ if (!client) return void 0;
551
+ return client.subscribe("job", (next) => {
552
+ setLatestId((prevLatest) => {
553
+ if (prevLatest === null || prevLatest === next.correlationId) {
554
+ setInvocation(next);
555
+ return next.correlationId;
556
+ }
557
+ return prevLatest;
558
+ });
559
+ });
560
+ }, [client]);
561
+ const call = useCallback(
562
+ async (args) => {
563
+ if (!client) {
564
+ throw new Error(
565
+ "Job invocation not available in dev mode without a configured stub. See `setDevJobHandler` in the testing harness."
566
+ );
567
+ }
568
+ setInvocation(null);
569
+ const result = await client.callJob(args);
570
+ setLatestId(result.correlationId);
571
+ return result;
572
+ },
573
+ [client]
574
+ );
575
+ const reset = useCallback(() => {
576
+ setInvocation(null);
577
+ setLatestId(null);
578
+ }, []);
579
+ return useMemo2(
580
+ () => ({ call, invocation, reset, isPending: invocation?.status === "pending" || invocation?.status === "streaming" }),
581
+ [call, invocation, reset]
582
+ );
583
+ }
584
+ function useTangleAddress() {
585
+ return useTangleIframeContext().wallet.address;
586
+ }
587
+ function useTangleReady() {
588
+ return useTangleIframeContext().isReady;
589
+ }
590
+ function useTangleMode() {
591
+ return useTangleIframeContext().mode;
592
+ }
593
+ export {
594
+ TANGLE_CLOUD_ORIGINS_DEFAULT,
595
+ TangleIframeClient,
596
+ TangleIframeProvider,
597
+ useCallJob,
598
+ useChainContext,
599
+ useTangleAddress,
600
+ useTangleIframeContext,
601
+ useTangleMode,
602
+ useTanglePublicClient,
603
+ useTangleReady,
604
+ useTangleService,
605
+ useTangleWallet
606
+ };
607
+ //# sourceMappingURL=index.js.map