@tangle-network/blueprint-ui 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,483 @@
1
+ // Thin-iframe SDK client — the framework-agnostic state machine that talks
2
+ // to a Tangle Cloud parent dapp over postMessage. React hooks (below) are
3
+ // thin wrappers around an instance of this class.
4
+ //
5
+ // Why a class, not a bag of functions: the iframe lifecycle is stateful —
6
+ // handshake, account changes, service-context broadcasts, in-flight job
7
+ // requests. The class owns that state once; hooks subscribe via listeners.
8
+ // Testing the protocol shape doesn't require React.
9
+
10
+ import type { Address, Hex } from 'viem';
11
+
12
+ import {
13
+ makeCorrelationId,
14
+ NO_WALLET_ADDRESS,
15
+ TANGLE_IFRAME_PROTOCOL_VERSION,
16
+ type CallJobRequest,
17
+ type ChainContext,
18
+ type JobInputs,
19
+ type JobResultEvent,
20
+ type JobResultStatus,
21
+ type ParentMessage,
22
+ type ServiceContextBroadcast,
23
+ type ServiceContextJob,
24
+ type ServiceContextOperator,
25
+ type SignTypedDataRequest,
26
+ } from '../wallet/parentBridgeProtocol';
27
+
28
+ export type WalletSnapshot = {
29
+ readonly address: Address | null;
30
+ readonly chainId: number | null;
31
+ readonly isConnected: boolean;
32
+ };
33
+
34
+ export type ServiceSnapshot = {
35
+ readonly blueprintId: string | null;
36
+ readonly serviceId: string | null;
37
+ readonly operators: readonly ServiceContextOperator[];
38
+ readonly jobs: readonly ServiceContextJob[];
39
+ readonly mode: string | null;
40
+ /** Chain context broadcast by the parent — drives `useTanglePublicClient`.
41
+ * `null` when the parent hasn't sent one (older parent or dev mode). */
42
+ readonly chain: ChainContext | null;
43
+ };
44
+
45
+ export type JobInvocation = {
46
+ readonly correlationId: string;
47
+ readonly status: JobResultStatus;
48
+ readonly data?: unknown;
49
+ readonly chunks: readonly unknown[];
50
+ readonly error?: string;
51
+ readonly progress?: { readonly percent?: number; readonly eta_ms?: number };
52
+ };
53
+
54
+ export type ClientEventMap = {
55
+ wallet: WalletSnapshot;
56
+ service: ServiceSnapshot;
57
+ job: JobInvocation;
58
+ };
59
+
60
+ type Listener<K extends keyof ClientEventMap> = (
61
+ value: ClientEventMap[K],
62
+ ) => void;
63
+
64
+ export type TangleIframeClientOptions = {
65
+ /**
66
+ * Origin of the parent dapp. The client posts every message with this
67
+ * exact `targetOrigin` and rejects inbound messages from any other origin.
68
+ * Pass `'*'` only in dev — production must pin to the real parent
69
+ * (`https://cloud.tangle.tools` etc.).
70
+ */
71
+ parentOrigin: string;
72
+ /**
73
+ * Stable identifier for this iframe app. The parent surfaces it in
74
+ * handshake logs + uses it for permission scoping.
75
+ */
76
+ appId: string;
77
+ /**
78
+ * Per-request timeout. Defaults to 60s — long enough for a user to
79
+ * read + approve a signing prompt in the parent. Long-running jobs
80
+ * stream progress events; the request "completes" only on terminal
81
+ * status, so the timeout protects against parents that drop replies
82
+ * entirely.
83
+ */
84
+ requestTimeoutMs?: number;
85
+ };
86
+
87
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
88
+ const NULL_WALLET: WalletSnapshot = {
89
+ address: null,
90
+ chainId: null,
91
+ isConnected: false,
92
+ };
93
+ const NULL_SERVICE: ServiceSnapshot = {
94
+ blueprintId: null,
95
+ serviceId: null,
96
+ operators: [],
97
+ jobs: [],
98
+ mode: null,
99
+ chain: null,
100
+ };
101
+
102
+ type PendingJob = {
103
+ resolve: (value: JobInvocation) => void;
104
+ reject: (reason: Error) => void;
105
+ timer: ReturnType<typeof setTimeout>;
106
+ invocation: JobInvocation;
107
+ };
108
+
109
+ export class TangleIframeClient {
110
+ private wallet: WalletSnapshot = NULL_WALLET;
111
+ private service: ServiceSnapshot = NULL_SERVICE;
112
+ private handshakeAcked = false;
113
+ private handshakeWaiters: Array<() => void> = [];
114
+ private installed = false;
115
+ private listeners: {
116
+ [K in keyof ClientEventMap]: Set<Listener<K>>;
117
+ } = {
118
+ wallet: new Set(),
119
+ service: new Set(),
120
+ job: new Set(),
121
+ };
122
+ private pendingJobs = new Map<string, PendingJob>();
123
+
124
+ constructor(private readonly options: TangleIframeClientOptions) {}
125
+
126
+ /** Wire the global message listener + initial handshake. Idempotent. */
127
+ install(): void {
128
+ if (this.installed || typeof window === 'undefined') return;
129
+ this.installed = true;
130
+ window.addEventListener('message', this.handleParentMessage);
131
+ this.postHandshake();
132
+ }
133
+
134
+ uninstall(): void {
135
+ if (!this.installed || typeof window === 'undefined') return;
136
+ this.installed = false;
137
+ window.removeEventListener('message', this.handleParentMessage);
138
+ for (const [, pending] of this.pendingJobs) {
139
+ clearTimeout(pending.timer);
140
+ pending.reject(new Error('Tangle iframe client uninstalled'));
141
+ }
142
+ this.pendingJobs.clear();
143
+ }
144
+
145
+ // ── State accessors ─────────────────────────────────────────────────────
146
+
147
+ getWallet(): WalletSnapshot {
148
+ return this.wallet;
149
+ }
150
+ getService(): ServiceSnapshot {
151
+ return this.service;
152
+ }
153
+
154
+ // ── Subscription API (used by React hooks) ──────────────────────────────
155
+
156
+ subscribe<K extends keyof ClientEventMap>(
157
+ event: K,
158
+ listener: Listener<K>,
159
+ ): () => void {
160
+ this.listeners[event].add(listener);
161
+ return () => {
162
+ this.listeners[event].delete(listener);
163
+ };
164
+ }
165
+
166
+ // ── Wallet operations ───────────────────────────────────────────────────
167
+
168
+ async signMessage(message: string): Promise<Hex> {
169
+ await this.ensureBootstrapped();
170
+ return this.dispatchWallet('tangle.app.signMessage', {
171
+ chainId: this.wallet.chainId ?? 0,
172
+ message,
173
+ }).then((data) => (data as { signature: Hex }).signature);
174
+ }
175
+
176
+ async sendTransaction(tx: {
177
+ to: Address;
178
+ data: Hex;
179
+ value?: bigint;
180
+ }): Promise<Hex> {
181
+ await this.ensureBootstrapped();
182
+ return this.dispatchWallet('tangle.app.signTransaction', {
183
+ chainId: this.wallet.chainId ?? 0,
184
+ to: tx.to,
185
+ data: tx.data,
186
+ ...(tx.value !== undefined ? { value: tx.value.toString(10) } : {}),
187
+ }).then((data) => (data as { txHash: Hex }).txHash);
188
+ }
189
+
190
+ async switchChain(chainId: number): Promise<number> {
191
+ await this.ensureBootstrapped();
192
+ return this.dispatchWallet('tangle.app.switchChain', { chainId }).then(
193
+ (data) => (data as { chainId: number }).chainId,
194
+ );
195
+ }
196
+
197
+ /**
198
+ * EIP-712 typed-data signing. The parent renders the typed-data fields in
199
+ * its approval modal; the user audits what they're signing. Use for
200
+ * operator envelopes, off-chain attestations, anything that needs a
201
+ * signature outside the standard blueprint-job RFQ flow.
202
+ *
203
+ * Shape mirrors viem's `signTypedData` argument. Do not include the
204
+ * EIP712Domain entry in `types` — the parent injects it from `domain`.
205
+ */
206
+ async signTypedData(args: {
207
+ domain: SignTypedDataRequest['domain'];
208
+ types: SignTypedDataRequest['types'];
209
+ primaryType: string;
210
+ message: Readonly<Record<string, unknown>>;
211
+ }): Promise<Hex> {
212
+ await this.ensureBootstrapped();
213
+ return this.dispatchWallet('tangle.app.signTypedData', {
214
+ chainId: this.wallet.chainId ?? 0,
215
+ domain: args.domain,
216
+ types: args.types,
217
+ primaryType: args.primaryType,
218
+ message: args.message,
219
+ }).then((data) => (data as { signature: Hex }).signature);
220
+ }
221
+
222
+ // ── Job invocation ──────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Invoke a blueprint job. Returns a Promise that resolves on terminal
226
+ * status (`success` or `error`); subscribe to the `job` event for
227
+ * intermediate streaming chunks.
228
+ *
229
+ * Streaming opt-in: pass `stream: true` if the publisher's job emits
230
+ * chunks (LLM generation, video encoding). One-shot jobs (embeddings,
231
+ * classifications) skip the streaming machinery.
232
+ */
233
+ async callJob(args: {
234
+ jobIndex: number;
235
+ inputs: JobInputs;
236
+ stream?: boolean;
237
+ }): Promise<JobInvocation> {
238
+ await this.ensureBootstrapped();
239
+ const correlationId = makeCorrelationId('tangle.app.callJob');
240
+ const timeout =
241
+ this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
242
+ return new Promise<JobInvocation>((resolve, reject) => {
243
+ const invocation: JobInvocation = {
244
+ correlationId,
245
+ status: 'pending',
246
+ chunks: [],
247
+ };
248
+ const timer = setTimeout(() => {
249
+ this.pendingJobs.delete(correlationId);
250
+ reject(
251
+ bridgeError(4900, `Job did not respond within ${timeout}ms`),
252
+ );
253
+ }, timeout);
254
+ this.pendingJobs.set(correlationId, {
255
+ resolve,
256
+ reject,
257
+ timer,
258
+ invocation,
259
+ });
260
+ const message: CallJobRequest = {
261
+ kind: 'tangle.app.callJob',
262
+ correlationId,
263
+ jobIndex: args.jobIndex,
264
+ inputs: args.inputs,
265
+ ...(args.stream !== undefined ? { stream: args.stream } : {}),
266
+ };
267
+ this.postToParent(message);
268
+ // Emit pending immediately so consumer UIs can show optimistic state.
269
+ this.emit('job', invocation);
270
+ });
271
+ }
272
+
273
+ // ── Internals ───────────────────────────────────────────────────────────
274
+
275
+ private postHandshake(): void {
276
+ this.postToParent({
277
+ kind: 'tangle.app.handshake',
278
+ appId: this.options.appId,
279
+ version: TANGLE_IFRAME_PROTOCOL_VERSION,
280
+ });
281
+ }
282
+
283
+ private postToParent(message: object): void {
284
+ if (typeof window === 'undefined') return;
285
+ try {
286
+ window.parent.postMessage(message, this.options.parentOrigin);
287
+ } catch {
288
+ // Cross-origin / sandboxed; defensive only — postMessage shouldn't throw.
289
+ }
290
+ }
291
+
292
+ private handleParentMessage = (event: MessageEvent): void => {
293
+ if (event.origin !== this.options.parentOrigin) return;
294
+ const data = event.data;
295
+ if (typeof data !== 'object' || data === null) return;
296
+ const message = data as ParentMessage;
297
+ switch (message.kind) {
298
+ case 'tangle.app.handshakeAck':
299
+ this.handshakeAcked = true;
300
+ for (const resolve of this.handshakeWaiters) resolve();
301
+ this.handshakeWaiters = [];
302
+ return;
303
+ case 'tangle.app.readAccountResult':
304
+ if (message.ok) {
305
+ this.updateWallet({
306
+ address:
307
+ message.data.account === NO_WALLET_ADDRESS
308
+ ? null
309
+ : message.data.account,
310
+ chainId: message.data.chainId,
311
+ isConnected: message.data.account !== NO_WALLET_ADDRESS,
312
+ });
313
+ }
314
+ return;
315
+ case 'tangle.app.accountChanged':
316
+ this.updateWallet({
317
+ address: message.account,
318
+ chainId: this.wallet.chainId,
319
+ isConnected: message.account !== null,
320
+ });
321
+ return;
322
+ case 'tangle.app.chainChanged':
323
+ this.updateWallet({
324
+ address: this.wallet.address,
325
+ chainId: message.chainId,
326
+ isConnected: this.wallet.isConnected,
327
+ });
328
+ return;
329
+ case 'tangle.app.serviceContext':
330
+ this.updateService(message);
331
+ return;
332
+ case 'tangle.app.jobResult':
333
+ this.handleJobResult(message);
334
+ return;
335
+ // Wallet-shape responses (signMessageResult etc.) are routed by
336
+ // dispatchWallet's promise resolver, not here.
337
+ default:
338
+ return;
339
+ }
340
+ };
341
+
342
+ private async dispatchWallet(
343
+ kind:
344
+ | 'tangle.app.signMessage'
345
+ | 'tangle.app.signTransaction'
346
+ | 'tangle.app.signTypedData'
347
+ | 'tangle.app.switchChain',
348
+ payload: Record<string, unknown>,
349
+ ): Promise<unknown> {
350
+ return new Promise((resolve, reject) => {
351
+ const correlationId = makeCorrelationId(kind);
352
+ const timeout =
353
+ this.options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
354
+ const expectedKind = (
355
+ {
356
+ 'tangle.app.signMessage': 'tangle.app.signMessageResult',
357
+ 'tangle.app.signTransaction': 'tangle.app.signTransactionResult',
358
+ 'tangle.app.signTypedData': 'tangle.app.signTypedDataResult',
359
+ 'tangle.app.switchChain': 'tangle.app.switchChainResult',
360
+ } as const
361
+ )[kind];
362
+ const timer = setTimeout(() => {
363
+ window.removeEventListener('message', listener);
364
+ reject(bridgeError(4900, `Parent did not respond to ${kind} in ${timeout}ms`));
365
+ }, timeout);
366
+ const listener = (event: MessageEvent) => {
367
+ if (event.origin !== this.options.parentOrigin) return;
368
+ const data = event.data;
369
+ if (typeof data !== 'object' || data === null) return;
370
+ const msg = data as ParentMessage;
371
+ if (
372
+ msg.kind !== expectedKind ||
373
+ !('correlationId' in msg) ||
374
+ msg.correlationId !== correlationId
375
+ ) {
376
+ return;
377
+ }
378
+ clearTimeout(timer);
379
+ window.removeEventListener('message', listener);
380
+ // Narrow the type — expectedKind is the wallet-shape `{ok, data|error}` envelope
381
+ const env = msg as {
382
+ ok: boolean;
383
+ data?: unknown;
384
+ error?: string;
385
+ };
386
+ if (env.ok) {
387
+ resolve(env.data);
388
+ } else {
389
+ reject(bridgeError(4001, env.error ?? 'Parent rejected request'));
390
+ }
391
+ };
392
+ window.addEventListener('message', listener);
393
+ this.postToParent({ kind, correlationId, ...payload });
394
+ });
395
+ }
396
+
397
+ private handleJobResult(message: JobResultEvent): void {
398
+ const pending = this.pendingJobs.get(message.correlationId);
399
+ if (!pending) return;
400
+ const updated: JobInvocation = {
401
+ correlationId: message.correlationId,
402
+ status: message.status,
403
+ chunks:
404
+ message.chunk !== undefined
405
+ ? [...pending.invocation.chunks, message.chunk]
406
+ : pending.invocation.chunks,
407
+ ...(message.data !== undefined ? { data: message.data } : {}),
408
+ ...(message.error !== undefined ? { error: message.error } : {}),
409
+ ...(message.progress !== undefined ? { progress: message.progress } : {}),
410
+ };
411
+ pending.invocation = updated;
412
+ this.emit('job', updated);
413
+ if (message.status === 'success' || message.status === 'error') {
414
+ clearTimeout(pending.timer);
415
+ this.pendingJobs.delete(message.correlationId);
416
+ if (message.status === 'success') {
417
+ pending.resolve(updated);
418
+ } else {
419
+ pending.reject(bridgeError(4001, message.error ?? 'Job failed'));
420
+ }
421
+ }
422
+ }
423
+
424
+ private updateWallet(next: WalletSnapshot): void {
425
+ if (
426
+ this.wallet.address === next.address &&
427
+ this.wallet.chainId === next.chainId &&
428
+ this.wallet.isConnected === next.isConnected
429
+ ) {
430
+ return;
431
+ }
432
+ this.wallet = next;
433
+ this.emit('wallet', next);
434
+ }
435
+
436
+ private updateService(broadcast: ServiceContextBroadcast): void {
437
+ const next: ServiceSnapshot = {
438
+ blueprintId: broadcast.blueprintId,
439
+ serviceId: broadcast.serviceId,
440
+ operators: broadcast.operators,
441
+ jobs: broadcast.jobs,
442
+ mode: broadcast.mode,
443
+ chain: broadcast.chain ?? null,
444
+ };
445
+ this.service = next;
446
+ this.emit('service', next);
447
+ }
448
+
449
+ private emit<K extends keyof ClientEventMap>(
450
+ event: K,
451
+ value: ClientEventMap[K],
452
+ ): void {
453
+ for (const listener of [...this.listeners[event]]) {
454
+ try {
455
+ (listener as Listener<K>)(value);
456
+ } catch {
457
+ // Listener bugs shouldn't break the bridge.
458
+ }
459
+ }
460
+ }
461
+
462
+ private async ensureBootstrapped(): Promise<void> {
463
+ if (this.handshakeAcked) return;
464
+ this.install();
465
+ await new Promise<void>((resolve) => {
466
+ this.handshakeWaiters.push(resolve);
467
+ const retry = setInterval(() => {
468
+ if (this.handshakeAcked) {
469
+ clearInterval(retry);
470
+ return;
471
+ }
472
+ this.postHandshake();
473
+ }, 500);
474
+ setTimeout(() => clearInterval(retry), 10_000);
475
+ });
476
+ }
477
+ }
478
+
479
+ function bridgeError(code: number, message: string): Error {
480
+ const err = new Error(message) as Error & { code?: number };
481
+ err.code = code;
482
+ return err;
483
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Testing-only utilities for the iframe SDK. Imported from
3
+ * `@tangle-network/blueprint-ui/iframe/testing` so production bundles don't
4
+ * pull in the debug panel + mock factories.
5
+ */
6
+
7
+ export {
8
+ TangleParentHarness,
9
+ HARNESS_ORIGIN,
10
+ mockWallet,
11
+ mockServiceContext,
12
+ type CallJobHandler,
13
+ type MockServiceInput,
14
+ type MockWalletInput,
15
+ } from './testing';