Skip to content

refactor(call): Session-backed WebRTC — delegate topology to SDK - #159

Open
HexaField wants to merge 18 commits into
devfrom
feat/sfu-integration
Open

refactor(call): Session-backed WebRTC — delegate topology to SDK#159
HexaField wants to merge 18 commits into
devfrom
feat/sfu-integration

Conversation

@HexaField

@HexaField HexaField commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What

This PR replaces the SfuBackend adapter with a structural CallBackend interface that the AD4M SDK Session satisfies directly. The call module now delegates topology resolution (mesh / SFU / auto), signalling, roster polling, and peer connection management to the SDK. Two runtime bugs that prevented the call settings UI from rendering have also been fixed.

Why

The previous SfuBackend adapter (211 lines) duplicated logic that now lives in the SDK Session — topology selection, ICE negotiation, renegotiation handling, and participant tracking. Removing the adapter eliminates that duplication and lets the call module drive lifecycle through one stable interface. The settings UI bugs (dead Solid effects and spread-killed getters) blocked runtime verification of the call topology configuration surface.

How

CallBackend interface (store.ts)

CallBackend defines a structural contract that SDK Session satisfies without an explicit implements:

  • join(localStream) / leave() / destroy() — lifecycle
  • replaceTrack(kind, track) — camera/screen swap without renegotiation
  • setQualityPreference(quality) — delegates to SFU quality signalling
  • participants — reactive participant map with streams
  • Event emitter: participant-joined, participant-left, stream-added, stream-removed, topology-changed, error

The store subscribes to backend events and reads participants for tile streams. The mesh fallback path remains unchanged for when no backend exists.

Late-binding fixes (moduleHostServices.ts, moduleRegistry.ts)

Two bugs prevented callConfigSupported from returning true at runtime:

  1. Dead effects — deps closures like () => services.dataset?.() read nothing reactive when services.dataset remained undefined (module stores mount before host stores). The first effect run tracked zero Solid dependencies and never re-fired. Fix: added a revision signal that every closure reads; provideModuleHostServices bumps it after Object.assign.

  2. Spread kills getters{ ...storeDeps, onDispose, settings } evaluated every getter at spread time, copying undefined as plain properties. Fix: Object.assign(Object.create(storeDeps), { onDispose, settings }) preserves getters via prototypal delegation.

Type alignment (callConfigAdapter.ts)

Widened setCallConfig parameter from CallConfig to unknown to match the ModuleHostServices interface boundary (the module system has no access to the CallConfig type). Internal cast at the nhProxy.setSfuConfig call site.

Files changed

Package Files Change
module-system/call store.ts, index.ts CallBackend interface, backend event wiring, SfuBackend removal
module-system/call sfu.ts Deleted (211 lines)
app-shell moduleHostServices.ts Revision signal for late-bound service reactivity
app-shell moduleRegistry.ts Object.create prototypal delegation for deps
backend-system/ad4m callConfigAdapter.ts setCallConfig parameter widened to unknown

References

  • Depends on coasys/ad4m#712 (feat/embedded-sfu) for the SDK Session interface
  • Net −163 lines. Zero new test failures, zero new type errors.

@netlify

netlify Bot commented Aug 26, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit 3648681
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6aa24665c840a40008b0ec7d
😎 Deploy Preview https://deploy-preview-159--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Add an SFU adapter that wraps any SfuManager-compatible backend behind
the existing CallMesh interface. The store branches on an optional
sfuBackend in deps — when provided, the call connects through the SFU
relay instead of building N-1 direct peer connections.

New files:
  sfu.ts — SfuBackend structural interface + createCallSfu() adapter

Store changes:
  - sfuBackend?: SfuBackend in CallStoreDeps
  - topology signal (mesh/sfu) for the template to surface mode
  - qualityPreference signal + setQualityPreference action (high/med/low)
  - SFU join with automatic mesh fallback on failure
  - Proper teardown of SFU state

Exports:
  - createCallSfu, SfuBackend, SfuQuality, CallTopology

The SfuBackend interface matches SfuManager from @coasys/ad4m without
importing it — the host constructs the manager and passes it in.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@HexaField
HexaField force-pushed the feat/sfu-integration branch from 8b01954 to 216ec28 Compare August 27, 2026 02:04
…ckend

Delete sfu.ts entirely — the SDK's Session (coasys/ad4m#712) now unifies
mesh and SFU topologies behind a single interface, making the adapter
redundant.

The store accepts an optional CallBackend (structurally satisfied by
Session from @coasys/ad4m) through deps. When provided:
- Session handles topology resolution, signalling, and roster internally
- Store calls session.join/leave/destroy for lifecycle
- Store calls session.replaceTrack for camera↔screen swap (no renego)
- Store reads session.participants for tile streams
- Store delegates setQualityPreference to session
- Roster effect skips (Session polls onlineAgents internally)

When no backend: falls back to WE's built-in peer-to-peer mesh unchanged.

Net -163 lines. Depends on coasys/ad4m#712 (feat/embedded-sfu).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@HexaField HexaField changed the title feat(call): SFU relay support via optional backend refactor(call): Session-backed WebRTC — delegate topology to SDK Aug 28, 2026
HexaField and others added 16 commits August 28, 2026 18:11
- Add BackendDataMessage type for data channel messages
- Add sendData/onData to CallBackend interface
- Wire backend.onData() subscription in backend join path
- Expose sendData/onData in store return value
- Clean up data subscription on teardown
- Export BackendDataMessage from call module

Enables reactions, raised hands, collaborative pointers through
the Session relay when a backend handles the call. Mesh-only calls
return silently from sendData (no server relay available).

Zero new type errors (46 pre-existing monorepo cross-ref errors).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add automatic quality adjustment and relay availability signals
to the call store for the UI to consume:

RelayInfo interface:
- relayActive: whether a relay server handles the call
- participantCount: total participants including self
- meshLimitReached: true when mesh call exceeds 6 participants

Auto quality (SFU only):
- Adjusts simulcast layer based on participant count:
  5-8 participants → medium, 9+ → low
- Only active when user has not explicitly set a preference
- qualityIsManual flag tracks explicit user choice
- Resets on call teardown

Mesh limit warning:
- relayInfo().meshLimitReached signals the UI to show a quality
  hint when a peer-to-peer call grows beyond 6 participants

Zero new type errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…/billing

Export bundledModules from shared barrel so platform deployments can
register their own modules before PlatformProvider runs initializeIntegrations.

Add Settings.schema.ts with Account and Billing sub-routes. Account shows
DID, email, and plan via $store resolution (modules.platform.*). Billing
shows current plan, credits, upgrade/buy buttons. Both gated on
modules.platform.email signal — hidden for guest sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
# Conflicts:
#	packages/module-system/call/src/index.ts
#	packages/module-system/call/src/store.ts
…dicator

Gap 1 — Session construction and backend injection:
- callSessionAdapter.ts: factory bridging NeighbourhoodProxy.createSession
  to the call module's CallBackend interface (late-binding getters)
- moduleHostServices.ts: createCallBackend service + getter forwarding
- DatasetStore.tsx: binds factory via provideModuleHostServices
- module-shared/module.ts: createBackend on ModuleStoreDeps (neutral port)
- store.ts: join() resolves backend from createBackend factory or static
  backend, with error handling and fallback to mesh

Gap 2 — Quality selector UI:
- store.ts: cycleQuality action (high → medium → low → high)
- index.ts: qualitySelector fragment — cycling button with signal-strength
  icon, only visible when topology is SFU

Gap 3 — Topology indicator UI:
- index.ts: topologyIndicator fragment — broadcast icon for SFU, graph
  icon for mesh, with tooltips

Also: pnpm overrides migrated from package.json to pnpm-workspace.yaml
(pnpm 10.18 no longer reads the pnpm field in package.json).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ssionBackend signal

Topology indicator previously rendered even without a Session backend
(pure mesh fallback). Wraps the indicator in an outer $if on
hasSessionBackend — only shows mesh/SFU state when a backend exists.

Adds hasSessionBackend boolean signal to the call store, set when
createBackend succeeds, reset on leave. Exported for schema bindings.

Verified: 8/8 Playwright E2E tests pass on Macbook Electron (CDP).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The pnpm override pointed to `link:../ad4m-sfu/core` — a local
checkout that does not exist in CI. Reverts package management to match
dev: overrides in package.json, lockfile from dev.

Restructures callSessionAdapter to compile against the published
`@coasys/ad4m` (0.13.0-test-model-layer) which does not export
Session or createSession. The adapter now:

- Imports only `type PerspectiveProxy` (exists in published build)
- Gets the NeighbourhoodProxy via `PerspectiveProxy.getNeighbourhoodProxy()`
  rather than constructing directly (avoids constructor signature diff)
- Checks for `createSession` at runtime and throws a clear error when
  paired with an executor that lacks SFU support
- Defines a local `SessionCapableProxy` interface for the cast

Once `@coasys/ad4m` publishes the SFU types, the local interface
can be replaced with direct imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
CallStoreDeps.createBackend returned Promise<CallBackend> while
ModuleStoreDeps.createBackend (the shared contract) returned
Promise<unknown>. TypeScript rejected the assignment at createStore.

Widens the return type to Promise<unknown> and casts at the call site.
The structural contract still holds at runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Accidentally dropped in 0b1dcdb when testing dev build — the
git checkout origin/dev test wiped the staged export line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The WE expression DSL supports == and != but not === or !==.
The strict-equality operators caused ExpressionSyntaxError at import
time, failing index.test.ts in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two settings surfaces for moderators to control call topology:

1. Space Settings → Features → Call topology section
   - Topology mode selector (mesh / designated / gateway / cascaded)
   - Conditional fields for designated peer DID, cascade params
   - Fallback mode selector
   - Max mesh participants before SFU escalation
   - Available SFU node scanner
   - Gated on callConfigSupported && canAdminister

2. Per-call settings popover (gear icon in call bar)
   - Read-only topology indicator (mesh / SFU icon)
   - Quality preference selector (High / Medium / Low)
   - Connection summary with mesh limit warning

Backend adapter (callConfigAdapter.ts):
   - Reads/writes SFU config via NeighbourhoodProxy Social DNA
   - Runtime capability gating for published @coasys/ad4m compat
   - Discovers SFU-capable nodes via presence scan

Session adapter now reads moderator's topology choice from Social DNA
before creating a session, instead of hardcoding 'auto'.

Module host services wired through ModuleStoreDeps (getCallConfig,
setCallConfig, getAvailableSfuNodes, callConfigSupported).

Call store exposes reactive signals for config state, saving state,
available nodes, and connection info summary.

Tests: 29 new tests across 3 files
  - callConfig.test.ts (11): store config signals, load/save/refresh
  - callConfigAdapter.test.ts (11): adapter read/write/probe/fallback
  - callSessionAdapter.test.ts (7): topology resolution, fallbacks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two bugs prevented callConfigSupported (and other late-bound service ports)
from resolving at runtime:

1. moduleHostServices: deps closures like `() => services.dataset?.()` read
   nothing reactive when services have not mounted yet. Effects that call them
   first establish zero Solid dependencies and never re-fire. Add a revision
   signal that every closure reads; bump it in provideModuleHostServices so
   effects re-run when a new service slice arrives.

2. moduleRegistry: `{ ...storeDeps }` evaluates every getter at spread time,
   copying undefined values as plain properties. Switch to
   `Object.assign(Object.create(storeDeps), ...)` so prototypal delegation
   preserves getters — reads at any later time still reach the real service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The adapter declared `(config: CallConfig)` but ModuleHostServices uses
`(config: unknown)` because the module-system boundary has no access to
the CallConfig type. TypeScript rejects the narrower parameter type
(contravariance). Widen the adapter signature to `unknown` and cast
internally where the concrete type reaches the nhProxy call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@HexaField
HexaField marked this pull request as ready for review September 10, 2026 06:10
@HexaField
HexaField requested a review from jhweir as a code owner September 10, 2026 06:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant