diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 40b108d..e1301c7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -34,6 +34,7 @@ GoodWidget/ core/ # @goodwidget/core — provider, hooks, EIP-1193, host detection ui/ # @goodwidget/ui — Tamagui component library, theme system embed/ # @goodwidget/embed — Web Component wrapper + CSS bridge + bridge/ # @goodwidget/bridge — iframe/WebView EIP-1193 bridge + EmbeddedWidget claim-widget/ # @goodwidget/claim-widget — sample publishable widget (React + Web Component) examples/ @@ -57,6 +58,9 @@ GoodWidget/ @goodwidget/core (depends on @goodwidget/ui for createGoodWidgetConfig, mergeThemeOverrides) ^ | +@goodwidget/bridge (depends on core — iframe/WebView provider bridge) + ^ + | @goodwidget/embed (depends on core + ui, plus @r2wc/react-to-web-component) ``` @@ -234,6 +238,7 @@ custom element before being passed to `GoodWidgetProvider`. | `@goodwidget/core` | tsup | ESM + CJS + `.d.ts` (two entry points: index, wagmi) | | `@goodwidget/ui` | tsup | ESM + CJS + `.d.ts` | | `@goodwidget/embed` | tsup | ESM + CJS + `.d.ts` | +| `@goodwidget/bridge` | tsup | ESM + CJS + `.d.ts` (four entry points: index, child, host, inject) | | `@goodwidget/claim-widget` | tsup | ESM + CJS + `.d.ts` (three entry points: index, element, register) | | `examples/react-web` | Vite + @vitejs/plugin-react | Static site | | `examples/html` | Vite | Static site (bundles widget + React into a single JS file) | @@ -346,6 +351,117 @@ See `packages/claim-widget/` for a complete working example. --- +## Package: `@goodwidget/bridge` + +**NPM name:** `@goodwidget/bridge` +**Entry points:** `index.ts` (all), `child.ts` (embedded app), `host.ts` (embedding app), `inject.ts` (provider injection) +**Build:** tsup -> ESM + CJS + `.d.ts` + +### Purpose + +Bridges an EIP-1193 wallet provider from a host application to a third-party widget +running inside an **iframe** or **WebView**. The host's real wallet provider stays in +the parent context; the child receives a proxy provider that routes requests over +`postMessage`. + +### Key files + +| File | Responsibility | +|------|----------------| +| `src/protocol.ts` | Message envelope types (`init`, `init-ack`, `request`, `response`, `event`), namespace guard, version | +| `src/childProvider.ts` | `BridgeProvider` — EIP-1193-compatible class that sends requests via postMessage to the host | +| `src/hostRouter.ts` | `HostRouter` — listens for child messages, validates origins, forwards to real provider, broadcasts events | +| `src/inject.ts` | `injectBridgeProvider()` — sets `window.ethereum`, `window.goodWidget.provider`, announces via EIP-6963 | +| `src/enableIframeBridge.ts` | `enableIframeBridge()` — child opt-in helper: creates provider, performs handshake, injects | +| `src/createIframeBridgeHost.ts` | `createIframeBridgeHost()` — host-side convenience for a single iframe | +| `src/webviewInjection.ts` | `createWebViewBridgeScript()` — generates self-contained JS to inject into React Native WebView | +| `src/EmbeddedWidget.tsx` | `` — React component that renders an iframe with auto-configured bridge | + +### Bridge protocol (v1) + +All messages carry `ns: 'gw-bridge'` and `version: '1.0.0'`. Messages without the +namespace are silently ignored. + +**Handshake:** +1. Child sends `init` with optional `appId` and `capabilities`. +2. Host validates `event.origin` against its allowlist. +3. Host responds `init-ack` with a `sessionId` and optional `initialState` (accounts, chainId). + +**RPC flow:** +1. Child sends `request` with `method`, `params`, `sessionId`. +2. Host calls `provider.request()` and sends `response` with `result` or `error`. + +**Events:** +Host forwards `accountsChanged`, `chainChanged`, `connect`, `disconnect` as `event` messages. + +### EIP-6963 integration + +The bridge provider is announced via the standard EIP-6963 multi-provider discovery mechanism: + +- **RDNS:** `org.gooddollar.goodwidget.bridge` +- **Name:** GoodWidget Bridge +- **Behavior:** `injectBridgeProvider()` dispatches `eip6963:announceProvider` and listens for + `eip6963:requestProvider` to re-announce. +- **Detection:** `discoverEIP6963Provider()` listens for announcements and resolves the first matching provider. +- **Scope:** Browser/iframe contexts. For React Native WebView, direct injection (`window.ethereum`) is canonical. + +### Security model (medium) + +- **Origin allowlist:** Host specifies `allowedOrigins`; child specifies `allowedParents`. + Messages from unknown origins are silently dropped. +- **Handshake gating:** No RPC requests are processed until the handshake completes. +- **Session binding:** Each child gets a unique `sessionId`; responses are scoped to the session. +- **No secret material crosses the bridge:** Private keys stay in the host wallet. + Only JSON-RPC request/response envelopes are exchanged. + +### Iframe opt-in contract + +A third-party widget opts in to iframe communication by calling `enableIframeBridge()` in +its entrypoint: + +```ts +import { enableIframeBridge } from '@goodwidget/bridge/child' + +const result = await enableIframeBridge({ + allowedParents: ['https://host.app'], + appId: 'my-widget', +}) +// result.provider is a full EIP-1193 provider +// Also injected as window.ethereum and announced via EIP-6963 +``` + +If the widget is not in an iframe (`window.parent === window`), the call returns `null`. + +### Auto-bridge in GoodWidgetProvider + +`GoodWidgetProvider` automatically detects iframe/WebView contexts and attempts a +bridge handshake before any other provider detection: + +1. On mount, calls `tryBridgeHandshake()` which sends a `gw-bridge` init message to `window.parent`. +2. If a host responds within 3 seconds, the bridge provider is installed as `window.goodWidget.provider`. +3. `detectHost()` then finds the bridge provider and uses it — **even if an explicit `provider` prop was also passed** (bridge always wins because the host is the source of truth when embedding). +4. If no host responds (not in an iframe, or host doesn't have the bridge), the timeout expires silently and detection falls through to other methods. + +This means GoodWidget-based apps work in iframes **without any additional code** — +just using `` is enough. The `enableIframeBridge()` helper remains +available for non-GoodWidget apps or when you need manual control over injection/EIP-6963. + +### Host detection priority + +`detectHost()` in `@goodwidget/core` checks (in order): +1. GoodWidget bridge globals (`window.goodWidget.provider`, `window.ethereum.isGoodWidgetBridge`) +2. EIP-6963 discovered bridge provider (`rdns === 'org.gooddollar.goodwidget.bridge'`) +3. Explicit `provider` prop +4. Farcaster SDK +5. World App MiniKit +6. MiniPay (Celo) +7. Generic `window.ethereum` + +**Bridge always wins:** if both a bridge and an explicit provider are available, the bridge +takes priority because the host embedding the iframe is the authoritative wallet source. + +--- + ## Known Limitations and Future Work - **No SSR support yet.** Tamagui CSS injection and Shadow DOM setup are client-only. diff --git a/README.md b/README.md index befab05..fdcffbe 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ A cross-platform mini app framework for building web3 widgets that run inside wa ## Packages -| Package | Description | -|---------|-------------| -| `@goodwidget/core` | EIP-1193 provider normalization, host detection, wallet hooks, React context | -| `@goodwidget/ui` | Tamagui-based themeable component library (React + React Native Web) | -| `@goodwidget/embed` | Web Component wrapper for embedding mini apps in any HTML page | -| `@goodwidget/claim-widget` | Sample publishable widget — React component + Web Component | +| Package | Description | +| -------------------------- | ---------------------------------------------------------------------------- | +| `@goodwidget/core` | EIP-1193 provider normalization, host detection, wallet hooks, React context | +| `@goodwidget/ui` | Tamagui-based themeable component library (React + React Native Web) | +| `@goodwidget/embed` | Web Component wrapper for embedding mini apps in any HTML page | +| `@goodwidget/bridge` | Iframe/WebView EIP-1193 bridge, EmbeddedWidget component, EIP-6963 support | +| `@goodwidget/claim-widget` | Sample publishable widget — React component + Web Component | ## Quick Start @@ -91,9 +92,9 @@ const config = createGoodWidgetConfig({ ```css good-miniapp { - --gw-color-primary: #FF6B00; - --gw-Card-background: #FFF3E0; - --gw-Button-background: #FF6B00; + --gw-color-primary: #ff6b00; + --gw-Card-background: #fff3e0; + --gw-Button-background: #ff6b00; } ``` @@ -121,6 +122,45 @@ customElements.define('my-miniapp', Element) ``` +## Embedding Third-Party Widgets (Iframe/WebView) + +### As a host (React) + +```tsx +import { EmbeddedWidget } from '@goodwidget/bridge' + +; console.log('connected')} + style={{ width: '100%', height: 400, border: 'none' }} +/> +``` + +### As a host (WebView / React Native) + +```ts +import { createWebViewBridgeScript } from '@goodwidget/bridge/host' + +const injectedJS = createWebViewBridgeScript({ eip6963: true }) +// Pass to +``` + +### As the embedded widget (opt-in) + +```ts +import { enableIframeBridge } from '@goodwidget/bridge/child' + +const result = await enableIframeBridge({ + allowedParents: ['https://host.app'], + appId: 'my-widget', +}) +// result.provider is window.ethereum (bridged to host wallet) +// Also announced via EIP-6963 (rdns: org.gooddollar.goodwidget.bridge) +``` + ## Creating Custom Components Use `createComponent()` to ensure your components are theme-overridable by hosts: @@ -165,6 +205,7 @@ GoodWidget/ core/ → @goodwidget/core (provider, hooks, EIP-1193, host detection) ui/ → @goodwidget/ui (component library, theme system) embed/ → @goodwidget/embed (Web Component wrapper) + bridge/ → @goodwidget/bridge (iframe/WebView EIP-1193 bridge) claim-widget/ → @goodwidget/claim-widget (sample publishable widget) examples/ react-web/ → React demo with style override showcase diff --git a/docs/PACKAGING.md b/docs/PACKAGING.md index 63685c8..86912b9 100644 --- a/docs/PACKAGING.md +++ b/docs/PACKAGING.md @@ -605,3 +605,49 @@ Consumers of this package: | `examples/react-web/` | React web app importing `ClaimWidget` component | | `examples/html/` | Plain HTML page using `` element | | `examples/expo/` | Expo React Native app importing GoodWidget components | + +--- + +## Enabling Iframe/WebView Embedding + +If your widget will be loaded inside an iframe or WebView by a host app, you need to +opt in to bridge communication so the host's wallet provider is accessible. + +### 1. Add `@goodwidget/bridge` as a dependency + +```bash +pnpm add @goodwidget/bridge +``` + +### 2. Call `enableIframeBridge()` in your entrypoint + +```ts +// src/main.ts or src/index.ts +import { enableIframeBridge } from '@goodwidget/bridge/child' + +const bridge = await enableIframeBridge({ + allowedParents: ['https://host1.app', 'https://host2.app'], + appId: 'my-widget', +}) + +if (bridge) { + // Running in an iframe — bridge.provider is a full EIP-1193 provider + // Also injected as window.ethereum and announced via EIP-6963 + console.log('Connected to host, session:', bridge.sessionId) +} + +// Then render your app as normal — GoodWidgetProvider will auto-detect the provider +``` + +### 3. Security considerations + +- Always specify `allowedParents` — don't use `['*']` in production. +- The bridge only exposes JSON-RPC request/response envelopes; private keys never + leave the host wallet. +- The host controls which origins can communicate; both sides must agree. + +### 4. EIP-6963 compatibility + +The bridge provider is announced via EIP-6963 with `rdns: 'org.gooddollar.goodwidget.bridge'`. +This means any dapp that uses the standard multi-provider discovery flow (e.g. wagmi, web3-onboard) +will automatically detect it, even without reading `window.ethereum` directly. diff --git a/examples/expo/.expo/settings.json b/examples/expo/.expo/settings.json index 78e82da..660c60d 100644 --- a/examples/expo/.expo/settings.json +++ b/examples/expo/.expo/settings.json @@ -1,3 +1,3 @@ { - "urlRandomness": "jgvGdH8" + "urlRandomness": "XNDOASU" } diff --git a/examples/expo/README.md b/examples/expo/README.md index c207550..a66dddd 100644 --- a/examples/expo/README.md +++ b/examples/expo/README.md @@ -39,6 +39,29 @@ The app shows four override strategies applied to the **same** ClaimWidget: - **`app/index.tsx`** — Main screen showing all four override levels vertically - **`app/theme-demo.tsx`** — Side-by-side comparison of the same widget with different themes +- **`app/webview-bridge.tsx`** — Live WebView bridge demo using `createWebViewBridgeConfig()` from `@goodwidget/bridge/host` + +### WebView bridge helper + +This example includes a real end-to-end WebView host setup using a single helper: + +```ts +import { createWebViewBridgeConfig } from '@goodwidget/bridge/host' + +const bridge = createWebViewBridgeConfig({ + provider, // host EIP-1193 provider + sendToWebView: (message) => webViewRef.current?.postMessage(message), +}) + + void bridge.onMessage(event)} +/> +``` + +The helper bundles both: +- injected script creation (`window.ethereum` + `window.goodWidget.provider` + EIP-6963) +- host-side request/response callback (`onMessage`) for forwarding RPC to the real provider ## Notes diff --git a/examples/expo/app/index.tsx b/examples/expo/app/index.tsx index e73b647..1e69f66 100644 --- a/examples/expo/app/index.tsx +++ b/examples/expo/app/index.tsx @@ -1,5 +1,6 @@ import React from 'react' import { SafeAreaView, StyleSheet } from 'react-native' +import { Link } from 'expo-router' import { ClaimWidget } from '@goodwidget/claim-widget' import { Card, @@ -7,6 +8,8 @@ import { Text, Alert, Separator, + Button, + ButtonText, YStack, } from '@goodwidget/ui' @@ -108,6 +111,21 @@ export default function HomeScreen() { }, }} /> + + + + + WebView Bridge Demo + + Open a real WebView demo where a child page calls + window.ethereum through the bridge host helper. + + + + + ) diff --git a/examples/expo/app/webview-bridge.tsx b/examples/expo/app/webview-bridge.tsx new file mode 100644 index 0000000..29e584b --- /dev/null +++ b/examples/expo/app/webview-bridge.tsx @@ -0,0 +1,132 @@ +import React, { useMemo, useRef, useState } from 'react' +import { SafeAreaView, StyleSheet } from 'react-native' +import { WebView } from 'react-native-webview' +import { createWebViewBridgeConfig } from '@goodwidget/bridge/host' +import type { EIP1193Provider, RequestArguments, EIP1193EventMap } from '@goodwidget/core' +import { Card, Heading, Text, Alert, YStack } from '@goodwidget/ui' + +function createMockHostProvider(): EIP1193Provider { + const listeners = new Map void>>() + + return { + async request(args: RequestArguments): Promise { + switch (args.method) { + case 'eth_chainId': + return '0xa4ec' // Celo Mainnet + case 'eth_accounts': + case 'eth_requestAccounts': + return ['0x1234567890abcdef1234567890abcdef12345678'] + case 'personal_sign': + return '0xmocksignature' + default: + return { ok: true, method: args.method, params: args.params ?? null } + } + }, + on(event: E, listener: EIP1193EventMap[E]): void { + let set = listeners.get(event) + if (!set) { + set = new Set() + listeners.set(event, set) + } + set.add(listener as (...args: unknown[]) => void) + }, + removeListener(event: E, listener: EIP1193EventMap[E]): void { + listeners.get(event)?.delete(listener as (...args: unknown[]) => void) + }, + } +} + +const DEMO_HTML = ` + + + + + + + +

WebView Bridge Child

+

This page calls window.ethereum provided by the host bridge.

+ + + +
Ready...
+ + +` + +export default function WebViewBridgeScreen() { + const webViewRef = useRef(null) + const [status, setStatus] = useState('waiting for child handshake...') + const provider = useMemo(() => createMockHostProvider(), []) + + const bridge = useMemo( + () => + createWebViewBridgeConfig({ + provider, + sendToWebView: (message) => webViewRef.current?.postMessage(message), + onReady: (sessionId) => setStatus(`connected (${sessionId})`), + }), + [provider], + ) + + return ( + + + WebView Bridge Demo + + + Bridge status + {status} + + { + void bridge.onMessage(event) + }} + style={styles.webview} + /> + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#f5f5f5', + }, + webview: { + flex: 1, + minHeight: 450, + backgroundColor: '#fff', + borderRadius: 12, + overflow: 'hidden', + }, +}) diff --git a/examples/expo/package.json b/examples/expo/package.json index 2b36d63..f85757a 100644 --- a/examples/expo/package.json +++ b/examples/expo/package.json @@ -13,10 +13,12 @@ "@goodwidget/core": "workspace:*", "@goodwidget/ui": "workspace:*", "@goodwidget/claim-widget": "workspace:*", + "@goodwidget/bridge": "workspace:*", "expo": "~52.0.0", "expo-router": "~4.0.0", "react": "^18.3.0", "react-native": "0.76.9", + "react-native-webview": "^13.12.5", "tamagui": "^1.121.0", "@tamagui/config": "^1.121.0" }, diff --git a/examples/expo/tsconfig.json b/examples/expo/tsconfig.json index 15c65aa..fab359a 100644 --- a/examples/expo/tsconfig.json +++ b/examples/expo/tsconfig.json @@ -16,7 +16,9 @@ "@goodwidget/core": ["../../packages/core/src/index.ts"], "@goodwidget/core/*": ["../../packages/core/src/*"], "@goodwidget/ui": ["../../packages/ui/src/index.ts"], - "@goodwidget/claim-widget": ["../../packages/claim-widget/src/index.ts"] + "@goodwidget/claim-widget": ["../../packages/claim-widget/src/index.ts"], + "@goodwidget/bridge": ["../../packages/bridge/src/index.ts"], + "@goodwidget/bridge/*": ["../../packages/bridge/src/*"] } }, "include": ["app", "components"], diff --git a/examples/html/index.html b/examples/html/index.html index b13dfc4..6086aa7 100644 --- a/examples/html/index.html +++ b/examples/html/index.html @@ -160,6 +160,35 @@

Wallet provider injection

+ +
+

Iframe Embedding (Bridge)

+

+ Embed a third-party GoodWidget app in an iframe. The host's wallet + is bridged via postMessage using @goodwidget/bridge. +

+
import { createIframeBridgeHost } from '@goodwidget/bridge/host'
+
+const cleanup = createIframeBridgeHost({
+  iframe: document.getElementById('widget-iframe'),
+  provider: window.ethereum,
+  allowedOrigins: ['https://widget.example.com'],
+})
+
+// In the embedded app's entrypoint:
+import { enableIframeBridge } from '@goodwidget/bridge/child'
+await enableIframeBridge({
+  allowedParents: ['https://yourhost.com'],
+  appId: 'claim-widget',
+})
+// window.ethereum is now a bridge provider
+// Also discoverable via EIP-6963
+

+ The bridge supports EIP-6963 announcements so modern dapps + can discover the provider without relying on window.ethereum. +

+
+ diff --git a/examples/react-web/package.json b/examples/react-web/package.json index 60229d3..15080f3 100644 --- a/examples/react-web/package.json +++ b/examples/react-web/package.json @@ -15,6 +15,7 @@ "@goodwidget/ui": "workspace:*", "@goodwidget/embed": "workspace:*", "@goodwidget/claim-widget": "workspace:*", + "@goodwidget/bridge": "workspace:*", "react": "^18.3.0", "react-dom": "^18.3.0", "react-native-web": "^0.19.13" diff --git a/examples/react-web/src/App.tsx b/examples/react-web/src/App.tsx index 4a512f0..c84b500 100644 --- a/examples/react-web/src/App.tsx +++ b/examples/react-web/src/App.tsx @@ -1,6 +1,7 @@ -import React, { useState } from 'react' +import React, { useState, useCallback } from 'react' import { GoodWidgetProvider, useWallet, useHost } from '@goodwidget/core' import { ClaimWidget } from '@goodwidget/claim-widget' +import { EmbeddedWidget } from '@goodwidget/bridge' import { createGoodWidgetConfig, getThemeManifest, @@ -28,7 +29,7 @@ import { function OverrideShowcase() { const [activeTab, setActiveTab] = useState< - 'default' | 'tokens' | 'component' | 'host' | 'inline' + 'default' | 'tokens' | 'component' | 'host' | 'inline' | 'iframe' >('default') const { address, chainId } = useWallet() const { host } = useHost() @@ -45,6 +46,7 @@ function OverrideShowcase() { { key: 'component', label: 'Component' }, { key: 'host', label: 'Host' }, { key: 'inline', label: 'Inline' }, + { key: 'iframe', label: 'Iframe' }, ] return ( @@ -472,6 +474,69 @@ function OverrideShowcase() { )} + {/* ============================================================== + IFRAME — Embed a third-party widget via + ============================================================== */} + {activeTab === 'iframe' && ( + + + + How it works + + {`import { EmbeddedWidget } from '@goodwidget/bridge' + + console.log('widget connected')} + style={{ width: '100%', height: 400 }} +/>`} + + + + + Bridge Protocol + + The host creates a HostRouter that listens for postMessage + from the iframe. The child app calls enableIframeBridge() + to initiate a handshake. Once connected, EIP-1193 requests + flow through the postMessage channel and the host forwards + them to the real wallet provider. + + + + {`// In the embedded widget's entrypoint: +import { enableIframeBridge } from '@goodwidget/bridge/child' + +const result = await enableIframeBridge({ + allowedParents: ['https://host.app'], + appId: 'claim-widget', +}) +// result.provider is now window.ethereum (bridged) +// Also announced via EIP-6963`} + + + + + EIP-6963 Discovery + + The bridge provider is announced via EIP-6963 so modern dapps + can discover it through the standard multi-provider flow + (rdns: org.gooddollar.goodwidget.bridge). + + + + )} + setSheetOpen(false)} diff --git a/examples/react-web/tsconfig.json b/examples/react-web/tsconfig.json index 74ff935..c58cf31 100644 --- a/examples/react-web/tsconfig.json +++ b/examples/react-web/tsconfig.json @@ -8,6 +8,8 @@ "@goodwidget/ui": ["../../packages/ui/src/index.ts"], "@goodwidget/embed": ["../../packages/embed/src/index.ts"], "@goodwidget/claim-widget": ["../../packages/claim-widget/src/index.ts"], + "@goodwidget/bridge": ["../../packages/bridge/src/index.ts"], + "@goodwidget/bridge/*": ["../../packages/bridge/src/*"], "react-native": ["./node_modules/react-native-web"] } }, diff --git a/packages/bridge/package.json b/packages/bridge/package.json new file mode 100644 index 0000000..c8a6ac5 --- /dev/null +++ b/packages/bridge/package.json @@ -0,0 +1,53 @@ +{ + "name": "@goodwidget/bridge", + "version": "0.1.0", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./child": { + "types": "./dist/child.d.ts", + "import": "./dist/child.js", + "require": "./dist/child.cjs" + }, + "./host": { + "types": "./dist/host.d.ts", + "import": "./dist/host.js", + "require": "./dist/host.cjs" + }, + "./inject": { + "types": "./dist/inject.d.ts", + "import": "./dist/inject.js", + "require": "./dist/inject.cjs" + } + }, + "scripts": { + "build": "tsup", + "clean": "rm -rf dist .turbo", + "lint": "eslint src/" + }, + "dependencies": { + "@goodwidget/core": "workspace:*" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react": { "optional": true }, + "react-dom": { "optional": true } + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "tsup": "^8.0.0", + "typescript": "^5.7.0" + }, + "files": ["dist", "README.md"] +} diff --git a/packages/bridge/src/EmbeddedWidget.tsx b/packages/bridge/src/EmbeddedWidget.tsx new file mode 100644 index 0000000..3172036 --- /dev/null +++ b/packages/bridge/src/EmbeddedWidget.tsx @@ -0,0 +1,117 @@ +/** + * — React component for embedding a third-party + * GoodWidget-based app inside an iframe with a bridged EIP-1193 provider. + * + * Usage: + * console.log('widget connected')} + * style={{ width: '100%', height: 400, border: 'none' }} + * /> + */ + +import React, { useRef, useEffect, useCallback, type CSSProperties } from 'react' +import type { EIP1193Provider } from '@goodwidget/core' +import type { GoodWidgetThemeOverrides } from '@goodwidget/core' +import { HostRouter } from './hostRouter' +import { GW_BRIDGE_NS, GW_BRIDGE_VERSION, generateId } from './protocol' + +export interface EmbeddedWidgetProps { + /** URL of the widget to load */ + src: string + /** The host's EIP-1193 provider to bridge to the child */ + provider: EIP1193Provider + /** Origins allowed for bridge communication */ + allowedOrigins: string[] + /** Theme overrides to send to the child widget */ + themeOverrides?: GoodWidgetThemeOverrides + /** Called when the child widget completes the handshake */ + onReady?: (info: { sessionId: string }) => void + /** Called if the child widget encounters an error */ + onError?: (error: Error) => void + /** Iframe sandbox attributes (default: 'allow-scripts allow-same-origin') */ + sandbox?: string + /** CSS style for the iframe */ + style?: CSSProperties + /** CSS class for the iframe */ + className?: string + /** Title attribute for accessibility */ + title?: string +} + +export function EmbeddedWidget({ + src, + provider, + allowedOrigins, + themeOverrides, + onReady, + onError, + sandbox = 'allow-scripts allow-same-origin', + style, + className, + title = 'Embedded Widget', +}: EmbeddedWidgetProps) { + const iframeRef = useRef(null) + const routerRef = useRef(null) + + const handleChildConnected = useCallback( + (info: { sessionId: string; origin: string; appId?: string }) => { + if (themeOverrides && iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.postMessage( + { + ns: GW_BRIDGE_NS, + version: GW_BRIDGE_VERSION, + type: 'event', + id: generateId(), + sessionId: info.sessionId, + event: 'themeOverrides', + data: themeOverrides, + }, + allowedOrigins[0] ?? '*', + ) + } + onReady?.({ sessionId: info.sessionId }) + }, + [themeOverrides, onReady, allowedOrigins], + ) + + useEffect(() => { + if (!provider) return + + try { + const router = new HostRouter({ + provider, + allowedOrigins, + onChildConnected: handleChildConnected, + }) + routerRef.current = router + + return () => { + router.destroy() + routerRef.current = null + } + } catch (err) { + onError?.(err instanceof Error ? err : new Error(String(err))) + } + }, [provider, allowedOrigins, handleChildConnected, onError]) + + useEffect(() => { + if (routerRef.current && provider) { + routerRef.current.setProvider(provider) + } + }, [provider]) + + return ( +