Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/devframe/src/client/rpc-shared-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
}
}

return new Promise<SharedState<T>>((resolve) => {
return new Promise<SharedState<T>>((resolve, reject) => {
if (!rpc.isTrusted) {
resolve(state)
let initialized = false
Expand All @@ -133,7 +133,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
})
}
else {
initSharedState().then(resolve)
initSharedState().then(resolve, reject)
}
})
},
Expand Down
3 changes: 2 additions & 1 deletion packages/devframe/src/client/scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function createMockClient() {

// eslint-disable-next-line slop/no-chained-type-assertions -- partial test mock exercises only the members client.scope() touches
const rpc = {
ensureTrusted: async () => true,
call: vi.fn((..._args: any[]) => Promise.resolve('ok')),
callEvent: vi.fn((..._args: any[]) => {}),
callOptional: vi.fn((..._args: any[]) => Promise.resolve('ok')),
Expand Down Expand Up @@ -126,7 +127,7 @@ describe('client.scope()', () => {
const { settings } = createScopedClientContext(rpc, 'my-plugin')

await settings.global.set('token', 'abc')
expect(rpc.sharedState.get).toHaveBeenCalledWith('devframe:settings:global:my-plugin', { initialValue: {} })
expect(rpc.sharedState.get).toHaveBeenCalledWith('devframe:settings:global:my-plugin')
expect(await settings.global.get('token')).toBe('abc')

await settings.project.set('theme', 'dark')
Expand Down
97 changes: 97 additions & 0 deletions packages/devframe/src/client/settings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { DevframeRpcClient } from './rpc'
import { setImmediate } from 'node:timers/promises'
import { createEventEmitter } from 'devframe/utils/events'
import { describe, expect, it, vi } from 'vitest'
import { DEVFRAME_EVENTS } from '../events'
import { createRpcSharedStateClientHost } from './rpc-shared-state'
import { createClientSettings } from './settings'

function setup(backend = 'websocket', trusted = true) {
const snapshot = Promise.withResolvers<Record<string, unknown> | undefined>()
const trust = Promise.withResolvers<boolean>()
const handlers = new Map<string, (...args: any[]) => void>()
// eslint-disable-next-line slop/no-chained-type-assertions -- partial RPC mock retains the real shared-state client
const rpc = {
connectionMeta: { backend },
isTrusted: trusted,
events: createEventEmitter<any>(),
client: { register: (fn: any) => handlers.set(fn.name, fn.handler) },
ensureTrusted: vi.fn(() => trusted ? Promise.resolve(true) : trust.promise),
call: vi.fn(() => snapshot.promise),
callEvent: vi.fn(),
} as unknown as DevframeRpcClient
rpc.sharedState = createRpcSharedStateClientHost(rpc)
const settings = createClientSettings(rpc, 'test')
return { rpc, settings, snapshot, trust, handlers }
}

describe('client settings initialization', () => {
it.each(['project', 'global'] as const)('reads the initial %s snapshot before resolving', async (scope) => {
const { settings, snapshot } = setup()
const read = settings[scope].get('theme')
await setImmediate()
snapshot.resolve({ theme: 'light' })
await expect(read).resolves.toBe('light')
})

it.each(['project', 'global'] as const)('preserves an immediate %s write and unrelated settings', async (scope) => {
const { rpc, settings, snapshot } = setup()
const write = settings[scope].set('theme', 'dark')
const read = settings[scope].all()
await setImmediate()
snapshot.resolve({ theme: 'light', language: 'en' })
await write
await expect(settings[scope].get('theme')).resolves.toBe('dark')
await expect(read).resolves.toEqual({ theme: 'dark', language: 'en' })
expect(rpc.call).toHaveBeenCalledTimes(1)
})

it('applies an immediate deletion after the initial snapshot', async () => {
const { settings, snapshot } = setup()
const deletion = settings.project.delete('theme')
await setImmediate()
snapshot.resolve({ theme: 'light', language: 'en' })
await deletion
await expect(settings.project.all()).resolves.toEqual({ language: 'en' })
})

it.each(['websocket', 'static'])('starts an absent %s store with an empty object', async (backend) => {
const { settings, snapshot } = setup(backend)
const read = settings.project.all()
await setImmediate()
snapshot.resolve(undefined)
await expect(read).resolves.toEqual({})
await settings.project.set('theme', 'dark')
await expect(settings.project.get('theme')).resolves.toBe('dark')
})

it('waits for trust before requesting the initial snapshot', async () => {
const { rpc, settings, snapshot, trust } = setup('websocket', false)
const write = settings.project.set('theme', 'dark')
await setImmediate()
expect(rpc.call).not.toHaveBeenCalled()
expect(rpc.callEvent).not.toHaveBeenCalled()
Object.defineProperty(rpc, 'isTrusted', { value: true })
trust.resolve(true)
snapshot.resolve({ language: 'en' })
await write
await expect(settings.project.all()).resolves.toEqual({ theme: 'dark', language: 'en' })
})

it('rejects a failed initial snapshot and retries on the next operation', async () => {
const { rpc, settings } = setup()
vi.mocked(rpc.call).mockRejectedValueOnce(new Error('snapshot failed'))
await expect(settings.project.get('theme')).rejects.toThrow('snapshot failed')
vi.mocked(rpc.call).mockResolvedValueOnce({ theme: 'light' })
await expect(settings.project.get('theme')).resolves.toBe('light')
})

it('continues to accept remote updates after initialization', async () => {
const { settings, snapshot, handlers } = setup()
await setImmediate()
snapshot.resolve({ theme: 'light' })
await settings.project.set('theme', 'dark')
handlers.get(DEVFRAME_EVENTS.broadcast.clientStateUpdated)!('devframe:settings:project:test', { theme: 'system' }, 'remote')
await expect(settings.project.get('theme')).resolves.toBe('system')
})
})
17 changes: 12 additions & 5 deletions packages/devframe/src/client/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ function createClientSettingsStore<T extends Record<string, any>>(
const stateKey = `devframe:settings:${scope}:${namespace}`
let statePromise: Promise<SharedState<T>> | undefined

// The client mirrors the server's file-backed settings store over the
// shared-state sync protocol: providing an empty initial value lets the
// client subscribe and merge the authoritative server snapshot, and any
// local `set` is pushed back to (and persisted by) the server.
// Resolve the server snapshot before reading or changing settings so that
// initialization cannot overwrite the first local operation.
function store(): Promise<SharedState<T>> {
if (!statePromise) {
statePromise = (rpc.sharedState.get as any)(stateKey, { initialValue: {} }) as Promise<SharedState<T>>
statePromise = (async () => {
await rpc.ensureTrusted()
const state = await rpc.sharedState.get<T>(stateKey)
if (state.value() === undefined)
state.mutate(() => ({} as T))
return state
})().catch((error) => {
statePromise = undefined
throw error
})
}
return statePromise
}
Expand Down
Loading