Shared State

Shared state is observable, immutable-by-default state synced between the node side and every RPC client, surviving reconnects: a new RPC client gets the snapshot.

Shared state is observable, immutable-by-default state synced between the node side and every RPC client, surviving reconnects: a new RPC client gets the snapshot.

Overview

Creating state

A scoped context's rpc.sharedState(key, options) namespaces it:

import { defineDevframe } from 'devframe'

export default defineDevframe({
  id: 'my-tool',
  name: 'My Tool',
  async setup(ctx) {
    const my = ctx.scope('my-tool')

    const state = await my.rpc.sharedState('state', { // -> my-tool:state
      initialValue: {
        count: 0,
        items: [] as { id: string, name: string }[],
      },
    })

    console.log(state.value().count) // 0
  },
})

The scope prefixes <devframe-id>: (wrapping ctx.rpc.sharedState.get(...)), so pass a bare key.

Reading

state.value() returns an immutable snapshot.

const current = state.value()
console.log(current.count)
// current.count = 1 // ✗ TypeScript error: snapshot is Immutable<T>

Mutating

Pass a recipe to state.mutate():

state.mutate((draft) => {
  draft.count += 1
  draft.items.push({ id: 'a', name: 'Alpha' })
})

Devframe applies the recipe to a draft. When Immer returns a new state reference, Devframe emits updated and broadcasts it to RPC clients. Explicit replacement objects also notify. With patches enabled, updated carries SharedStatePatch[]. Sync IDs remain recorded for unchanged recipes, so replays stay idempotent.

Patches (advanced)

Enable patches for minimal network diffs; updated then carries Patch[]:

const state = await ctx.rpc.sharedState.get('my-tool:big-state', {
  initialValue: largeTree,
  /** sharedState-level enablePatches is opt-in: */
  sharedState: createSharedState({ initialValue: largeTree, enablePatches: true }),
})

Subscribing

state.on('updated', (fullState, patches, syncId) => {
  // `patches` is populated only when enablePatches is set.
})

Client-side access

The same key is on the browser RPC client, scoped identically; browser-side mutations round-trip the node side, keeping state.value() authoritative.

import { connectDevframe } from 'devframe/client'

const my = (await connectDevframe()).scope('my-tool')

const state = await my.rpc.sharedState('state') // -> my-tool:state

console.log(state.value().count)

state.mutate((draft) => {
  draft.count += 1
})

Enumerating keys

Both sides expose keys() and onKeyAdded:

for (const key of ctx.rpc.sharedState.keys()) {
  console.log(key)
}

const unsubscribe = ctx.rpc.sharedState.onKeyAdded((key) => {
  console.log('new shared-state key:', key)
})

Protocol adapters (e.g. MCP) surface shared state as dynamic resources.

Type-safe keys

Augment DevframeRpcSharedStates to type each key once; lookups stay typed:

declare module 'devframe' {
  interface DevframeRpcSharedStates {
    'my-tool:state': {
      count: number
      items: { id: string, name: string }[]
    }
  }
}

When to use shared state vs RPC

Use shared state forUse RPC for
Long-lived UI state (selections, filters, expanded nodes)One-shot queries (get-modules, read-file)
Coordination across RPC clientsCommands / actions with side effects
Data that should reappear after reconnectEvent streams (prefer broadcast / callEvent)

For actions and events, use ctx.rpc.register + broadcast (RPC).