@realitycollective/service-framework-iwsdk 1.0.1-preview.1 → 1.0.1-preview.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,10 +6,27 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
6
6
 
7
7
  ## [1.0.1]
8
8
 
9
- Packaging, tooling and documentation. No runtime behaviour changed, and no public API was added, removed or altered.
9
+ Packaging, tooling and documentation, plus additive runtime API: the engine-free adapter contracts move into the core, the IWSDK adapter derives capabilities from the live session and gains a session facet, and the three.js and Babylon.js packages gain runtime adapters so a three.js, Babylon or desktop app reaches the same seam. Nothing that existed was removed, and one interface changed shape: `AdapterCapabilities` gains a required `environmentBlendMode` key, so code that builds the whole object by hand - an adapter of your own, or a test fake - has one more field to supply. Code that only reads capabilities is unaffected.
10
10
 
11
11
  ### Added
12
12
 
13
+ - `@realitycollective/service-framework` now exports `RuntimeAdapter`, `FrameInfo`, `AdapterCapabilities`, `DEFAULT_CAPABILITIES`, `FrameListener`, `CapabilitiesListener`, `Unsubscribe`, `SnapshotService`, `ServiceContext`, `SnapshotListener` and `MockRuntimeAdapter`. All eleven arrived in `@realitycollective/service-framework-iwsdk`, and none of them ever touched IWSDK: the adapter contract is the seam every host binding needs, `SnapshotService` is a plain `BaseService` subclass, and the mock adapter is how any service is unit-tested headless. An app on three.js or Babylon.js had to depend on the IWSDK package to reach them. They now live in the core, and the IWSDK package re-exports every one of them unchanged, so existing imports keep resolving.
14
+ - `deriveCapabilities(session)` in `@realitycollective/service-framework`, with its structural input types `CapabilitySessionLike` and `CapabilityInputSourceLike`. It reads `immersive`, `handTracking`, `planeDetection` and `passthrough` off a live XR session, and is the derivation `IWSDKAdapter` had been doing privately. A second adapter would have re-implemented the same four rules and then drifted from them, so they live in the core once and every host binding reports the same flags for the same session. `IWSDKAdapter` now calls it and reports exactly what it reported before, and `IWSDKSessionLike` is `CapabilitySessionLike` with `inputSources` required, so nothing changes for a consumer.
15
+ - `WebXRRuntimeAdapter` in `@realitycollective/service-framework-three`, with the structural host types it is written against: `WebXRManagerLike` (`renderer.xr`), `WebXRSystemLike` (`navigator.xr`), `WebXRSessionLike`, `WebXRRuntimeAdapterOptions`, `WebXRManagerEventType`, `WebXRSessionEventType` and `WebXREventListener`. Until now only an IWSDK app could get a `RuntimeAdapter`, so a service written against that seam had no host on a plain three.js page or a desktop build. The adapter orchestrates the entry points the platform already provides - `navigator.xr` to negotiate a session, `renderer.xr` for the session the renderer presents, `setAnimationLoop` for frames - and publishes them through the same interface. It renders nothing, plays nothing and owns no scene state. Given a `host` it owns the animation loop, and given a `scheduler` as well it emits `renderTick` with `source: "three"` exactly as `ThreeRenderLoopBridge` does, so an app needs one loop owner rather than two; given no host, the app drives `emitFrame` itself. `sessionInit` supplies the `XRSessionInit` per mode. Capabilities come from the core's `deriveCapabilities` and are re-derived on the renderer's `sessionstart` and `sessionend` and the session's `inputsourceschange`, with the same override layer, `refreshCapabilities()` and `clearCapabilityOverrides()` the IWSDK adapter has. The same adapter serves a desktop build with no headset: `session.request` returns `{ ok: false, reason: "unsupported" }` where there is no `navigator.xr` or the mode is not supported, and capabilities stay at the all-false defaults.
16
+ - `FIRST_FRAME_DELTA_MS` from the three.js package - the 16 ms the bridge and the adapter both report for a first frame that has no previous timestamp.
17
+ - `BabylonRuntimeAdapter` in `@realitycollective/service-framework-babylon`, with the structural host types it is written against: `BabylonXRExperienceLike` (`WebXRDefaultExperience.baseExperience`), `BabylonSessionManagerLike`, `BabylonXRSessionLike`, `BabylonObservableLike`, `BabylonObserverLike`, `BabylonRuntimeAdapterOptions`, `BabylonXRSessionEventType`, `BabylonXREventListener` and `BabylonWebXRState`, plus the `BABYLON_WEBXR_STATE` and `DEFAULT_REFERENCE_SPACE_TYPE` constants. The package shipped a render-loop bridge and nothing else, so a Babylon app could emit `renderTick` but could not host a service written against `RuntimeAdapter`. The adapter orchestrates the entry points Babylon already provides - the experience helper to negotiate a session, the session manager for the live `XRSession`, `runRenderLoop` for frames - and publishes them through the same interface, with the same members and the same semantics as `WebXRRuntimeAdapter`, so a consumer moving between renderers sees no difference at this seam. Given a `host` it owns the render loop, and given a `scheduler` as well it emits `renderTick` with `source: "babylon"` exactly as `BabylonRenderLoopBridge` does, so an app needs one loop owner rather than two; given no host, the app drives `emitFrame` itself. `referenceSpaceType` defaults to `"local-floor"`, and `sessionInit` supplies the session creation options per mode. Capabilities come from the core's `deriveCapabilities` and are re-derived on `onXRSessionInit`, `onXRSessionEnded` and the session's `inputsourceschange`, with the same override layer, `refreshCapabilities()` and `clearCapabilityOverrides()` the other adapters have. A session started outside the adapter - by Babylon's own enter-XR UI, for instance - is followed through `onStateChangedObservable`, so the facet is correct either way. The same adapter serves a desktop build with no headset: `session.request` returns `{ ok: false, reason: "unsupported" }` where there is no experience or the mode is not supported, and capabilities stay at the all-false defaults. `@babylonjs/core` is still neither imported nor installed: every Babylon shape is structural, and anything a version might move or drop - the session manager, the observables, `isSessionSupportedAsync` - is optional and read through a guard.
18
+ - `FIRST_FRAME_DELTA_MS` from the Babylon.js package - the same 16 ms, now shared by `BabylonRenderLoopBridge` and `BabylonRuntimeAdapter` instead of sitting inline in the bridge.
19
+ - Capability derivation in `IWSDKAdapter`. It reads the live session on construction and on every visibility change: `immersive` when a session exists, `handTracking` from the `hand-tracking` enabled feature or any input source carrying a hand, `planeDetection` from the `plane-detection` enabled feature, and `passthrough` when `environmentBlendMode` is present and is not `opaque`. Subscribers are notified only when a flag actually changes. `setCapabilities` becomes a manual override layer on top, dropped by the new `clearCapabilityOverrides()` or by the new `dispose()`. `refreshCapabilities()` re-derives on demand, for a host whose visibility signal cannot push. Before this, the adapter reported all-false until the app called `setCapabilities` by hand.
20
+ - `inputsourceschange` handling in `IWSDKAdapter`, closing a parity gap with the three.js and Babylon.js adapters, which both bound the event already. The IWSDK adapter re-derived only on the world's visibility signal and at session start and end, so an app that wanted `handTracking` to update when the player put the controllers down had to attach the listener itself. It now attaches one listener to the live session, moves it when a session is replaced, and drops it when the session goes or the adapter is disposed. `IWSDKSessionLike` gains optional `addEventListener` and `removeEventListener` for it, with the two new types `IWSDKSessionEventType` and `IWSDKSessionEventListener`. Both members are optional, so a world faked in a test still satisfies the type, and the adapter guards for their absence: such a host pushes nothing and `refreshCapabilities()` remains the way to nudge it. IWSDK's own world carries a real `XRSession`, so the event arrives there.
21
+ - `requiredFeatures` and `optionalFeatures` on `SessionRequestOptions`, both optional arrays of WebXR feature strings, plus the `mergeSessionInit(init, options)` helper and its `SessionInitLike` type in the core. `SessionRequestOptions` carried only `timeoutMs`, so an app that swapped from `"immersive-vr"` to `"immersive-ar"` mid-session got whatever defaults the host binding was built with, which were chosen for the mode it was leaving. Each binding now folds the request's features over its own: the host's entries come first, the request's are appended, and a feature named twice appears once. A request that names none passes the host's init through untouched, identity included. The three.js and Babylon.js bindings share `mergeSessionInit` rather than writing the same merge twice.
22
+ - `toIWSDKFeatures(options)`, `IWSDK_FEATURE_KEYS` and `IWSDKFeatureMapping` in `@realitycollective/service-framework-iwsdk`, with the structural `IWSDKXRFeatureOptionsLike`, `IWSDKFeatureFlagLike` and `IWSDKDepthSensingFlagLike` types and a `features` member on `IWSDKXROptionsLike`. IWSDK does not take WebXR feature strings: `launchXR` takes an `XROptions` whose `features` is a structured object, one key per feature. The adapter therefore maps `hand-tracking`, `anchors`, `hit-test`, `plane-detection`, `mesh-detection`, `depth-sensing`, `layers` and `unbounded` onto `handTracking`, `anchors`, `hitTest`, `planeDetection`, `meshDetection`, `depthSensing`, `layers` and `unbounded`, setting a required feature as `{ required: true }` and an optional one as `true`; a feature named in both lists comes out required. A string IWSDK has no key for - `local-floor` and `bounded-floor`, which it configures through `referenceSpace`, or `dom-overlay`, which it does not model - is dropped from the request rather than thrown, because the session is still one the host can serve. `toIWSDKFeatures` is exported so an app can read the `unmapped` list and decide for itself before asking. `launchXR` merges what it is given over the world's `xrDefaults`, so a request naming no features leaves the app's defaults alone.
23
+ - `environmentBlendMode` on `AdapterCapabilities`, typed `EnvironmentBlendMode | null` where `EnvironmentBlendMode` is the new `"opaque" | "alpha-blend" | "additive"` union, plus `environmentBlendMode: null` in `DEFAULT_CAPABILITIES`. `deriveCapabilities` reduced the session's blend mode to the boolean `passthrough` and threw the string away, which says whether the world shows through but not how. The two passthrough modes behave oppositely: `"alpha-blend"` is video passthrough, as on a Quest, and composites normally, so black stays black; `"additive"` is a see-through optical display and adds the rendered image to the light already reaching the eye, so black is fully transparent. A service that dims the world must draw brighter on an additive display, not darker, and could not tell the two apart. The key is required rather than optional, mirroring the `presence` decision in `@realitycollective/webxr-input`, so the conformance suite's capability-key check stays meaningful. A value WebXR does not define derives as `null` rather than passing through, so a consumer switching on the mode never meets a string it has no rule for. `passthrough` keeps its exact meaning, still `true` for any blend mode other than `"opaque"`, including one that derives as `null`. Every adapter reports the new key and every adapter's change detection compares it.
24
+ - `IWSDKXROptionsLike` is now exported from `@realitycollective/service-framework-iwsdk`. It was already the declared parameter type of `IWSDKWorldLike.launchXR`, so a consumer typing a world by hand could not name it.
25
+ - An optional session facet on `RuntimeAdapter`: `session?: SessionFacet`, with `getState`, `request(mode, options)`, `end()`, `onStateChange` and `onVisibilityChange`, plus the `SessionMode`, `SessionState`, `SessionResult`, `SessionFailureReason`, `SessionVisibility` and `SessionRequestOptions` types and the `DEFAULT_SESSION_TIMEOUT_MS` constant. A request resolves with a result rather than throwing, because a host that cannot start a session is a normal runtime condition. `IWSDKAdapter` implements it over the world's `launchXR` and `exitXR`; `MockRuntimeAdapter` implements it in memory, driven by `simulateSessionStart()`, `simulateSessionEnd()` and `simulateVisibility()`.
26
+ - `RUNTIME_ADAPTER_FACETS`, the list of optional facets an adapter can carry, and the `RuntimeAdapterFacet` type. A conformance test walks the list, so a facet added without a mock implementation fails the suite instead of being found by a consumer.
27
+ - `renderTick` under IWSDK. `makeServiceBridgeSystem` now emits the scheduler's `renderTick` channel on every focused frame, with `source: "iwsdk"`, alongside the adapter's `onFrame` fan-out it already drove. A service written against the scheduler now runs under IWSDK exactly as it does under the three.js and Babylon.js bridges. IWSDK reports its frame delta in seconds and the scheduler's `LifecycleContext` is in milliseconds, so the bridge converts.
28
+ - Structural contracts for the parts of an IWSDK world the adapter now reads: `IWSDKSessionLike`, `IWSDKInputSourceLike`, an optional `session` and `launchXR` / `exitXR` on `IWSDKWorldLike`, and an optional `subscribe` on `IWSDKSignalLike`. Every addition is optional, so a world carrying nothing but the visibility signal still type-checks and still works. `@iwsdk/core` remains undeclared as a dependency of any kind.
29
+ - A shared runtime-adapter conformance suite, run against `MockRuntimeAdapter`, `IWSDKAdapter`, `WebXRRuntimeAdapter` and `BabylonRuntimeAdapter`, and published as `runtimeAdapterContractCases()` from `@realitycollective/service-framework` with the `RuntimeAdapterContractCase`, `RuntimeAdapterSubject` and `RuntimeAdapterDriver` types. It began as an in-repo vitest helper, which meant an adapter written outside this repository could not prove it conformed: the checks existed but were not shipped. Following `inputProviderContractCases()` in `@realitycollective/webxr-input`, the suite is now data rather than a runner - each case is a `{ name, run }` pair that returns silently on success and throws a plain `Error` on failure - so an adapter repository hosts the whole thing in three lines of its own runner. Some cases are asynchronous, so a runner must await what `run` returns, and each case needs a fresh subject because the session cases drive a session through its whole lifecycle. A subject whose adapter has no session facet passes the session cases without running them. Test coverage now measures `@realitycollective/service-framework-iwsdk`, `@realitycollective/service-framework-three` and `@realitycollective/service-framework-babylon` as well, at the same 100% thresholds as the core and client packages.
13
30
  - Updated documentation packs for all projects.
14
31
  - Improved release scripting and validation to improve delivery coherance.
15
32
  - `scripts/release.config.json` - names the core package and the publish order, so the release tooling is identical across every Reality Collective TypeScript repository.
@@ -18,6 +35,9 @@ Packaging, tooling and documentation. No runtime behaviour changed, and no publi
18
35
 
19
36
  ### Changed
20
37
 
38
+ - The runtime adapter now abstracts session lifecycle. It previously did not, on the stated grounds that IWSDK already owns sessions, input and rendering. That held while every consumer was an IWSDK app. It stopped holding when a reference client needed to request a session, end one and read session visibility: with no seam for it, the client reached past the adapter into the host, which is the coupling the adapter exists to prevent. Sessions are now an optional facet, so a host that owns them can expose them and a host that does not can leave the property off. Input and rendering are still not abstracted.
39
+ - `IWSDKAdapter.setCapabilities` now notifies subscribers only when at least one flag actually changes value. It previously notified on every call, including calls that set what was already set. The signature is unchanged; a consumer that relied on a callback per call will see fewer callbacks.
40
+ - `ThreeRenderLoopBridge` moved out of the three.js package's `index.ts` into `three-render-loop-bridge.ts`, and `index.ts` is now a barrel that re-exports it unchanged beside the new adapter. Import paths are unaffected. The file-wide v8 coverage exclusion the package carried went with it: the bridge is tested against a fake host, and the whole package is measured.
21
41
  - CI and deployment merged into one workflow. They previously ran concurrently and repeated the same install, build, typecheck and test on every pull request. The deploy jobs now consume the artifacts the build job already produced.
22
42
  - CI runs on every pull request regardless of target branch, and reports through merge queues.
23
43
  - Published sourcemaps embed their sources (`inlineSources`), so stepping into the framework works for consumers. Declaration maps are no longer emitted, because they can only resolve against a `src` directory that is not shipped. Each package is roughly 12% smaller as a result.
package/README.md CHANGED
@@ -24,12 +24,16 @@ ServiceBridgeSystem ← the only place the engine touches services
24
24
  every update(delta, time):
25
25
  • visibilityState → manager.emitFocusChange / emitPauseChange
26
26
  • if focused: adapter.emitFrame(time, delta)
27
+ • if focused: scheduler.emit("renderTick", { source: "iwsdk", ... })
27
28
  ▼
28
- IWSDKAdapter (RuntimeAdapter) → onFrame fan-out → services
29
+ IWSDKAdapter (RuntimeAdapter) → onFrame fan-out → services
30
+ ServiceManager.scheduler → renderTick channel → services
29
31
  ▼
30
32
  ServiceManager → your SnapshotService graph
31
33
  ```
32
34
 
35
+ Each focused frame goes out twice, on the two channels a service can be written against. `adapter.onFrame` is the adapter seam, and carries IWSDK's own units: `timestamp` in milliseconds, `delta` in seconds. `renderTick` is the scheduler channel the three.js and Babylon.js bridges emit, and carries a `LifecycleContext` in scheduler units: `timestamp` and `deltaTime` both in milliseconds, a frame counter, and `source: "iwsdk"`. A service written against `renderTick` therefore runs under IWSDK exactly as it does under three.js, with no change. Frames while the session is not focused are emitted on neither channel and do not advance the counter.
36
+
33
37
  **Invariant:** services depend only on `RuntimeAdapter`, never on `@iwsdk/core`. That is what keeps them unit-testable headless - swap `IWSDKAdapter` for `MockRuntimeAdapter`.
34
38
 
35
39
  ---
@@ -38,7 +42,7 @@ ServiceManager → your SnapshotService graph
38
42
 
39
43
  Like the three.js and Babylon.js bridges keep their renderer packages at arm's length, this package **never imports `@iwsdk/core`**. The IWSDK primitives the bridge needs - `createSystem` and the `VisibilityState.Visible` value - are passed in by the consumer (who owns IWSDK). This keeps the package tree-shakeable, version-tolerant across IWSDK `0.4.x` and `0.5.x`, and trivially mockable in unit tests.
40
44
 
41
- `@iwsdk/core` is **not declared as a dependency of any kind** - not even an optional peer. The four contracts this bridge relies on (`World`, `createSystem`, `VisibilityState.Visible` and the visibility signal) are structurally typed and passed in, and they are identical across 0.4.x and 0.5.x. A peer range here would constrain nothing while still being able to fail a consumer's clean install, so there is deliberately no entry.
45
+ `@iwsdk/core` is **not declared as a dependency of any kind** - not even an optional peer. Everything this package reads off the world is structurally typed in `iwsdk-host.ts` and passed in: `createSystem`, the `VisibilityState.Visible` value, the visibility signal, and - all optional - `visibilityState.subscribe`, the live `session`, `launchXR` and `exitXR`. Because the additions are optional, a world that carries nothing but the visibility signal still type-checks and still works; it simply reports no capabilities and no session. Those contracts are identical across 0.4.x and 0.5.x. A peer range here would constrain nothing while still being able to fail a consumer's clean install, so there is deliberately no entry.
42
46
 
43
47
  ---
44
48
 
@@ -77,10 +81,10 @@ Game logic ticks **only while the session is visible/focused** - in the browser
77
81
 
78
82
  ## Services own their state: `SnapshotService`
79
83
 
80
- `SnapshotService<TConfig, TSnapshot>` is the "services own state" base - one immutable snapshot plus pub/sub. Subscribers receive the current value immediately, then every publish. It has **no IWSDK dependency**.
84
+ `SnapshotService<TConfig, TSnapshot>` is the "services own state" base - one immutable snapshot plus pub/sub. Subscribers receive the current value immediately, then every publish. It has **no IWSDK dependency**, which is why it now lives in the core package; this package re-exports it, so the older import still works.
81
85
 
82
86
  ```typescript
83
- import { SnapshotService, type ServiceContext, type RuntimeAdapter } from "@realitycollective/service-framework-iwsdk";
87
+ import { SnapshotService, type ServiceContext, type RuntimeAdapter } from "@realitycollective/service-framework";
84
88
 
85
89
  interface EnergySnapshot { readonly energy: number; }
86
90
 
@@ -111,7 +115,7 @@ const unsubscribe = energy.subscribe(({ energy }) => hud.setEnergy(energy));
111
115
  Services run against `MockRuntimeAdapter` with no IWSDK / renderer / headset. Tests drive the loop by calling `emitFrame`:
112
116
 
113
117
  ```typescript
114
- import { MockRuntimeAdapter } from "@realitycollective/service-framework-iwsdk";
118
+ import { MockRuntimeAdapter } from "@realitycollective/service-framework";
115
119
 
116
120
  const adapter = new MockRuntimeAdapter({ immersive: true });
117
121
  const service = new EnergyService(makeContext(), adapter);
@@ -123,32 +127,134 @@ expect(service.getSnapshot().energy).toBeLessThan(1);
123
127
 
124
128
  No `@iwsdk/core` import appears anywhere in the test.
125
129
 
130
+ `MockRuntimeAdapter` implements the session facet in memory too: `simulateSessionStart()`, `simulateSessionEnd()` and `simulateVisibility(v)` drive it, and `request()` resolves `{ ok: true }` when a start is simulated before the timeout, `{ ok: false, reason: "timeout" }` otherwise. Both adapters run the same conformance suite, published from the core as `runtimeAdapterContractCases()`, so a service that behaves one way headless behaves the same way on a headset.
131
+
126
132
  ---
127
133
 
128
134
  ## Capabilities
129
135
 
130
- `AdapterCapabilities` (`immersive`, `handTracking`, `planeDetection`, `passthrough`) is what gating services read (`adapter.getCapabilities()`) or subscribe to (`adapter.onCapabilitiesChange(cb)` - mirrors `onFrame`, so gates don't poll every frame). Refine them with `adapter.setCapabilities({ ... })`.
136
+ `AdapterCapabilities` (`immersive`, `handTracking`, `planeDetection`, `passthrough`, `environmentBlendMode`) is what gating services read (`adapter.getCapabilities()`) or subscribe to (`adapter.onCapabilitiesChange(cb)` - mirrors `onFrame`, so gates don't poll every frame).
137
+
138
+ `IWSDKAdapter` derives all four from the live session through `deriveCapabilities(session)`, exported by `@realitycollective/service-framework`. The rules live in the core so that every host binding - this one, the three.js `WebXRRuntimeAdapter`, and whatever comes next - reports the same flags for the same session. It derives on construction, every time the world's visibility signal fires, which is when a session comes or goes, and every time the live session raises `inputsourceschange`:
139
+
140
+ | Flag | Derived from |
141
+ | --- | --- |
142
+ | `immersive` | a session exists on the world |
143
+ | `handTracking` | `"hand-tracking"` in `session.enabledFeatures`, or any `session.inputSources[i].hand` |
144
+ | `planeDetection` | `"plane-detection"` in `session.enabledFeatures` |
145
+ | `passthrough` | `session.environmentBlendMode` is present and is not `"opaque"` |
146
+ | `environmentBlendMode` | `session.environmentBlendMode` where WebXR defines the value, otherwise `null` |
147
+
148
+ `passthrough` says whether the world shows through; `environmentBlendMode` says how, which is what a service needs to decide what to draw. The two passthrough modes behave oppositely. `"alpha-blend"` is video passthrough, as on a Quest, and composites normally, so black stays black. `"additive"` is a see-through optical display and adds the rendered image to the light already reaching the eye, so black is fully transparent. A service that dims the world has to draw brighter on an additive display, not darker. A value WebXR does not define reports as `null` rather than passing a string through, so a consumer switching on the mode never meets one it has no rule for; `passthrough` still reports `true` for such a value.
149
+
150
+ With no session the adapter reports `DEFAULT_CAPABILITIES`: every flag false and the blend mode `null`, so gating services behave conservatively before the player enters XR. Subscribers are notified only when a flag actually changes, so a signal that fires every visibility blip does not wake every gate.
151
+
152
+ Input sources are followed without a nudge. The adapter attaches an `inputsourceschange` listener to the live session and detaches it when the session goes, so `handTracking` updates when the player picks the controllers up or puts them down. IWSDK's world carries a real `XRSession`, which raises the event; both listener methods are optional on `IWSDKSessionLike` and the adapter guards for their absence, so a world faked in a test still works and simply never pushes.
153
+
154
+ One host still needs a nudge. If the world's `visibilityState` has no `subscribe`, nothing tells the adapter that a session came or went: call `adapter.refreshCapabilities()` after the host changes something. Call it too if the host enables a feature mid-session without a visibility transition.
155
+
156
+ ### Overrides
157
+
158
+ `adapter.setCapabilities({ ... })` still exists, and is now a layer on top of the derived values rather than the only source of them. An override wins for as long as it is set: it survives every later derivation, so forcing `passthrough: true` on a device that under-reports its blend mode keeps working when the session changes. Overrides are dropped by `adapter.clearCapabilityOverrides()`, which falls back to the derived values, or by `adapter.dispose()`.
159
+
160
+ `adapter.dispose()` releases the visibility subscription, settles any in-flight session request, drops the overrides and clears every listener. Call it when tearing the world down.
161
+
162
+ ---
163
+
164
+ ## Sessions
165
+
166
+ `adapter.session` is the optional session facet from `RuntimeAdapter`, implemented here over the world's `launchXR` and `exitXR`. It exists so a client can start, end and observe an XR session without reaching past the adapter into IWSDK - which is what a client had to do before, and the reason the "the adapter does not abstract sessions" position was reversed.
167
+
168
+ ```typescript
169
+ const result = await adapter.session.request("immersive-vr", { timeoutMs: 8000 });
170
+
171
+ if (!result.ok) {
172
+ // "unsupported" | "denied" | "timeout" | "error" - a normal runtime outcome,
173
+ // so it comes back as a result rather than a thrown error.
174
+ showEnterVrError(result.reason);
175
+ }
176
+
177
+ const stop = adapter.session.onStateChange((state) => hud.setSessionState(state));
178
+ adapter.session.onVisibilityChange((visibility) => hud.setDimmed(visibility !== "visible"));
179
+
180
+ await adapter.session.end();
181
+ ```
182
+
183
+ - `getState()` walks `"none"` → `"requesting"` → `"active"` → `"ending"` → `"none"`.
184
+ - `request(mode, options)` calls `launchXR` with `{ sessionMode: mode }` (the `XROptions` shape IWSDK accepts; its `SessionMode` enum values are the WebXR mode strings), plus a `features` object when the request named any, and resolves once a session appears, whether the adapter learns that from the visibility signal or from its polling fallback. A synchronous throw from `launchXR` comes back as `{ ok: false, reason: "unsupported", error }`. Nothing appearing within `timeoutMs` (default 10000) comes back as `{ ok: false, reason: "timeout" }`.
185
+ - `end()` calls `exitXR` and resolves once the session is gone.
186
+ - `onVisibilityChange` maps IWSDK's signal onto `"visible"`, `"visible-blurred"`, `"hidden"` and `"non-immersive"`. A value the adapter does not recognise is reported as `"hidden"`, because treating an unknown state as visible would keep game logic running when it should not.
131
187
 
132
- > **One gap still to close:** deriving capabilities from the live `XRSession` (enabled features, blend mode, hand input sources) is intentionally left to the host wiring because it needs IWSDK session internals. Until that is wired, the adapter reports `DEFAULT_CAPABILITIES` (all-false) and gating services behave conservatively. `IWSDKAdapter.getWorld()` exposes the bound world as the source for that derivation, and `onCapabilitiesChange` is the notification channel for when it lands.
188
+ A world with no `launchXR` reports `{ ok: false, reason: "unsupported" }` rather than throwing, so a 2D preview build needs no special case.
189
+
190
+ ### Per-request features
191
+
192
+ `SessionRequestOptions` carries `requiredFeatures` and `optionalFeatures` as WebXR feature strings, the same on every host binding. An app that swaps from `"immersive-vr"` to `"immersive-ar"` mid-session needs them: without them the second session gets whatever defaults the host was built with, which were chosen for the first.
193
+
194
+ ```typescript
195
+ await adapter.session.request("immersive-ar", {
196
+ requiredFeatures: ["hand-tracking"],
197
+ optionalFeatures: ["plane-detection"],
198
+ });
199
+ ```
200
+
201
+ IWSDK does not take feature strings. Its `launchXR` takes an `XROptions` whose `features` is a structured object, so the adapter translates. A required feature becomes `{ required: true }`, an optional one becomes `true`, and a feature named in both lists comes out required.
202
+
203
+ | WebXR feature string | IWSDK `XRFeatureOptions` key |
204
+ | --- | --- |
205
+ | `hand-tracking` | `handTracking` |
206
+ | `anchors` | `anchors` |
207
+ | `hit-test` | `hitTest` |
208
+ | `plane-detection` | `planeDetection` |
209
+ | `mesh-detection` | `meshDetection` |
210
+ | `depth-sensing` | `depthSensing` |
211
+ | `layers` | `layers` |
212
+ | `unbounded` | `unbounded` |
213
+
214
+ Anything else has nowhere to go. `local-floor` and `bounded-floor` are reference spaces, which IWSDK configures through `XROptions.referenceSpace` rather than as features; `dom-overlay` it does not model at all. Such a string is dropped from the request rather than thrown, because the session is still one the host can serve and failing it over a feature the app may not need would be worse. Nothing is logged.
215
+
216
+ To see what would be dropped before you ask, call the mapping yourself:
217
+
218
+ ```typescript
219
+ import { toIWSDKFeatures } from "@realitycollective/service-framework-iwsdk";
220
+
221
+ const { features, unmapped } = toIWSDKFeatures({ requiredFeatures: ["hand-tracking", "dom-overlay"] });
222
+ // features -> { handTracking: { required: true } }
223
+ // unmapped -> ["dom-overlay"]
224
+ ```
225
+
226
+ `launchXR` merges what it is given over the world's `xrDefaults`, so a request that names no features leaves the app's defaults exactly as they were, and one that names some adds to them.
133
227
 
134
228
  ---
135
229
 
136
230
  ## API surface
137
231
 
232
+ Owned by this package:
233
+
138
234
  | Symbol | Kind | Purpose |
139
235
  | --- | --- | --- |
140
- | `RuntimeAdapter` | interface | The seam services depend on (`onFrame`, `getCapabilities`, `onCapabilitiesChange`). |
236
+ | `IWSDKAdapter` | class | Production adapter; `emitFrame`, `refreshCapabilities`, `setCapabilities`, `clearCapabilityOverrides`, `session`, `getWorld`, `dispose`. |
237
+ | `makeServiceBridgeSystem` | factory | Returns the IWSDK `ServiceBridgeSystem` class; pumps `onFrame` and `renderTick`. |
238
+ | `ServiceBridgeSystemOptions` | interface | `{ adapter, manager, world, createSystem, visibleState }`. |
239
+ | `startServiceRuntime` / `ServiceRuntime` | function / interface | Bootstraps `{ manager, adapter }` from a profile factory. |
240
+ | `toIWSDKFeatures` / `IWSDK_FEATURE_KEYS` / `IWSDKFeatureMapping` | function / const / interface | Maps WebXR feature strings onto IWSDK's structured flags and reports what it could not map. |
241
+ | `IWSDKWorldLike` / `IWSDKSignalLike` / `IWSDKSessionLike` / `IWSDKInputSourceLike` / `IWSDKSessionEventType` / `IWSDKSessionEventListener` / `IWSDKXROptionsLike` / `IWSDKXRFeatureOptionsLike` / `IWSDKFeatureFlagLike` / `IWSDKDepthSensingFlagLike` / `IWSDKSystemLike` / `IWSDKSystemConstructor` / `CreateSystemLike` | types | Structural `@iwsdk/core` contracts (no engine import). |
242
+
243
+ Re-exported from `@realitycollective/service-framework`. None of these ever touched IWSDK, and every host binding needs them, so they moved into the core in 1.0.1. They are re-exported here unchanged, so importing them from this package keeps working; new code should import them from the core package.
244
+
245
+ | Symbol | Kind | Purpose |
246
+ | --- | --- | --- |
247
+ | `RuntimeAdapter` | interface | The seam services depend on (`onFrame`, `getCapabilities`, `onCapabilitiesChange`, optional `session`). |
141
248
  | `FrameInfo` | interface | `{ timestamp, delta }`. |
142
249
  | `AdapterCapabilities` / `DEFAULT_CAPABILITIES` | interface / const | XR capability flags; all-false default. |
143
250
  | `Unsubscribe` / `FrameListener` / `CapabilitiesListener` | types | Callback / handle aliases. |
144
- | `IWSDKAdapter` | class | Production adapter; `emitFrame`, `setCapabilities`, `getWorld`. |
145
- | `MockRuntimeAdapter` | class | Headless adapter; `emitFrame(ts?, delta?)`, `setCapabilities`. |
251
+ | `SessionFacet` | interface | `getState`, `request`, `end`, `onStateChange`, `onVisibilityChange`. |
252
+ | `SessionMode` / `SessionState` / `SessionResult` / `SessionFailureReason` / `SessionVisibility` / `SessionRequestOptions` | types | The session facet's vocabulary. |
253
+ | `DEFAULT_SESSION_TIMEOUT_MS` | const | 10000 - the default `request` timeout. |
254
+ | `RUNTIME_ADAPTER_FACETS` / `RuntimeAdapterFacet` | const / type | Every optional facet an adapter can carry; walked by the conformance suite. |
255
+ | `MockRuntimeAdapter` | class | Headless adapter; `emitFrame(ts?, delta?)`, `setCapabilities`, `simulateSessionStart`, `simulateSessionEnd`, `simulateVisibility`. |
146
256
  | `SnapshotService<C, S>` | abstract class | State-owning base (`subscribe` / `getSnapshot` / `publishSnapshot` / `updateSnapshot`). |
147
257
  | `ServiceContext<C>` / `SnapshotListener<S>` | types | Activation-context alias; snapshot callback. |
148
- | `makeServiceBridgeSystem` | factory | Returns the IWSDK `ServiceBridgeSystem` class. |
149
- | `ServiceBridgeSystemOptions` | interface | `{ adapter, manager, world, createSystem, visibleState }`. |
150
- | `startServiceRuntime` / `ServiceRuntime` | function / interface | Bootstraps `{ manager, adapter }` from a profile factory. |
151
- | `IWSDKWorldLike` / `CreateSystemLike` / … | types | Structural `@iwsdk/core` contracts (no engine import). |
152
258
 
153
259
  ---
154
260
 
package/dist/index.d.ts CHANGED
@@ -1,10 +1,15 @@
1
- export { DEFAULT_CAPABILITIES } from "./runtime-adapter.js";
2
- export type { AdapterCapabilities, CapabilitiesListener, FrameInfo, FrameListener, RuntimeAdapter, Unsubscribe, } from "./runtime-adapter.js";
3
- export type { CreateSystemLike, IWSDKSignalLike, IWSDKSystemConstructor, IWSDKSystemLike, IWSDKWorldLike, } from "./iwsdk-host.js";
1
+ /**
2
+ * The runtime-adapter contract, the snapshot service base and the headless mock
3
+ * moved into `@realitycollective/service-framework` in 1.0.1: none of them ever
4
+ * touched IWSDK, and every host binding needs them. They are re-exported here
5
+ * unchanged so existing imports from this package keep working.
6
+ */
7
+ export { DEFAULT_CAPABILITIES, DEFAULT_SESSION_TIMEOUT_MS, MockRuntimeAdapter, RUNTIME_ADAPTER_FACETS, SnapshotService, } from "@realitycollective/service-framework";
8
+ export type { AdapterCapabilities, CapabilitiesListener, FrameInfo, FrameListener, RuntimeAdapter, RuntimeAdapterFacet, ServiceContext, SessionFacet, SessionFailureReason, SessionMode, SessionRequestOptions, SessionResult, SessionState, SessionVisibility, SnapshotListener, Unsubscribe, } from "@realitycollective/service-framework";
9
+ export type { CreateSystemLike, IWSDKDepthSensingFlagLike, IWSDKFeatureFlagLike, IWSDKInputSourceLike, IWSDKSessionEventListener, IWSDKSessionEventType, IWSDKSessionLike, IWSDKSignalLike, IWSDKSystemConstructor, IWSDKSystemLike, IWSDKWorldLike, IWSDKXRFeatureOptionsLike, IWSDKXROptionsLike, } from "./iwsdk-host.js";
10
+ export { IWSDK_FEATURE_KEYS, toIWSDKFeatures } from "./iwsdk-features.js";
11
+ export type { IWSDKFeatureMapping } from "./iwsdk-features.js";
4
12
  export { IWSDKAdapter } from "./iwsdk-adapter.js";
5
- export { MockRuntimeAdapter } from "./mock-runtime-adapter.js";
6
- export { SnapshotService } from "./snapshot-service.js";
7
- export type { ServiceContext, SnapshotListener } from "./snapshot-service.js";
8
13
  export { makeServiceBridgeSystem } from "./service-bridge-system.js";
9
14
  export type { ServiceBridgeSystemOptions } from "./service-bridge-system.js";
10
15
  export { startServiceRuntime } from "./bootstrap.js";
package/dist/index.js CHANGED
@@ -1,7 +1,12 @@
1
- export { DEFAULT_CAPABILITIES } from "./runtime-adapter.js";
1
+ /**
2
+ * The runtime-adapter contract, the snapshot service base and the headless mock
3
+ * moved into `@realitycollective/service-framework` in 1.0.1: none of them ever
4
+ * touched IWSDK, and every host binding needs them. They are re-exported here
5
+ * unchanged so existing imports from this package keep working.
6
+ */
7
+ export { DEFAULT_CAPABILITIES, DEFAULT_SESSION_TIMEOUT_MS, MockRuntimeAdapter, RUNTIME_ADAPTER_FACETS, SnapshotService, } from "@realitycollective/service-framework";
8
+ export { IWSDK_FEATURE_KEYS, toIWSDKFeatures } from "./iwsdk-features.js";
2
9
  export { IWSDKAdapter } from "./iwsdk-adapter.js";
3
- export { MockRuntimeAdapter } from "./mock-runtime-adapter.js";
4
- export { SnapshotService } from "./snapshot-service.js";
5
10
  export { makeServiceBridgeSystem } from "./service-bridge-system.js";
6
11
  export { startServiceRuntime } from "./bootstrap.js";
7
12
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAkB5D,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,2BAA2B,CAAC;AAE/D,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAGxD,OAAO,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAGrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC","sourcesContent":["export { DEFAULT_CAPABILITIES } from \"./runtime-adapter.js\";\nexport type {\n AdapterCapabilities,\n CapabilitiesListener,\n FrameInfo,\n FrameListener,\n RuntimeAdapter,\n Unsubscribe,\n} from \"./runtime-adapter.js\";\n\nexport type {\n CreateSystemLike,\n IWSDKSignalLike,\n IWSDKSystemConstructor,\n IWSDKSystemLike,\n IWSDKWorldLike,\n} from \"./iwsdk-host.js\";\n\nexport { IWSDKAdapter } from \"./iwsdk-adapter.js\";\nexport { MockRuntimeAdapter } from \"./mock-runtime-adapter.js\";\n\nexport { SnapshotService } from \"./snapshot-service.js\";\nexport type { ServiceContext, SnapshotListener } from \"./snapshot-service.js\";\n\nexport { makeServiceBridgeSystem } from \"./service-bridge-system.js\";\nexport type { ServiceBridgeSystemOptions } from \"./service-bridge-system.js\";\n\nexport { startServiceRuntime } from \"./bootstrap.js\";\nexport type { ServiceRuntime } from \"./bootstrap.js\";\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,oBAAoB,EACpB,0BAA0B,EAC1B,kBAAkB,EAClB,sBAAsB,EACtB,eAAe,GAChB,MAAM,sCAAsC,CAAC;AAoC9C,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAG1E,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD,OAAO,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AAGrE,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC","sourcesContent":["/**\n * The runtime-adapter contract, the snapshot service base and the headless mock\n * moved into `@realitycollective/service-framework` in 1.0.1: none of them ever\n * touched IWSDK, and every host binding needs them. They are re-exported here\n * unchanged so existing imports from this package keep working.\n */\nexport {\n DEFAULT_CAPABILITIES,\n DEFAULT_SESSION_TIMEOUT_MS,\n MockRuntimeAdapter,\n RUNTIME_ADAPTER_FACETS,\n SnapshotService,\n} from \"@realitycollective/service-framework\";\nexport type {\n AdapterCapabilities,\n CapabilitiesListener,\n FrameInfo,\n FrameListener,\n RuntimeAdapter,\n RuntimeAdapterFacet,\n ServiceContext,\n SessionFacet,\n SessionFailureReason,\n SessionMode,\n SessionRequestOptions,\n SessionResult,\n SessionState,\n SessionVisibility,\n SnapshotListener,\n Unsubscribe,\n} from \"@realitycollective/service-framework\";\n\nexport type {\n CreateSystemLike,\n IWSDKDepthSensingFlagLike,\n IWSDKFeatureFlagLike,\n IWSDKInputSourceLike,\n IWSDKSessionEventListener,\n IWSDKSessionEventType,\n IWSDKSessionLike,\n IWSDKSignalLike,\n IWSDKSystemConstructor,\n IWSDKSystemLike,\n IWSDKWorldLike,\n IWSDKXRFeatureOptionsLike,\n IWSDKXROptionsLike,\n} from \"./iwsdk-host.js\";\n\nexport { IWSDK_FEATURE_KEYS, toIWSDKFeatures } from \"./iwsdk-features.js\";\nexport type { IWSDKFeatureMapping } from \"./iwsdk-features.js\";\n\nexport { IWSDKAdapter } from \"./iwsdk-adapter.js\";\n\nexport { makeServiceBridgeSystem } from \"./service-bridge-system.js\";\nexport type { ServiceBridgeSystemOptions } from \"./service-bridge-system.js\";\n\nexport { startServiceRuntime } from \"./bootstrap.js\";\nexport type { ServiceRuntime } from \"./bootstrap.js\";\n"]}
@@ -1,29 +1,96 @@
1
1
  /**
2
2
  * IWSDK implementation of {@link RuntimeAdapter}. IWSDK owns the render loop, so
3
- * the adapter is a passive fan-out: the `ServiceBridgeSystem` (a normal IWSDK
4
- * system) calls {@link IWSDKAdapter.emitFrame} once per frame and the adapter
5
- * notifies subscribed services. Capabilities are refined from the XR session via
6
- * {@link IWSDKAdapter.setCapabilities}.
3
+ * frames are pushed in rather than pulled: the `ServiceBridgeSystem` (a normal
4
+ * IWSDK system) calls {@link IWSDKAdapter.emitFrame} once per frame and the
5
+ * adapter notifies subscribed services.
6
+ *
7
+ * Capabilities are derived from the live session on construction and on every
8
+ * visibility change, through the core's shared `deriveCapabilities`, with a
9
+ * manual override layer on top. Session lifecycle is
10
+ * exposed through {@link IWSDKAdapter.session}, over the world's `launchXR` and
11
+ * `exitXR` entry points.
7
12
  */
8
- import { type AdapterCapabilities, type CapabilitiesListener, type FrameListener, type RuntimeAdapter, type Unsubscribe } from "./runtime-adapter.js";
13
+ import { type AdapterCapabilities, type CapabilitiesListener, type FrameListener, type RuntimeAdapter, type SessionFacet, type Unsubscribe } from "@realitycollective/service-framework";
9
14
  import type { IWSDKWorldLike } from "./iwsdk-host.js";
10
15
  export declare class IWSDKAdapter implements RuntimeAdapter {
11
16
  private readonly world;
12
17
  private readonly frameListeners;
13
18
  private readonly capabilitiesListeners;
19
+ private readonly stateListeners;
20
+ private readonly visibilityListeners;
21
+ private readonly pendingWaits;
22
+ private derived;
23
+ private overrides;
14
24
  private capabilities;
25
+ private sessionState;
26
+ private unsubscribeVisibility;
27
+ private boundSession;
28
+ /**
29
+ * Pre-bound, because `removeEventListener` has to be handed the same function
30
+ * `addEventListener` got.
31
+ */
32
+ private readonly onInputSourcesChange;
33
+ /** Session lifecycle over the world's `launchXR` / `exitXR` entry points. */
34
+ readonly session: SessionFacet;
15
35
  constructor(world: IWSDKWorldLike);
16
36
  onFrame(listener: FrameListener): Unsubscribe;
17
37
  getCapabilities(): AdapterCapabilities;
18
38
  onCapabilitiesChange(listener: CapabilitiesListener): Unsubscribe;
39
+ /** The IWSDK `World` this adapter is bound to. */
40
+ getWorld(): IWSDKWorldLike;
19
41
  /**
20
- * The IWSDK `World` this adapter is bound to. Reserved for capability
21
- * derivation from the live XR session (see the package README "Capabilities"
22
- * section) - the one piece still to be wired to the real IWSDK session API.
42
+ * Re-read the live session and publish any capability change. The adapter
43
+ * does this itself whenever the visibility signal fires and whenever the
44
+ * session's input sources change; call it directly on a host whose signal has
45
+ * no `subscribe`, or after the host enables a feature mid-session.
46
+ */
47
+ refreshCapabilities(): void;
48
+ /**
49
+ * Force capability flags regardless of what the session reports. Overrides
50
+ * are a layer on top of the derived values: they win for as long as they are
51
+ * set, survive every later derivation, and are dropped only by
52
+ * {@link IWSDKAdapter.clearCapabilityOverrides} or
53
+ * {@link IWSDKAdapter.dispose}. Subscribers are notified if the effective
54
+ * capabilities changed.
23
55
  */
24
- getWorld(): IWSDKWorldLike;
25
- /** Refine capabilities once the XR session reports them; notifies subscribers. */
26
56
  setCapabilities(capabilities: Partial<AdapterCapabilities>): void;
57
+ /** Drop every manual override and fall back to the derived capabilities. */
58
+ clearCapabilityOverrides(): void;
27
59
  /** Called once per frame by the ECS bridge system. */
28
60
  emitFrame(timestamp: number, delta: number): void;
61
+ /**
62
+ * Release everything the adapter holds: the visibility subscription, the
63
+ * session's own listener, any in-flight session wait, the manual overrides
64
+ * and every listener.
65
+ */
66
+ dispose(): void;
67
+ private hasSession;
68
+ /**
69
+ * Follow one session's `inputsourceschange` event, so `handTracking` updates
70
+ * when the player picks the controllers up or puts them down. IWSDK's world
71
+ * carries a real `XRSession`, which raises the event; a host that reports a
72
+ * session without listener methods is bound all the same and simply never
73
+ * pushes, which is what `refreshCapabilities()` remains for.
74
+ */
75
+ private bindSession;
76
+ private unbindSession;
77
+ private publishCapabilities;
78
+ private handleVisibilityChange;
79
+ /**
80
+ * Follow the world when nothing local is mid-transition. `request()` and
81
+ * `end()` own the state while they run, so the signal does not fight them.
82
+ */
83
+ private syncSessionState;
84
+ private requestSession;
85
+ private endSession;
86
+ /**
87
+ * Resolve `true` once `predicate` holds, `false` on timeout. The visibility
88
+ * signal drives this where the host has one; the interval is the fallback for
89
+ * a host whose signal does not push, and for a session that appears without
90
+ * a visibility transition.
91
+ */
92
+ private waitFor;
93
+ private checkWaits;
94
+ private settleWait;
95
+ private setSessionState;
29
96
  }