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
36 changes: 36 additions & 0 deletions packages/devframe/src/node/__tests__/scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
3 changes: 2 additions & 1 deletion packages/devframe/src/node/host-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -38,7 +39,7 @@ export class RpcFunctionsHostImpl extends RpcFunctionsCollectorBase<DevframeRpcS
constructor(context: DevframeNodeContext) {
super(context)

this.sharedState = createRpcSharedStateServerHost(this)
this.sharedState = createRpcSharedStateServerHost(this, key => resolveSettingsState(context, key))
this.streaming = createRpcStreamingServerHost(this)
}

Expand Down
44 changes: 25 additions & 19 deletions packages/devframe/src/node/rpc-shared-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const debugSubscribe = createDebug('devframe:rpc:state:subscribe')

export function createRpcSharedStateServerHost(
rpc: RpcFunctionsHost,
resolveState?: (key: string) => SharedState<any> | undefined,
): RpcSharedStateHost {
const sharedState = new Map<string, SharedState<any>>()
const stateDisposers = new Map<string, () => void>()
Expand Down Expand Up @@ -46,24 +47,35 @@ export function createRpcSharedStateServerHost(
}
}

function addState(key: string, state: SharedState<any>) {
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 <T extends object>(key: string, options?: RpcSharedStateGetOptions<T>) => {
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<T>({
return addState(key, options.sharedState ?? createSharedState<T>({
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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
},
})

Expand Down
23 changes: 18 additions & 5 deletions packages/devframe/src/node/settings.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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<Record<string, any>> | 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<T extends Record<string, any>>(
context: DevframeNodeContext,
namespace: string,
Expand All @@ -23,11 +39,8 @@ function createNodeSettingsStore<T extends Record<string, any>>(
// backing `createStorage` debounces writes to disk.
function store(): Promise<SharedState<T>> {
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<T>({ filepath, initialValue: {} as T }) as any },
) as Promise<SharedState<T>>
}
return statePromise
Expand All @@ -38,7 +51,7 @@ function createNodeSettingsStore<T extends Record<string, any>>(

/**
* 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<T extends Record<string, any> = Record<string, any>>(
Expand Down
Loading