Skip to content
Open
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
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ The "default" column shows the default value if the attribute is not set.
| branding | boolean to include logos | true |
| mock | boolean to use the mock wasm client data | false |
| target | string "web", "extension-offscreen" or "extension-popup" | web |
| headless | boolean to skip UI rendering (headless mode) | false |

In development, these settings can be customized using the `REACT_APP_*` environment variables in the `.env` or in your terminal.
For example, to run the widget in "panel" layout, you can run `REACT_APP_LAYOUT=panel yarn start`. To run the widget with mock data,
Expand All @@ -174,6 +175,81 @@ you can set `data-layout="panel"` in `ui/public/index.html`.
If you enable the editor (by setting `REACT_APP_EDITOR=true` or `data-editor="true"`), you can also edit the settings dynamically in the browser using a UI editor the renders above the widget.
*Note* that the `mock` and `target` settings are not dynamic and therefore not editable in the browser. These two settings are static and must be set at the time the wasm interface is initialized.

#### Headless mode (programmatic API)

Headless mode lets you run the WASM proxy without rendering any UI, giving the host page full control over the user experience. When `data-headless="true"` is set, React rendering is skipped entirely. A global `window.LanternProxy` API is exposed for programmatic control — call `init()` to load the WASM proxy engine, then `start()` to begin sharing.

This is useful when you want to:
- Build a custom UI around the proxy (e.g. embed proxy stats in your own dashboard)
- Run the proxy silently in the background
- Integrate proxy data (connections, throughput) into an existing application

**Minimal example:**

```html
<browsers-unbounded data-headless="true"></browsers-unbounded>
<script defer src="https://embed.lantern.io/static/js/main.js"></script>
<!-- Use type="module" so this runs after deferred scripts and supports top-level await -->
<script type="module">
const proxy = window.LanternProxy;

// Subscribe to events BEFORE calling init() to avoid missing them
proxy.on('ready', (isReady) => {
console.log('Proxy ready:', isReady);
if (isReady) proxy.start();
});

proxy.on('sharing', (isSharing) => {
console.log('Sharing:', isSharing);
});

proxy.on('connections', (conns) => {
console.log('Active connections:', conns.length);
});

proxy.on('throughput', (bps) => {
console.log('Throughput:', bps, 'bytes/sec');
});

proxy.on('lifetimeConnections', (count) => {
console.log('Total connections served:', count);
});

proxy.on('chunks', (chunks) => {
console.log('Data chunks:', chunks);
});

// Now initialize — events registered above will fire during init
await proxy.init();
</script>
```

**API reference:**

| Method / Property | Description |
|---|---|
| `init(options?)` | Initialize the WASM proxy. Accepts optional `{ mock: boolean }`. Must be called before `start()`. Safe to call concurrently — duplicate calls return the same promise. |
| `start()` | Begin proxying traffic (fire-and-forget). |
| `stop()` | Stop proxying traffic (fire-and-forget). |
| `on(event, callback)` | Subscribe to an event. Returns an unsubscribe function. |
| `off(event, callback)` | Unsubscribe from an event. |
| `getState()` | Returns a snapshot of current state: `{ ready, sharing, connections, throughput, lifetimeConnections, chunks }`. |
| `initialized` | Boolean — whether `init()` has been called successfully. |
| `config` | Read-only copy of the WASM client config (discovery server, egress, etc). |

**Events:**

| Event | Payload | Description |
|---|---|---|
| `ready` | `boolean` | Fires when the proxy engine is ready to start |
| `sharing` | `boolean` | Fires when the proxy begins/stops sharing traffic |
| `connections` | `Connection[]` | Active connection list updates |
| `throughput` | `number` | Average throughput in bytes/sec |
| `lifetimeConnections` | `number` | Cumulative connections served |
| `chunks` | `Chunk[]` | Data chunk updates |

**Note:** `window.LanternProxy` is registered on every page load (even without `data-headless`), so you can use the API alongside the standard UI embed too. The `data-headless` attribute only controls whether the React UI renders.

Links:

[Github pages sandbox](https://embed.lantern.io)
Expand Down
140 changes: 140 additions & 0 deletions ui/src/headlessApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import {readyEmitter, sharingEmitter, connectionsEmitter, averageThroughputEmitter, lifetimeConnectionsEmitter, lifetimeChunksEmitter, WasmInterface} from './utils/wasmInterface'

// Mock WasmInterface before importing headlessApi
jest.mock('./utils/wasmInterface', () => {
const {StateEmitter} = jest.requireActual('./hooks/useStateEmitter')
const readyEmitter = new StateEmitter(false)
const sharingEmitter = new StateEmitter(false)
const connectionsEmitter = new StateEmitter([])
const averageThroughputEmitter = new StateEmitter(0)
const lifetimeConnectionsEmitter = new StateEmitter(0)
const lifetimeChunksEmitter = new StateEmitter([])

const mockInstance = {}

class MockWasmInterface {
initialize = jest.fn().mockResolvedValue(mockInstance)
start = jest.fn()
stop = jest.fn()
ready = false
initializing = false
connectionMap = {}
throughput = {bytesPerSec: 0}
connections = []
}

return {
WasmInterface: MockWasmInterface,
readyEmitter,
sharingEmitter,
connectionsEmitter,
averageThroughputEmitter,
lifetimeConnectionsEmitter,
lifetimeChunksEmitter,
}
})

// Import after mock is set up
import {LanternProxy} from './headlessApi'

beforeEach(() => {
// Reset emitter state between tests
readyEmitter.update(false)
sharingEmitter.update(false)
connectionsEmitter.update([])
averageThroughputEmitter.update(0)
lifetimeConnectionsEmitter.update(0)
lifetimeChunksEmitter.update([])
})

describe('LanternProxy.on / off', () => {
test('on() delivers emitter updates to subscribers', () => {
const cb = jest.fn()
LanternProxy.on('ready', cb)
readyEmitter.update(true)
expect(cb).toHaveBeenCalledWith(true)
})

test('on() returns an unsubscribe function', () => {
const cb = jest.fn()
const unsub = LanternProxy.on('throughput', cb)
averageThroughputEmitter.update(100)
expect(cb).toHaveBeenCalledTimes(1)

unsub()
averageThroughputEmitter.update(200)
expect(cb).toHaveBeenCalledTimes(1) // no new calls
})

test('off() removes a specific callback', () => {
const cb1 = jest.fn()
const cb2 = jest.fn()
LanternProxy.on('sharing', cb1)
LanternProxy.on('sharing', cb2)

LanternProxy.off('sharing', cb1)
sharingEmitter.update(true)

expect(cb1).not.toHaveBeenCalled()
expect(cb2).toHaveBeenCalledWith(true)
})

test('multiple event types work independently', () => {
const readyCb = jest.fn()
const connCb = jest.fn()
LanternProxy.on('ready', readyCb)
LanternProxy.on('connections', connCb)

readyEmitter.update(true)
expect(readyCb).toHaveBeenCalledWith(true)
expect(connCb).not.toHaveBeenCalled()

const conns = [{state: 1, workerIdx: 0, addr: '1.2.3.4'}]
connectionsEmitter.update(conns)
expect(connCb).toHaveBeenCalledWith(conns)
})
})

describe('LanternProxy.getState', () => {
test('returns current emitter state', () => {
readyEmitter.update(true)
sharingEmitter.update(true)
averageThroughputEmitter.update(500)
lifetimeConnectionsEmitter.update(42)

const state = LanternProxy.getState()
expect(state.ready).toBe(true)
expect(state.sharing).toBe(true)
expect(state.throughput).toBe(500)
expect(state.lifetimeConnections).toBe(42)
})

test('returns shallow copies of arrays', () => {
const conns = [{state: 1, workerIdx: 0, addr: '1.2.3.4'}]
connectionsEmitter.update(conns)

const state = LanternProxy.getState()
expect(state.connections).toEqual(conns)
expect(state.connections).not.toBe(conns) // different reference
})
})

describe('LanternProxy.init', () => {
test('concurrent calls return the same promise', () => {
const p1 = LanternProxy.init()
const p2 = LanternProxy.init()
expect(p1).toBe(p2)
})
})

describe('window.LanternProxy', () => {
test('is exposed globally', () => {
expect((window as any).LanternProxy).toBe(LanternProxy)
})

test('is not writable', () => {
expect(() => {
(window as any).LanternProxy = 'overwrite'
}).toThrow()
})
})
141 changes: 141 additions & 0 deletions ui/src/headlessApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Headless API for controlling the unbounded WASM proxy without rendering any UI.
*
* Usage (as a module or after the deferred script has loaded):
* <browsers-unbounded data-headless="true"></browsers-unbounded>
* <script defer src="https://embed.lantern.io/static/js/main.js"></script>
* <script type="module">
* const proxy = window.LanternProxy;
* // Subscribe to events BEFORE calling init() to avoid missing them
* proxy.on('ready', (isReady) => {
* if (isReady) proxy.start();
* });
* proxy.on('connections', (conns) => console.log(conns));
* proxy.on('throughput', (bps) => console.log(bps));
* await proxy.init();
* </script>
*/

import {WasmInterface, connectionsEmitter, averageThroughputEmitter, lifetimeConnectionsEmitter, lifetimeChunksEmitter, readyEmitter, sharingEmitter, type Connection, type Chunk} from './utils/wasmInterface'
import {Targets, WASM_CLIENT_CONFIG} from './constants'

export type ProxyEvent = 'ready' | 'sharing' | 'connections' | 'throughput' | 'lifetimeConnections' | 'chunks'

export interface ProxyState {
ready: boolean
sharing: boolean
connections: Connection[]
throughput: number
lifetimeConnections: number
chunks: Chunk[]
}

type EventCallback<T = unknown> = (value: T) => void

const listeners = new Map<string, Set<EventCallback>>()

function emitToListeners(event: string, value: unknown) {
const set = listeners.get(event)
if (set) set.forEach(cb => cb(value))
}

// Wire up emitters to forward to external listeners
function wireEmitters() {
readyEmitter.on((v) => emitToListeners('ready', v))
sharingEmitter.on((v) => emitToListeners('sharing', v))
connectionsEmitter.on((v) => emitToListeners('connections', v))
averageThroughputEmitter.on((v) => emitToListeners('throughput', v))
lifetimeConnectionsEmitter.on((v) => emitToListeners('lifetimeConnections', v))
lifetimeChunksEmitter.on((v) => emitToListeners('chunks', v))
}

let wasmInterface: WasmInterface | null = null
let initialized = false
let initPromise: Promise<void> | null = null

export const LanternProxy = {
/**
* Initialize the WASM proxy. Must be called before start().
* Safe to call concurrently — subsequent calls return the same promise.
* @param options.mock - Use mock client for testing (default: false)
*/
init(options?: { mock?: boolean }): Promise<void> {
if (initialized) {
return Promise.resolve()
}
if (initPromise) {
return initPromise
}
initPromise = (async () => {
const mock = options?.mock ?? false
wasmInterface = new WasmInterface()
const instance = await wasmInterface.initialize({mock, target: Targets.WEB})
if (!instance) {
initPromise = null
throw new Error('WASM proxy failed to initialize')
}
initialized = true
})()
return initPromise
},

/** Start proxying traffic (fire-and-forget). Must call init() first. */
start(): void {
if (!initialized || !wasmInterface) throw new Error('LanternProxy not initialized — call and await init() first')
wasmInterface.start()
},

/** Stop proxying traffic (fire-and-forget). Must call init() first. */
stop(): void {
if (!initialized || !wasmInterface) throw new Error('LanternProxy not initialized — call and await init() first')
wasmInterface.stop()
},

/** Subscribe to a proxy event. Returns an unsubscribe function. */
on<T = unknown>(event: ProxyEvent, callback: EventCallback<T>): () => void {
if (!listeners.has(event)) listeners.set(event, new Set())
const set = listeners.get(event)!
set.add(callback as EventCallback)
return () => set.delete(callback as EventCallback)
},

/** Unsubscribe from a proxy event. */
off(event: ProxyEvent, callback: EventCallback): void {
listeners.get(event)?.delete(callback)
},

/** Get a snapshot of the current proxy state. Arrays are shallow-copied. */
getState(): ProxyState {
return {
ready: readyEmitter.state,
sharing: sharingEmitter.state,
connections: [...connectionsEmitter.state],
throughput: averageThroughputEmitter.state,
lifetimeConnections: lifetimeConnectionsEmitter.state,
chunks: [...lifetimeChunksEmitter.state],
}
},

/** Whether init() has been called successfully. */
get initialized(): boolean {
return initialized
},

/** The WASM client config (discovery server, egress, etc). Read-only. */
get config() {
return {...WASM_CLIENT_CONFIG}
},
}

// Wire emitters immediately so subscriptions work before init()
wireEmitters()

// Expose globally — use defineProperty to prevent accidental overwrites
if (!(window as any).LanternProxy) {
Object.defineProperty(window, 'LanternProxy', {
value: LanternProxy,
writable: false,
enumerable: false,
configurable: false,
})
}
Loading
Loading