Migrating Connect to BridgeKit
This is the first real-world BridgeKit adoption: the Lidl Plus Connect feature. It is not
a hypothetical — it is runtime-proven on iOS (isLoggedIn returned true across the bridge;
trackEvent fired end-to-end). Use it as the template for migrating any feature.
The before
Section titled “The before”Connect’s native actions used to reach the host through three uncontracted channels:
connectGet*functions — ad-hoc native getters.- feature-actions — a generic
execute({ featureName, actionName, params })dispatcher. NativeEventEmitterModule— event streams off a shared emitter.
None of these is typed end-to-end, none is owned by the feature, and each new action means a new native module or a new string action name. That is exactly the duplication the framework was built to replace — see where we came from.
The after: a feature-owned connect.host contract
Section titled “The after: a feature-owned connect.host contract”Connect now owns a single contract with all 17 native actions it needs. It lives in the
feature bundle (src/contracts/connect-host.contract.ts) and is the single source of truth for
both sides.
import { Async, defineContract, Sync, t, Void } from '@axion/bridgekit/contract';
export const useConnectHost = defineContract('connect.host', { methods: { // Void — fire-and-forget closeFeature: Void(), showWebpage: Void(t.object({ url: t.string() })), goToStore: Void(), openQRScanner: Void(), goToSettings: Void(),
// Sync — synchronous reads (native answers in-memory) getAppHeaders: Sync(t.json()), isEsimSupported: Sync(t.string()), getDeepLink: Sync(t.nullable(t.string())), getTimeZone: Sync(t.string()), isLoggedIn: Sync(t.boolean()),
// Async — round-trips that may show UI or do work showLoginScreen: Async(t.boolean()), getPaymentMethod: Async(t.nullable(t.json())), getUserAgent: Async(t.string()), showDatePicker: Async(t.object({ date: t.nullable(t.string()) }), t.nullable(t.string())), askCameraPermission: Async(t.string()), showBillingInfoScreen: Async(t.object({ url: t.string() }), t.nullable(t.json())), installEsim: Async(t.object({ url: t.string(), iccId: t.string() }), t.string()), },});Seventeen actions, owned by Connect. The native side provides this contract at the feature
scope — ConnectViewController provides it at Scope.Feature("Lidl.Plus.Connect").
Consuming it in JS with feature scoping
Section titled “Consuming it in JS with feature scoping”Because the contract is provided at a feature scope, the JS consumer resolves it at the same
scope with .scoped({ feature }). Each action is wrapped in a useCallback with a try/catch
fallback, so a missing provider degrades gracefully instead of throwing into the UI.
import { FEATURE_NAME } from '../config'; // 'Lidl.Plus.Connect'import { useConnectHost } from '../../../contracts/connect-host.contract';
export const useNativeActions = () => { const { isLoggedIn: isLoggedInNative, showLoginScreen: showLoginScreenNative, closeFeature: closeFeatureNative, installEsim: installEsimNative, // ...all 17 } = useConnectHost.scoped({ feature: FEATURE_NAME })(); // scope-bound, then snapshot
const isLoggedIn = useCallback( () => { try { return isLoggedInNative(); } catch { return false; } }, [isLoggedInNative], ); // Sync
const showLoginScreen = useCallback( async () => { try { return (await showLoginScreenNative()) ?? false; } catch { return false; } }, [showLoginScreenNative], ); // Async
const closeFeature = useCallback( () => { try { closeFeatureNative(); } catch {} }, [closeFeatureNative], ); // Void
const installEsim = useCallback( async (url: string, iccId: string) => { try { const r = await installEsimNative({ url, iccId }); return isInstallEsimResult(r) ? r : 'error'; } catch { return 'error'; } }, [installEsimNative], );
return { isLoggedIn, showLoginScreen, closeFeature, installEsim /* ... */ };};The split — three homes for native capability
Section titled “The split — three homes for native capability”Not everything belongs on connect.host. The migration deliberately split native capability
across three owners:
-
connect.host(feature-owned) — the 17 actions above. Connect owns and ships them. -
axion.host(shared, host-owned) —getLiteralandtrackEventcome from the globally-provided host contract owned by@axion/core, not from Connect.import { useHost } from '@axion/core';const { getLiteral: getLiteralHost, trackEvent: trackEventHost } = useHost();const getLiteral = (key: string) => {try { const l = getLiteralHost({ key }); return typeof l === 'string' ? l : key; }catch { return key; }};const trackEvent = (event: { name: string; payload?: unknown }) => {try { trackEventHost({ eventName: event.name, eventParams: event.payload }); } catch {}};See the host contract for the full
axion.hostsurface. -
FeatureActions(intentionally not migrated) — five host-provided actions stay on the generic dispatcher because they are host concerns, not feature-owned:copyToClipboard,goToConnect,callPhone,startOtpAutofill,showShareSheet.const { execute } = useFeatureActions();// execute({ featureName: FEATURE_NAME, actionName, params })
This coexistence is the point: a feature migrates the actions it owns onto a typed contract without a big-bang rewrite, leaving genuinely shared host actions where they belong.
The native end-state — pure SPM, no C++ in the app target
Section titled “The native end-state — pure SPM, no C++ in the app target”On iOS the providers are wired in the Lidl Plus app:
// ConnectViewController provides the feature-owned contractbridgeKit.provide(ConnectHostContract(), scope: .feature("Lidl.Plus.Connect")) { ConnectHostImpl() }
// App.swift provides the shared host contract globally@_implementationOnly import BridgeKit@_implementationOnly import AxionContracts
hostBinding = bridgeKit.provide(AxionHostContract(), scope: .global) { StandaloneConnectHost() }The integration is pure SPM — the standalone consumes Axion via a local Package.swift
path, with no CocoaPods and no C++ interop in the app target. BridgeKit and
AxionContracts are imported as Swift-only facades through @_implementationOnly import, so the
Nitro C++ seam never leaks into the app’s module. (This is the production end-state; the
standalone demo’s BridgeKitDemoKit/ObjC-shim isolation is a CocoaPods harness detail you do
not need here — see the demo walkthrough.)
Runtime-proven, from the device log:
axion.host provided at .globalconnect.host provided at .feature(Lidl.Plus.Connect)connect.host.isLoggedIn() -> true ← Sync JS → native, correct valueaxion.host.trackEvent: view_item ← fired end-to-endMigrate your own feature — checklist
Section titled “Migrate your own feature — checklist”-
Define a feature-owned contract. One
*.contract.tsin your bundle with the native actions your feature owns. Pick the marker per direction:Syncfor in-memory reads,Asyncfor round-trips that may do work or show UI,Voidfor fire-and-forget. Keep host-wide capabilities (localisation, analytics) onaxion.host— don’t re-declare them. -
Generate the native binding. Run
axion bridgekit generate --platform swift(and--platform kotlinif you target Android) and commit the output. The contract hash keeps both sides honest — drift fails--checkin CI. -
Provide it natively at the right scope. Implement the generated protocol and provide it at
Scope.Feature("<Your.Feature>")for feature-owned actions, or.globalfor host-owned ones. On iOS, register explicitly at the relevant view-controller / app-init site. -
Consume it in JS with the matching scope. Use
useYourContract.scoped({ feature })()and wrap each action in auseCallbackwith atry/catchfallback so a missing provider degrades instead of throwing. -
Leave shared host actions on
FeatureActions. Move only what your feature owns. Genuinely host-level actions can stay on the generic dispatcher — coexistence is expected, not a smell. -
Verify on-device. Confirm a
Syncread returns the real value across the bridge and aVoid/analytics call fires end-to-end before you call the migration done.