跳转到内容

Dougong Core API 规范 ​

这是规范层

本文是 @dougongjs/core 的可观察行为规范,按精确性而非可读性组织,用于确定边界情形和实现一致性。

第一次接触 Dougong,请先读核心概念和编写插件——那两页讲的是同样的模型,但按学习顺序展开。

实现与本文冲突时,应治本式修改实现或规范,不增加兼容别名。

Dougong Core 的定位是:

纯 JavaScript/TypeScript 的能力组合与结构化生命周期内核。

它不是 IoC 容器、响应式框架、事件框架或前端框架。它提供少量正交原子,使高级能力能够由普通函数和普通对象组合出来。

一、不可破坏的设计公理 ​

1. 单路径 ​

同一抽象层、同一种语义只允许一个正式入口。高层语法糖必须机械展开到该入口,不能拥有第二套状态机、事务、依赖图、资源栈或错误模型。

语义唯一入口Core 不提供
安装插件install()use / apply / load
修改安装计划change()第二套 transaction / batch
发布 Serviceprovides + setup 返回值ctx.provide / host.provide
贡献 ExtensionPointcontribute()add / append / register
监听 Eventon()listen / hook
发送 Eventemit()dispatch / publish / fire
注册清理cleanup()using / own / defer
创建子生命周期lifetime(label)child / scope / fiber
启动后台任务spawn()run / fork / task
判定取消结果isCancellationReason()只看 signal.aborted / 只匹配错误名
识别外部 Error 值isError()各边界自行对拒绝值做原型检查
校验声明 recordnormalizePlainRecord()各高层复制 prototype / own-key 校验
读取实时值get().value / 函数调用 / getSnapshot()
订阅变化subscribe()watch / listen / observeChanges
更新 Installationupdate()replace / reload / restart
删除安装remove()uninstall / delete
释放资源dispose()close / destroy / off

host.install()、installation.update() 和 installation.remove() 是单目标语法糖,内部只创建一份 one-shot ChangeSet 并提交。它们不拥有第二套校验、队列或回滚逻辑。

normalizePlainRecord(value, label, { fields, createError }) 是 Core 与高层共享的声明边界:它只接受 Object.prototype 或 null prototype 的数据 record,并拒绝数组、Symbol key、不可枚举 own key、accessor 与 fields 之外的字段。每个字段从自有数据描述符捕获一次,返回冻结的 null-prototype record,不经普通属性读取再次访问输入。调用方必须消费返回值,不能校验后继续读取原对象;旧断言接口已删除。捕获是浅层的,回调、配置载荷等不透明值仍保留原身份,嵌套声明在各自边界归一化。默认错误是 TypeError;高层可用 createError(message) 保留自己的错误类型,而不复制捕获与校验算法。

host.get() 与 Plugin requirement 归一化共享同一条身份捕获路径。optional 包装及其中的 Service 一次性捕获后,查找只使用冻结结果;包装对象的普通属性读取或后续变化不能重新选择 Service。

2. 组合闭包 ​

同类对象组合后仍保持原语义:

text
Lifetime + owned resources → Lifetime
Plugin + Group → Installation
ExtensionPoint contributions + ordinary composer → Catalog / Pipeline
Manifest + Reference → Artifact → Registration → Core Installation

Group 机械展开到 canonical ChangeSet;Platform 和 reactive observe() 只能用公开 API 组合。三者都不能创建第二套 Registry 或事务状态机。

3. 语义正交 ​

  • Service 表达稳定能力,不广播事件。
  • ExtensionPoint 保存开放贡献,不决定排序、覆盖或业务冲突策略。
  • Event 表达已经发生的事实,不查询结果、不保存状态。
  • Lifetime 只管理时间所有权,不解析依赖。
  • Group 只管理安装所有权,不创建能力命名空间。
  • Plugin 不加载其他插件;Loader 位于 Platform。
  • Host 不理解 HTTP、React、数据库、窗口或文件系统。

4. 显式优于隐式 ​

任何会改变能力解析、生命周期所有权或执行顺序的关系,都必须能从 Contract、Plugin 或显式参数中直接读出:

  • Service 选择只由 requires 中的稳定 Contract ID 决定,不读取 Group、调用栈、当前 workspace 或祖先 Context;
  • setup 顺序只由 Service 依赖图决定,不把安装顺序、Event、ExtensionPoint 或完成时序当作隐藏依赖;
  • 资源所有权来自创建它的 Lifetime,跨边界转交必须通过普通参数或 Disposable 明确表达;
  • 领域配置通过插件配置、方法参数或显式适配器 Service 组合,不使用全局拦截链、Proxy shadow 或原型链覆盖。

“约定默认值”可以减少样板,但不能改变上述语义。若删除一段声明后 Core 仍会从环境中猜出关系,说明抽象已经过度隐式。

5. 公共协议与辅助类型 ​

公开类型服务于下游组合,不是实现残留:

导出精确职责
Service、ExtensionPoint、Event、OptionalService、Requirement三种 Contract 身份与可选 Service 依赖
ContractKind、ContractValueContract kind 联合与值类型提取
Plugin、AnyPlugin、PluginContext、AwaitablePlugin 声明、异构集合形状、setup Context 与同步/异步返回边界
Requirements、ResolvedRequirement、ResolvedRequirements声明依赖表及其解析后值类型
Provisions、ProvidedServicesService 提供声明及 setup 返回值类型
Host、HostOptions、HostStatus、HostSnapshot执行边界、构造选项、状态与诊断快照
Installer、ChangeSet、Group、InstallationUpdate、LifecycleStatus安装能力、事务、结构所有权、安装更新和 Group/Installation 共享生命周期状态
GroupSnapshot、SnapshotView结构诊断条目与统一只读观察协议
LifetimeContext、LifetimeOperations、LifetimePhaseLifetime 的完整 Context、可组合操作协议与诊断阶段
Task、BackgroundTask、Cleanup被拥有任务、任务回调与清理回调
Disposable、AsyncDisposable分别供 using 与 await using 使用的同步/异步释放协议
disposeSymbol、asyncDisposeSymbol两种释放协议唯一的跨运行时 key
EventListenerEvent 监听器签名
Logger、isLogger诊断输出端口及其唯一运行时判定器

二、能力代数 ​

Core 只有四个能力原子和两个编排原子:

text
能力原子
├── Service          稳定的一对一能力
├── ExtensionPoint   动态的开放贡献集合
├── Event            不保留状态的事实
└── Lifetime         资源所有权与取消

编排原子
├── Plugin   一次 setup 的能力生产者
└── Host     依赖图、事务和 Instance 编排
原子保留当前值动态变化变化后的行为
Service是提供者拓扑可变化重建消费者
ExtensionPoint是贡献可实时增删通知订阅者
Event否监听器可增删广播本次事实
Lifetime不适用可创建子级父释放全部存活子级

Signal 是能力内部的值类型,不是可由 requires 获取的新 Contract kind。

三、Contract ​

Core 的作者入口只有六个:

ts
import {
  createHost,
  definePlugin,
  service,
  extensionPoint,
  event,
  optional,
} from "@dougongjs/core"

错误类是捕获边界,不属于能力原子预算。

声明:

ts
const DATABASE = service<Database>("app/database")
const ROUTES = extensionPoint<Route>("http/routes")
const USER_CREATED = event<User>("users/created")

统一规则:

  • 第一个参数是稳定字符串 ID,也是执行身份;对象身份不参与匹配。
  • 运行时返回值是冻结普通对象,形状只有 { id, kind };TypeScript 类型另带工厂私有的 phantom brand,使普通 { id, kind } 不能在编译期冒充 Contract。该 brand 不参与运行时匹配。
  • 调用边界只接受 id/kind 的 own data property,不执行 getter 或读取继承字段;规范化捕获并冻结同一份身份,再用于校验、查找与存储。
  • Contract 不持有执行状态,可跨应用复用。
  • ID 必须非空且首尾无空白;区分大小写,不做 trim 或 Unicode 规范化。
  • 同一 ID 在一个 Host 中不能同时承担两种 kind,否则抛 CONTRACT_CONFLICT。
  • 只有成功提交的声明和 active Lifetime 的使用才登记 kind;失败的 setup、rollback 和未命中的应用代码读取不会占用 Contract ID。
  • Lifetime 先捕获外部 token 的纯数据身份,再读取当前阶段与 signal 决定操作授权。若反射期间发生释放,本次操作以 LIFETIME_DISPOSED 拒绝,不登记 kind、不创建资源、不派发 Event;InstanceCoordinator 不再读取外部 token。
  • optional() 是 branded OptionalService 的唯一类型化构造入口,且只接受 Service;ExtensionPoint 的空 Map 本身就是合法值,Event 没有提供者概念。

固定 Contract 的同一 ID 应在代码库中只声明一次并从稳定模块导出。TypeScript 本身无法阻止两个模块为同一 ID 写出不同类型参数,因此 Dougong 仓库的架构门禁会拒绝重复的固定字符串声明;下游代码库也应执行同类静态检查。参数化 Contract family 不属于重复的固定声明。

同一种接口需要多份静态实例时,使用普通函数构造显式 Contract family,而不是引入隐式 Scope:

ts
const workspaceStore = (workspace: string) =>
  service<Store>(`workspace/${encodeURIComponent(workspace)}/store`)

const ALPHA_STORE = workspaceStore("alpha")
const BETA_STORE = workspaceStore("beta")

family 函数本身是唯一声明源:类型和 ID namespace 只写一次,重复调用相同参数得到等价 ID,不依赖对象身份。提供者和消费者必须声明同一个具体 token。Contract ID 因而同时携带“能力是什么”和“选择哪一份”的稳定身份,依赖图、错误和诊断无需再解释第二张作用域树。动态且由每次请求选择的租户不应无限扩张 Installation 图,应改为一个显式接收 tenant/workspace 参数的 Service。

Contract 的值类型是严格不变量:Service<Admin> 不能静默拓宽为 Service<User>,Event 与 ExtensionPoint 同理。它们既参与读又参与写;允许协变会让同一 Contract 身份随后接收一个只满足较宽类型的值。需要暴露较窄或较宽的领域能力时,应声明一个新的 Contract,并由普通 adapter Plugin 显式转换。

Requirement 只表示“可出现在 Plugin requires 中的工厂创建身份”,不伪造一个所有 Service 与 ExtensionPoint 共享的值类型参数。需要提取某个精确 Contract 的值时使用 ContractValue<T> 或 ResolvedRequirement<T>;异构声明表使用 Requirements。Provisions 直接取 Requirement 的 Service 分支,不维护第二套不可见的 Service 身份。

局部配置叠加同样使用显式 Service adapter,而不是通用 intercept():

ts
const HTTP = service<HttpClient>("http/client")
const ALPHA_HTTP = service<HttpClient>("workspace/alpha/http")

const alphaHttpPlugin = definePlugin({
  name: "workspace.alpha.http",
  requires: { base: HTTP },
  provides: { http: ALPHA_HTTP },
  setup: ctx => ({
    http: withDefaults(ctx.base, { timeout: 5_000 }),
  }),
})

adapter 的输入、输出和影响闭包都在普通依赖图中可见,包装策略由真正理解 HttpClient 类型的领域代码决定。Core 不用 Proxy 猜方法调用,也不需要一套独立的配置合并协议。

Core 刻意不提供 extensionPoint.keyed()、extensionPoint.many()、ordered() 或 override()。这些是 Catalog、Pipeline 或具体领域的组合策略,不是贡献集合原子。

四、Plugin ​

插件只有一种形态:

ts
const usersPlugin = definePlugin({
  name: "app.users",
  config: usersConfigSchema,
  requires: {
    db: DATABASE,
    cache: optional(CACHE),
    routes: ROUTES,
  },
  provides: {
    users: USERS,
  },
  setup(ctx, config) {
    const users = createUsers({ db: ctx.db, cache: ctx.cache })

    ctx.contribute(ROUTES, "users.show", {
      method: "GET",
      path: "/users/:id",
      handler: request => users.find(request.params.id),
    })

    return { users }
  },
})

不支持函数插件、插件基类、装饰器或 { apply() } 形态。

Plugin 保留单份声明的精确 config、requires 与 provides 类型。组合根需要保存不同形状的插件时,使用只擦除作者期泛型的 AnyPlugin:

ts
const plugins: readonly AnyPlugin[] = [databasePlugin, usersPlugin]
for (const plugin of plugins) host.install(plugin)

AnyPlugin 不是另一种插件,也没有第二条执行路径;它只用于保存已经由 definePlugin() 定义好的值,不是原始对象字面量的作者期输入类型。安装边界仍会按同一套规则重新校验并规范化声明。

擦除沿稳定身份完整传播:精确 Plugin 得到 Installation<typeof plugin>,保留安装与更新的 config 输入;从 readonly AnyPlugin[] 取出的声明得到 Installation<AnyPlugin>,后续可以直接用另一个 AnyPlugin 更新,无需在替换边界再次断言。精确 Plugin 不能偷偷落入擦除签名来省略必填 config;需要放弃作者期类型时,调用方必须先显式把声明保存为 AnyPlugin。

4.1 requires ​

依赖 alias 成为 Context 的 own property:

ts
requires: {
  primary: PRIMARY_DATABASE,
  analytics: ANALYTICS_DATABASE,
}

ctx.primary
ctx.analytics

没有 ctx.get(string)、Service Locator、Proxy、原型链注入或模块声明合并。

Service alias 得到稳定值;ExtensionPoint alias 得到稳定的 ContributionView 对象。Context 和 ctx.meta 浅冻结,但 Service 值本身不被代理或冻结。

同一个 Plugin 内,每个 Contract ID 只能声明一次。两个 alias 不能引用同一份能力,一个 Contract 也不能同时出现在 requires 与 provides;这些歧义会在 definePlugin() 边界立即以 TypeError 拒绝,而不是留到依赖图或 setup 阶段猜测调用者意图。

保留 alias:

text
signal meta log cleanup lifetime spawn on emit contribute

4.2 provides ​

Service 只有一个发布入口:声明 provides,并从 setup 返回同名 own property。

ts
provides: { database: DATABASE },
setup() {
  return { database }
}

缺少声明的输出抛 SERVICE_NOT_RETURNED。即使现成值来自应用代码,也应包装成普通 Plugin;Core 不提供 host.provide() 分支。

4.3 配置 ​

配置接受 Standard Schema,并区分输入与输出:

ts
StandardSchemaV1<ConfigInput, Config>
  • install(plugin, input) 接收 ConfigInput。
  • setup(ctx, config) 接收校验或转换后的 Config。
  • Schema 可以异步校验。
  • Core 只等待 Schema 的校验结果;无 Schema 时原样传递输入,有 Schema 时原样传递成功结果的 value。配置载荷即使是 Promise 或 thenable,也不会被 Core 展开或读取其 then;需要解析载荷时,由 Schema 或 setup 显式等待。安装与更新遵守同一契约。
  • 配置结果只用 own value / issues 判别成功与失败,不读取原型链;失败抛含冻结 issues 的 ConfigValidationError。每个 issue 的 message / path 只读取一次,验证与保存使用同一份捕获值,不保留第三方附带载荷。
  • Schema 结果必须是含 value 的成功对象,或含数组 issues 的失败对象;畸形 issue、message 或 path 会在 setup 前以精确 TypeError 拒绝。
  • Core 不克隆或深冻结配置;防御性转换属于 Schema。

definePlugin() 在定义期校验并规范化声明。Plugin 本身必须是仅含 name、config、requires、provides、setup 的普通数据 record;未知字段、Symbol、隐藏属性、accessor 和类实例都会被拒绝。配置 Schema 必须完整声明 Standard Schema V1 的 version、vendor 与 validate;requires 与 provides 也必须是仅含可枚举字符串 own key 的普通数据 record,不能用数组、Map、accessor 或类实例表达声明。ChangeSet 在安装与更新边界重新规范化,防止 JavaScript 调用方绕过工厂。

五、Context API 预算 ​

无依赖时,Context 只有:

ts
ctx.signal
ctx.meta
ctx.log

ctx.cleanup(disposer)
ctx.lifetime(label)
ctx.spawn(task)
ctx.on(event, listener)
ctx.emit(event, payload)
ctx.contribute(point, localKey, value)

ctx.signal 是标准 AbortSignal。ctx.meta 是冻结的 InstanceMeta:

ts
interface InstanceMeta {
  hostName: string
  pluginName: string
  installationId: string
  groupId: string
}

ctx.log 仍使用 Host 提供的 Logger,但 Core 会把当前冻结的 InstanceMeta 作为第一项 detail 传入,因此日志端无需从消息文本猜归属。它是 Lifetime 可撤销的窄 facade:cleanup 期间仍可记录,Lifetime 终止后会切断 InstanceCoordinator/Logger 引用并以 LIFETIME_DISPOSED 拒绝继续使用。Logger 的四个成员是严格的同步函数属性;会丢弃 unknown message / rest details 的窄实现不能通过类型检查,意外返回 thenable 也会在调用处被拒绝并观察其拒绝值。

LifetimeOperations 的操作也都是严格函数属性。面向它编写的通用工具必须接受完整的 label、Task、Event 与 ExtensionPoint 输入域,不能用只处理某个局部字面量的窄实现冒充完整 Context;类方法仍可直接满足这个结构协议。

文件系统、网络、窗口、剪贴板、存储、通知和路由器都应声明为 Service,不得成为 Context namespace。

Context 不提供 effect()、observe() 或 using():

  • using(ctx, resource) 可以机械展开为 ctx.cleanup(() => dispose(resource))。
  • reactive observe(ctx, source, listener) 可以由 get/subscribe + lifetime/spawn/cleanup 完整实现。
  • 两者位于更高层,Core 不需要私有特权。

六、Service ​

Service 是 Instance 生命周期内的稳定快照:

ts
const db = ctx.db
db === ctx.db

禁止 live Service Proxy。提供者更新时,Host:

text
预校验候选图与配置
→ 逆序停止受影响消费者
→ 停止旧提供者
→ 启动新提供者
→ 按依赖顺序重建消费者

optional(SERVICE) 也遵循快照语义。提供者从不存在变为存在或反向变化时,消费者实例会重建,不会修改活着的 Context。

Host 外部和测试边界可以读取:

ts
const users = host.get(USERS)
const cache = host.get(optional(CACHE))

host.get() 只接受 Service 或现有的 optional(Service) 包装,并且只在 Host 为 active 时读取。必需 Service 当前不可用时抛 SERVICE_UNAVAILABLE;optional Service 没有 provider 时返回 undefined。Plugin 内部仍只能使用声明依赖。Core 不增加语义重复的 tryGet()。

Engine 拥有已提交执行状态对应的已验证依赖图;host.get() 只做 provider 与 Service Map 查询,不重新构图。idle 状态允许分步安装暂时不完整的计划,因此只在 start() 或 Host active 时提交的 ChangeSet 中构造候选图。

Host active 时执行 ChangeSet 会明确进入 changing,应用代码的 host.get() 暂时拒绝读取;它不会在旧 Instance 停止、新 Instance 启动的窗口中伪装成稳定的 active。事务成功或 rollback 完成后才重新进入 active 并切换对应图;无法恢复则 fail closed 到 idle。因此应用代码的读取边界只存在“提交前”和“提交后”,候选图或半重建的 Service Map 都不会泄漏。

6.1 启动调度 ​

依赖图使用确定性的拓扑层:同一层没有 Service 依赖关系,可以并发 setup;下一层必须等待上一层完整提交。每层分为两个阶段:

text
prepare: 并发创建 Lifetime、解析依赖、执行 setup、校验 Service 输出
commit:  整层成功后按稳定安装序发布 Service / Listener / Contribution

任一 setup 失败会 abort 同层其余 setup 的 ctx.signal,等待所有同层 setup 落定,并释放所有已 prepare 的 Lifetime。失败层公开零项新能力;已经提交的前置层由外层启动或 ChangeSet 回滚边界统一清理。

并发只来自显式 Service 依赖图。Event 和 ExtensionPoint 不建立启动边,独立插件的 setup 先后与完成顺序未定义;需要顺序就声明一个 Service 依赖,不能依赖安装顺序或定时。停止仍按完整依赖图逆序串行执行,以保持确定的资源撤销顺序。

Core 当前不提供并发上限配置。它没有第二种调度模式,也没有在缺少数据时预设进程级策略;需要限制某类昂贵操作时,由相应 Service 自己提供队列或 semaphore,因为它才知道资源类型和真实容量。

七、ExtensionPoint ​

7.1 原子模型 ​

所有 ExtensionPoint 都是:

由 Instance 拥有、以稳定局部 key 标识的动态贡献 Map。

ts
const ROUTES = extensionPoint<Route>("http/routes")

const contribution = ctx.contribute(ROUTES, "users.show", route)
contribution.update(nextRoute)
contribution.dispose()

真实 key 由 Core 组合:

text
<escaped installation id>/<escaped local key>

其中 % 与 / 分别转义为 %25 与 %2F,因此分隔符不会让两组不同的 installation ID / 局部 key 组合产生同一个真实 key,同时常见 key 仍保持可读。

这个 key 只表达所有权身份,不是领域 ID,重装后也会变化。消费者应把命令 ID、排序权重等领域数据放进 value,并显式校验冲突或排序;不能依赖 Map 的真实 key 或插入顺序表达领域策略。

因此:

  • 不同插件使用相同局部 key 不冲突;
  • 同一 installation、同一局部 key 只能有一个存活贡献;
  • 更新必须通过原 Contribution;
  • undefined 是合法贡献值,存活性由真实 key 与记录身份决定,不用值充当终态哨兵;
  • 旧 Contribution 使用记录身份校验,不能删除后来创建的同 key 贡献;
  • Instance 停止时自动删除全部贡献;
  • setup 失败不会发布贡献。

Contribution<T> 是可写 handle,类型不会协变成 Contribution<Base>;只读的 ContributionView<T> 才能按值类型安全拓宽。权限收窄应交出 View,而不是用更宽的泛型标注隐藏 update()。

7.2 统一观察协议 ​

ts
interface ContributionView<T> {
  get(): ReadonlyMap<string, T>
  subscribe(listener: () => void): Disposable
}

subscribe() 只通知未来失效,不立即调用,也不携带值。调用方显式执行“读当前值,再订阅未来”:

ts
const rebuild = () => {
  router.replace([...ctx.routes.get().values()])
}

rebuild()
ctx.routes.subscribe(rebuild)

从 Context 获得的 ContributionView 会把订阅自动归当前 Lifetime 所有;提前释放仍使用返回值的 dispose()。

应用代码可从同一 committed Store 取得 Host 拥有的稳定视图:

ts
const routes = host.contributions(ROUTES)
const subscription = routes.subscribe(render)
await host.start()
render()

该视图可在启动前创建,并跨 stop/start 保持身份;它只呈现已提交快照,订阅的 Disposable 由调用方所有。调用同时把 Contract ID 作为 ExtensionPoint 身份登记,因此同一 Host 后续不能用另一种 Contract kind 复用该 ID。Plugin 内部仍只能通过 requires 获取受 Lifetime 约束的视图。跨重启的稳定身份也意味着保留该 View 会有意保留所属 Host 的观察链路;应用应让它与 Host 一同退出作用域,而不是把它放进更长寿的全局缓存。

快照是真正只读的 Map,没有 set/delete/clear。条目迭代顺序与每个值的身份都未变化时保持对象身份;提交变化时创建新快照。发布顺序仅在 ContributionStore 中保存,快照按同一顺序投影,即使 key 和值相同,仅顺序改变也会发布新快照。一次 Core ChangeSet 内同一 ExtensionPoint 无论改变多少次,只通知一次。

ContributionView 是 Plugin Lifetime 拥有的 live capability,而不是可永久泄漏的 Store 引用。Instance 停止后,旧 View 的 get/subscribe 会拒绝使用并切断对 Store 的引用;新 Instance 会获得新 View。View 的公开 get/subscribe 由只持有可撤销 binding 的窄 facade 提供,不能用在 Store 方法作用域中创建的箭头函数暗中捕获 Store。这个边界与旧 Service 闭包的处理不同:Service 是一次解析出的普通值,ContributionView 则持续观察已提交贡献。

后续订阅者抛错进入 Host onError,不能破坏产生通知的 Host 命令;首次读取与 Plugin 自己的同步 rebuild() 错误仍正常使 setup 失败。

7.3 高层组合 ​

领域唯一性、排序、覆盖和折叠必须基于原始贡献组合:

text
ExtensionPoint<Command> + keyOf(command.id) + conflict policy → CommandCatalog Service
ExtensionPoint<Middleware> + orderBy(order) + reduceRight    → Middleware Pipeline
ExtensionPoint<Theme> + keyOf(theme.id) + stack policy       → ThemeCatalog Service

这些组合器可以提供更适合领域的 API,但其输入必须是公开 ContributionView,生命周期必须使用公开 cleanup/subscribe,不能访问内部 ContributionStore。

八、Event ​

ts
const TRACK_CHANGED = event<Track>("playback/track-changed")

const subscription = ctx.on(TRACK_CHANGED, listener)
await ctx.emit(TRACK_CHANGED, track)
subscription.dispose()

每次 on() 都创建一份独立 Listener 注册;即使复用同一个函数,释放其中一份也不会撤销另一份。

Event 只有一种派发语义:

  • 单 payload;复杂参数使用对象;
  • 按捕获的监听器集合异步并发广播;回调开始前已撤销的注册会被跳过;
  • 等待全部完成;
  • 不返回业务结果;
  • 任意监听器失败时抛 AggregateError,即使只有一个原因;
  • 监听器自动归创建它的 Lifetime 所有;
  • setup 期间注册的监听器在 setup 成功前不可见。

emit() 始终以 Promise 报告失败,包括 Lifetime 已停止、Contract 非法等调用边界错误;所以 void ctx.emit(...).catch(report) 能同时覆盖派发前与监听器阶段。Event<void> 可写成 ctx.emit(READY),无需显式传 undefined。

如果需要结果,使用 Service;如果需要有序处理链,使用 ExtensionPoint + 普通函数;如果需要当前状态,使用 Service 暴露 Readable/Signal。

Event 不重放。初始状态必须由 Service getter、Signal/Readable 或 ExtensionPoint 当前快照承载,不能在 setup 中 emit 一个“初值”并依赖其他插件恰好已经提交监听器。

Event 是事实而不是状态,因此一次 emit() 本身不可回滚。setup 中产生的外部副作用也应由插件负责补偿;Core 的事务承诺针对框架可见的 Service、Contribution 和 Listener。

九、Lifetime 与 Disposable ​

每个 Instance 天然拥有一个根 Lifetime。所有通过 Context 创建的监听、贡献、订阅、任务、子 Lifetime 和 cleanup 都自动归它所有。

释放操作统一为 dispose(),时序则由两个严格协议显式区分:

ts
interface Disposable {
  dispose(): void
  [Symbol.dispose](): void
}

interface AsyncDisposable {
  dispose(): Promise<void>
  [Symbol.asyncDispose](): Promise<void>
}

所有资源统一用 dispose() 释放;Installation 从计划中删除统一用 remove()。两者不能混用。

同步资源可用于 using,需要等待的 Lifetime、Task 与 cleanup 可用于 await using。Symbol 方法是同一 dispose() 操作对 JavaScript 语法的协议投影,不拥有第二套状态机或错误语义。Dougong 会为缺少 well-known Symbol 的运行时选择稳定的协议 key,但不会修改全局对象;因此显式 dispose() 始终可用,而 using 需要运行时原生支持或由应用预先 polyfill 对应 Symbol。

实现 Dougong 的结构化释放协议时必须使用 Core 导出的 canonical key,不能再次判断运行时:

ts
import { asyncDisposeSymbol, type AsyncDisposable } from "@dougongjs/core"

class Session implements AsyncDisposable {
  async dispose() {}
  [asyncDisposeSymbol]() { return this.dispose() }
}

两个值在运行时有原生 well-known Symbol 时直接引用它,否则使用对应的全局 Symbol registry key。Platform 与 Core 资源因此使用同一协议身份;独立且零依赖的 Reactive 包保留自己的等价基础声明。

9.1 cleanup ​

ts
const cleanup = ctx.cleanup(() => server.close())
await cleanup.dispose()

cleanup 按注册逆序执行;某项失败不会跳过更早资源。一个失败原样抛出,多个失败聚合。

9.2 子 Lifetime ​

ts
const session = ctx.lifetime("session")

session.on(MESSAGE, listener)
session.spawn(signal => pump(signal))
session.cleanup(() => transport.close())

await session.dispose()
  • 父释放所有仍存活的子级;
  • 子释放不影响父;
  • 提前释放的子级从父拥有集合中脱离;
  • dispose() 幂等;
  • 父 Context 与子 Lifetime 使用相同资源 API。
  • label 是必填、非空且首尾无空白的诊断描述,不参与执行查找或身份判定;同级重名合法。
  • 主动释放 Lifetime 或 Task 会用冻结的 AbortError 取消其 signal;父级取消仍显式转发父 signal 的 reason。取消判定必须先确认 signal 已 aborted,再接受与 signal.reason 相同的值或标准 AbortError;不能单独依赖其中任何一项。
  • 释放进行中的重复 dispose() 共享同一个完成 Promise;进入终态后的重复调用只是已完成的无操作。发起释放的调用方仍收到原始失败,但终态资源不继续保存已拒绝 Promise 或错误栈。Lifetime 完成释放后以一个新的 aborted signal 和共享的无状态 AbortError 表达终态,从而同时切断旧 signal 的监听器闭包与可能携带应用对象的历史 reason。

9.3 spawn ​

ts
const task = ctx.spawn(signal => synchronize({ signal }))
await task.result
await task.dispose()

释放任务先 abort,再等待结果 settle。未被调用方同步处理的后台失败通过 Host onError 上报。只有与 signal.reason 相同的拒绝值或明确的 AbortError 才分类为取消;仅仅发生在 abort 之后的其他失败仍会上报,避免把取消期间的真实收尾故障静默吞掉。

isCancellationReason(signal, error) 是这条规则唯一的公开判定器,Platform Loader 和下游适配器复用它,而不是各自复制一份“看起来像取消”的启发式逻辑。异常的 name 无法读取时不构成取消证据,原始失败继续传播;与 signal.reason 相同的值仍按身份判定。

isError(value: unknown): value is Error 是 Core 与高层共用的 Error 识别边界。它检查当前原型链;原型无法读取时返回 false,不让反射异常覆盖原拒绝值。非 Error 的业务错误归类保留原值作为 cause;取消判断仍优先匹配 signal.reason 的身份。识别为 Error 不保证它的自定义字段可读取,终态记录独立捕获允许的诊断字段。

任务自然 settle 后会立即从父 Lifetime 的拥有集合和 AbortSignal 监听器中脱离;之后调用该 Task 的 dispose() 只是幂等完成,不会追溯性 abort 已结束任务的 signal。已完成任务不会在长生命周期中按历史次数累积;父释放仍会 abort 并等待当时尚未 settle 的全部任务。

等待是结构化所有权的承诺,不是超时策略:任务若停在不可取消且不 settle 的操作上,Task.dispose()、父 Lifetime 释放与 host.stop() 都会保持 pending。Core 不会暗中摘除这类工作;需要放弃等待时,必须由应用代码明确承担该策略及其资源后果。安全模式见 Lifetime 指南。

9.4 停止顺序 ​

Instance 停止时顺序固定,不依赖注册巧合:

text
撤销 Service
→ 封住整棵 Lifetime 子树的 Context 工作入口
→ 撤销全部监听、订阅和 View,再撤回贡献
→ 取消子树 signal
→ 任务与各后代 Lifetime 并行收尾
→ 各 Lifetime 在自身任务与子级结束后 LIFO 执行 cleanup

因此 cleanup 中不能继续 emit() 或申请新资源;停止已经越过“接受新工作”边界。

十、Host 与 ChangeSet ​

ts
const host = createHost({
  name: "desktop",
  logger,
  onError,
})

Host options 是仅含 name、logger、onError 的普通数据 record;只接受可枚举 own data property,未知字段、Symbol、隐藏属性、accessor、数组与类实例都会立即拒绝。logger 与 onError 本身仍是结构化端口,可以由普通对象或类实例实现。onError 可同步返回或返回 thenable;Host 不让错误上报阻塞命令,但会观察异步拒绝并转入 logger 兜底。

Installer 精确表示“能安装到一个所有权位置”的能力,包含 install/group/change,并由 Host 与 Group 实现。只需要事务入口的高层协作者应在消费侧声明 Pick<Installer, "change">,不能把缺少安装能力的窄端口命名为 Installer。

10.1 安装与启动 ​

ts
const database = host.install(databasePlugin, config)
host.install(usersPlugin)

await host.start()
await database.ready()

install() 同步返回稳定 Installation,并把单项 ChangeSet 排入 Host 命令队列。Plugin 形状错误同步抛出;提交或启动错误由 ready() / start() 暴露。

ready() 的屏障位于整笔命令之后:候选图验证、Instance 切换和 ExtensionPoint 批次发布全部结束后才 settle。调用方在 await installation.ready() 后立即读取 ContributionView 时只能得到已提交快照,不需要额外等待一个 tick。屏障由 Host 的同一 SerialQueue 提供,覆盖调用前已提交的命令,包括其他 Group 中的依赖提供者变更;Installation 不再维护一份仅针对直接更新的命令屏障。

命令队列线性化 install、update、remove、start 和 stop。一次失败不会破坏后续命令排队能力。

Core 用唯一的 SerialQueue 表达这条语义,Platform 的变更与激活队列也复用同一原语:

ts
const commands = new SerialQueue()
const result = commands.run(operation)
await commands.settled

settled 等待读取它时已经排入队列的全部操作。

run() 无论前一项成功还是失败都会继续执行下一项;内部 tail 只记录完成边界且永不 reject,单项原始结果只返回给对应调用方。它是 Host 和高层编排器共享的命令串行协议,不拥有 Host、事务或错误分类状态。

10.2 Installation ​

ts
installation.status
installation.diagnostics.get()
installation.diagnostics.subscribe(notify)
installation.ready()
installation.update({ plugin })
installation.update({ config })
installation.update({ plugin, config })
installation.remove()

update() 同时覆盖配置与 Plugin 声明替换,参数必须是仅含可枚举 plugin / config own data property 的普通 record,并且至少包含其中之一;未知字段、Symbol、隐藏属性、accessor、数组与类实例都会立即拒绝。这里不提供 replace/reload/restart。Plugin 更新不能改变 name;Installation 及其 ID 保持稳定,活动 Instance 被替换。

Installation 的唯一类型参数是它背后的 Plugin 声明,而不是四份彼此可能失配的 config/requires/provides 参数。这个表示让声明成为单一真相源:精确声明保持精确,AnyPlugin 擦除一次后则在 install 与 update 全链路保持擦除。

因为 update() 会接收该声明对应的 Plugin 与 config,带写权限的 Installation 必须保持不变,不能把 Installation<typeof adminPlugin> 静默拓宽成 Installation<AnyPlugin>。异构集合若只负责等待或删除,应在消费侧声明它真正需要的最小只读/控制协议,而不是再导出一个会丢失更新约束的框架类型:

ts
type ManagedInstallation = Pick<Installation, "id" | "status" | "ready" | "remove">
const managed: ManagedInstallation[] = [host.install(adminPlugin), host.install(auditPlugin)]

Installation 进入 removed 后撤销对 Host 的控制引用并释放 Plugin 声明与配置。已经进入队列的重复移除请求也按同一成员身份幂等完成;终态 remove() 幂等成功,update() 以 INSTALLATION_REMOVED 拒绝;保留一个已删除 Installation 不会反向保活 Host。

失败的命令传播原始 Error;setup 或配置校验器抛出非 Error 值时,首次公开命令与仍附着于 Host 的稳定失败状态共享同一个 INSTALLATION_UNAVAILABLE 错误,原值保存在 cause。被丢弃的 Installation 后续 ready() 使用 RecordedFailure,只持有冻结、有界的错误记录,不重建原异常类。仍附着于 Host 的失败 Installation 保留原始错误以支持命令传播和重试,但诊断始终使用记录投影。Platform 的 Registration 遵守相同规则。

installation.diagnostics 暴露 Host 诊断使用的同一份不可变 InstallationSnapshot,在已提交声明或生命周期变化时失效。Installation 与 Host 快照在同一通知批次中失效,回调不能读到相互矛盾的状态。移除先发布终态快照,最后一次观察者异常仍进入 Host onError;在命令 readiness settle 时释放 reader、reporter 和订阅。所有诊断错误都使用不可变 RecordedFailure,即使快照取自仍可恢复的失败状态,也不会通过原始 Error、自定义字段或 cause 持有执行对象。

10.3 canonical ChangeSet ​

ts
const change = host.change()
change.update(provider, { plugin: providerV2 })
change.update(consumer, { plugin: consumerV2 })
change.remove(legacy)
const extra = change.install(extraPlugin)
await change.commit()

规则:

  • one-shot;首次 commit() 后封口;
  • 暂存入口只从草稿当前阶段获得写入权;若输入反射期间重入 commit(),尚在读取输入的操作同步拒绝,不能向已提交草稿追加 Installation 或变更;
  • commit 幂等,重复调用返回同一 Promise;
  • 空 ChangeSet 不制造伪 changing 状态或诊断 revision,但仍按提交顺序经过 Host 命令队列与 owner authority 边界;先提交的 Group 删除会使随后提交的旧空草稿以 GROUP_REMOVED 拒绝;
  • 同一 Installation 在一份 ChangeSet 中只能出现一次;
  • 拒绝其他 Host 的 Installation;
  • 候选声明与成员关系保留在事务草稿中;Group 和诊断公开读取在 Engine 提交前只使用已提交声明;
  • 候选依赖图和全部受影响配置在停止任何实例前完成校验;
  • 执行期间 Host 为 changing,应用代码的 Service 读取关闭;
  • active 变更只重建目标和新旧图中受影响的传递消费者;
  • 多项变更共用一份停止、启动、回滚与 ExtensionPoint 通知边界。

change.install() 返回的是该 ChangeSet 独占的 draft Installation。调用 commit() 时它才获得 Host 控制权限;commit 前直接调用其 update/remove,或把它作为另一份 ChangeSet 的目标,都会以 INSTALLATION_UNAVAILABLE 拒绝。已删除或失败后脱离 Host 的 Installation 同样不能重新进入 ChangeSet。host.install() 之所以返回即可控制的 Installation,是因为该语法糖在返回前已经同步提交了内部单项 ChangeSet。

ChangeSet 直接复用 Installer 的同一份 install 类型,不维护第二套 config 推导。install() 返回需要保留的 draft Installation;update() 与 remove() 只暂存命令并返回 void。ChangeSet 因而刻意不采用半链式 API:逐项写入,再单独 commit()。

变更 setup 失败时,Core 释放部分 activation 并恢复旧图。若旧资源无法停止、部分 activation 无法清理或旧图无法恢复,Host fail closed 到 idle,不谎报 active。

10.4 stop ​

host.stop() 按依赖逆序停止。停止后安装计划仍在,Installation 回到 pending;再次 start() 会按当前 Plugin 声明和配置创建新 Instance。remove() 才会从计划删除。

十一、Group ​

Group 是安装组合和子树所有权,不是第七个内核原子,也不是依赖注入 Scope:

ts
const backend = host.group("backend", group => {
  group.install(databasePlugin, config.database)
  group.install(usersPlugin)
  group.group("transport", transport => {
    transport.install(httpPlugin, config.http)
  })
})

await backend.ready()
await backend.remove()

Host 与 Group 使用同一组 install/group/change 动词,并共同实现 Installer。供 Platform 等只编译事务的高层内部协作者使用消费侧的 Pick<Installer, "change">,无需伪造未使用的能力。首次 configure 必须同步,以便所有声明编译进一份 ChangeSet;返回 thenable 会立即拒绝。

Group 的规则:

  • 可嵌套;
  • configure 内全部安装共享一次提交;
  • ready() 先等待调用前已提交的 Host 命令,再等待配置建立结果和子树中的安装就绪;这个边界也覆盖子树外的依赖变更与空 Group;
  • remove() 用一次 Core 事务删除整棵子树;
  • Group ChangeSet 只能修改自身子树的 Installation;
  • Group 与 Installation 共享 status/ready/remove,Installation 另有 update 与 diagnostics;
  • Group 不改变能力可见性:Service、ExtensionPoint 和 Event 都属于整个 Host。

嵌套 Group configure 共享同一个显式配置会话。名称校验失败、重复子节点、非法 Plugin、禁止的 change() / remove() 调用或回调失败,都会把整份会话置为 failed。即使调用方捕获了异常,也不能继续追加声明或提交部分配置;会话始终保留首次错误。非 Error 的失败值在配置和运行事务边界统一分类为 GROUP_UNAVAILABLE;ready() 失败后 Group 状态必须是 failed,不能因为失败值恰好为 undefined 而呈现为健康。

每个 Group 只保留一份当前 readiness barrier。尚未成功建立的 Group 在提交失败后保持 failed,后续成功变更会替换旧 barrier 并建立 Group;已经建立的 Group 若变更失败且 Core 恢复了原提交状态,则继续保持健康。status 与 ready() 始终读取同一份生命周期状态。

删除 Group 会在清理完成后,按本次 detach 实际终止的子树统一撤权,异步清理期间新建的后代同样终止;删除前已经创建但尚未提交的 ChangeSet 也会失去权限;这些旧草稿的后续 install/update/remove/commit 都统一以 GROUP_REMOVED 拒绝,不能越过 Group 边界进入 Host。终态 Group 只保留身份和 removed 状态,remove() 幂等;它不再持有 Host、配置会话或历史失败调用栈,也不能创建 Installation、子 Group 或 ChangeSet。

需要工作区或租户区分时,根据语义选择:固定且需要独立依赖图的少量实例使用显式 Contract family;请求期选择的数据使用带 tenant/workspace 参数的 Service;完全独立的能力图使用多个 Host;安全隔离使用 Worker/iframe/进程。不要把 Group 冒充解析或安全边界。

十二、事务发布 ​

单个拓扑层启动:

text
1. 校验配置
2. 为层内每个插件解析稳定 Service 快照与 ContributionView
3. 创建各自根 Lifetime,并发执行 setup
4. 暂存 Contract kind、Listener 与 Contribution;cleanup 立即进入各自回滚栈
5. 校验整层全部 Service 输出
6. 整层成功后按稳定安装序注册 Service、发布暂存能力
7. 标记整层 active,再进入下一拓扑层

prepare 阶段失败时:

text
公开 Service       0
公开 Contribution  0
公开 Listener      0
已登记 Contract kind 0
已取得资源          全部尝试释放

Host start、stop 和 active 状态下提交的 ChangeSet 使用 ExtensionPoint 批次:观察者只能看到操作前或操作后的快照,不看到逐插件半成品。

批次提交先准备所有受影响 ExtensionPoint 的快照,再交付任何通知。一个集合的订阅者读取另一个集合时,同样只能读到整批提交后的值。

十三、统一观察协议与 reactive 层 ​

读取快照期间发生的失效不会被读取完成覆盖;先提交本次读取结果,再处理读取引起的通知,避免重入读取后用旧结果覆盖新投影。活动状态下读取失败保留失效状态并原样抛出。若释放时最终读取失败,首次 dispose 仍抛原值,后续 get 抛有界 RecordedFailure;终态释放旧快照、reader、reporter 与订阅,不把过期值伪装成成功结果。快照通知中的再次失效会排队,在当前回调返回后处理;尚未轮到的同一订阅只排入一次。该顺序也适用于诊断回调引起的另一份快照变化,避免跨 Publisher 重入应用代码。

SnapshotPublisher 自己拥有读取执行边界:reader 直接或间接读取正在物化的同一 view 时抛 TypeError(Circular snapshot read),不以旧缓存代替结果;reader 运行期间不能首次 dispose 该 Publisher,必须在读取结束后释放。reader 引起的失效仍会保留,读取完成后的订阅通知仍可释放 Publisher。

ContributionView、Installation diagnostics、Host diagnostics、Platform diagnostics 和 @dougongjs/reactive Signal 统一采用结构协议:

ts
interface Readable<T> {
  get(): T
  subscribe(listener: () => void): Disposable
}

生产这类运行诊断时使用 Core 唯一的写侧原语 SnapshotPublisher:

ts
const snapshots = new SnapshotPublisher(readSnapshot, reportError)

export const diagnostics = snapshots.view
snapshots.invalidate()
snapshots.dispose()

view 是权限收窄,不是第二套观察 API:读取方只能 get/subscribe,拥有方只能通过 SnapshotPublisher 驱动失效和终止。每次订阅都有独立身份;释放会立即撤回尚未轮到的通知。订阅者与错误 reporter 都必须同步;意外返回的 thenable 会被观察并作为精确 TypeError 进入同一个错误边界,不能变成无关的 unhandled rejection。订阅者失败交给显式 reporter 后仍继续通知其余订阅者;若 reporter 自身失败,Publisher 完成整轮通知后用 AggregateError 同时保留订阅者错误与 reporter 错误。dispose() 会固化最终读取的结果并切断 reader、reporter 与现有订阅;历史 view 在成功终态读取最后快照,失败终态则抛 RecordedFailure,不保留过期快照载荷或原始异常对象。Host、Installation、Lifetime 与 Platform diagnostics 直接走这条路径;ContributionStore 同样组合这一个 Publisher,只在订阅外层增加 Lifetime 所有权,因而重复注册同一个函数仍是两份独立订阅。任何高层都不得重写订阅注册表和错误边界。

快照需要 Map 语义时统一使用 ReadonlyMapSnapshot。它只接受类型声明中的 Map 或条目 iterable,复制输入并只暴露 ReadonlyMap 方法,避免 Object.freeze(new Map()) 仍可调用 set/delete/clear 的伪不可变性;它只保证容器结构不可变,条目值仍应在进入快照时自行冻结。

@dougongjs/reactive 是独立基础包:

ts
signal(initial)
computed(calculate)
batch(callback)
observe(lifetimeOwner, source, observer)
  • Signal 保存当前值;
  • computed 自动追踪仅用于同步、纯、懒、缓存计算;
  • batch 只接受同步 callback,并按订阅身份合并 callback 内的重复通知;
  • observe 是更高层的 Lifetime 组合器:显式读取一个 source,为当前值创建子 Lifetime,变化时先释放旧子级再创建新子级;observer 必须同步,异步清理结束后再次读取任务的取消信号,已取消的观察不会再创建子 Lifetime;后续替换失败会停止观察,释放订阅与当前子 Lifetime,并从 owner 摘除自己的 cleanup。
ts
const endpoint = computed(() => `${base.get()}/${account.get()}`)

observe(ctx, endpoint, (url, lifetime) => {
  const socket = new WebSocket(url)
  lifetime.cleanup(() => socket.close())
})

observe() 只使用公开 get/subscribe/lifetime/spawn/cleanup,因此不是 Core 特权或第二套执行引擎。Core 不依赖 reactive,第三方 Readable 也可结构兼容。

第三方 source.subscribe() 返回订阅时,Observation 根据自己的当前阶段决定是否接收;若调用期间观察已关闭,立即释放返回的订阅并使构造失败,不会把资源挂到已经完成释放的状态上。释放订阅本身的失败也会同步传播。

结构兼容只统一观察协议,不抹平资源来源的所有权边界:Context 注入的 ContributionView 是绑定当前 Lifetime 的 live capability,直接 subscribe() 产生的订阅会自动归该 Lifetime 所有;独立 Signal 或第三方 Readable 没有隐含 owner,直接订阅时由调用方持有返回的 Disposable,或交给 observe(owner, source, observer) 组合进显式 Lifetime。两者仍只有同一个 subscribe() 和同一个 dispose(),差异只在是否已经存在明确的结构化 owner。

不提供 Solid 式裸 effect()、依赖数组、深层 Proxy Store、watchEffect/autorun/reaction。Effect-TS 可以在 Service 内使用或经单向适配器接入,不进入 Core。

十四、诊断与封装边界 ​

ts
host.diagnostics.get()
host.diagnostics.subscribe(notify)

快照包含 Host name/status/revision、InstallationSnapshot Map 和 GroupSnapshot Map。若条目含最近失败,其 error 为 RecordedFailure,只保存冻结、有界的诊断数据,不保留原异常的对象图。快照、条目、数组与 Map 都只读;诊断不能控制 Host。

运行中的 InstallationSnapshot 还包含一份独立的 lifetime 观察视图:

ts
interface LifetimeSnapshot {
  readonly label: string
  readonly phase: "active" | "disposing" | "disposed"
  readonly cleanups: number
  readonly tasks: number
  readonly listeners: number
  readonly contributions: number
  readonly contributionViews: number
  readonly subscriptions: number
  readonly children: readonly LifetimeSnapshot[]
}

const lifetime = host.diagnostics.get().installations.get(id)?.lifetime
const current = lifetime?.get()
const subscription = lifetime?.subscribe(render)

根节点的 label 是稳定的 installation ID;每个 children 条目严格对应一次真实的 lifetime(label) 所有权关系。节点计数只描述该 Lifetime 直接拥有的资源,children 只列直接子 Lifetime。子树总量可由这组不可再约简的事实递归推导,不在快照中保存第二份聚合状态。阶段、分类计数与子级成员关系直接读取各自唯一拥有者,不维护第二棵诊断树或增减计数。整棵快照递归冻结,但不暴露 Lifetime、资源对象、回调或 Store。

label 只回答“这组资源为何共同存活”,不是 capability ID、查找 key 或新的 Scope。重复 label 不产生冲突,也不改变释放语义。cleanup、task、listener 等叶资源不各自增加命名重载;只有确实存在共同释放边界时才创建子 Lifetime。Core 不从函数名、调用栈或序号猜测节点,也不为了实现分类计数伪造树层级。

资源变化只更新这份小型视图,不增加 Host revision,也不重建全部 InstallationSnapshot;调用方要观察资源变化就显式订阅嵌套视图。子 Lifetime 终止后立即从树中摘除;Instance 停止后新的 InstallationSnapshot 不再含 lifetime,已经取得的旧视图会停在无子节点、全零计数的 disposed 终态,且不再保留 Host。

Instance 从创建时保存同一份只读 Lifetime view,不在诊断读取时重新取得执行绑定。Lifetime 已释放而 Installation 仍处于 stopping 的交接期间,Installation 与 Host 诊断仍可读取该 view 的 disposed 终态;这份投影不能推进 Lifetime 阶段或创建资源。

公共 facade 对象与顶层 Host / Platform 都经过冻结且保持狭窄。纯 JavaScript 检查自有属性或原型,也不会看到:

text
InstallationRecord
GroupNode
ContributionStore
EventHub
LifetimePort
ChangeSet port
ChangeSet discard / Installation attach / revoke
Host 的 Group 编排端口
staged publication method
Platform Artifact / Core Installation

TypeScript 的 private 不被当作安全措施;实现使用真正的 #private 字段或独立 facade 对象,防止 JavaScript 形状泄露。

Context 限制同样不是安全沙箱。同 Realm 插件仍可访问 globalThis、DOM 或 fetch。不可信插件必须进入 Worker、iframe、受限 Realm 或独立进程。

十五、错误约定 ​

编程形状错误使用 TypeError。可判定模型错误使用 DougongError.code:

code含义
CONFIG_INVALIDStandard Schema 拒绝配置
CONTRACT_CONFLICT同一 ID 承担多个 kind
SERVICE_CONFLICTService 有多个提供者
SERVICE_MISSING必需 Service 无提供者
SERVICE_CYCLEService 依赖环或自依赖
SERVICE_NOT_RETURNEDsetup 未返回声明输出
SERVICE_UNAVAILABLE外部读取或 Instance 绑定当前不可用
INSTALLATION_REMOVED操作已从计划移除的实例
INSTALLATION_UNAVAILABLEInstallation 无法进入可等待状态
INSTALLATION_IDENTITYupdate 试图改变插件 name
GROUP_REMOVED操作已移除的 Group
GROUP_UNAVAILABLEGroup 尚未成功建立

Event 因定义要求收集全部监听器失败,总是抛 AggregateError。Lifetime 与关停先尝试所有资源:一个失败原样抛出,多个失败聚合。rollback/fail-closed 跨多个阶段时统一使用 AggregateError。

后台任务、订阅者和后续 observe 的错误无法回到原同步调用栈,通过 onError 上报。onError 自身失败也不得改变正在观察的 Host 命令。

RecordedFailure 是共享的终态错误类型。其只读 snapshot: ErrorSnapshot getter 返回私有拥有的冻结记录;只有真实 RecordedFailure 的私有记录可直接复用,继承其原型的外部 Error 仍须重新捕获。记录保存诊断值:原始名称、消息、错误码、栈文本、有界 cause 和聚合错误树、校验问题与权限字段。它不重建原错误子类,也不保留任意错误载荷。操作本身和可恢复失败仍传播原始 Error;被丢弃的 Installation 或 Registration 的 ready() 拒绝值为 RecordedFailure。需要枚举或序列化诊断数据时显式使用 error.snapshot;snapshot 不再是可枚举的实例数据字段。

new RecordedFailure(reason: unknown) 接受任意拒绝值,不要求调用者预先归类。每个错误节点最多检查一次原型;无法识别的值记录为 NonError,原始值为基本类型时保留有界文本,其他对象省略。SnapshotPublisher 直接记录最终 reader 的拒绝值,不额外构造另一份错误。非 Error 包装的来源由归类路径登记在模块私有集合中,原型与 constructor 不能授予重新归类权限。清理是否未完成只由本次激活的内部结果携带;错误类型、code、原型、constructor 或另一次激活的历史错误都不能触发本次 fail-closed。

Core 创建的非 Error 包装错误自身冻结,cause 与 code 不能被后来持有错误的代码改写,从而改变外层归类的原始原因。cause 指向的应用对象仍由应用拥有;显式抛出的应用 Error 原样传播。

十六、禁止方向 ​

  • 插件基类和框架继承树;
  • 装饰器依赖注入与字符串 Service Locator;
  • Proxy Context、原型链 shadow、live Service Proxy;
  • Core 内置 Signal/effect、React、HTTP、Node、文件系统或定时器;
  • extensionPoint.keyed/many/ordered/override 等领域策略进入 Core;
  • Scope 与 Group 混为一谈;
  • lifecycle hook 矩阵;
  • Event 增加 serial/bail/waterfall 等查询模式;
  • Plugin 任意修改全局 Installation 图;
  • Loader、Manifest、HMR 或权限进入 Core;
  • 用 Context API 限制冒充安全沙箱;
  • 只在类型声明隐藏、但在 JavaScript 对象上泄露内部字段或方法。

十七、最终判据 ​

任何新需求先回答:

  1. 它是稳定能力、开放贡献、瞬时事实还是资源?
  2. 能否由 Service、ExtensionPoint、Event、Lifetime 和普通函数组合?
  3. 是否真的需要修改 Core?
  4. 同层是否已经存在语义等价入口?
  5. 高层实现能否只使用公开 API?
  6. 组合后是否保持原生命周期、事务和错误语义?
  7. 是否泄露内部 Registry、Host、安装状态对象或安全假象?
  8. 去掉 React、Node、Wails 和 reactive 包后,Core 是否仍成立?

设计公式:

text
Plugin =
  setup(
    immutable service snapshot,
    live ContributionView,
    config,
    lifetime,
  )
  →
  atomic service outputs
  + owned contributions
  + owned listeners
  + owned resources

用户只需记住三句话:

text
插件通过 requires 获得能力,通过 return 提供 Service,通过 contribute 加入开放扩展。
所有监听、贡献、任务和 cleanup 自动属于创建它们的 Lifetime。
Service 变化重建消费者,ExtensionPoint 变化通知订阅者,Event 只广播本次事实。

事务失败边界 ​

候选配置验证不撤销运行中 Instance 的能力:Event 与 Contribution 操作由 Lifetime 授权,直到开始释放。并发启动错误的聚合必须保留清理完整性;任何不完整清理都禁止自动回滚。

fail-closed 终结整张已停止图的 readiness,包括间接消费者和原本不受变更影响的 Installation。它们进入 failed,已有 ready() 等待在命令完成后拒绝;声明仍然安装,后续成功 start() 可恢复就绪。普通 stop() 则让 Installation 保持 pending,等待下一次启动。较早的 Group 提交成功,即使较晚变更已经拥有当前屏障,也必须推进已建立基线。

基于 MIT 许可证发布。