Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Full examples:
- [`apps/p2p-chat`](./apps/p2p-counter/README.md): Direct sandbox-to-sandbox chat demo.
- [`apps/p2p-counter`](./apps/p2p-counter/README.md): Direct sandbox-to-sandbox communication demo.
- [`apps/fs-experiment`](./apps/fs-experiment/README.md): File system & storage isolation with TurboModule substitutions.
- [`apps/origin-pooling`](./apps/origin-pooling/): Origin sharing, idle TTL, and per-surface messaging demo.

## 📚 API Reference

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

let nextId = 0
let nextIsolatedId = 0

const ORIGIN_ALPHA = 'alpha'
const ORIGIN_BETA = 'beta'
const ISOLATED_PREFIX = 'isolated-'
const COLOR_ALPHA = '#8232ff'
const COLOR_BETA = '#e67e22'
const COLOR_ISOLATED = '#6c757d'

/**
* Only "isolated-1" is permitted to send messages to alpha/beta.
* All other isolated origins will be denied.
*/
const PERMITTED_ISOLATED = `${ISOLATED_PREFIX}1`

/** Alpha uses a function-based TTL (4 seconds) */
const ALPHA_TTL = () => 4000
/** Beta and isolated use a static TTL (2 seconds) */
Expand All @@ -48,7 +61,14 @@ export default function App() {

const addSandbox = useCallback((origin: string) => {
const id = String(++nextId)
setSandboxes(prev => [...prev, {key: id, label: `#${id}`, origin}])
const actualOrigin =
origin === ISOLATED_PREFIX
? `${ISOLATED_PREFIX}${++nextIsolatedId}`
: origin
setSandboxes(prev => [
...prev,
{key: id, label: `#${id}`, origin: actualOrigin},
])
}, [])

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

const alphas = sandboxes.filter(s => s.origin === ORIGIN_ALPHA)
const betas = sandboxes.filter(s => s.origin === ORIGIN_BETA)
const isolated = sandboxes.filter(s => s.origin === '')
const isolated = sandboxes.filter(s => s.origin.startsWith(ISOLATED_PREFIX))

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

<View style={styles.controls}>
Expand All @@ -83,7 +104,7 @@ export default function App() {
<Button
title="+ Isolated"
color={COLOR_ISOLATED}
onPress={() => addSandbox('')}
onPress={() => addSandbox(ISOLATED_PREFIX)}
/>
<Button title="Clear Log" onPress={clearLog} />
</View>
Expand All @@ -101,6 +122,8 @@ export default function App() {
key={sb.key}
entry={sb}
color={COLOR_ALPHA}
componentName="SandboxAppConvention"
allowedOrigins={[ORIGIN_ALPHA, ORIGIN_BETA, PERMITTED_ISOLATED]}
idleTTL={ALPHA_TTL}
onRemove={() => removeSandbox(sb.key)}
onMessage={data =>
Expand Down Expand Up @@ -129,6 +152,8 @@ export default function App() {
key={sb.key}
entry={sb}
color={COLOR_BETA}
componentName="SandboxApp"
allowedOrigins={[ORIGIN_ALPHA, ORIGIN_BETA, PERMITTED_ISOLATED]}
idleTTL={DEFAULT_TTL}
onRemove={() => removeSandbox(sb.key)}
onMessage={data => addLog(`beta ${sb.label}`, JSON.stringify(data))}
Expand All @@ -142,9 +167,9 @@ export default function App() {
)}
</ScrollView>

{/* Isolated sandboxes */}
{/* Isolated sandboxes — each gets a unique origin (own VM) */}
<Text style={[styles.groupLabel, {color: COLOR_ISOLATED}]}>
no origin / isolated ({isolated.length})
isolated ({isolated.length}) — only isolated-1 can reach alpha/beta
</Text>
<ScrollView
horizontal
Expand All @@ -155,6 +180,8 @@ export default function App() {
key={sb.key}
entry={sb}
color={COLOR_ISOLATED}
componentName="SandboxApp"
allowedOrigins={[ORIGIN_ALPHA, ORIGIN_BETA]}
idleTTL={DEFAULT_TTL}
onRemove={() => removeSandbox(sb.key)}
onMessage={data =>
Expand Down Expand Up @@ -192,6 +219,8 @@ export default function App() {
type SandboxCardProps = {
entry: SandboxEntry
color: string
componentName: string
allowedOrigins: string[]
idleTTL: number | (() => number)
onRemove: () => void
onMessage: (data: unknown) => void
Expand All @@ -201,6 +230,8 @@ type SandboxCardProps = {
function SandboxCard({
entry,
color,
componentName,
allowedOrigins,
idleTTL,
onRemove,
onMessage,
Expand All @@ -210,17 +241,17 @@ function SandboxCard({
<View style={[styles.card, {borderColor: color}]}>
<View style={[styles.cardHeader, {backgroundColor: color}]}>
<Text style={styles.cardLabel}>
{entry.origin || 'isolated'} {entry.label}
{entry.origin} {entry.label}
</Text>
<Text style={styles.cardRemove} onPress={onRemove}>
</Text>
</View>
<SandboxReactNativeView
origin={entry.origin || undefined}
allowedOrigins={[ORIGIN_ALPHA, ORIGIN_BETA]}
origin={entry.origin}
allowedOrigins={allowedOrigins}
idleTTL={idleTTL}
componentName="SandboxApp"
componentName={componentName}
jsBundleSource="sandbox"
onMessage={onMessage}
onError={onError}
Expand Down
78 changes: 78 additions & 0 deletions apps/origin-pooling/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,81 @@ npx react-native run-ios
# or
npx react-native run-android
```

## Multi-Origin Messaging Approaches

This demo showcases two approaches for per-surface messaging in sandboxes
that share a Hermes VM. Alpha sandboxes use the convention-based approach,
while Beta sandboxes use the library-based approach.

### Library approach (`useSurfaceMessaging`)

Import the hook from `@callstack/react-native-sandbox`:

```tsx
import {useSurfaceMessaging} from '@callstack/react-native-sandbox'

function MySandboxWidget({__sandboxDelegateId}: Props) {
const {postMessage, setOnMessage} = useSurfaceMessaging(__sandboxDelegateId)

// Send to host (per-surface routed)
postMessage({type: 'hello'})

// Send to another origin
postMessage({type: 'ping'}, 'beta')

// Receive messages (per-surface)
setOnMessage((msg) => console.log('received', msg))
}
```

The hook handles delegate ID injection and per-surface listener registration
internally. This is the recommended approach when you already depend on the
sandbox library.

See: `SandboxApp.tsx`

### Convention approach (no library dependency)

Use `globalThis.postMessage` and `globalThis.setOnMessage` directly,
following the delegate ID conventions:

```tsx
declare const globalThis: {
postMessage: (msg: unknown, targetOrigin?: string) => void
setOnMessage: (cb: (msg: unknown) => void, delegateId?: string) => void
}

function MySandboxWidget({__sandboxDelegateId}: Props) {
// Send to host — spread delegateId into payload for per-surface routing
const send = (msg: Record<string, unknown>, targetOrigin?: string) => {
const payload = !targetOrigin && __sandboxDelegateId
? {...msg, __sandboxDelegateId}
: msg
globalThis.postMessage(payload, targetOrigin)
}

// Send to another origin (no delegateId needed)
send({type: 'ping'}, 'alpha')

// Receive messages — pass delegateId as 2nd arg for per-surface listener
globalThis.setOnMessage((msg) => {
console.log('received', msg)
}, __sandboxDelegateId)
}
```

This approach has zero library dependencies — useful when the sandbox JS
bundle is built independently or when you want to minimize the sandbox's
dependency footprint.

See: `SandboxAppConvention.tsx`

### Key differences

| | Library | Convention |
|---|---|---|
| Import needed | `@callstack/react-native-sandbox` | None |
| Delegate routing | Handled by hook | Manual (`__sandboxDelegateId` in payload) |
| Cross-origin send | `postMessage(msg, origin)` | `globalThis.postMessage(msg, origin)` |
| Per-surface listen | `setOnMessage(cb)` | `globalThis.setOnMessage(cb, delegateId)` |
39 changes: 20 additions & 19 deletions apps/origin-pooling/SandboxApp.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* SandboxApp — runs INSIDE each sandbox.
* SandboxApp — runs INSIDE each sandbox (library approach).
*
* Uses globalThis.postMessage directly (broadcast fallback, no per-surface routing).
* Provides buttons to ping alpha/beta origins and send heartbeats to the host.
* Displays an internal log of incoming and outgoing messages.
* Uses useSurfaceMessaging from @callstack/react-native-sandbox
* for per-surface routing.
*/
import {useSurfaceMessaging} from '@callstack/react-native-sandbox'
import React, {useEffect, useRef, useState} from 'react'
import {
ScrollView,
Expand All @@ -14,14 +14,14 @@ import {
View,
} from 'react-native'

declare const globalThis: {
postMessage: (msg: unknown, targetOrigin?: string) => void
setOnMessage: (cb: (msg: unknown) => void) => void
}

type LogEntry = {dir: 'in' | 'out'; text: string; ts: number}

export default function SandboxApp() {
type Props = {
__sandboxSurfaceId?: string
}

export default function SandboxApp({__sandboxSurfaceId}: Props) {
const {postMessage, setOnMessage} = useSurfaceMessaging(__sandboxSurfaceId)
const [log, setLog] = useState<LogEntry[]>([])
const instanceId = useRef(Math.random().toString(36).slice(2, 6)).current
const seq = useRef(0)
Expand All @@ -30,33 +30,32 @@ export default function SandboxApp() {
const addLog = (dir: 'in' | 'out', text: string) =>
setLog(prev => [...prev.slice(-19), {dir, text, ts: Date.now()}])

// Signal first render to host
useEffect(() => {
globalThis.postMessage({type: 'rendered', instanceId})
postMessage({type: 'rendered', instanceId})
addLog('out', `rendered (${instanceId})`)
}, [instanceId])
}, [instanceId, postMessage])

// Listen for incoming messages (pings from other origins)
useEffect(() => {
globalThis.setOnMessage((msg: unknown) => {
const unsubscribe = setOnMessage((msg: unknown) => {
const data = msg as Record<string, unknown>
addLog('in', `from ${data.instanceId ?? '?'}: ${data.type}`)
})
}, [])
return unsubscribe
}, [setOnMessage])

const sendHeartbeat = () => {
const s = ++seq.current
globalThis.postMessage({type: 'heartbeat', instanceId, seq: s})
postMessage({type: 'heartbeat', instanceId, seq: s})
addLog('out', `heartbeat seq=${s}`)
}

const pingAlpha = () => {
globalThis.postMessage({type: 'ping', instanceId}, 'alpha')
postMessage({type: 'ping', instanceId}, 'alpha')
addLog('out', 'ping → alpha')
}

const pingBeta = () => {
globalThis.postMessage({type: 'ping', instanceId}, 'beta')
postMessage({type: 'ping', instanceId}, 'beta')
addLog('out', 'ping → beta')
}

Expand All @@ -70,6 +69,7 @@ export default function SandboxApp() {
return (
<View style={styles.root}>
<Text style={styles.title}>ID: {instanceId}</Text>
<Text style={styles.approach}>Library (useSurfaceMessaging)</Text>
<View style={styles.buttons}>
<View style={styles.btnRow}>
<TouchableOpacity style={styles.btnGreen} onPress={sendHeartbeat}>
Expand Down Expand Up @@ -106,6 +106,7 @@ export default function SandboxApp() {
const styles = StyleSheet.create({
root: {flex: 1, padding: 6, backgroundColor: '#1a1a2e'},
title: {color: '#d3e945', fontWeight: '700', fontSize: 13},
approach: {color: '#888', fontSize: 9, marginBottom: 4},
buttons: {gap: 4, marginBottom: 4},
btnRow: {flexDirection: 'row', gap: 4},
btnGreen: {
Expand Down
Loading
Loading