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