From 54514015e6a19f4efeffbd8374e2a32396923e3a Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Fri, 2 Oct 2026 22:12:14 +0800 Subject: [PATCH] fix(settings): persist client-first settings updates --- .../devframe/src/node/__tests__/scope.test.ts | 36 +++++++++++++++ packages/devframe/src/node/host-functions.ts | 3 +- .../devframe/src/node/rpc-shared-state.ts | 44 +++++++++++-------- packages/devframe/src/node/settings.ts | 23 +++++++--- 4 files changed, 81 insertions(+), 25 deletions(-) diff --git a/packages/devframe/src/node/__tests__/scope.test.ts b/packages/devframe/src/node/__tests__/scope.test.ts index 47c2c73f9..debe999d7 100644 --- a/packages/devframe/src/node/__tests__/scope.test.ts +++ b/packages/devframe/src/node/__tests__/scope.test.ts @@ -180,6 +180,42 @@ describe('ctx.scope()', () => { expect(JSON.parse(readFileSync(globalFile, 'utf-8'))).toEqual({ token: 'abc' }) }) + it.each(['project', 'global'] as const)('persists client-first %s settings and loads them on first RPC read', async (scope) => { + const { ctx, dir } = await createCtx() + const key = `devframe:settings:${scope}:client-only` + // Exercise the handlers used by clients without touching node settings first. + await ctx.rpc.invokeLocal('devframe:rpc:server-state:set', key, { theme: 'dark' }, 'client') + await sleep(250) + const restarted = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) }) + expect(await restarted.rpc.invokeLocal('devframe:rpc:server-state:get', key)).toEqual({ theme: 'dark' }) + expect(await restarted.scope('client-only').settings[scope].all()).toEqual({ theme: 'dark' }) + }) + + it('loads existing settings for a client-first patch and shares the same state with node settings', async () => { + const { ctx, dir } = await createCtx() + await ctx.scope('my-plugin').settings.project.set('theme', 'dark') + await sleep(250) + const restarted = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) }) + const key = 'devframe:settings:project:my-plugin' + await restarted.rpc.invokeLocal('devframe:rpc:server-state:patch', key, [{ op: 'add', path: ['zoom'], value: 2 }], 'client') + expect(await restarted.scope('my-plugin').settings.project.all()).toEqual({ theme: 'dark', zoom: 2 }) + expect(await restarted.scope('other-plugin').settings.project.all()).toEqual({}) + expect(await restarted.scope('my-plugin').settings.global.all()).toEqual({}) + await sleep(250) + expect(JSON.parse(readFileSync(join(dir, 'project/settings/my-plugin.json'), 'utf-8'))).toEqual({ theme: 'dark', zoom: 2 }) + }) + + it.each(['ordinary:state', 'devframe:settings:workspace:plugin', 'devframe:settings:project:../escaped'])('keeps %s in memory', async (key) => { + const { ctx, dir } = await createCtx() + expect(await ctx.rpc.invokeLocal('devframe:rpc:server-state:get', key)).toBeUndefined() + await ctx.rpc.invokeLocal('devframe:rpc:server-state:set', key, { value: 1 }, 'client') + expect(await ctx.rpc.invokeLocal('devframe:rpc:server-state:get', key)).toEqual({ value: 1 }) + await sleep(250) + expect(existsSync(join(dir, 'project'))).toBe(false) + const restarted = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) }) + expect(await restarted.rpc.invokeLocal('devframe:rpc:server-state:get', key)).toBeUndefined() + }) + it('notifies onChange subscribers', async () => { const { ctx } = await createCtx() const { settings } = ctx.scope('my-plugin') diff --git a/packages/devframe/src/node/host-functions.ts b/packages/devframe/src/node/host-functions.ts index ea2b2c9c7..7f8e4f6ed 100644 --- a/packages/devframe/src/node/host-functions.ts +++ b/packages/devframe/src/node/host-functions.ts @@ -7,6 +7,7 @@ import { removeClientAgentSession } from './client-agent' import { diagnostics } from './diagnostics' import { createRpcSharedStateServerHost } from './rpc-shared-state' import { createRpcStreamingServerHost } from './rpc-streaming' +import { resolveSettingsState } from './settings' const debugBroadcast = createDebug('devframe:rpc:broadcast') @@ -38,7 +39,7 @@ export class RpcFunctionsHostImpl extends RpcFunctionsCollectorBase resolveSettingsState(context, key)) this.streaming = createRpcStreamingServerHost(this) } diff --git a/packages/devframe/src/node/rpc-shared-state.ts b/packages/devframe/src/node/rpc-shared-state.ts index 306b217c6..70ddcc30d 100644 --- a/packages/devframe/src/node/rpc-shared-state.ts +++ b/packages/devframe/src/node/rpc-shared-state.ts @@ -10,6 +10,7 @@ const debugSubscribe = createDebug('devframe:rpc:state:subscribe') export function createRpcSharedStateServerHost( rpc: RpcFunctionsHost, + resolveState?: (key: string) => SharedState | undefined, ): RpcSharedStateHost { const sharedState = new Map>() const stateDisposers = new Map void>() @@ -46,24 +47,35 @@ export function createRpcSharedStateServerHost( } } + function addState(key: string, state: SharedState) { + debug('new-state', key) + stateDisposers.set(key, registerSharedState(key, state)) + sharedState.set(key, state) + for (const fn of keyAddedListeners) + fn(key) + return state + } + + function resolve(key: string) { + const existing = sharedState.get(key) + if (existing) + return existing + const state = resolveState?.(key) + return state ? addState(key, state) : undefined + } + const host: RpcSharedStateHost = { get: async (key: string, options?: RpcSharedStateGetOptions) => { - if (sharedState.has(key)) { - return sharedState.get(key)! - } + const existing = resolve(key) + if (existing) + return existing if (options?.initialValue === undefined && options?.sharedState === undefined) { throw diagnostics.DF0013({ key }) } - debug('new-state', key) - const state = options.sharedState ?? createSharedState({ + return addState(key, options.sharedState ?? createSharedState({ initialValue: options.initialValue as T, enablePatches: false, - }) - stateDisposers.set(key, registerSharedState(key, state)) - sharedState.set(key, state) - for (const fn of keyAddedListeners) - fn(key) - return state + })) }, keys() { return Array.from(sharedState.keys()) @@ -106,10 +118,7 @@ export function createRpcSharedStateServerHost( name: 'devframe:rpc:server-state:get', type: 'query', handler: async (key: string) => { - if (!sharedState.has(key)) - return undefined - const state = await host.get(key) - return state.value() + return resolve(key)?.value() }, /** * Pre-compute snapshots for the build-mode static dump so the SPA @@ -139,10 +148,7 @@ export function createRpcSharedStateServerHost( name: 'devframe:rpc:server-state:patch', type: 'query', handler: async (key: string, patches: SharedStatePatch[], syncId: string) => { - if (!sharedState.has(key)) - return - const state = await host.get(key) - state.patch(patches, syncId) + resolve(key)?.patch(patches, syncId) }, }) diff --git a/packages/devframe/src/node/settings.ts b/packages/devframe/src/node/settings.ts index ce2a12ffb..977e58473 100644 --- a/packages/devframe/src/node/settings.ts +++ b/packages/devframe/src/node/settings.ts @@ -1,6 +1,6 @@ import type { DevframeNodeContext, DevframeRpcSharedStates, DevframeSettings, DevframeSettingsStore } from 'devframe/types' import type { SharedState } from 'devframe/utils/shared-state' -import { join } from 'pathe' +import { isAbsolute, join, relative } from 'pathe' import { createSettingsStore } from '../settings-store' import { createStorage } from './storage' @@ -9,6 +9,22 @@ import { createStorage } from './storage' // host's ignored `project` dir (not the committable `workspace` one). const STORAGE_SCOPE = { global: 'global', project: 'project' } as const +/** Resolve reserved settings keys before the sync protocol creates an in-memory state. */ +export function resolveSettingsState(context: DevframeNodeContext, key: string): SharedState> | undefined { + const match = /^devframe:settings:(global|project):(.+)$/.exec(key) + if (!match) + return + const scope = match[1] as keyof typeof STORAGE_SCOPE + const namespace = match[2]! + const dir = join(context.host.getStorageDir(STORAGE_SCOPE[scope]), 'settings') + const filepath = join(dir, `${namespace}.json`) + const path = relative(dir, filepath) + // Keys arrive from clients, so they must stay within the settings directory. + if (namespace.includes('\0') || path === '..' || path.startsWith('../') || isAbsolute(path)) + return + return createStorage({ filepath, initialValue: {} }) +} + function createNodeSettingsStore>( context: DevframeNodeContext, namespace: string, @@ -23,11 +39,8 @@ function createNodeSettingsStore>( // backing `createStorage` debounces writes to disk. function store(): Promise> { if (!statePromise) { - const dir = context.host.getStorageDir(STORAGE_SCOPE[scope]) - const filepath = join(dir, 'settings', `${namespace}.json`) statePromise = context.rpc.sharedState.get( stateKey as keyof DevframeRpcSharedStates, - { sharedState: createStorage({ filepath, initialValue: {} as T }) as any }, ) as Promise> } return statePromise @@ -38,7 +51,7 @@ function createNodeSettingsStore>( /** * Build the node-side `settings` surface for a scope namespace. `project` - * persists under the host's `workspace` storage dir, `global` under its + * persists under the host's `project` storage dir, `global` under its * `global` dir. Each is a file-backed, client-synced key-value store. */ export function createNodeSettings = Record>(