Skip to content

Commit de7548a

Browse files
ryan-karnRyan Karn
andauthored
fix: fixes for sandbox communication (shared origins and permissions) (#36)
* fix: sandbox msging bugs (shared origin #29, isPermittedFrom #35) Fixing issues for handling messages and errors when sandboxes use the same origin. Namely, this approach defines and passes an id that is passed to the sandbox, and provides a hook that allows sandboxes to use this for messaging. This is to distinguish views on the same origin that share the same runtime and globals. This also addresses an issue there allowOrigin checks where checking sender side allows rather than receiver side. ref: #29 ref: #35 * fix: add iOS surface ID injection and rename delegateId to surfaceId Adds __sandboxSurfaceId injection into iOS initialProperties for per-surface message routing parity with Android. Renames the concept from delegateId to surfaceId across both platforms and JS to align with existing RN surface terminology (PR #36 feedback). * fix(ios): unregister delegate from SandboxRegistry on immediate factory teardown The TTL=0 path in releaseSharedFactory removed the factory from the pool but left the stale delegate wrapper in the C++ registry. When another sandbox then tried to route a message to the removed origin, it found the dangling wrapper and crashed accessing a deallocated RCTInstance. Verified fix in the p2p-chat app. Prior to this, the app would crash on ios after removing a chat sandbox and trying to message it from another chat. * docs: add useSurfaceMessaging and origin-pooling documentation Documents the per-surface messaging API for bundle authors sharing an origin, with both hook and convention-based examples. Adds the origin-pooling demo to the examples list and reorders sections for better flow. --------- Co-authored-by: Ryan Karn <rkarn@amazon.com>
1 parent d558ae6 commit de7548a

33 files changed

Lines changed: 103378 additions & 214 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ Full examples:
150150
- [`apps/p2p-chat`](./apps/p2p-counter/README.md): Direct sandbox-to-sandbox chat demo.
151151
- [`apps/p2p-counter`](./apps/p2p-counter/README.md): Direct sandbox-to-sandbox communication demo.
152152
- [`apps/fs-experiment`](./apps/fs-experiment/README.md): File system & storage isolation with TurboModule substitutions.
153+
- [`apps/origin-pooling`](./apps/origin-pooling/): Origin sharing, idle TTL, and per-surface messaging demo.
153154
154155
## 📚 API Reference
155156

apps/origin-pooling/App.tsx

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22
* Origin Pooling Demo
33
*
44
* Dynamically add/remove sandboxes under two shared origins (alpha, beta)
5-
* plus an isolated (no-origin) option. Same-origin sandboxes share a
6-
* ReactHost / Hermes VM; removing the last one triggers the idle TTL.
5+
* plus isolated sandboxes (each gets a unique origin and its own VM).
6+
* Same-origin sandboxes share a ReactHost / Hermes VM; removing the last
7+
* one triggers the idle TTL.
8+
*
9+
* Access control demo: alpha and beta only accept messages from "isolated-1".
10+
* Other isolated sandboxes (isolated-2, isolated-3, …) will get
11+
* AccessDeniedError when trying to ping alpha or beta.
712
*
813
* Messaging is handled inside the sandbox widget via globalThis.postMessage.
914
* The host only logs messages received via onMessage.
@@ -25,13 +30,21 @@ type SandboxEntry = {key: string; label: string; origin: string}
2530
type LogEntry = {source: string; text: string; ts: number}
2631

2732
let nextId = 0
33+
let nextIsolatedId = 0
2834

2935
const ORIGIN_ALPHA = 'alpha'
3036
const ORIGIN_BETA = 'beta'
37+
const ISOLATED_PREFIX = 'isolated-'
3138
const COLOR_ALPHA = '#8232ff'
3239
const COLOR_BETA = '#e67e22'
3340
const COLOR_ISOLATED = '#6c757d'
3441

42+
/**
43+
* Only "isolated-1" is permitted to send messages to alpha/beta.
44+
* All other isolated origins will be denied.
45+
*/
46+
const PERMITTED_ISOLATED = `${ISOLATED_PREFIX}1`
47+
3548
/** Alpha uses a function-based TTL (4 seconds) */
3649
const ALPHA_TTL = () => 4000
3750
/** Beta and isolated use a static TTL (2 seconds) */
@@ -48,7 +61,14 @@ export default function App() {
4861

4962
const addSandbox = useCallback((origin: string) => {
5063
const id = String(++nextId)
51-
setSandboxes(prev => [...prev, {key: id, label: `#${id}`, origin}])
64+
const actualOrigin =
65+
origin === ISOLATED_PREFIX
66+
? `${ISOLATED_PREFIX}${++nextIsolatedId}`
67+
: origin
68+
setSandboxes(prev => [
69+
...prev,
70+
{key: id, label: `#${id}`, origin: actualOrigin},
71+
])
5272
}, [])
5373

5474
const removeSandbox = useCallback((key: string) => {
@@ -59,14 +79,15 @@ export default function App() {
5979

6080
const alphas = sandboxes.filter(s => s.origin === ORIGIN_ALPHA)
6181
const betas = sandboxes.filter(s => s.origin === ORIGIN_BETA)
62-
const isolated = sandboxes.filter(s => s.origin === '')
82+
const isolated = sandboxes.filter(s => s.origin.startsWith(ISOLATED_PREFIX))
6383

6484
return (
6585
<SafeAreaView style={styles.safe}>
6686
<Text style={styles.heading}>Origin Pooling Demo</Text>
6787
<Text style={styles.subtitle}>
68-
Same-origin sandboxes share a VM. Alpha: function-based TTL (4s). Beta:
69-
static TTL (2s).
88+
Same-origin sandboxes share a VM. Isolated sandboxes each get a unique
89+
origin (own VM). Only isolated-1 can message alpha/beta — others get
90+
AccessDeniedError.
7091
</Text>
7192

7293
<View style={styles.controls}>
@@ -83,7 +104,7 @@ export default function App() {
83104
<Button
84105
title="+ Isolated"
85106
color={COLOR_ISOLATED}
86-
onPress={() => addSandbox('')}
107+
onPress={() => addSandbox(ISOLATED_PREFIX)}
87108
/>
88109
<Button title="Clear Log" onPress={clearLog} />
89110
</View>
@@ -101,6 +122,8 @@ export default function App() {
101122
key={sb.key}
102123
entry={sb}
103124
color={COLOR_ALPHA}
125+
componentName="SandboxAppConvention"
126+
allowedOrigins={[ORIGIN_ALPHA, ORIGIN_BETA, PERMITTED_ISOLATED]}
104127
idleTTL={ALPHA_TTL}
105128
onRemove={() => removeSandbox(sb.key)}
106129
onMessage={data =>
@@ -129,6 +152,8 @@ export default function App() {
129152
key={sb.key}
130153
entry={sb}
131154
color={COLOR_BETA}
155+
componentName="SandboxApp"
156+
allowedOrigins={[ORIGIN_ALPHA, ORIGIN_BETA, PERMITTED_ISOLATED]}
132157
idleTTL={DEFAULT_TTL}
133158
onRemove={() => removeSandbox(sb.key)}
134159
onMessage={data => addLog(`beta ${sb.label}`, JSON.stringify(data))}
@@ -142,9 +167,9 @@ export default function App() {
142167
)}
143168
</ScrollView>
144169

145-
{/* Isolated sandboxes */}
170+
{/* Isolated sandboxes — each gets a unique origin (own VM) */}
146171
<Text style={[styles.groupLabel, {color: COLOR_ISOLATED}]}>
147-
no origin / isolated ({isolated.length})
172+
isolated ({isolated.length}) — only isolated-1 can reach alpha/beta
148173
</Text>
149174
<ScrollView
150175
horizontal
@@ -155,6 +180,8 @@ export default function App() {
155180
key={sb.key}
156181
entry={sb}
157182
color={COLOR_ISOLATED}
183+
componentName="SandboxApp"
184+
allowedOrigins={[ORIGIN_ALPHA, ORIGIN_BETA]}
158185
idleTTL={DEFAULT_TTL}
159186
onRemove={() => removeSandbox(sb.key)}
160187
onMessage={data =>
@@ -192,6 +219,8 @@ export default function App() {
192219
type SandboxCardProps = {
193220
entry: SandboxEntry
194221
color: string
222+
componentName: string
223+
allowedOrigins: string[]
195224
idleTTL: number | (() => number)
196225
onRemove: () => void
197226
onMessage: (data: unknown) => void
@@ -201,6 +230,8 @@ type SandboxCardProps = {
201230
function SandboxCard({
202231
entry,
203232
color,
233+
componentName,
234+
allowedOrigins,
204235
idleTTL,
205236
onRemove,
206237
onMessage,
@@ -210,17 +241,17 @@ function SandboxCard({
210241
<View style={[styles.card, {borderColor: color}]}>
211242
<View style={[styles.cardHeader, {backgroundColor: color}]}>
212243
<Text style={styles.cardLabel}>
213-
{entry.origin || 'isolated'} {entry.label}
244+
{entry.origin} {entry.label}
214245
</Text>
215246
<Text style={styles.cardRemove} onPress={onRemove}>
216247
217248
</Text>
218249
</View>
219250
<SandboxReactNativeView
220-
origin={entry.origin || undefined}
221-
allowedOrigins={[ORIGIN_ALPHA, ORIGIN_BETA]}
251+
origin={entry.origin}
252+
allowedOrigins={allowedOrigins}
222253
idleTTL={idleTTL}
223-
componentName="SandboxApp"
254+
componentName={componentName}
224255
jsBundleSource="sandbox"
225256
onMessage={onMessage}
226257
onError={onError}

apps/origin-pooling/README.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,81 @@ npx react-native run-ios
3232
# or
3333
npx react-native run-android
3434
```
35+
36+
## Multi-Origin Messaging Approaches
37+
38+
This demo showcases two approaches for per-surface messaging in sandboxes
39+
that share a Hermes VM. Alpha sandboxes use the convention-based approach,
40+
while Beta sandboxes use the library-based approach.
41+
42+
### Library approach (`useSurfaceMessaging`)
43+
44+
Import the hook from `@callstack/react-native-sandbox`:
45+
46+
```tsx
47+
import {useSurfaceMessaging} from '@callstack/react-native-sandbox'
48+
49+
function MySandboxWidget({__sandboxDelegateId}: Props) {
50+
const {postMessage, setOnMessage} = useSurfaceMessaging(__sandboxDelegateId)
51+
52+
// Send to host (per-surface routed)
53+
postMessage({type: 'hello'})
54+
55+
// Send to another origin
56+
postMessage({type: 'ping'}, 'beta')
57+
58+
// Receive messages (per-surface)
59+
setOnMessage((msg) => console.log('received', msg))
60+
}
61+
```
62+
63+
The hook handles delegate ID injection and per-surface listener registration
64+
internally. This is the recommended approach when you already depend on the
65+
sandbox library.
66+
67+
See: `SandboxApp.tsx`
68+
69+
### Convention approach (no library dependency)
70+
71+
Use `globalThis.postMessage` and `globalThis.setOnMessage` directly,
72+
following the delegate ID conventions:
73+
74+
```tsx
75+
declare const globalThis: {
76+
postMessage: (msg: unknown, targetOrigin?: string) => void
77+
setOnMessage: (cb: (msg: unknown) => void, delegateId?: string) => void
78+
}
79+
80+
function MySandboxWidget({__sandboxDelegateId}: Props) {
81+
// Send to host — spread delegateId into payload for per-surface routing
82+
const send = (msg: Record<string, unknown>, targetOrigin?: string) => {
83+
const payload = !targetOrigin && __sandboxDelegateId
84+
? {...msg, __sandboxDelegateId}
85+
: msg
86+
globalThis.postMessage(payload, targetOrigin)
87+
}
88+
89+
// Send to another origin (no delegateId needed)
90+
send({type: 'ping'}, 'alpha')
91+
92+
// Receive messages — pass delegateId as 2nd arg for per-surface listener
93+
globalThis.setOnMessage((msg) => {
94+
console.log('received', msg)
95+
}, __sandboxDelegateId)
96+
}
97+
```
98+
99+
This approach has zero library dependencies — useful when the sandbox JS
100+
bundle is built independently or when you want to minimize the sandbox's
101+
dependency footprint.
102+
103+
See: `SandboxAppConvention.tsx`
104+
105+
### Key differences
106+
107+
| | Library | Convention |
108+
|---|---|---|
109+
| Import needed | `@callstack/react-native-sandbox` | None |
110+
| Delegate routing | Handled by hook | Manual (`__sandboxDelegateId` in payload) |
111+
| Cross-origin send | `postMessage(msg, origin)` | `globalThis.postMessage(msg, origin)` |
112+
| Per-surface listen | `setOnMessage(cb)` | `globalThis.setOnMessage(cb, delegateId)` |

apps/origin-pooling/SandboxApp.tsx

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
/**
2-
* SandboxApp — runs INSIDE each sandbox.
2+
* SandboxApp — runs INSIDE each sandbox (library approach).
33
*
4-
* Uses globalThis.postMessage directly (broadcast fallback, no per-surface routing).
5-
* Provides buttons to ping alpha/beta origins and send heartbeats to the host.
6-
* Displays an internal log of incoming and outgoing messages.
4+
* Uses useSurfaceMessaging from @callstack/react-native-sandbox
5+
* for per-surface routing.
76
*/
7+
import {useSurfaceMessaging} from '@callstack/react-native-sandbox'
88
import React, {useEffect, useRef, useState} from 'react'
99
import {
1010
ScrollView,
@@ -14,14 +14,14 @@ import {
1414
View,
1515
} from 'react-native'
1616

17-
declare const globalThis: {
18-
postMessage: (msg: unknown, targetOrigin?: string) => void
19-
setOnMessage: (cb: (msg: unknown) => void) => void
20-
}
21-
2217
type LogEntry = {dir: 'in' | 'out'; text: string; ts: number}
2318

24-
export default function SandboxApp() {
19+
type Props = {
20+
__sandboxSurfaceId?: string
21+
}
22+
23+
export default function SandboxApp({__sandboxSurfaceId}: Props) {
24+
const {postMessage, setOnMessage} = useSurfaceMessaging(__sandboxSurfaceId)
2525
const [log, setLog] = useState<LogEntry[]>([])
2626
const instanceId = useRef(Math.random().toString(36).slice(2, 6)).current
2727
const seq = useRef(0)
@@ -30,33 +30,32 @@ export default function SandboxApp() {
3030
const addLog = (dir: 'in' | 'out', text: string) =>
3131
setLog(prev => [...prev.slice(-19), {dir, text, ts: Date.now()}])
3232

33-
// Signal first render to host
3433
useEffect(() => {
35-
globalThis.postMessage({type: 'rendered', instanceId})
34+
postMessage({type: 'rendered', instanceId})
3635
addLog('out', `rendered (${instanceId})`)
37-
}, [instanceId])
36+
}, [instanceId, postMessage])
3837

39-
// Listen for incoming messages (pings from other origins)
4038
useEffect(() => {
41-
globalThis.setOnMessage((msg: unknown) => {
39+
const unsubscribe = setOnMessage((msg: unknown) => {
4240
const data = msg as Record<string, unknown>
4341
addLog('in', `from ${data.instanceId ?? '?'}: ${data.type}`)
4442
})
45-
}, [])
43+
return unsubscribe
44+
}, [setOnMessage])
4645

4746
const sendHeartbeat = () => {
4847
const s = ++seq.current
49-
globalThis.postMessage({type: 'heartbeat', instanceId, seq: s})
48+
postMessage({type: 'heartbeat', instanceId, seq: s})
5049
addLog('out', `heartbeat seq=${s}`)
5150
}
5251

5352
const pingAlpha = () => {
54-
globalThis.postMessage({type: 'ping', instanceId}, 'alpha')
53+
postMessage({type: 'ping', instanceId}, 'alpha')
5554
addLog('out', 'ping → alpha')
5655
}
5756

5857
const pingBeta = () => {
59-
globalThis.postMessage({type: 'ping', instanceId}, 'beta')
58+
postMessage({type: 'ping', instanceId}, 'beta')
6059
addLog('out', 'ping → beta')
6160
}
6261

@@ -70,6 +69,7 @@ export default function SandboxApp() {
7069
return (
7170
<View style={styles.root}>
7271
<Text style={styles.title}>ID: {instanceId}</Text>
72+
<Text style={styles.approach}>Library (useSurfaceMessaging)</Text>
7373
<View style={styles.buttons}>
7474
<View style={styles.btnRow}>
7575
<TouchableOpacity style={styles.btnGreen} onPress={sendHeartbeat}>
@@ -106,6 +106,7 @@ export default function SandboxApp() {
106106
const styles = StyleSheet.create({
107107
root: {flex: 1, padding: 6, backgroundColor: '#1a1a2e'},
108108
title: {color: '#d3e945', fontWeight: '700', fontSize: 13},
109+
approach: {color: '#888', fontSize: 9, marginBottom: 4},
109110
buttons: {gap: 4, marginBottom: 4},
110111
btnRow: {flexDirection: 'row', gap: 4},
111112
btnGreen: {

0 commit comments

Comments
 (0)