diff --git a/android/src/main/java/com/boltreactnativesdk/creditcardfield/BoltCardFieldView.kt b/android/src/main/java/com/boltreactnativesdk/creditcardfield/BoltCardFieldView.kt index 3803c62..551a5e2 100644 --- a/android/src/main/java/com/boltreactnativesdk/creditcardfield/BoltCardFieldView.kt +++ b/android/src/main/java/com/boltreactnativesdk/creditcardfield/BoltCardFieldView.kt @@ -23,7 +23,7 @@ import com.facebook.react.uimanager.UIManagerHelper * * Row 1: [card icon] Card number (full width, rounded border) * Row 2: Expiration | CVV (split 50/50 with divider, rounded border) - * Row 3: Billing zip (full width, hidden by default, rounded border) + * Row 3: Postal code (full width, hidden by default, rounded border) * * Row spacing: 16dp. Row height: 48dp. Corner radius: 10dp. * Border: #d1d5db, 1dp. Background: #fafafa. Cursor: Bolt purple #5A31F4. @@ -198,7 +198,7 @@ class BoltCardFieldView(context: Context) : LinearLayout(context) { postalField.background = null postalField.setTextColor(colorNormal) postalField.setHintTextColor(Color.parseColor("#9ca3af")) - postalField.hint = "Billing zip" + postalField.hint = "Postal code" postalField.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f) postalField.gravity = Gravity.CENTER_VERTICAL postalField.setPadding(dp(12), 0, dp(12), 0) diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index c21ce8d..6f610ce 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - BoltReactNativeSdk (0.9.0): + - BoltReactNativeSdk (0.9.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1754,9 +1754,9 @@ PODS: - React-runtimeexecutor - ReactCommon/turbomodule/core - ReactNativeDependencies - - ReactAppDependencyProvider (0.85.0): + - ReactAppDependencyProvider (0.85.2): - ReactCodegen - - ReactCodegen (0.85.0): + - ReactCodegen (0.85.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2050,7 +2050,7 @@ EXTERNAL SOURCES: :path: "../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - BoltReactNativeSdk: 73295af8005b194c7e540f5ec24c01052a407336 + BoltReactNativeSdk: 39dc5824d68bad253daccec5c53ac3fb51ce96ad FBLazyVector: 26fd21c75314e101f280d401e97f27d54f3f7064 hermes-engine: bd88c0774d9ce8cc1a486c246afaffcfdef8237f RCTDeprecation: c7a2768f905d76ca6d2cfefb26e4349eacbdfca3 @@ -2121,8 +2121,8 @@ SPEC CHECKSUMS: React-timing: c307da445abf09b4597796951f15118707dd99b5 React-utils: 76e964b4a6b480ea47cc22392595028a2a4c879b React-webperformancenativemodule: f7cdde6e05a1f94e7dec4ff4956bdabfd544f2a5 - ReactAppDependencyProvider: 706b65371b90b5cc797b6639e8979f2e5cecd6da - ReactCodegen: 7aad71c10c1852b664587c865175f73819fb9460 + ReactAppDependencyProvider: 2201c845e5f506d5957669e66abb8de5ce20ad1f + ReactCodegen: bde8734501b3183c7a5e33be4473caaef8d715c4 ReactCommon: 376f3a275bcf5e59a360e0b1762beb3bd41495ce ReactNativeDependencies: e7def8d3e9a65cecae96ee4713e8b4f9bc40281b Yoga: df8920a8f8ca99996048f6aea23a529f44992eac diff --git a/ios/BoltPostalField.swift b/ios/BoltPostalField.swift index b77876b..00ab0e9 100644 --- a/ios/BoltPostalField.swift +++ b/ios/BoltPostalField.swift @@ -15,7 +15,7 @@ class BoltPostalField: BoltBaseField { } private func setup() { - placeholder = "Billing zip" + placeholder = "Postal code" keyboardType = .default autocorrectionType = .no spellCheckingType = .no diff --git a/package.json b/package.json index 2d3a520..a76b429 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ ], "scripts": { "example": "yarn workspace bolt-react-native-sdk-example", - "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib example/src/boltConfig.ts", "prepare": "node scripts/update-sdk-version.js && bob build", "typecheck": "tsc", "test": "jest", diff --git a/scripts/gen-bolt-config.js b/scripts/gen-bolt-config.js index 26a3276..6f6d7b3 100644 --- a/scripts/gen-bolt-config.js +++ b/scripts/gen-bolt-config.js @@ -4,11 +4,10 @@ * Generates example/src/boltConfig.ts from environment variables or a .env file. * The generated file is gitignored — never committed. * - * If boltConfig.ts already exists, this script does nothing (preserves manual edits). - * Delete the file and re-run to regenerate. - * - * If BOLT_PUBLISHABLE_KEY is not set, copies the example template so the project - * still compiles (you'll need to fill in a real key to use the example app). + * If BOLT_PUBLISHABLE_KEY is set (env or .env), regenerates the file from those + * values. If not set and the file is missing, copies the example template so the + * project still compiles. If not set and the file exists, leaves it alone + * (preserves any manual edits you made directly in boltConfig.ts). * * Reads from: * 1. Environment variables @@ -30,11 +29,6 @@ const ENV_FILE = path.join(ROOT, '.env'); const OUT_FILE = path.join(ROOT, 'example', 'src', 'boltConfig.ts'); const EXAMPLE_FILE = path.join(ROOT, 'example', 'src', 'boltConfig.example.ts'); -// Skip if already generated -if (fs.existsSync(OUT_FILE)) { - process.exit(0); -} - // Load .env file if present (simple key=value parser) const loadDotEnv = () => { if (!fs.existsSync(ENV_FILE)) return; @@ -61,7 +55,11 @@ const publishableKey = process.env.BOLT_PUBLISHABLE_KEY; const environment = process.env.BOLT_ENVIRONMENT ?? 'sandbox'; if (!publishableKey) { - // No key configured — copy the example template so the project compiles. + // No key configured. Leave existing file alone (preserves manual edits); + // otherwise copy the example template so the project still compiles. + if (fs.existsSync(OUT_FILE)) { + process.exit(0); + } fs.copyFileSync(EXAMPLE_FILE, OUT_FILE); console.log( 'No BOLT_PUBLISHABLE_KEY found. Copied boltConfig.example.ts → boltConfig.ts' diff --git a/src/__tests__/BoltBridgeDispatcher.test.ts b/src/__tests__/BoltBridgeDispatcher.test.ts index ea9fc04..1491ef7 100644 --- a/src/__tests__/BoltBridgeDispatcher.test.ts +++ b/src/__tests__/BoltBridgeDispatcher.test.ts @@ -238,6 +238,47 @@ describe('BoltBridgeDispatcher', () => { expect(mockWebView.injectJavaScript).toHaveBeenCalled(); }); + it('sendBootstrapPort emits a postMessage envelope with virtualPortId', () => { + const { dispatcher, injectedScripts } = createDispatcher(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + + dispatcher.sendBootstrapPort('vp_test', { type: 'setPort', payload: 'x' }); + + expect(injectedScripts).toHaveLength(1); + const match = injectedScripts[0]!.match(/__boltBridgeReceive\((".*")\)/s); + expect(match).not.toBeNull(); + const inner = JSON.parse(match![1]!) as string; + const envelope = JSON.parse(inner); + expect(envelope).toMatchObject({ + __boltBridge: true, + type: 'postMessage', + virtualPortId: 'vp_test', + data: { type: 'setPort', payload: 'x' }, + }); + }); + + it('sendBootstrapPort queues until bridge ready', () => { + const { dispatcher, injectedScripts } = createDispatcher(); + dispatcher.sendBootstrapPort('vp_test', { type: 'setPort' }); + expect(injectedScripts).toHaveLength(0); + + dispatcher.handleMessage(makeBridgeReadyEvent()); + expect(injectedScripts).toHaveLength(1); + }); + + it('onReady listener fires on every transition to ready', () => { + const { dispatcher } = createDispatcher(); + const onReady = jest.fn(); + dispatcher.onReady(onReady); + + dispatcher.handleMessage(makeBridgeReadyEvent()); + expect(onReady).toHaveBeenCalledTimes(1); + + dispatcher.reset(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + expect(onReady).toHaveBeenCalledTimes(2); + }); + it('should not throw when sending message with no webView', () => { const ref = { current: null }; const dispatcher = new BoltBridgeDispatcher(ref as any); @@ -296,6 +337,140 @@ describe('INJECTED_BRIDGE_JS', () => { }); }); +describe('INJECTED_BRIDGE_JS — VirtualMessagePort runtime', () => { + // Evaluate the injected script in a sandboxed window so we can drive the + // virtual port directly. The script bails out unless window.parent === + // window, so we set that up before evaluating. + const makeSandbox = () => { + const sandbox: any = {}; + sandbox.window = sandbox; + sandbox.self = sandbox; + sandbox.parent = sandbox; + sandbox.location = { origin: 'https://iframe.test' }; + sandbox.document = { + readyState: 'complete', + addEventListener: () => {}, + referrer: '', + }; + sandbox.ReactNativeWebView = { postMessage: jest.fn() }; + sandbox.console = { error: jest.fn(), log: jest.fn(), warn: jest.fn() }; + sandbox.addEventListener = () => {}; + sandbox.removeEventListener = () => {}; + // The script references bare `document` and `console` (e.g. + // document.readyState, console.error). Pass them as function + // parameters so lookups resolve locally instead of escaping to the + // test's global scope. + // eslint-disable-next-line no-new-func + new Function('window', 'document', 'console', INJECTED_BRIDGE_JS)( + sandbox, + sandbox.document, + sandbox.console + ); + return sandbox; + }; + + const capturePort = (sandbox: any, portId: string) => { + let port: any = null; + sandbox.addEventListener('message', (event: any) => { + if (event.ports && event.ports[0]) port = event.ports[0]; + }); + sandbox.__boltBridgeReceive( + JSON.stringify({ + __boltBridge: true, + type: 'postMessage', + virtualPortId: portId, + data: null, + }) + ); + return port; + }; + + it('dispatches port messages to addEventListener("message") with origin ""', () => { + const sandbox = makeSandbox(); + const port = capturePort(sandbox, 'vp_capture'); + + expect(port).not.toBeNull(); + expect(typeof port.addEventListener).toBe('function'); + expect(typeof port.removeEventListener).toBe('function'); + + port.start(); + const events: any[] = []; + const listener = (e: any) => events.push(e); + port.addEventListener('message', listener); + // Duplicate add is deduped. + port.addEventListener('message', listener); + + sandbox.__boltBridgeReceive( + JSON.stringify({ + __boltBridge: true, + type: 'portMessage', + virtualPortId: 'vp_capture', + data: 'payload-1', + }) + ); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ data: 'payload-1', origin: '' }); + + port.removeEventListener('message', listener); + sandbox.__boltBridgeReceive( + JSON.stringify({ + __boltBridge: true, + type: 'portMessage', + virtualPortId: 'vp_capture', + data: 'payload-2', + }) + ); + expect(events).toHaveLength(1); + }); + + it('addEventListener ignores non-message types and non-functions', () => { + const sandbox = makeSandbox(); + const port = capturePort(sandbox, 'vp_filter'); + port.start(); + + expect(() => port.addEventListener('not-message', () => {})).not.toThrow(); + expect(() => port.addEventListener('message', null)).not.toThrow(); + expect(() => port.addEventListener('message', 'not-a-fn')).not.toThrow(); + + const real = jest.fn(); + port.addEventListener('message', real); + sandbox.__boltBridgeReceive( + JSON.stringify({ + __boltBridge: true, + type: 'portMessage', + virtualPortId: 'vp_filter', + data: 'x', + }) + ); + expect(real).toHaveBeenCalledTimes(1); + }); + + it('a throwing listener does not block sibling listeners', () => { + const sandbox = makeSandbox(); + const port = capturePort(sandbox, 'vp_throw'); + port.start(); + + const sibling = jest.fn(); + port.addEventListener('message', () => { + throw new Error('boom'); + }); + port.addEventListener('message', sibling); + + sandbox.__boltBridgeReceive( + JSON.stringify({ + __boltBridge: true, + type: 'portMessage', + virtualPortId: 'vp_throw', + data: 'x', + }) + ); + + expect(sibling).toHaveBeenCalledTimes(1); + expect(sandbox.console.error).toHaveBeenCalled(); + }); +}); + describe('parseBoltMessage', () => { it('should parse a JSON string', () => { const result = parseBoltMessage('{"type":"Focus"}'); diff --git a/src/__tests__/BoltRpcHandler.test.ts b/src/__tests__/BoltRpcHandler.test.ts new file mode 100644 index 0000000..0d42f25 --- /dev/null +++ b/src/__tests__/BoltRpcHandler.test.ts @@ -0,0 +1,360 @@ +import { Bolt } from '../client/Bolt'; +import { BoltBridgeDispatcher } from '../bridge/BoltBridgeDispatcher'; +import { BoltRpcHandler } from '../bridge/BoltRpcHandler'; + +const RPC_PORT_ID = 'vp_rpc_main'; + +const makeBridgeReadyEvent = () => ({ + nativeEvent: { + data: JSON.stringify({ + __boltBridge: true, + direction: 'outbound', + type: 'bridgeReady', + }), + }, +}); + +const makePortMessageEvent = (data: unknown, virtualPortId = RPC_PORT_ID) => ({ + nativeEvent: { + data: JSON.stringify({ + __boltBridge: true, + direction: 'outbound', + type: 'portMessage', + data, + virtualPortId, + }), + }, +}); + +const createDispatcher = () => { + const injectedScripts: string[] = []; + const ref = { + current: { + injectJavaScript: jest.fn((js: string) => { + injectedScripts.push(js); + }), + reload: jest.fn(), + }, + }; + const dispatcher = new BoltBridgeDispatcher(ref as any); + return { dispatcher, injectedScripts }; +}; + +const parseLastInjected = (script: string) => { + // injected JS form: window.__boltBridgeReceive("");true; + const match = script.match(/__boltBridgeReceive\((".*")\)/s); + if (!match) throw new Error(`Could not parse injected JS: ${script}`); + const inner = JSON.parse(match[1]!) as string; + return JSON.parse(inner); +}; + +const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve)); + +describe('BoltRpcHandler', () => { + let bolt: Bolt; + + beforeEach(() => { + bolt = new Bolt({ + publishableKey: 'pk_test_123', + environment: 'sandbox', + }); + (globalThis as any).fetch = jest.fn(); + }); + + afterEach(() => { + delete (globalThis as any).fetch; + }); + + it('emits the setPort handshake when the bridge becomes ready', () => { + const { dispatcher, injectedScripts } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + + dispatcher.handleMessage(makeBridgeReadyEvent()); + + expect(injectedScripts).toHaveLength(1); + const envelope = parseLastInjected(injectedScripts[0]!); + expect(envelope).toMatchObject({ + __boltBridge: true, + type: 'postMessage', + virtualPortId: RPC_PORT_ID, + data: { type: 'setPort', payload: 'rn-bridge' }, + }); + }); + + it('responds to loadMerchantDetails by fetching from the public API', async () => { + const merchantPayload = { + merchant_description: 'Acme Inc', + copy_customizations: [ + { copy_key: 'card.label', custom_text: 'Card', language_code: 'en' }, + ], + }; + (globalThis as any).fetch = jest.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => merchantPayload, + })); + + const { dispatcher, injectedScripts } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + + dispatcher.handleMessage(makeBridgeReadyEvent()); + // First injected script is the setPort bootstrap. + injectedScripts.length = 0; + + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + await flushMicrotasks(); + + expect((globalThis as any).fetch).toHaveBeenCalledTimes(1); + const fetchUrl = (globalThis as any).fetch.mock.calls[0][0] as string; + expect(fetchUrl).toContain(`${bolt.apiUrl}/v1/merchant`); + expect(fetchUrl).toContain('publishable_key=pk_test_123'); + + expect(injectedScripts).toHaveLength(1); + const envelope = parseLastInjected(injectedScripts[0]!); + expect(envelope).toMatchObject({ + type: 'portMessage', + virtualPortId: RPC_PORT_ID, + data: { + type: 'loadMerchantDetailsSucceeded', + payload: merchantPayload, + }, + }); + }); + + it('caches loadMerchantDetails responses across requests', async () => { + (globalThis as any).fetch = jest.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ merchant_description: 'Acme' }), + })); + + const { dispatcher } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + await flushMicrotasks(); + + expect((globalThis as any).fetch).toHaveBeenCalledTimes(1); + }); + + it('reports loadMerchantDetailsFailed on a non-OK response', async () => { + (globalThis as any).fetch = jest.fn(async () => ({ + ok: false, + status: 500, + statusText: 'Server Error', + json: async () => ({}), + })); + + const { dispatcher, injectedScripts } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + injectedScripts.length = 0; + + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + await flushMicrotasks(); + + expect(injectedScripts).toHaveLength(1); + const envelope = parseLastInjected(injectedScripts[0]!); + expect(envelope).toMatchObject({ + type: 'portMessage', + virtualPortId: RPC_PORT_ID, + data: { + type: 'loadMerchantDetailsFailed', + }, + }); + expect(envelope.data.payload).toContain('500'); + }); + + it('replies Failed for unknown RPC types so the iframe never hangs', async () => { + const { dispatcher, injectedScripts } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + injectedScripts.length = 0; + + dispatcher.handleMessage(makePortMessageEvent({ type: 'somethingElse' })); + await flushMicrotasks(); + + expect(injectedScripts).toHaveLength(1); + const envelope = parseLastInjected(injectedScripts[0]!); + expect(envelope).toMatchObject({ + type: 'portMessage', + virtualPortId: RPC_PORT_ID, + data: { type: 'somethingElseFailed' }, + }); + expect(envelope.data.payload).toContain('unsupported'); + }); + + it('retries loadMerchantDetails after a failure (cache evicted)', async () => { + const fetchMock = jest + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Server Error', + json: async () => ({}), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ merchant_description: 'Acme' }), + }); + (globalThis as any).fetch = fetchMock; + + const { dispatcher, injectedScripts } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + injectedScripts.length = 0; + + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + await flushMicrotasks(); + await flushMicrotasks(); + + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + await flushMicrotasks(); + await flushMicrotasks(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const replies = injectedScripts.map(parseLastInjected); + expect(replies[0].data.type).toBe('loadMerchantDetailsFailed'); + expect(replies[1].data.type).toBe('loadMerchantDetailsSucceeded'); + }); + + it('reset() drops the merchant cache so a reload re-fetches', async () => { + (globalThis as any).fetch = jest.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ merchant_description: 'Acme' }), + })); + + const { dispatcher } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + await flushMicrotasks(); + expect((globalThis as any).fetch).toHaveBeenCalledTimes(1); + + handler.reset(); + + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + await flushMicrotasks(); + expect((globalThis as any).fetch).toHaveBeenCalledTimes(2); + }); + + it('re-emits the setPort handshake after dispatcher.reset() + bridgeReady', () => { + const { dispatcher, injectedScripts } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + + dispatcher.handleMessage(makeBridgeReadyEvent()); + expect(injectedScripts).toHaveLength(1); + + dispatcher.reset(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + + expect(injectedScripts).toHaveLength(2); + const second = parseLastInjected(injectedScripts[1]!); + expect(second.data).toMatchObject({ + type: 'setPort', + payload: 'rn-bridge', + }); + }); + + it('drops malformed JSON port messages without replying', async () => { + (globalThis as any).fetch = jest.fn(); + const { dispatcher, injectedScripts } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + injectedScripts.length = 0; + + dispatcher.handleMessage(makePortMessageEvent('not-json{')); + dispatcher.handleMessage(makePortMessageEvent({ payload: {} })); + await flushMicrotasks(); + + expect((globalThis as any).fetch).not.toHaveBeenCalled(); + expect(injectedScripts).toHaveLength(0); + }); + + it('omits an empty referrer query param', async () => { + (globalThis as any).fetch = jest.fn(async () => ({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({}), + })); + + const { dispatcher } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + await flushMicrotasks(); + + const fetchUrl = (globalThis as any).fetch.mock.calls[0][0] as string; + expect(fetchUrl).not.toContain('referrer='); + }); + + it('ignores the iframe-side initialized event without responding', async () => { + const { dispatcher, injectedScripts } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + injectedScripts.length = 0; + + dispatcher.handleMessage(makePortMessageEvent({ type: 'initialized' })); + await flushMicrotasks(); + + expect(injectedScripts).toHaveLength(0); + }); + + it('stop() unsubscribes so further port messages are ignored', async () => { + (globalThis as any).fetch = jest.fn(); + const { dispatcher, injectedScripts } = createDispatcher(); + const handler = new BoltRpcHandler(dispatcher, bolt); + handler.start(); + dispatcher.handleMessage(makeBridgeReadyEvent()); + injectedScripts.length = 0; + + handler.stop(); + dispatcher.handleMessage( + makePortMessageEvent({ type: 'loadMerchantDetails' }) + ); + await flushMicrotasks(); + + expect((globalThis as any).fetch).not.toHaveBeenCalled(); + expect(injectedScripts).toHaveLength(0); + }); +}); diff --git a/src/bridge/BoltBridgeDispatcher.ts b/src/bridge/BoltBridgeDispatcher.ts index 250f174..19595ac 100644 --- a/src/bridge/BoltBridgeDispatcher.ts +++ b/src/bridge/BoltBridgeDispatcher.ts @@ -108,6 +108,33 @@ export class BoltBridgeDispatcher { this.injectEnvelope(envelope); } + /** + * Deliver a virtual MessagePort to the iframe via a window-level + * postMessage. Queues until the bridge is ready. + */ + sendBootstrapPort(virtualPortId: string, data: unknown): void { + const envelope: BridgeEnvelope = { + __boltBridge: true, + direction: 'inbound', + type: 'postMessage', + data, + virtualPortId, + }; + + if (!this.ready) { + logger.debug('Bootstrap port queued (bridge not ready)', { + [BoltAttributes.BRIDGE_DIRECTION]: 'outbound', + }); + this.pendingMessages.push(envelope); + return; + } + + logger.debug('Sending bootstrap port', { + [BoltAttributes.BRIDGE_DIRECTION]: 'outbound', + }); + this.injectEnvelope(envelope); + } + /** * Register a listener for postMessage events from the iframe. */ @@ -131,14 +158,16 @@ export class BoltBridgeDispatcher { } /** - * Register a listener for when the bridge becomes ready. + * Register a listener that fires every time the bridge becomes ready. + * If the bridge is already ready, fires immediately. Continues to fire on + * subsequent ready transitions (e.g., after reset() + reload) until the + * returned unsubscribe function is called. */ onReady(listener: () => void): () => void { + this.readyListeners.push(listener); if (this.ready) { listener(); - return () => {}; } - this.readyListeners.push(listener); return () => { this.readyListeners = this.readyListeners.filter((l) => l !== listener); }; @@ -197,10 +226,17 @@ export class BoltBridgeDispatcher { this.bridgeReadySpan = undefined; } this.flushPendingMessages(); + // Listeners persist across ready transitions (so reload re-arms them). for (const listener of this.readyListeners) { - listener(); + try { + listener(); + } catch (err) { + logger.error('Error in ready listener', { + [BoltAttributes.ERROR_MESSAGE]: + err instanceof Error ? err.message : String(err), + }); + } } - this.readyListeners = []; break; case 'postMessage': @@ -211,7 +247,14 @@ export class BoltBridgeDispatcher { if (envelope.virtualPortId) { const portListener = this.portListeners.get(envelope.virtualPortId); if (portListener) { - portListener(envelope.data, envelope.virtualPortId); + try { + portListener(envelope.data, envelope.virtualPortId); + } catch (err) { + logger.error('Error in port listener', { + [BoltAttributes.ERROR_MESSAGE]: + err instanceof Error ? err.message : String(err), + }); + } } } break; diff --git a/src/bridge/BoltPaymentWebView.tsx b/src/bridge/BoltPaymentWebView.tsx index 8320b49..c897449 100644 --- a/src/bridge/BoltPaymentWebView.tsx +++ b/src/bridge/BoltPaymentWebView.tsx @@ -1,6 +1,7 @@ import { forwardRef, useCallback, + useEffect, useImperativeHandle, useMemo, useRef, @@ -11,6 +12,7 @@ import WebView, { type WebViewMessageEvent } from 'react-native-webview'; import type { ShouldStartLoadRequest } from 'react-native-webview/lib/WebViewTypes'; import { useBolt } from '../client/useBolt'; import { BoltBridgeDispatcher } from './BoltBridgeDispatcher'; +import { BoltRpcHandler } from './BoltRpcHandler'; import { buildIframeUrl } from './buildIframeUrl'; import { INJECTED_BRIDGE_JS } from './injectedBridge'; @@ -51,12 +53,28 @@ export const BoltPaymentWebView = forwardRef< const webViewRef = useRef(null); const [webViewHeight, setWebViewHeight] = useState(200); - // Expose handle to parent + // Without this the iframe's loadMerchantDetails RPC hangs and elements + // render with default copy. + const rpcRef = useRef(null); + useEffect(() => { + const rpc = new BoltRpcHandler(dispatcher, bolt); + rpcRef.current = rpc; + rpc.start(); + return () => { + rpc.stop(); + rpcRef.current = null; + }; + }, [dispatcher, bolt]); + useImperativeHandle( ref, () => ({ dispatcher, - reload: () => webViewRef.current?.reload(), + reload: () => { + // Drop cached RPC responses so the reloaded iframe re-fetches. + rpcRef.current?.reset(); + webViewRef.current?.reload(); + }, }), [dispatcher] ); diff --git a/src/bridge/BoltRpcHandler.ts b/src/bridge/BoltRpcHandler.ts new file mode 100644 index 0000000..400292f --- /dev/null +++ b/src/bridge/BoltRpcHandler.ts @@ -0,0 +1,171 @@ +import type { Bolt } from '../client/Bolt'; +import { BoltAttributes } from '../telemetry/attributes'; +import { logger } from '../telemetry/logger'; +import type { BoltBridgeDispatcher } from './BoltBridgeDispatcher'; + +/** + * Bridges the iframe's RPC port to the React Native host: receives request + * envelopes on a virtual MessagePort and replies with `${type}Succeeded` + * or `${type}Failed`. Always replies — silence makes the iframe hang. + */ + +// Must match the iframe-side rpc layer's expected port name and id; +// changing these silently breaks the handshake. +const RPC_PORT_ID = 'vp_rpc_main'; +const RPC_PORT_NAME = 'rn-bridge'; +const FETCH_TIMEOUT_MS = 10_000; + +interface ValidatedRpcRequest { + type: string; + payload: unknown; +} + +type RequestHandler = ( + payload: Req +) => Promise; + +interface MerchantDetails { + copy_customizations?: unknown; + [key: string]: unknown; +} + +export class BoltRpcHandler { + private readonly dispatcher: BoltBridgeDispatcher; + private readonly bolt: Bolt; + private readonly handlers: ReadonlyMap; + private cleanups: Array<() => void> = []; + private merchantDetailsCache?: Promise; + + constructor(dispatcher: BoltBridgeDispatcher, bolt: Bolt) { + this.dispatcher = dispatcher; + this.bolt = bolt; + this.handlers = new Map([ + ['loadMerchantDetails', this.handleLoadMerchantDetails.bind(this)], + ]); + } + + start(): void { + const removeReady = this.dispatcher.onReady(() => { + this.dispatcher.sendBootstrapPort(RPC_PORT_ID, { + type: 'setPort', + payload: RPC_PORT_NAME, + }); + }); + + const removePort = this.dispatcher.onPortMessage(RPC_PORT_ID, (data) => { + this.handlePortMessage(data); + }); + + this.cleanups.push(removeReady, removePort); + } + + stop(): void { + for (const cleanup of this.cleanups) cleanup(); + this.cleanups = []; + } + + // Drop cached responses so the next request re-fetches. Call after a + // WebView reload to avoid serving stale merchant details. + reset(): void { + this.merchantDetailsCache = undefined; + } + + private handlePortMessage(raw: unknown): void { + const msg = this.normalizeRequest(raw); + if (!msg) return; + + // The iframe-side rpc emits 'initialized' as soon as it receives the + // port. No reply expected. + if (msg.type === 'initialized') return; + + const handler = this.handlers.get(msg.type); + if (!handler) { + logger.warn('Unhandled RPC request', { + [BoltAttributes.BRIDGE_MESSAGE_TYPE]: msg.type, + }); + this.sendReply(msg.type, false, `unsupported RPC type: ${msg.type}`); + return; + } + + handler(msg.payload).then( + (result) => this.sendReply(msg.type, true, result), + (err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + logger.warn('RPC request failed', { + [BoltAttributes.BRIDGE_MESSAGE_TYPE]: msg.type, + [BoltAttributes.ERROR_MESSAGE]: message, + }); + this.sendReply(msg.type, false, message); + } + ); + } + + private sendReply(requestType: string, ok: boolean, payload: unknown): void { + const replyType = `${requestType}${ok ? 'Succeeded' : 'Failed'}`; + try { + this.dispatcher.sendMessage({ type: replyType, payload }, RPC_PORT_ID); + } catch (err) { + // If dispatching itself throws, the iframe will hang. Nothing left + // to do but log loudly. + logger.error('Failed to send RPC reply', { + [BoltAttributes.BRIDGE_MESSAGE_TYPE]: replyType, + [BoltAttributes.ERROR_MESSAGE]: + err instanceof Error ? err.message : String(err), + }); + } + } + + private normalizeRequest(raw: unknown): ValidatedRpcRequest | undefined { + let obj: unknown = raw; + if (typeof raw === 'string') { + try { + obj = JSON.parse(raw); + } catch { + return undefined; + } + } + if (!obj || typeof obj !== 'object') return undefined; + const { type, payload } = obj as { type?: unknown; payload?: unknown }; + if (typeof type !== 'string') return undefined; + return { type, payload }; + } + + private handleLoadMerchantDetails(): Promise { + if (!this.merchantDetailsCache) { + this.merchantDetailsCache = this.fetchMerchantDetails().catch((err) => { + // Don't cache failures. + this.merchantDetailsCache = undefined; + throw err; + }); + } + return this.merchantDetailsCache; + } + + private async fetchMerchantDetails(): Promise { + const url = new URL('/v1/merchant', this.bolt.apiUrl); + url.searchParams.set('publishable_key', this.bolt.publishableKey); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + ...this.bolt.apiHeaders(), + Accept: 'application/json', + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error( + `loadMerchantDetails failed: ${response.status} ${response.statusText}` + ); + } + + return (await response.json()) as MerchantDetails; + } finally { + clearTimeout(timeoutId); + } + } +} diff --git a/src/bridge/injectedBridge.ts b/src/bridge/injectedBridge.ts index 10ad514..308b704 100644 --- a/src/bridge/injectedBridge.ts +++ b/src/bridge/injectedBridge.ts @@ -66,6 +66,7 @@ export const INJECTED_BRIDGE_JS = ` this.onmessage = null; this._started = false; this._queue = []; + this._listeners = []; virtualPorts[portId] = this; } @@ -87,9 +88,31 @@ export const INJECTED_BRIDGE_JS = ` delete virtualPorts[this.portId]; }; + // EventTarget-style API: storm's RPC layer (rpc.ts) registers handlers via + // port.addEventListener('message', ...), so we must support it in addition + // to the .onmessage property assignment path. + VirtualMessagePort.prototype.addEventListener = function(type, listener) { + if (type !== 'message' || typeof listener !== 'function') return; + if (this._listeners.indexOf(listener) === -1) { + this._listeners.push(listener); + } + }; + + VirtualMessagePort.prototype.removeEventListener = function(type, listener) { + if (type !== 'message') return; + var idx = this._listeners.indexOf(listener); + if (idx !== -1) this._listeners.splice(idx, 1); + }; + VirtualMessagePort.prototype._dispatchMessage = function(data) { + // Real MessagePort message events report origin === '' (per spec). + // storm's defineRPCHandlers filters by portOrigin: '' so we must match. + var event = { data: data, origin: '', source: null }; if (this.onmessage) { - this.onmessage({ data: data, origin: BOLT_ORIGIN, source: null }); + try { this.onmessage(event); } catch (e) { console.error('[BoltBridge] port onmessage error:', e); } + } + for (var i = 0; i < this._listeners.length; i++) { + try { this._listeners[i](event); } catch (e) { console.error('[BoltBridge] port listener error:', e); } } };