|
| 1 | +# @forgerock/devtools-bridge |
| 2 | + |
| 3 | +Opt-in SDK adapter that connects your Ping Identity / ForgeRock application to the [Ping DevTools extension](../devtools-extension). Add it to your app in one line — it is a no-op when the extension is not installed, so it is safe to ship in production builds. |
| 4 | + |
| 5 | +## Contents |
| 6 | + |
| 7 | +- [Installation](#installation) |
| 8 | +- [Bridges](#bridges) |
| 9 | + - [DaVinci — `attachDevToolsBridge`](#davinci--attachdevtoolsbridge) |
| 10 | + - [AM Journey — `attachJourneyBridge`](#am-journey--attachjourneybridge) |
| 11 | + - [OIDC / OAuth — `attachOidcBridge`](#oidc--oauth--attachoidcbridge) |
| 12 | +- [Low-level API](#low-level-api) |
| 13 | +- [How it works](#how-it-works) |
| 14 | +- [Safety](#safety) |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Installation |
| 19 | + |
| 20 | +```bash |
| 21 | +pnpm add @forgerock/devtools-bridge |
| 22 | +``` |
| 23 | + |
| 24 | +`effect` is a peer dependency. `@forgerock/davinci-client` is an optional peer dependency required only if you use `attachDevToolsBridge`. |
| 25 | + |
| 26 | +--- |
| 27 | + |
| 28 | +## Bridges |
| 29 | + |
| 30 | +### DaVinci — `attachDevToolsBridge` |
| 31 | + |
| 32 | +Subscribes to a DaVinci client store and emits `sdk:node-change` on every node status transition, plus `session:cookie` / `session:storage` diffs after each transition. |
| 33 | + |
| 34 | +```ts |
| 35 | +import { davinci } from '@forgerock/davinci-client'; |
| 36 | +import { attachDevToolsBridge } from '@forgerock/devtools-bridge'; |
| 37 | + |
| 38 | +const client = await davinci({ config }); |
| 39 | + |
| 40 | +// Pass config as the second argument — emitted once as sdk:config on the first transition |
| 41 | +const bridge = attachDevToolsBridge(client, config); |
| 42 | + |
| 43 | +// Unsubscribe when the component unmounts |
| 44 | +bridge.detach(); |
| 45 | +``` |
| 46 | + |
| 47 | +**What it captures per node transition:** |
| 48 | + |
| 49 | +| Field | Source | |
| 50 | +| ---------------- | --------------------------------------------- | |
| 51 | +| `nodeStatus` | DaVinci node `.status` | |
| 52 | +| `previousStatus` | Previous status (tracked locally) | |
| 53 | +| `interactionId` | `server.interactionId` | |
| 54 | +| `nodeName` | `client.name` | |
| 55 | +| `collectors` | `client.collectors` (full objects) | |
| 56 | +| `error` | `error.code / message / type` | |
| 57 | +| `session` | `server.session` (DaVinci session token) | |
| 58 | +| `responseBody` | Full DaVinci server response (from RTK cache) | |
| 59 | + |
| 60 | +The bridge only emits when `nodeStatus` actually changes, so rapid store updates that don't advance the node do not generate noise. |
| 61 | + |
| 62 | +--- |
| 63 | + |
| 64 | +### AM Journey — `attachJourneyBridge` |
| 65 | + |
| 66 | +Subscribes to a Journey RTK store and emits `sdk:journey-step` for each mutation that settles (`fulfilled` or `rejected`). Each event carries the full AM step response including all callbacks with their `input`/`output` arrays. |
| 67 | + |
| 68 | +```ts |
| 69 | +import { journey } from '@forgerock/journey-client'; // your RTK-based journey client |
| 70 | +import { attachJourneyBridge } from '@forgerock/devtools-bridge'; |
| 71 | + |
| 72 | +const client = await journey({ config }); |
| 73 | + |
| 74 | +attachJourneyBridge(client, config); |
| 75 | +``` |
| 76 | + |
| 77 | +**`JourneySubscribable` interface** — any object with this shape works: |
| 78 | + |
| 79 | +```ts |
| 80 | +interface JourneySubscribable { |
| 81 | + subscribe: (listener: () => void) => () => void; |
| 82 | + getState: () => unknown; // must expose { journeyReducer: { mutations: Record<string, MutationEntry> } } |
| 83 | +} |
| 84 | +``` |
| 85 | + |
| 86 | +**Emitted events by step type:** |
| 87 | + |
| 88 | +| `stepType` | When | Notable fields | |
| 89 | +| -------------- | --------------------------------- | ------------------------------------------ | |
| 90 | +| `Step` | AM returns `authId` | `callbacks`, `authId`, `stage`, `header` | |
| 91 | +| `LoginSuccess` | AM returns `tokenId` | `tokenId`, `successUrl` | |
| 92 | +| `LoginFailure` | AM returns an error / RTK rejects | `errorCode`, `errorMessage`, `errorReason` | |
| 93 | + |
| 94 | +--- |
| 95 | + |
| 96 | +### OIDC / OAuth — `attachOidcBridge` |
| 97 | + |
| 98 | +Subscribes to an OIDC client RTK store and emits `sdk:oidc-state` for each settled mutation. Maps RTK endpoint names to human-readable phases. |
| 99 | + |
| 100 | +```ts |
| 101 | +import { oidcClient } from '@forgerock/oidc-client'; // your RTK-based OIDC client |
| 102 | +import { attachOidcBridge } from '@forgerock/devtools-bridge'; |
| 103 | + |
| 104 | +const client = oidcClient({ config }); |
| 105 | + |
| 106 | +attachOidcBridge(client, config); |
| 107 | +``` |
| 108 | + |
| 109 | +**`OidcSubscribable` interface:** |
| 110 | + |
| 111 | +```ts |
| 112 | +interface OidcSubscribable { |
| 113 | + subscribe: (listener: () => void) => () => void; |
| 114 | + getState: () => unknown; // must expose { oidc: { mutations: Record<string, MutationEntry> } } |
| 115 | +} |
| 116 | +``` |
| 117 | + |
| 118 | +**Endpoint → phase mapping:** |
| 119 | + |
| 120 | +| RTK endpoint name | Emitted phase | |
| 121 | +| ----------------- | ------------- | |
| 122 | +| `authorizeFetch` | `authorize` | |
| 123 | +| `authorizeIframe` | `authorize` | |
| 124 | +| `exchange` | `exchange` | |
| 125 | +| `revoke` | `revoke` | |
| 126 | +| `userInfo` | `userinfo` | |
| 127 | +| `endSession` | `logout` | |
| 128 | + |
| 129 | +Pass `config.clientId` to surface it in the extension's node detail card: |
| 130 | + |
| 131 | +```ts |
| 132 | +attachOidcBridge(client, { clientId: 'my-spa-client', ...rest }); |
| 133 | +``` |
| 134 | + |
| 135 | +--- |
| 136 | + |
| 137 | +## Low-level API |
| 138 | + |
| 139 | +If you need to emit events from outside a supported client, use the primitives directly. |
| 140 | + |
| 141 | +```ts |
| 142 | +import { emitAuthEvent, emitConfigEvent, DEVTOOLS_EVENT_NAME } from '@forgerock/devtools-bridge'; |
| 143 | + |
| 144 | +emitAuthEvent({ |
| 145 | + id: crypto.randomUUID(), |
| 146 | + timestamp: performance.now(), |
| 147 | + type: 'sdk:node-change', |
| 148 | + source: 'sdk', |
| 149 | + flowId: null, |
| 150 | + causedBy: null, |
| 151 | + data: { _tag: 'sdk', nodeStatus: 'next' }, |
| 152 | + flags: { isCors: false, isError: false, isAuthRelated: true }, |
| 153 | +}); |
| 154 | + |
| 155 | +emitConfigEvent({ clientId: 'my-app', environment: 'dev' }); |
| 156 | +``` |
| 157 | + |
| 158 | +Both functions dispatch a `CustomEvent` named `DEVTOOLS_EVENT_NAME` (`'pingDevtools'`) on `window`. The content script picks this up and forwards it to the extension service worker. |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +## How it works |
| 163 | + |
| 164 | +``` |
| 165 | +Your app |
| 166 | + ├── attachDevToolsBridge(davinciClient) ─┐ |
| 167 | + ├── attachJourneyBridge(journeyClient) ─┤─ emitAuthEvent() |
| 168 | + └── attachOidcBridge(oidcClient) ─┘ |
| 169 | + │ |
| 170 | + │ window.dispatchEvent(new CustomEvent('pingDevtools', { detail: event })) |
| 171 | + ▼ |
| 172 | + content-script.js |
| 173 | + │ |
| 174 | + │ chrome.runtime.sendMessage({ type: 'SDK_EVENT', payload: event }) |
| 175 | + ▼ |
| 176 | + service-worker.ts ──(validates via AuthEventSchema)──▶ EventStore |
| 177 | + │ |
| 178 | + │ chrome.runtime.sendMessage({ type: 'EVENTS_UPDATED' }) |
| 179 | + ▼ |
| 180 | + panel (Elm) ── Timeline view + Flow view |
| 181 | +``` |
| 182 | + |
| 183 | +Each bridge function: |
| 184 | + |
| 185 | +1. Subscribes to the client store |
| 186 | +2. Validates the current state with an Effect Schema decoder (returns `Option.none` on mismatch — never throws) |
| 187 | +3. Deduplicates by tracking already-emitted request IDs in a `Set` |
| 188 | +4. Trims that `Set` to only IDs still present in the store, bounding memory use |
| 189 | +5. Dispatches the event only when `window.__PING_DEVTOOLS_EXTENSION__` is present |
| 190 | + |
| 191 | +--- |
| 192 | + |
| 193 | +## Safety |
| 194 | + |
| 195 | +- **No-op without the extension** — all bridges check for `window.__PING_DEVTOOLS_EXTENSION__` before dispatching. If the marker is absent, nothing is emitted. |
| 196 | +- **No-op in SSR / Node** — all bridges return `{ detach: () => undefined }` immediately when `typeof window === 'undefined'`. |
| 197 | +- **Tree-shakeable** — `sideEffects: false` in `package.json`; unused bridges are eliminated by your bundler. |
| 198 | +- **No sensitive data leakage** — the bridge never reads passwords or form values; it only observes the client's Redux/RTK state. |
0 commit comments