Workbench 与配置

编写 React 管理页面

从一个可读取、可刷新的页面开始,再按需添加分页和实时订阅。

需要自定义布局、分页或 React 组件时,使用 View。Plugin 提供服务端 API 和 React 页面,Workbench 负责登录与打开、关闭页面。 如果只有状态和几个按钮,先用更短的 Content

开始前,宿主应已启用 Workbench,Plugin 能正常启动。下面分为四份文件:页面声明、服务端实现、查询声明和 React 入口。 RpcTarget 是可以由页面调用的服务端对象;scope 将 React 组件与它对应的页面 API 关联。

默认路径:snapshot + mutation

将 browser-safe DTO、API 和静态 definition 放进 src/workbench.ts

import type { RpcTarget } from '@pluxel/runtime/capnweb'
import { workbench } from '@pluxel/runtime/workbench'

export type OrdersSnapshot = Readonly<{
	revision: number
	openOrders: number
}>

export interface OrdersApi extends RpcTarget {
	snapshot(): Promise<OrdersSnapshot>
	refresh(): Promise<void>
}

export const OrdersWorkbench = workbench.define({
	overview: workbench.view<OrdersApi>({
		renderer: workbench.entry(import.meta.url, './ui/overview.tsx'),
		placement: workbench.tab({ label: 'Orders', order: 20 }),
	}),
})

Definition 是固定 flat record。entry key 是稳定 declaration identity;workbench.entry() 的路径必须是相对当前 module 的 literal。 不要按 item、principal 或运行时状态增删 entry。

接着在 src/OrdersPlugin.ts 提供服务端实现。这个最小例子用内存计数,实际项目把读取和刷新替换为自己的业务方法:

import { BasePlugin, Plugin } from '@pluxel/runtime'
import { RpcTarget } from '@pluxel/runtime/capnweb'
import { OrdersWorkbench, type OrdersApi } from './workbench.ts'

@Plugin({ displayName: 'Orders' })
export class OrdersPlugin extends BasePlugin {
	private revision = 0

	protected override init() {
		this.ctx.workbench?.publish(OrdersWorkbench, {
			overview: () => new OrdersTarget(this),
		})
	}

	async snapshot() {
		return { revision: this.revision, openOrders: 0 }
	}

	async refresh() {
		this.revision += 1
	}
}

class OrdersTarget extends RpcTarget implements OrdersApi {
	constructor(private readonly orders: OrdersPlugin) {
		super()
	}
	snapshot() {
		return this.orders.snapshot()
	}
	refresh() {
		return this.orders.refresh()
	}
}

每次打开都创建一个新的 OrdersTarget。如果真实页面要按用户授权、按路由加载对象或持有订阅, 从 factory 参数取得 principalparamssignal,在这里检查权限并在关闭时清理资源。 此例没有订阅或长任务,因此不需要额外的空 disposer。

RpcTarget 是 Cap’n Web 的能力对象,不归 Workbench 所有。同一份 browser-safe API contract 和 target class 可以用 createLocalRpcClient() 独立测试,也可在确有 CLI 或其他客户端需求时由另一条明确拥有的 Cap’n Web session 挂载。 复用的是 contract、target class 和底层领域 service,不是已经打开的 target 实例:每个 session/open 都必须创建 fresh target,并由新的挂载方自己提供认证、授权、输入预算、取消和释放语义,不能假定 Workbench session 的保障仍然存在。 只有出现这种真实的第二消费者时,才将共用 DTO/API 从 workbench.ts 提取到中立 contract module 或独立 package subpath; 仅供 Workbench 使用时保持当前结构。

每个 renderer 声明一个 module-scoped scope 和资源:

// src/ui/overview.scope.ts
import { createWorkbenchRenderer } from '@pluxel/runtime/workbench/react'
import { OrdersWorkbench } from '../workbench.ts'

export const overviewScope = createWorkbenchRenderer(OrdersWorkbench.overview)

export const ordersQuery = overviewScope.query(({ api }) => ({
	queryKey: ['orders', 'snapshot'] as const,
	queryFn: () => api.snapshot(),
}))

export const refreshOrders = overviewScope.mutation(({ api }) => ({
	mutationFn: () => api.refresh(),
	workbench: { invalidates: [ordersQuery] },
}))

entry 保持零 props,只把 page 绑定到 scope:

// src/ui/overview.tsx
import { OrdersPage } from './orders-page.tsx'
import { overviewScope } from './overview.scope.ts'

export default overviewScope.render(OrdersPage)

最后创建 src/ui/orders-page.tsx,显示查询结果并调用刷新:

import { ordersQuery, refreshOrders } from './overview.scope.ts'

export function OrdersPage() {
	const orders = ordersQuery.useQuery()
	const refresh = refreshOrders.useMutation()

	if (orders.status === 'pending') return <p>Loading…</p>
	if (orders.status === 'error' && orders.data === undefined) return <p>Unavailable</p>

	return (
		<button disabled={refresh.isPending} onClick={() => refresh.mutate()}>
			Refresh {orders.data.openOrders} orders (revision {orders.data.revision})
		</button>
	)
}

每次 Bridge mount 都有独立 renderer owner 和 private QueryClient;不同 open、principal、params 或 Plugin generation 不共享 query、mutation、cache 或 subscription。

必须遵守的 renderer scope 边界

每个 renderer graph 只能有一个 server-definition value boundary。默认把它放在 <entry>.scope.ts

import { createWorkbenchRenderer } from '@pluxel/runtime/workbench/react'
import { OrdersWorkbench } from '../workbench.ts'

export const overviewScope = createWorkbenchRenderer(OrdersWorkbench.overview)

其他 page/panel 只能 import 这个 scope 或其 resources,不能再 import/re-export/dynamic import server definition。Toolchain 会把这条 唯一边界改写成 browser-only projection,避免 browser 执行 Plugin implementation。构建错误会指出违规 module、specifier 和 期望 descriptor;直接按错误中的 createWorkbenchRenderer(Definition.entry) 形状修复。

低层 useWorkbench(exactDescriptor) 只能在 renderer default entry 保留这唯一边界;它不是普通 snapshot 页面首选路径。

何时升级

  • read model 会在外部变化:query 通过 workbench.subscribe: ({ invalidate }) => api.watch(invalidate) 订阅权威 invalidation。 此时 mutation 若必然触发同一 subscription,不重复声明 invalidation。
  • 按 input 读取:使用 queryFamily(),为每个 input 返回有界、可移植且领域可读的 queryKey;失效时明确选 target(input)all()
  • callback/progress/cancel/lossless stream:由领域 API 返回具有自己 disposer 语义的 child RpcTarget。只有 server-side custom callback target 需要 dup() observer、释放每次 callback result,并在 open signal abort 时 unsubscribe。
  • 复杂三栏布局:使用 WorkbenchPaneLayout/WorkbenchPane;不要读取 Shell router、store 或 raw socket。

普通 scope.query() 负责 browser-side subscription 的 retain、abort 和 dispose。不要为了普通 latest snapshot 手写 observer target。

页面 API 与宿主边界

  • 返回 bounded immutable snapshot;大列表使用 cursor/limit。
  • mutation 只有页面需要 result 时才返回 DTO;否则返回 void,用 subscription 或 typed invalidation 刷新。
  • API 不返回 Plugin、Context、database handle、native object、raw socket 或 Shell service。
  • host 只提供 locale、color scheme、notify/confirm、relative navigation 和 parameterized document facade。
  • route params 由 server match 后冻结;browser 不能提交 principal 或 authority object。

检查结果

运行应用,打开 Plugin 的 Orders 标签,应看到订单数量和 Refresh 按钮。点击后按钮在请求期间禁用,成功后重新读取并看到 revision 增加。 关闭再打开应重新读取;两个同时打开的页面不应共用操作状态。

查询选项、分页输入、错误代码和 Mantine Provider 见 查询、写入与页面资源。 认证、完整刷新和反向代理见 使用与排查工作台

Placement 与参数化 route

Tab 适合 Plugin 详情中的固定页面:

workbench.tab({
	label: 'Settings',
	icon: workbench.icons.Settings,
	group: { id: 'operations', label: 'Operations' },
	order: 30,
})

Route 适合全局导航或独立 document:

account: workbench.view<AccountApi>({
	renderer: workbench.entry(import.meta.url, './ui/account.tsx'),
	placement: workbench.route('/accounts/:accountId', {
		title: 'Account',
		frame: 'shell',
	}),
})

Parameterized route 不进入固定 navigation。Manager 使用:

host.navigation?.openDocument({
	path: `/accounts/${encodeURIComponent(account.id)}`,
	title: account.displayName,
	meta: 'Bot account',
})

Server 会重新匹配 route,再把 frozen params 交给 factory。Browser 不能提交 principal 或 authority object。 Document renderer 可以用 host.document?.paramssetTitle()setDirty() 管理当前文档 chrome。

Host 能力

host 是固定的受限 facade:

  • localecolorScheme
  • notify()confirm()
  • 可空的 relative navigation
  • 可空的 parameterized document

Renderer 不取得 generic HTTP client、任意 URL navigation、Shell router/store 或 raw WebSocket。需要三栏任务布局时, 从 @pluxel/runtime/workbench/react 使用 WorkbenchPaneLayoutWorkbenchPane;宿主负责 responsive drawer、 resize、focus 和 workspace persistence。

Pane Kit 的三段语义固定为 navigation | primary | inspector:两侧可由当前标签页头部的标准控件显示、隐藏或在窄屏 打开为 drawer;中间控件用于聚焦 primary 并恢复先前两侧。primary 始终可见,不能被隐藏。官方插件详情页中的 plugin rail、辅助栏和底部 dock 属于另一套宿主私有布局,不应被 View 当作 Pane Kit role 或自行复制其 chrome。

完整 View 参考:server push 与 custom callback

下面的例子刻意包含 watch() 和 server-side callback target,用于说明跨调用 observer 的 ownership。它不是普通 snapshot + mutation 页面的起点;没有已证实的实时更新需求时,使用本页的默认路径

1. 声明 API 和 View

把 browser-safe API、DTO 和 definition 放在 src/workbench.ts

import type { RpcTarget } from '@pluxel/runtime/capnweb'
import { workbench } from '@pluxel/runtime/workbench'

export type OrdersSnapshot = Readonly<{
	revision: number
	openOrders: number
}>

export interface OrdersObserver {
	(revision: number): void | Promise<void>
}

export interface OrdersApi extends RpcTarget {
	snapshot(): OrdersSnapshot
	refresh(): OrdersSnapshot
	watch(observer: OrdersObserver): RpcTarget
}

export const OrdersWorkbench = workbench.define({
	overview: workbench.view<OrdersApi>({
		renderer: workbench.entry(import.meta.url, './ui/overview.tsx'),
		placement: workbench.tab({ label: 'Orders', order: 20 }),
	}),
})

Definition 必须是固定的 flat record。Entry key 是稳定 declaration identity 的一部分;不要从运行时数据生成 key。 workbench.entry() 的路径必须相对当前 module,构建工具会据此生成 MF2 producer 和 Bridge expose。

2. 发布 fresh target

在 Plugin 的 init() 中发布 definition:

import { BasePlugin, Plugin } from '@pluxel/runtime'
import { RpcTarget, type RpcStub } from '@pluxel/runtime/capnweb'
import { OrdersWorkbench, type OrdersApi, type OrdersObserver } from './workbench.js'

@Plugin({ displayName: 'Orders' })
export class OrdersPlugin extends BasePlugin {
	private revision = 1
	private openOrders = 0
	private readonly listeners = new Set<(revision: number) => void>()

	override init() {
		this.ctx.workbench?.publish(OrdersWorkbench, {
			overview: ({ signal }) => new OrdersTarget(this, signal),
		})
	}

	snapshot() {
		return Object.freeze({ revision: this.revision, openOrders: this.openOrders })
	}

	refresh() {
		this.revision += 1
		for (const listener of this.listeners) listener(this.revision)
		return this.snapshot()
	}

	subscribe(listener: (revision: number) => void) {
		this.listeners.add(listener)
		return () => this.listeners.delete(listener)
	}
}

class OrdersTarget extends RpcTarget implements OrdersApi {
	constructor(
		private readonly plugin: OrdersPlugin,
		private readonly signal: AbortSignal,
	) {
		super()
	}

	snapshot() {
		return this.plugin.snapshot()
	}

	refresh() {
		return this.plugin.refresh()
	}

	watch(observer: OrdersObserver) {
		return new OrdersSubscription(this.plugin, observer as RpcStub<OrdersObserver>, this.signal)
	}
}

class OrdersSubscription extends RpcTarget {
	readonly #observer: RpcStub<OrdersObserver>
	readonly #unsubscribe: () => void
	readonly #signal: AbortSignal
	readonly #onAbort = () => this[Symbol.dispose]()
	#active = true

	constructor(plugin: OrdersPlugin, observer: RpcStub<OrdersObserver>, signal: AbortSignal) {
		super()
		this.#observer = observer.dup()
		this.#unsubscribe = plugin.subscribe((revision) => {
			try {
				const result = this.#observer(revision)
				void (async () => {
					try {
						await result
					} catch {
						this[Symbol.dispose]()
					} finally {
						result[Symbol.dispose]()
					}
				})()
			} catch {
				this[Symbol.dispose]()
			}
		})
		this.#signal = signal
		if (signal.aborted) this[Symbol.dispose]()
		else signal.addEventListener('abort', this.#onAbort, { once: true })
	}

	[Symbol.dispose]() {
		if (!this.#active) return
		this.#active = false
		this.#signal.removeEventListener('abort', this.#onAbort)
		this.#unsubscribe()
		this.#observer[Symbol.dispose]()
	}
}

每次打开 View 都会调用 factory,所以必须返回新的 RpcTargetprincipal、server-matched paramssignal 都在 factory context 中;按用户授权或按 route 打开对象时就在这里 admission。

OrdersSubscription 对需要跨调用保留的 observer 调用 dup(),在每次 callback settle 后释放 invocation result,并在自己的 [Symbol.dispose]() 中 unsubscribe 和释放 observer。这样 View close、socket close 和 Plugin replacement 都走同一清理路径。

Bindings 必须与 definition 的 key 完全一致。一个 Plugin generation 只调用一次 publish()PluginPart 把 UI 需求交给 owning Plugin 聚合。

3. 声明 renderer scope 与 resources

普通 snapshot/watch 页面先在 renderer-specific src/ui/overview.scope.ts 声明 scope。这个 module 是当前 renderer graph 唯一直接 value-import definition 的边界;scope 和 resource 都是 module-scoped immutable declaration,不保存当前 API root 或 React state:

import { createWorkbenchRenderer } from '@pluxel/runtime/workbench/react'
import { OrdersWorkbench } from '../workbench.js'

export const overviewScope = createWorkbenchRenderer(OrdersWorkbench.overview)

export const ordersQuery = overviewScope.query(({ api }) => ({
	queryKey: ['orders', 'snapshot'] as const,
	queryFn: () => api.snapshot(),
	workbench: {
		subscribe: ({ invalidate }) => api.watch(invalidate),
	},
}))

export const refreshOrders = overviewScope.mutation(({ api }) => ({
	mutationFn: () => api.refresh(),
}))

Renderer-specific scope module 优先与 descriptor entry 同名:overview 使用 overview.scope.ts,scope symbol 使用 overviewScope。这样 entry、scope 与 build error 能直接互相定位;resource 则继续使用领域名称。

这里 api.refresh() 提交后会通过领域 watch() 推送 subscription 通知,所以 mutation 不再重复声明 workbench.invalidates。如果 query 没有 subscription,或该 contract 不覆盖这项写操作,再由 mutation 显式声明 invalidation;不要为同一次提交同时建立两条刷新路径。

Toolchain 会把这条 exact definition import 改写为 browser-only projection,同时保留 RpcStub<OrdersApi> 的准确类型;原始 definition module 不会在浏览器执行。Indirect/re-export/dynamic definition import、绑定错误 entry、一个 scope 绑定多个 descriptor, 或跨 renderer 复用同一 scope 都会在 build 时拒绝。共享 UI 应保持为普通 props/data component,不 import renderer scope。 只使用低层 useWorkbench(exactDescriptor) 的高级 renderer 可以改在 default entry 保留唯一 direct definition import;两条路径都 不允许 graph 中出现第二个 definition value boundary。

按输入读取与定向刷新见页面资源参考

4. 绑定 entry 并渲染 page

默认 entry 保持零 props,只负责把 page 绑定到 scope:

// src/ui/overview.tsx
import { OrdersPage } from './orders-page.js'
import { overviewScope } from './overview.scope.js'

export default overviewScope.render(OrdersPage)

Renderer graph 内的 page/panel 可以直接 import scope 和 resources,不需要层层传递 apihost 或 cache:

// src/ui/orders-page.tsx
import { ordersQuery, overviewScope, refreshOrders } from './overview.scope.js'

export function OrdersPage() {
	const { host } = overviewScope.useWorkbench()
	const orders = ordersQuery.useQuery()
	const refresh = refreshOrders.useMutation()

	if (orders.status === 'pending') return <p>Loading…</p>
	if (orders.status === 'error' && orders.data === undefined) return <p>Unavailable</p>

	return (
		<>
			{orders.status === 'error' ? <p>Showing stale data.</p> : null}
			<button
				disabled={refresh.isPending}
				onClick={() => {
					void refresh
						.mutateAsync()
						.then((next) => host.notify({ message: `Revision ${next.revision}` }))
						.catch(() => host.notify({ message: 'Refresh failed', tone: 'error' }))
				}}
			>
				Refresh {orders.data.openOrders} orders
			</button>
		</>
	)
}

最后更新于

本页目录