Skip to content

Commit 5451401

Browse files
committed
fix(settings): persist client-first settings updates
1 parent 72d917d commit 5451401

4 files changed

Lines changed: 81 additions & 25 deletions

File tree

‎packages/devframe/src/node/__tests__/scope.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,42 @@ describe('ctx.scope()', () => {
180180
expect(JSON.parse(readFileSync(globalFile, 'utf-8'))).toEqual({ token: 'abc' })
181181
})
182182

183+
it.each(['project', 'global'] as const)('persists client-first %s settings and loads them on first RPC read', async (scope) => {
184+
const { ctx, dir } = await createCtx()
185+
const key = `devframe:settings:${scope}:client-only`
186+
// Exercise the handlers used by clients without touching node settings first.
187+
await ctx.rpc.invokeLocal('devframe:rpc:server-state:set', key, { theme: 'dark' }, 'client')
188+
await sleep(250)
189+
const restarted = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) })
190+
expect(await restarted.rpc.invokeLocal('devframe:rpc:server-state:get', key)).toEqual({ theme: 'dark' })
191+
expect(await restarted.scope('client-only').settings[scope].all()).toEqual({ theme: 'dark' })
192+
})
193+
194+
it('loads existing settings for a client-first patch and shares the same state with node settings', async () => {
195+
const { ctx, dir } = await createCtx()
196+
await ctx.scope('my-plugin').settings.project.set('theme', 'dark')
197+
await sleep(250)
198+
const restarted = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) })
199+
const key = 'devframe:settings:project:my-plugin'
200+
await restarted.rpc.invokeLocal('devframe:rpc:server-state:patch', key, [{ op: 'add', path: ['zoom'], value: 2 }], 'client')
201+
expect(await restarted.scope('my-plugin').settings.project.all()).toEqual({ theme: 'dark', zoom: 2 })
202+
expect(await restarted.scope('other-plugin').settings.project.all()).toEqual({})
203+
expect(await restarted.scope('my-plugin').settings.global.all()).toEqual({})
204+
await sleep(250)
205+
expect(JSON.parse(readFileSync(join(dir, 'project/settings/my-plugin.json'), 'utf-8'))).toEqual({ theme: 'dark', zoom: 2 })
206+
})
207+
208+
it.each(['ordinary:state', 'devframe:settings:workspace:plugin', 'devframe:settings:project:../escaped'])('keeps %s in memory', async (key) => {
209+
const { ctx, dir } = await createCtx()
210+
expect(await ctx.rpc.invokeLocal('devframe:rpc:server-state:get', key)).toBeUndefined()
211+
await ctx.rpc.invokeLocal('devframe:rpc:server-state:set', key, { value: 1 }, 'client')
212+
expect(await ctx.rpc.invokeLocal('devframe:rpc:server-state:get', key)).toEqual({ value: 1 })
213+
await sleep(250)
214+
expect(existsSync(join(dir, 'project'))).toBe(false)
215+
const restarted = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) })
216+
expect(await restarted.rpc.invokeLocal('devframe:rpc:server-state:get', key)).toBeUndefined()
217+
})
218+
183219
it('notifies onChange subscribers', async () => {
184220
const { ctx } = await createCtx()
185221
const { settings } = ctx.scope('my-plugin')

‎packages/devframe/src/node/host-functions.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { removeClientAgentSession } from './client-agent'
77
import { diagnostics } from './diagnostics'
88
import { createRpcSharedStateServerHost } from './rpc-shared-state'
99
import { createRpcStreamingServerHost } from './rpc-streaming'
10+
import { resolveSettingsState } from './settings'
1011

1112
const debugBroadcast = createDebug('devframe:rpc:broadcast')
1213

@@ -38,7 +39,7 @@ export class RpcFunctionsHostImpl extends RpcFunctionsCollectorBase<DevframeRpcS
3839
constructor(context: DevframeNodeContext) {
3940
super(context)
4041

41-
this.sharedState = createRpcSharedStateServerHost(this)
42+
this.sharedState = createRpcSharedStateServerHost(this, key => resolveSettingsState(context, key))
4243
this.streaming = createRpcStreamingServerHost(this)
4344
}
4445

‎packages/devframe/src/node/rpc-shared-state.ts‎

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const debugSubscribe = createDebug('devframe:rpc:state:subscribe')
1010

1111
export function createRpcSharedStateServerHost(
1212
rpc: RpcFunctionsHost,
13+
resolveState?: (key: string) => SharedState<any> | undefined,
1314
): RpcSharedStateHost {
1415
const sharedState = new Map<string, SharedState<any>>()
1516
const stateDisposers = new Map<string, () => void>()
@@ -46,24 +47,35 @@ export function createRpcSharedStateServerHost(
4647
}
4748
}
4849

50+
function addState(key: string, state: SharedState<any>) {
51+
debug('new-state', key)
52+
stateDisposers.set(key, registerSharedState(key, state))
53+
sharedState.set(key, state)
54+
for (const fn of keyAddedListeners)
55+
fn(key)
56+
return state
57+
}
58+
59+
function resolve(key: string) {
60+
const existing = sharedState.get(key)
61+
if (existing)
62+
return existing
63+
const state = resolveState?.(key)
64+
return state ? addState(key, state) : undefined
65+
}
66+
4967
const host: RpcSharedStateHost = {
5068
get: async <T extends object>(key: string, options?: RpcSharedStateGetOptions<T>) => {
51-
if (sharedState.has(key)) {
52-
return sharedState.get(key)!
53-
}
69+
const existing = resolve(key)
70+
if (existing)
71+
return existing
5472
if (options?.initialValue === undefined && options?.sharedState === undefined) {
5573
throw diagnostics.DF0013({ key })
5674
}
57-
debug('new-state', key)
58-
const state = options.sharedState ?? createSharedState<T>({
75+
return addState(key, options.sharedState ?? createSharedState<T>({
5976
initialValue: options.initialValue as T,
6077
enablePatches: false,
61-
})
62-
stateDisposers.set(key, registerSharedState(key, state))
63-
sharedState.set(key, state)
64-
for (const fn of keyAddedListeners)
65-
fn(key)
66-
return state
78+
}))
6779
},
6880
keys() {
6981
return Array.from(sharedState.keys())
@@ -106,10 +118,7 @@ export function createRpcSharedStateServerHost(
106118
name: 'devframe:rpc:server-state:get',
107119
type: 'query',
108120
handler: async (key: string) => {
109-
if (!sharedState.has(key))
110-
return undefined
111-
const state = await host.get(key)
112-
return state.value()
121+
return resolve(key)?.value()
113122
},
114123
/**
115124
* Pre-compute snapshots for the build-mode static dump so the SPA
@@ -139,10 +148,7 @@ export function createRpcSharedStateServerHost(
139148
name: 'devframe:rpc:server-state:patch',
140149
type: 'query',
141150
handler: async (key: string, patches: SharedStatePatch[], syncId: string) => {
142-
if (!sharedState.has(key))
143-
return
144-
const state = await host.get(key)
145-
state.patch(patches, syncId)
151+
resolve(key)?.patch(patches, syncId)
146152
},
147153
})
148154

‎packages/devframe/src/node/settings.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { DevframeNodeContext, DevframeRpcSharedStates, DevframeSettings, DevframeSettingsStore } from 'devframe/types'
22
import type { SharedState } from 'devframe/utils/shared-state'
3-
import { join } from 'pathe'
3+
import { isAbsolute, join, relative } from 'pathe'
44
import { createSettingsStore } from '../settings-store'
55
import { createStorage } from './storage'
66

@@ -9,6 +9,22 @@ import { createStorage } from './storage'
99
// host's ignored `project` dir (not the committable `workspace` one).
1010
const STORAGE_SCOPE = { global: 'global', project: 'project' } as const
1111

12+
/** Resolve reserved settings keys before the sync protocol creates an in-memory state. */
13+
export function resolveSettingsState(context: DevframeNodeContext, key: string): SharedState<Record<string, any>> | undefined {
14+
const match = /^devframe:settings:(global|project):(.+)$/.exec(key)
15+
if (!match)
16+
return
17+
const scope = match[1] as keyof typeof STORAGE_SCOPE
18+
const namespace = match[2]!
19+
const dir = join(context.host.getStorageDir(STORAGE_SCOPE[scope]), 'settings')
20+
const filepath = join(dir, `${namespace}.json`)
21+
const path = relative(dir, filepath)
22+
// Keys arrive from clients, so they must stay within the settings directory.
23+
if (namespace.includes('\0') || path === '..' || path.startsWith('../') || isAbsolute(path))
24+
return
25+
return createStorage({ filepath, initialValue: {} })
26+
}
27+
1228
function createNodeSettingsStore<T extends Record<string, any>>(
1329
context: DevframeNodeContext,
1430
namespace: string,
@@ -23,11 +39,8 @@ function createNodeSettingsStore<T extends Record<string, any>>(
2339
// backing `createStorage` debounces writes to disk.
2440
function store(): Promise<SharedState<T>> {
2541
if (!statePromise) {
26-
const dir = context.host.getStorageDir(STORAGE_SCOPE[scope])
27-
const filepath = join(dir, 'settings', `${namespace}.json`)
2842
statePromise = context.rpc.sharedState.get(
2943
stateKey as keyof DevframeRpcSharedStates,
30-
{ sharedState: createStorage<T>({ filepath, initialValue: {} as T }) as any },
3144
) as Promise<SharedState<T>>
3245
}
3346
return statePromise
@@ -38,7 +51,7 @@ function createNodeSettingsStore<T extends Record<string, any>>(
3851

3952
/**
4053
* Build the node-side `settings` surface for a scope namespace. `project`
41-
* persists under the host's `workspace` storage dir, `global` under its
54+
* persists under the host's `project` storage dir, `global` under its
4255
* `global` dir. Each is a file-backed, client-synced key-value store.
4356
*/
4457
export function createNodeSettings<T extends Record<string, any> = Record<string, any>>(

0 commit comments

Comments
 (0)