From 1fe81d4636f695b7cb902e9119310a914b39c7c9 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 9 Mar 2026 11:29:42 -0600 Subject: [PATCH 1/5] feat: add headless API for external UI control of WASM proxy Expose window.LanternProxy global API that allows host pages to control the proxy without rendering the unbounded UI. When data-headless="true" is set on the embed element, React rendering is skipped entirely while the WASM proxy remains fully functional. Co-Authored-By: Claude Opus 4.6 --- ui/src/headlessApi.ts | 121 ++++++++++++++++++++++++++++++++++++++++++ ui/src/index.tsx | 16 ++++-- 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 ui/src/headlessApi.ts diff --git a/ui/src/headlessApi.ts b/ui/src/headlessApi.ts new file mode 100644 index 00000000..38468d94 --- /dev/null +++ b/ui/src/headlessApi.ts @@ -0,0 +1,121 @@ +/** + * Headless API for controlling the unbounded WASM proxy without rendering any UI. + * + * Usage: + * + * + * + */ + +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 = (value: T) => void + +const listeners = new Map>() + +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 + +export const LanternProxy = { + /** + * Initialize the WASM proxy. Must be called before start(). + * @param options.mock - Use mock client for testing (default: false) + */ + async init(options?: { mock?: boolean }): Promise { + if (initialized) { + console.warn('LanternProxy already initialized') + return + } + const mock = options?.mock ?? false + wasmInterface = new WasmInterface() + const instance = await wasmInterface.initialize({mock, target: Targets.WEB}) + if (!instance) throw new Error('WASM proxy failed to initialize') + initialized = true + }, + + /** Start proxying traffic. Resolves once sharing begins. */ + start(): void { + if (!wasmInterface) throw new Error('LanternProxy not initialized — call init() first') + wasmInterface.start() + }, + + /** Stop proxying traffic. */ + stop(): void { + if (!wasmInterface) throw new Error('LanternProxy not initialized — call init() first') + wasmInterface.stop() + }, + + /** Subscribe to a proxy event. Returns an unsubscribe function. */ + on(event: ProxyEvent, callback: EventCallback): () => 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. */ + 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 +;(window as any).LanternProxy = LanternProxy diff --git a/ui/src/index.tsx b/ui/src/index.tsx index f86aad80..74d1c857 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -4,6 +4,9 @@ import App from './App' import {StateEmitter} from './hooks/useStateEmitter' import {defaultSettings, Settings, Themes} from './constants' +// Always register the headless API on window.LanternProxy +import './headlessApi' + export const settingsEmitter = new StateEmitter<{ [key: number]: Settings }>({}) const upperSnakeToCamel = (s: string | undefined) => { @@ -49,13 +52,20 @@ const hydrateSettings = (i: number, dataset: Settings) => { const init = (embeds: NodeListOf) => { embeds.forEach((embed, i) => { + const dataset = embed.dataset as unknown as Settings + hydrateSettings(i, dataset) + + // Headless mode: skip all UI rendering, just expose window.LanternProxy + // Usage: + if ((embed.dataset as any).headless === 'true') { + console.log('Unbounded: headless mode — UI rendering skipped, use window.LanternProxy') + return + } + const root = ReactDOM.createRoot( embed ) - const dataset = embed.dataset as unknown as Settings - hydrateSettings(i, dataset) - const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.attributeName && mutation.attributeName.includes('data-')) { From 5f9d73954316d78fae692e2a57cdba83472fd903 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 9 Mar 2026 11:41:28 -0600 Subject: [PATCH 2/5] docs: add headless API documentation to README Document the data-headless attribute, window.LanternProxy API, events, methods, and a minimal usage example. Co-Authored-By: Claude Opus 4.6 --- README.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/README.md b/README.md index dd732080..173447fc 100644 --- a/README.md +++ b/README.md @@ -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, @@ -174,6 +175,85 @@ 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 — only the WASM proxy engine loads. A global `window.LanternProxy` API is exposed for programmatic control. + +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 + + + +``` + +**API reference:** + +| Method / Property | Description | +|---|---| +| `init(options?)` | Initialize the WASM proxy. Accepts optional `{ mock: boolean }`. Must be called before `start()`. | +| `start()` | Begin proxying traffic. | +| `stop()` | Stop proxying traffic. | +| `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) From c5a58631b5b42e886f0fc8265a1df889a587f09e Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 9 Mar 2026 11:55:41 -0600 Subject: [PATCH 3/5] fix: address PR review comments on headless API - Fix concurrent init() race: use shared initPromise so duplicate calls return the same promise instead of creating multiple WasmInterface instances - Fix usage examples: subscribe to events BEFORE calling init() to avoid missing the ready event; use type="module" for top-level await - Fix JSDoc: start()/stop() are fire-and-forget, not Promise-returning - Use Object.defineProperty for window.LanternProxy to prevent accidental overwrites and make it non-enumerable - Add unit tests for on/off subscriptions, getState, init idempotency, and global immutability Co-Authored-By: Claude Opus 4.6 --- README.md | 74 +++++++++++----------- ui/src/headlessApi.test.ts | 125 +++++++++++++++++++++++++++++++++++++ ui/src/headlessApi.ts | 54 +++++++++++----- 3 files changed, 197 insertions(+), 56 deletions(-) create mode 100644 ui/src/headlessApi.test.ts diff --git a/README.md b/README.md index 173447fc..42de264d 100644 --- a/README.md +++ b/README.md @@ -189,42 +189,38 @@ This is useful when you want to: ```html - ``` @@ -232,9 +228,9 @@ This is useful when you want to: | Method / Property | Description | |---|---| -| `init(options?)` | Initialize the WASM proxy. Accepts optional `{ mock: boolean }`. Must be called before `start()`. | -| `start()` | Begin proxying traffic. | -| `stop()` | Stop proxying traffic. | +| `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 }`. | diff --git a/ui/src/headlessApi.test.ts b/ui/src/headlessApi.test.ts new file mode 100644 index 00000000..18064199 --- /dev/null +++ b/ui/src/headlessApi.test.ts @@ -0,0 +1,125 @@ +import {readyEmitter, sharingEmitter, connectionsEmitter, averageThroughputEmitter, lifetimeConnectionsEmitter, lifetimeChunksEmitter} 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 = {} + const WasmInterface = jest.fn().mockImplementation(() => ({ + initialize: jest.fn().mockResolvedValue(mockInstance), + start: jest.fn(), + stop: jest.fn(), + })) + + return { + WasmInterface, + 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) + }) +}) + +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() + }) +}) diff --git a/ui/src/headlessApi.ts b/ui/src/headlessApi.ts index 38468d94..93d1885e 100644 --- a/ui/src/headlessApi.ts +++ b/ui/src/headlessApi.ts @@ -1,15 +1,18 @@ /** * Headless API for controlling the unbounded WASM proxy without rendering any UI. * - * Usage: + * Usage (as a module or after the deferred script has loaded): * - * - * + * */ @@ -48,31 +51,41 @@ function wireEmitters() { let wasmInterface: WasmInterface | null = null let initialized = false +let initPromise: Promise | 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) */ - async init(options?: { mock?: boolean }): Promise { + init(options?: { mock?: boolean }): Promise { if (initialized) { - console.warn('LanternProxy already initialized') - return + return Promise.resolve() } - const mock = options?.mock ?? false - wasmInterface = new WasmInterface() - const instance = await wasmInterface.initialize({mock, target: Targets.WEB}) - if (!instance) throw new Error('WASM proxy failed to initialize') - initialized = true + 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. Resolves once sharing begins. */ + /** Start proxying traffic (fire-and-forget). */ start(): void { if (!wasmInterface) throw new Error('LanternProxy not initialized — call init() first') wasmInterface.start() }, - /** Stop proxying traffic. */ + /** Stop proxying traffic (fire-and-forget). */ stop(): void { if (!wasmInterface) throw new Error('LanternProxy not initialized — call init() first') wasmInterface.stop() @@ -117,5 +130,12 @@ export const LanternProxy = { // Wire emitters immediately so subscriptions work before init() wireEmitters() -// Expose globally -;(window as any).LanternProxy = LanternProxy +// 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, + }) +} From 9d6508fe7dd1d7a1f94cd98645d980e01c327aa0 Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 9 Mar 2026 12:08:00 -0600 Subject: [PATCH 4/5] fix: use const assertion for Connection.state type in test Co-Authored-By: Claude Opus 4.6 --- ui/src/headlessApi.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/headlessApi.test.ts b/ui/src/headlessApi.test.ts index 18064199..fb339662 100644 --- a/ui/src/headlessApi.test.ts +++ b/ui/src/headlessApi.test.ts @@ -83,7 +83,7 @@ describe('LanternProxy.on / off', () => { expect(readyCb).toHaveBeenCalledWith(true) expect(connCb).not.toHaveBeenCalled() - const conns = [{state: 1, workerIdx: 0, addr: '1.2.3.4'}] + const conns = [{state: 1 as const, workerIdx: 0, addr: '1.2.3.4'}] connectionsEmitter.update(conns) expect(connCb).toHaveBeenCalledWith(conns) }) From 2c1d04b58b15ad7440bb3f3fa85976fbca31f81d Mon Sep 17 00:00:00 2001 From: Adam Fisk Date: Mon, 9 Mar 2026 13:06:18 -0600 Subject: [PATCH 5/5] fix: address PR review comments (round 2) - Gate start()/stop() on `initialized` flag, not just wasmInterface existence, to prevent calls during in-progress init() - Return shallow copies of arrays from getState() to prevent external mutation of internal state - Fix README: clarify that headless mode requires explicit init() call (WASM engine doesn't auto-load) - Update index.tsx log message to match documentation - Fix test mock to use class-based MockWasmInterface - Add test for getState() returning shallow copies Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- ui/src/headlessApi.test.ts | 31 +++++++++++++++++++++++-------- ui/src/headlessApi.ts | 14 +++++++------- ui/src/index.tsx | 2 +- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 42de264d..4bd76e12 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ If you enable the editor (by setting `REACT_APP_EDITOR=true` or `data-editor="tr #### 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 — only the WASM proxy engine loads. A global `window.LanternProxy` API is exposed for programmatic control. +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) diff --git a/ui/src/headlessApi.test.ts b/ui/src/headlessApi.test.ts index fb339662..cd37f5c7 100644 --- a/ui/src/headlessApi.test.ts +++ b/ui/src/headlessApi.test.ts @@ -1,4 +1,4 @@ -import {readyEmitter, sharingEmitter, connectionsEmitter, averageThroughputEmitter, lifetimeConnectionsEmitter, lifetimeChunksEmitter} from './utils/wasmInterface' +import {readyEmitter, sharingEmitter, connectionsEmitter, averageThroughputEmitter, lifetimeConnectionsEmitter, lifetimeChunksEmitter, WasmInterface} from './utils/wasmInterface' // Mock WasmInterface before importing headlessApi jest.mock('./utils/wasmInterface', () => { @@ -11,14 +11,20 @@ jest.mock('./utils/wasmInterface', () => { const lifetimeChunksEmitter = new StateEmitter([]) const mockInstance = {} - const WasmInterface = jest.fn().mockImplementation(() => ({ - initialize: jest.fn().mockResolvedValue(mockInstance), - start: jest.fn(), - stop: jest.fn(), - })) + + class MockWasmInterface { + initialize = jest.fn().mockResolvedValue(mockInstance) + start = jest.fn() + stop = jest.fn() + ready = false + initializing = false + connectionMap = {} + throughput = {bytesPerSec: 0} + connections = [] + } return { - WasmInterface, + WasmInterface: MockWasmInterface, readyEmitter, sharingEmitter, connectionsEmitter, @@ -83,7 +89,7 @@ describe('LanternProxy.on / off', () => { expect(readyCb).toHaveBeenCalledWith(true) expect(connCb).not.toHaveBeenCalled() - const conns = [{state: 1 as const, workerIdx: 0, addr: '1.2.3.4'}] + const conns = [{state: 1, workerIdx: 0, addr: '1.2.3.4'}] connectionsEmitter.update(conns) expect(connCb).toHaveBeenCalledWith(conns) }) @@ -102,6 +108,15 @@ describe('LanternProxy.getState', () => { 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', () => { diff --git a/ui/src/headlessApi.ts b/ui/src/headlessApi.ts index 93d1885e..82c7c64b 100644 --- a/ui/src/headlessApi.ts +++ b/ui/src/headlessApi.ts @@ -79,15 +79,15 @@ export const LanternProxy = { return initPromise }, - /** Start proxying traffic (fire-and-forget). */ + /** Start proxying traffic (fire-and-forget). Must call init() first. */ start(): void { - if (!wasmInterface) throw new Error('LanternProxy not initialized — call init() first') + if (!initialized || !wasmInterface) throw new Error('LanternProxy not initialized — call and await init() first') wasmInterface.start() }, - /** Stop proxying traffic (fire-and-forget). */ + /** Stop proxying traffic (fire-and-forget). Must call init() first. */ stop(): void { - if (!wasmInterface) throw new Error('LanternProxy not initialized — call init() first') + if (!initialized || !wasmInterface) throw new Error('LanternProxy not initialized — call and await init() first') wasmInterface.stop() }, @@ -104,15 +104,15 @@ export const LanternProxy = { listeners.get(event)?.delete(callback) }, - /** Get a snapshot of the current proxy state. */ + /** Get a snapshot of the current proxy state. Arrays are shallow-copied. */ getState(): ProxyState { return { ready: readyEmitter.state, sharing: sharingEmitter.state, - connections: connectionsEmitter.state, + connections: [...connectionsEmitter.state], throughput: averageThroughputEmitter.state, lifetimeConnections: lifetimeConnectionsEmitter.state, - chunks: lifetimeChunksEmitter.state, + chunks: [...lifetimeChunksEmitter.state], } }, diff --git a/ui/src/index.tsx b/ui/src/index.tsx index 74d1c857..b7cafa56 100644 --- a/ui/src/index.tsx +++ b/ui/src/index.tsx @@ -58,7 +58,7 @@ const init = (embeds: NodeListOf) => { // Headless mode: skip all UI rendering, just expose window.LanternProxy // Usage: if ((embed.dataset as any).headless === 'true') { - console.log('Unbounded: headless mode — UI rendering skipped, use window.LanternProxy') + console.log('Unbounded: headless mode — UI rendering skipped. Call window.LanternProxy.init() to load the proxy engine.') return }