Skip to content
Merged
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
51 changes: 16 additions & 35 deletions packages/kit/src/plugin/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ComponentId, DevtoolsRuntime, InspectorId, MaybePromise, PluginId } from '../runtime'
import { PluginSettings } from './settings'

export interface PluginDescriptor {
id: PluginId
Expand Down Expand Up @@ -208,9 +209,20 @@ export function createPluginApiAdapter(
descriptor: PluginDescriptor,
runtime: DevtoolsRuntime,
resolvePluginSettings?: (pluginId: string) => Record<string, unknown> | undefined,
settings: PluginSettings = new PluginSettings(descriptor),
): PluginApiAdapter {
const handlers = new Map<keyof DevToolsPluginAPIHooks, Set<Function>>()
let settingsValues = readSettingsDefaults(descriptor.settings)
settings.subscribe(async ({ key, value, oldValue, settings: snapshot }) => {
await callHook('setPluginSettings', {
app: descriptor.app,
pluginId: descriptor.id,
key,
newValue: value,
oldValue,
value,
settings: snapshot,
})
})

function addHook<K extends keyof DevToolsPluginAPIHooks>(
key: K,
Expand Down Expand Up @@ -309,32 +321,15 @@ export function createPluginApiAdapter(

function getSettingsSnapshot(pluginId?: string): Record<string, unknown> {
if (pluginId && pluginId !== descriptor.id) return resolvePluginSettings?.(pluginId) ?? {}
return { ...settingsValues }
return settings.get()
}

function syncSettings() {
settingsValues = readSettingsDefaults(descriptor.settings, settingsValues)
settings.sync()
}

async function updateSetting(key: string, value: unknown) {
if (!descriptor.settings || !(key in descriptor.settings)) return

const oldValue = settingsValues[key]

settingsValues = {
...settingsValues,
[key]: value,
}

await callHook('setPluginSettings', {
app: descriptor.app,
pluginId: descriptor.id,
key,
newValue: value,
oldValue,
value,
settings: getSettingsSnapshot(),
})
await settings.update(key, value)
}

async function callHook<K extends keyof DevToolsPluginAPIHooks>(
Expand Down Expand Up @@ -402,17 +397,3 @@ function createHookRegistrarProxy(
},
})
}

function readSettingsDefaults(
settings: PluginDescriptor['settings'] | undefined,
current: Record<string, unknown> = {},
): Record<string, unknown> {
const result: Record<string, unknown> = {}
if (!settings) return result

for (const [key, value] of Object.entries(settings)) {
result[key] = key in current ? current[key] : value.defaultValue
}

return result
}
122 changes: 78 additions & 44 deletions packages/kit/src/plugin/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
PluginSetupFunction,
} from './api'
import { createPluginApiAdapter, reportPluginError } from './api'
import { PluginSettings } from './settings'

type PluginEntry = [PluginDescriptor, PluginSetupFunction]
type PluginListener = (entry: PluginEntry) => void
Expand Down Expand Up @@ -38,42 +39,66 @@ export interface PluginController {
dispose(): void
}

/**
* Everything registered under one plugin id. Plugins such as Pinia call setup once per app
* with the same id; each app gets its own adapter, so hooks and commands stay scoped to it,
* while settings stay shared.
*/
class RegisteredPlugin {
readonly adapters: PluginApiAdapter[] = []
private readonly settings: PluginSettings

constructor(
descriptor: PluginDescriptor,
private readonly runtime: DevtoolsRuntime,
private readonly resolvePluginSettings: (
pluginId: string,
) => Record<string, unknown> | undefined,
) {
this.settings = new PluginSettings(descriptor)
}

adapterFor(descriptor: PluginDescriptor): PluginApiAdapter {
if (this.adapters.length) {
for (const adapter of this.adapters) mergeDescriptor(adapter.descriptor, descriptor)
this.settings.sync()
}
// A setup without an app joins the first adapter.
const existing =
descriptor.app == null
? this.adapters[0]
: this.adapters.find((adapter) => adapter.descriptor.app === descriptor.app)
if (existing) return existing

const adapter = createPluginApiAdapter(
descriptor,
this.runtime,
this.resolvePluginSettings,
this.settings,
)
this.adapters.push(adapter)
return adapter
}
}

export function createPluginController(runtime: DevtoolsRuntime): PluginController {
const state = getPluginRegistryState()
const plugins = new Map<string, RegisteredPlugin>()
// First adapter per plugin id: what the plugin snapshot and settings commands use.
const adapters = new Map<string, PluginApiAdapter>()
const resolvePluginSettings = (pluginId: string) => adapters.get(pluginId)?.getSettings()

async function register(
descriptor: PluginDescriptor,
setupFn: PluginSetupFunction,
): Promise<PluginApiAdapter> {
const registeredAdapter = adapters.get(descriptor.id)
if (registeredAdapter) {
registeredAdapter.descriptor.label = descriptor.label ?? registeredAdapter.descriptor.label
registeredAdapter.descriptor.logo = descriptor.logo ?? registeredAdapter.descriptor.logo
registeredAdapter.descriptor.packageName =
descriptor.packageName ?? registeredAdapter.descriptor.packageName
registeredAdapter.descriptor.homepage =
descriptor.homepage ?? registeredAdapter.descriptor.homepage
registeredAdapter.descriptor.app = descriptor.app ?? registeredAdapter.descriptor.app
registeredAdapter.descriptor.componentStateTypes =
descriptor.componentStateTypes ?? registeredAdapter.descriptor.componentStateTypes
registeredAdapter.descriptor.disableAppScope =
descriptor.disableAppScope ?? registeredAdapter.descriptor.disableAppScope
registeredAdapter.descriptor.disablePluginScope =
descriptor.disablePluginScope ?? registeredAdapter.descriptor.disablePluginScope
registeredAdapter.descriptor.enableEarlyProxy =
descriptor.enableEarlyProxy ?? registeredAdapter.descriptor.enableEarlyProxy
registeredAdapter.descriptor.settings =
descriptor.settings ?? registeredAdapter.descriptor.settings
registeredAdapter.syncSettings()
await runPluginSetup(descriptor.id, setupFn, registeredAdapter.api)
return registeredAdapter
let plugin = plugins.get(descriptor.id)
if (!plugin) {
plugin = new RegisteredPlugin(descriptor, runtime, resolvePluginSettings)
plugins.set(descriptor.id, plugin)
}

const adapter = createPluginApiAdapter(descriptor, runtime, (pluginId) =>
adapters.get(pluginId)?.getSettings(),
)
adapters.set(descriptor.id, adapter)
const adapter = plugin.adapterFor(descriptor)
if (!adapters.has(descriptor.id)) adapters.set(descriptor.id, adapter)
await runPluginSetup(descriptor.id, setupFn, adapter.api)
return adapter
}
Expand All @@ -84,37 +109,46 @@ export function createPluginController(runtime: DevtoolsRuntime): PluginControll
state.listeners.add(listener)
state.bufferedPlugins.forEach(listener)

async function callAdapterHook(adapter: PluginApiAdapter, key: string, payload: unknown) {
if (!acceptsApp(adapter.descriptor, payload)) return
try {
await adapter.callHook(key as never, payload as never)
} catch (error) {
reportPluginError(adapter.descriptor.id, `"${key}" hook`, error)
}
}

return {
adapters,
register,
async callHook(key, payload) {
await Promise.all(
[...adapters.values()].map(async (adapter) => {
if (!acceptsApp(adapter.descriptor, payload)) return
try {
await adapter.callHook(key as never, payload as never)
} catch (error) {
reportPluginError(adapter.descriptor.id, `"${key}" hook`, error)
}
}),
)
const all = [...plugins.values()].flatMap((plugin) => plugin.adapters)
await Promise.all(all.map((adapter) => callAdapterHook(adapter, key, payload)))
},
async callHookFor(pluginId, key, payload) {
const adapter = adapters.get(pluginId)
if (!adapter || !acceptsApp(adapter.descriptor, payload)) return
try {
await adapter.callHook(key as never, payload as never)
} catch (error) {
reportPluginError(adapter.descriptor.id, `"${key}" hook`, error)
}
const all = plugins.get(pluginId)?.adapters ?? []
await Promise.all(all.map((adapter) => callAdapterHook(adapter, key, payload)))
},
dispose() {
state.listeners.delete(listener)
plugins.clear()
adapters.clear()
},
}
}

function mergeDescriptor(target: PluginDescriptor, descriptor: PluginDescriptor): void {
target.label = descriptor.label ?? target.label
target.logo = descriptor.logo ?? target.logo
target.packageName = descriptor.packageName ?? target.packageName
target.homepage = descriptor.homepage ?? target.homepage
target.componentStateTypes = descriptor.componentStateTypes ?? target.componentStateTypes
target.disableAppScope = descriptor.disableAppScope ?? target.disableAppScope
target.disablePluginScope = descriptor.disablePluginScope ?? target.disablePluginScope
target.enableEarlyProxy = descriptor.enableEarlyProxy ?? target.enableEarlyProxy
target.settings = descriptor.settings ?? target.settings
}

function acceptsApp(descriptor: PluginDescriptor, payload: unknown): boolean {
if (descriptor.disableAppScope || descriptor.app == null) return true
if (!payload || typeof payload !== 'object' || !('app' in payload)) return true
Expand Down
59 changes: 59 additions & 0 deletions packages/kit/src/plugin/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { PluginDescriptor } from './api'

// Internal: not re-exported from the plugin barrel.

export interface PluginSettingsChange {
key: string
value: unknown
oldValue: unknown
settings: Record<string, unknown>
}

/**
* Setting values for one plugin id. Adapters that register the same id for several apps
* share one instance, so a change reaches every app's `setPluginSettings` handlers.
*/
export class PluginSettings {
private values: Record<string, unknown>
private readonly listeners = new Set<(change: PluginSettingsChange) => Promise<void>>()

constructor(private readonly descriptor: PluginDescriptor) {
this.values = readSettingsDefaults(descriptor.settings)
}

get(): Record<string, unknown> {
return { ...this.values }
}

sync(): void {
this.values = readSettingsDefaults(this.descriptor.settings, this.values)
}

subscribe(listener: (change: PluginSettingsChange) => Promise<void>): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}

async update(key: string, value: unknown): Promise<void> {
if (!this.descriptor.settings || !(key in this.descriptor.settings)) return

const oldValue = this.values[key]
this.values = { ...this.values, [key]: value }
const change = { key, value, oldValue, settings: this.get() }
await Promise.all([...this.listeners].map((listener) => listener(change)))
}
}

function readSettingsDefaults(
settings: PluginDescriptor['settings'] | undefined,
current: Record<string, unknown> = {},
): Record<string, unknown> {
const result: Record<string, unknown> = {}
if (!settings) return result

for (const [key, value] of Object.entries(settings)) {
result[key] = key in current ? current[key] : value.defaultValue
}

return result
}
Loading
Loading