The app-bridge package implements a bridge pattern to manage state and events between web games and the OpenGame App. This pattern provides:
- Type-safe communication
- State synchronization
- Event handling
- Initialization management
graph LR
subgraph Web App
W[Web Components]
WB[Web Bridge]
end
subgraph Native App
WV[WebView]
NB[Native Bridge]
N[Native Components]
end
W --> WB
WB --> WV
WV --> NB
NB --> N
NB --> WV
WV --> WB
The type system ensures type safety across the bridge:
// shared/types.ts
export interface CounterState {
value: number;
}
export type CounterEvents =
| { type: "INCREMENT" }
| { type: "DECREMENT" }
| { type: "SET"; value: number };
export type AppStores = {
counter: {
state: CounterState;
events: CounterEvents;
};
};The bridge uses React Native's WebView for communication between web and native:
// In React Native app
function GameWebView() {
const webViewRef = useRef<WebView>(null);
useEffect(() => {
// Register the WebView with the bridge
bridge.registerWebView(webViewRef.current);
}, []);
return (
<WebView
ref={webViewRef}
source={{ uri: 'https://your-game-url.com' }}
onMessage={(event) => {
// Handle messages from the web side
bridge.handleWebMessage(event.nativeEvent.data);
}}
// You might still need injectedJavaScript for other purposes
// injectedJavaScript={bridge.getInjectedJavaScript()}
/>
);
}The integration works in three steps:
-
WebView Registration
- Native app registers the WebView with the bridge
- Bridge injects necessary JavaScript into the WebView | Bridge sets up message handlers
-
Message Passing
- Web side sends events via
postMessage - Native side receives events via
onMessage - Native side sends state updates via
injectJavaScript
- Web side sends events via
-
State Synchronization
- Native side maintains source of truth
- State updates are sent to web via WebView
- Web side reflects state changes in UI
Stores are the core building blocks for state management. Each store has two states:
- Uninitialized: Store is not yet ready (state is null)
- Initialized: Store is ready for use (state has a value)
stateDiagram-v2
[*] --> Uninitialized
Uninitialized --> Initialized: setState(value)
Initialized --> Initialized: Update State
Initialized --> Uninitialized: setState(null)
Initialized --> Initialized: setState(value)
Store initialization is handled by the React Native host application:
// In React Native app
const bridge = createNativeBridge<AppStores>({
initialState: {
counter: { value: 0 }
}
});
// Initialize stores when ready
bridge.setState('counter', { value: 0 }); // Initialize store
bridge.setState('counter', null); // Uninitialize storeState updates can happen in two ways:
-
From Native Side
// Direct state updates in native app bridge.produce('counter', draft => { draft.value += 1; }); // Set state directly bridge.setState('counter', { value: 42 }); // ⚠️ Warning: produce will throw in development if store is not initialized bridge.produce('uninitializedStore', draft => { draft.value += 1; // Throws in dev, warns in prod });
-
From Web Side
// In web app (WebView) // First get a reference to the store const webBridge = createWebBridge<AppStores>(); // Wait for store to be initialized by native side // Then get the store and dispatch events to it const counterStore = webBridge.getStore('counter'); if (counterStore) { counterStore.dispatch({ type: 'INCREMENT' }); } // Subscribe to state changes if (counterStore) { counterStore.subscribe(state => { console.log('Counter value:', state.value); }); } // You can also listen for store availability changes webBridge.subscribe(() => { console.log('Store availability changed'); const counterStore = webBridge.getStore('counter'); if (counterStore) { console.log('Counter store is now available'); } });
Events sent from the web side to the native side are handled by producer functions. These producers are defined when creating the native bridge and are responsible for updating the state in response to events.
Producers are defined as an object with keys that match your store keys:
const bridge = createNativeBridge<AppStores>({
initialState: {
counter: { value: 0 },
user: { name: '', loggedIn: false }
},
producers: {
// Counter store producer
counter: (draft, event) => {
// Handle counter events
switch (event.type) {
case 'INCREMENT':
draft.value += 1;
break;
case 'DECREMENT':
draft.value -= 1;
break;
}
},
// User store producer
user: (draft, event) => {
// Handle user events
switch (event.type) {
case 'LOGIN':
draft.loggedIn = true;
break;
case 'LOGOUT':
draft.loggedIn = false;
break;
}
}
}
});When the web side dispatches an event, the bridge:
- Identifies which store the event is for
- Finds the producer for that store
- If a producer exists, calls it with a draft state and the event
- If no producer exists, logs the event and notifies listeners (without state change)
flowchart TD
WebView[WebView] -->|dispatch event| Bridge[Native Bridge]
Bridge -->|find producer| Producer{Producer exists?}
Producer -->|Yes| UpdateState[Update state with producer]
Producer -->|No| LogEvent[Log event and notify listeners]
UpdateState --> NotifyListeners[Notify listeners]
LogEvent --> End[End]
NotifyListeners --> End
- Type Safety: Each producer receives correctly typed state and events for its store
- Separation of Concerns: Each store has its own producer function
- Immer Integration: Use Immer's draft objects for intuitive state updates
- Event Handling: Centralized, clean switch statements for handling different event types
When defining your event types as discriminated unions with TypeScript, you get excellent type checking in your producers:
// Define event types as discriminated unions
type CounterEvents =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET'; value: number }; // value is required
// In your producer
counter: (draft, event) => {
switch (event.type) {
case 'INCREMENT':
draft.value += 1;
break;
case 'SET':
// TypeScript knows 'value' exists and is a number
// No need for extra type guards
draft.value = event.value;
break;
}
}However, if you have optional properties in your event types, you will need type guards:
// Event with optional property
type CounterEvents =
| { type: 'INCREMENT' }
| { type: 'DECREMENT' }
| { type: 'SET'; value?: number }; // value is optional
// In your producer
counter: (draft, event) => {
switch (event.type) {
case 'INCREMENT':
draft.value += 1;
break;
case 'SET':
// Type guard needed because value is optional
if (event.value !== undefined) {
draft.value = event.value;
}
break;
}
}Key points about type safety:
- TypeScript narrows the event type based on the
typeproperty when you use a switch statement - Make sure your properties are required (not optional with
?) to avoid needing extra type guards - If you do have optional properties, use proper nullish checks (e.g.,
if (event.value !== undefined)) - For complete type safety, consider making all event properties required wherever possible
The React integration provides hooks and context for easy state management:
// Create store context
const CounterContext = BridgeContext.createStoreContext('counter');
// Use in components
function Counter() {
const value = CounterContext.useSelector(state => state.value);
const dispatch = CounterContext.useDispatch();
return (
<div>
<p>Count: {value}</p>
<button onClick={() => dispatch({ type: "INCREMENT" })}>+</button>
</div>
);
}
// Handle initialization states
function App() {
return (
<BridgeContext.Supported>
<CounterContext.Loading>
<div>Loading...</div>
</CounterContext.Loading>
<CounterContext.Provider>
<Counter />
</CounterContext.Provider>
</BridgeContext.Supported>
);
}The following example demonstrates how to test a component using the BridgeContext.Provider:
// Test component
test('Counter updates correctly', () => {
render(
<BridgeContext.Provider bridge={mockBridge}>
<CounterContext.Provider>
<CounterComponent />
</CounterContext.Provider>
</BridgeContext.Provider>
);
fireEvent.click(screen.getByText('+'));
expect(mockBridge.getHistory("counter")).toContainEqual({ type: "INCREMENT" });
});