宿主配置与扩展

Host 配置教程

用一份 HostApplication 组合插件、服务、开发工具和生产部署。

Host 拥有插件目录、运行策略和服务生命周期。应用入口默认导出 defineHostApplication(factory) 声明的 HostApplicationFactory;Vite 和生产构建读取同一个入口。 插件只依赖 Core 和实际使用的服务,不需要全局 Runtime。

本页是应用装配的权威教程,各章可独立读取。只改 Plugin 方法、schema 或日志写法时,直接读插件开发范式。编写自定义服务安装器再读服务扩展。

本次要配置什么章节
服务与固定插件、启动策略组合、应用入口
Vite 与动态插件来源Vite、动态来源
环境变量、JSON 文件与只读覆盖环境、插件配置绑定
配置及运行策略跨重启保存持久化
Vault 后端、部署凭据与备份后端、绑定、备份
控制台、Workbench store 与远程日志日志 plan、OTLP
数据库后端与应用共享数据库Backend、应用数据库
轻量独立 HostLogging、HTTP listener
生产制品构建

以下 HostApplication、servicesPreset() 和独立 createHost() 示例表示不同装配方式;选择一种适合现有项目的入口,不叠加创建第二个 Host。

选择组合

  • 自定义或轻量宿主:@pluxel/host,只安装显式列出的服务。
  • 官方服务器组合:servicesPreset(),包括 HTTP、Commands、Node artifacts、Workers、Persistence、Vault、Logging、Management 和默认启用的 Workbench。
  • 无管理页面的常用基础组合:standardServices(),包括 HTTP、Commands、Node artifacts、Workers 和 Persistence。

Database 单独安装;关闭 Workbench 不会关闭插件业务能力。详细服务列表与资源归属见组合 Host 服务。

应用入口

import { resolve } from 'node:path'
import { pluginNodeAddressOf } from '@pluxel/core'
import { defineHostApplication } from '@pluxel/host'
import { resolveHostEnv } from '@pluxel/host/environment'
import { servicesPreset } from '@pluxel/services/preset'
import { TodoPlugin } from '@app/todo'

export default defineHostApplication(async (startup) => {
	const environment = resolveHostEnv(startup.env)
	return {
		name: 'my-app',
		plugins: [TodoPlugin],
		services: await servicesPreset(startup, {
			persistence: resolve(startup.root, environment.dataRoot, 'persistence'),
			workbench: environment.workbench ?? true,
		}),
		state: { initial: { autoStart: [pluginNodeAddressOf(TodoPlugin)] } },
		configRecords: {
			initial: [{ owner: pluginNodeAddressOf(TodoPlugin), config: { maxItems: 100 } }],
		},
	}
})

plugins 表示代码可用,不代表自动启动。state.initial.autoStart 选择冷启动时运行的节点,插件自己的 required dependencies 由图统一处理。

配置工厂每次创建新 Host 时执行,可以读取 env、bindings、root 和 deployment。构建不会执行它。 Vite 和生产构建都要求入口直接默认导出 defineHostApplication(内联工厂):工厂直接返回对象,或在块中以唯一、无条件的最后一条顶层 return 返回对象;返回对象不使用 spread。delivery: 'standalone' 构建中的 plugins 使用直接数组或模块级 const/imported 数组,不使用条件、函数调用或 startup 计算。构建只分析静态清单,不执行工厂。没有环境绑定时,Vite 可运行条件插件数组。

静态清单的数组必须始终保留声明时的成员:不要在模块、工厂、helper 或 callback 中修改、通过别名修改,或用动态 import 追加插件。构建会拒绝工厂中的明显修改、别名和向 helper 传递数组;map、slice 等只读使用可以保留,但 callback 也不得修改原数组。这是部署合同,有限语法检查不能证明任意 JavaScript 的副作用。需要来源目录时使用 modules 交付和显式 sources;standalone 拒绝声明 sources。

prepare({ host, startup }) 在服务与开发附件准备完成后、插件启动前执行;适合应用级硬前提。资源获取成功后立即登记到明确的 owner,清理失败不会跳过其他资源。

configRecords 和 state 默认存于内存。安装 Persistence 不会隐式持久化 Host 的配置或策略;需要保存时,显式提供借用的 document storage,见保存配置和运行策略。已有持久文档优先于启动 seed。

Vite 开发

import { defineConfig } from 'vite'
import { vitePreset } from '@pluxel/services/vite'
import { hostEnv } from '@pluxel/host/environment'

export default defineConfig({
	server: { host: hostEnv.hostBind ?? '127.0.0.1', port: hostEnv.hostPort ?? 3000 },
	plugins: [vitePreset({ entry: './src/app.ts', devConsole: true })],
})

自定义宿主可以使用 @pluxel/host-vite 的 host() 并显式组合服务开发附件。应用、动态插件和控制台共享 Host 专用的 pluxel Vite environment;默认 SSR 和第三方 SSR 加载保持独立。插件源码必须经过 Pluxel lowering,执行空间边界见工具链说明。 React 页面由应用显式安装 React Vite plugin。

配置工厂 identity 变化时重新求值完整配置并重建 Host,固定插件的 import 更新也可能触发重建。工厂求值失败保留旧 Host。动态来源更新若未使工厂失效,则复用本次配置并提交 catalog replacement。失败候选保留旧实现;已提交后的 init 失败则报告新一代的生命周期问题,不声称旧代仍然运行。 在线检查与修改见开发控制台。

自行通过 Vite createServer() 持有开发会话、并向 bindings 借出连接或存储时,关闭顺序为: 先 await closeHostViteSession(server)(从 @pluxel/host-vite 导入),然后在 finally 中 await server.close(),最后释放借出的资源。首项停止 Host 更新接纳并排空已有工作,保留原始清理失败; 仅调用 Vite server.close() 会丢失部分插件关闭错误。未取得 Host 会话的 server 返回 undefined; 已取得会话的重复关闭复用同一个结果。这个函数不关闭 Vite server,也不接管调用者提供的连接。

动态来源

import { pluginSource } from '@pluxel/host/sources'

const sources = [
	pluginSource({
		kind: 'directory',
		path: './managed-plugins/entries',
		include: ['*.mjs'],
	}),
]

将 sources 放在应用声明中。来源负责发现入口,包安装由应用或 Package Manager 负责;新入口进入同一个 Host catalog,是否运行仍取决于策略。 pluginSource() 只创建并冻结 file/directory 数据,不打开 watcher。启动时缺失 directory 为空;缺失 file、逃逸路径和坏入口明确失败。Vite 运行时删除已经观察的 file 会撤回其定义。 声明指向的文件或目录根、以及 include 命中的入口必须是普通文件/目录,符号链接以 TypeError、code: 'PLUGIN_SOURCE_SYMLINK' 拒绝并附 file 位置;不遍历目录中的符号链接子目录。父级目录可以是别名,执行入口在打开来源时固定其真实路径。 Vite 会话打开后新建父目录别名或重定向其真实路径,会以 PLUGIN_SOURCE_PATH_CHANGED 拒绝;恢复原路径或重启 Vite 应用会话后再接纳,普通 Host replacement 不重新绑定观察路径。

原生 runHostApplication() 在一次启动中扫描预编译 .js/.mjs,与固定 imports 合并后启动。当前进程不响应后续新增、改写或删除,发布在新进程下次启动生效。它不编译 TS,也不启动 Vite。

Vite 开发和生产均持续接纳来源发布,普通 ESM 传递依赖属于同一个 runner 图;CommonJS、原生模块及显式 singleton 保留 Node 缓存边界。入口进入 catalog 与 Plugin 启动是独立事实。 显式设置 CHOKIDAR_USEPOLLING / CHOKIDAR_INTERVAL 时需符合固定观察策略;冲突会拒绝并给出所需值,规则见来源观察与关闭。

producer 在副作用前验证声明:

import { requirePluginSource } from '@pluxel/host/sources'

const policy = requirePluginSource(ctx, {
	kind: 'directory',
	path: entriesDir,
	include: ['*.mjs'],
})
// policy.updates 是 'next-start' 或 'live';缺失/不匹配会抛 SOURCE_REQUIRED / SOURCE_NOT_DECLARED。

createHost() 只接收已求值插件与服务;扫描和持续更新分别归上述执行入口。

Vite 生产

在一个新的 NODE_ENV=production 进程中,显式选已有配置文件;唯一应用 entry 仍写在该文件的 host() / vitePreset() 中:

// vite.runtime.config.ts;与启动脚本放在同一目录
import { defineConfig } from 'vite'
import { vitePreset } from '@pluxel/services/vite'

export default defineConfig({
	root: import.meta.dirname,
	plugins: [vitePreset({ entry: 'dist/app.mjs' })],
})
// start-vite.mjs
import { runViteApplication } from '@pluxel/host-vite/run'

const session = await runViteApplication({
	root: import.meta.dirname,
	configFile: './vite.runtime.config.ts',
})
const shutdown = () => {
	void session.close().catch((error) => {
		process.exitCode = 1
		console.error(error)
	})
}
process.once('SIGTERM', shutdown)
process.once('SIGINT', shutdown)

未设置 NODE_ENV 时入口会设置 production;development/test 进程会被拒绝。入口强制 production conditions、middleware mode 和关闭浏览器 HMR,启动 promise 等待目录、制品、Host 报告和已安装 listener。Plugin init issue 保留在启动报告中;装配或硬前提失败会关闭整个会话并抛错。

vitePreset() 的 HTTP 服务拥有独立业务 listener;生产没有 Vite 公共中间件、dev console 或 source Shell。配置中启用 devConsole/source Shell 会拒绝启动。close() 或 signal abort 停止接纳、排空已接纳更新并释放 watcher/listener。清理失败仍继续释放其余资源,close() 拒绝并保留原始失败;重复调用返回同一关闭结果。不要将开发前端配置直接用作生产配置;create 模板提供独立示例。

执行 root 必须是绝对路径;配置中另有 root 时必须指向同一目录。应用的相对来源和数据路径以 startup.root 为基准;开发前端 root 与生产应用 root 可能不同。需要共享已有数据时显式设置同一个绝对 PLUXEL_DATA_ROOT。

选择 Vite 生产部署时,直接使用的 @pluxel/host-vite、vite 以及配置 imports 必须声明为应用的 dependencies。选择 native 部署时,这些执行工具留在 devDependencies,生产安装不需要它们。

配置环境变量

@pluxel/host/environment 提供 env、hostEnv、resolveHostEnv(input)。显式参数用于根据本次 startup 环境解析,不依赖修改 process.env。

变量含义
PLUXEL_DATA_ROOT数据目录默认值,省略为 .pluxel;应用决定子目录
PLUXEL_HOST_BIND / PLUXEL_HOST_PORT物理 listener 地址与端口,分别优先于通用 HOST / PORT
PLUXEL_WORKBENCH由应用传给服务组合的页面开关
HOST / PORT通用监听输入;没有反向代理或公开 origin 时也生效
PLUXEL_PUBLIC_ORIGIN显式 HTTP(S) 公开 origin,优先于 PITCHFORK_URL
PITCHFORK_URLPitchfork 提供的公开 origin;未显式配置时自动采用

解析后的 hostEnv.publicOrigin 由 Vite 和 native listener 共同用于管理会话的浏览器同源校验,Vite 还用它打印公开访问地址;没有公开 origin 时使用监听 origin。它不改变物理监听地址、客户端地址或 TLS 判定,也不从 Forwarded / X-Forwarded-* 请求头推断。公开 origin 必须是 HTTP(S) origin,不含凭证、业务路径、查询参数或片段。开发代理的配置与排障见工作区工具链。

绑定部署环境与 JSON 文件

大多数插件只需 configs.use(schema),通过 Workbench 或 Host 配置 API 设置值。只有部署系统负责提供固定值时,才在应用入口加绑定:环境变量使用 envBinding,挂载的 JSON 文件使用 fileBinding。两者都由 Host 在启动时读取。

应用解析在首次异步读取前捕获绑定的文件路径、namespace 和环境 mapping;读取期间修改原声明不会改变本次输入或来源记录。schema 保留原实例,不能借修改 schema 对象改变已声明的契约。

环境绑定从本次 startup.env 生成覆盖层,优先于保存值;该层不写入配置存储。

Plugin 需要导出传给 configs.use() 的同一个 schema:

// WorkerPlugin.ts
import { BasePlugin, Plugin } from '@pluxel/core'
import * as v from 'valibot'

export const WorkerConfig = v.object({
	endpoint: v.pipe(v.string(), v.url()),
	http: v.object({
		enabled: v.optional(v.boolean(), true),
		timeoutMs: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 5_000),
	}),
})

@Plugin()
export class WorkerPlugin extends BasePlugin {
	private readonly config = this.configs.use(WorkerConfig)
}

应用入口 直接声明部署名称;不要在配置工厂中重复解析类型或拼装 Plugin address:

import { defineHostApplication, envBinding } from '@pluxel/host'
import { WorkerPlugin, WorkerConfig } from './WorkerPlugin.ts'

export default defineHostApplication(() => ({
	name: 'worker-app',
	plugins: [WorkerPlugin],
	envBindings: [
		envBinding(WorkerPlugin, {
			config: {
				schema: WorkerConfig,
				mapping: {
					endpoint: 'WORKER_ENDPOINT',
					http: { enabled: 'WORKER_HTTP_ENABLED', timeoutMs: 'WORKER_HTTP_TIMEOUT_MS' },
				},
			},
		}),
	],
}))

插件不声明静态 schema 字段。宿主导入传给 configs.use() 的同一个 Valibot schema 值,envBinding 根据 schema 推导 mapping 的输入字段;Host 启动时核对它与插件的配置声明一致。envBinding 和 fileBinding 都要求 Valibot schema,普通 Standard Schema 实现不能用于这两个绑定。这里只复用定义,不复制 schema。普通静态配置可用 satisfies;defineHostApplication 保留启动上下文,绑定 helper 提供字段之间的类型推导。

Mapping 从 schema input 推导:object 可展开,也可绑定一个 JSON 变量;array、tuple 与动态 record 使用完整 JSON。环境名称匹配 [A-Z_][A-Z0-9_]*。string 保留原文,number 要求有限 JSON number,boolean 只接受 true/false,复合类型使用 JSON。config 环境缺失不生成覆盖,空字符串和 null 按 schema 校验;诊断不包含输入值。

配置优先级为:

configRecords.initial < fileBindings config < saved config < envBindings config

对象递归合并,数组整体替换。持久文件只保存管理界面或 Host API 修改的层;基础值、schema 默认值和 env 覆盖不会被复制进去。移除 env 后,下一次启动重新显示 saved 或基础值。reset 删除 saved 值,重新显示基础值。

被 env 控制的路径及其祖先、后代拒绝修改和 reset,包括提交相同值。Workbench 显示来源并禁用对应字段;Host 返回的 sources 只有路径、来源种类、名称和只读状态,不含凭据。未绑定的兄弟字段仍可编辑。

JSON 文件通过 fileBindings: [fileBinding(WorkerPlugin, { config: { schema: WorkerConfig, path: './worker.json' } })] 提供基础值(fileBinding 从 @pluxel/host 导入),路径相对 startup.root。文件只在启动时读取,不由构建读取或打包。修改文件或 env 需要重新创建 Host。

部署凭据不放在普通 config。需要从环境变量或挂载文件提供凭据时,按 Vault 部署绑定声明只读记录;需要交互登录或刷新凭据时,使用可写 Vault。

应用生产构建 从同一声明生成 .env.example,只输出说明和注释状态的空 placeholder,不读取构建机环境或复制 schema default。envBindings 使用 direct array literal,每项调用从 @pluxel/host 导入的 envBinding,第一个参数是静态目录中的 Plugin 标识符,第二个参数直接声明 config/vault 与其 schema、mapping;mapping 使用对象树和字符串 literal。动态分支不作为构建期来源清单。

直接消费 runtime control-plane ConfigResult 时按 discriminant 处理返回值:query 返回 config 和 defaults,并标记 saved: false;成功 mutation 返回已持久化的 config、application 与 apply report,不再重复返回 defaults。 validation_failed 只有在 defaults 可以独立计算时才带可选 defaults。持久化失败不会发布 staged revision,也不会把未确认的值注入 Plugin generation。

Persistence 文件契约

@pluxel/services/persistence 的内存与 Node 后端使用相同的相对分层路径:namespace 和 key 保留原字面(包括 @pluxel/plugin-wretch),不 trim、替换字符或猜测绝对路径。空段、.、..、反斜杠、冒号和控制字符会抛出 TypeError;仅 list() / list('') 的空前缀表示 namespace 根。namespace 是路径前缀,不是互相隔离的权限域,例如 a/b 位于 a 之下。

list(prefix) 按 key 排序返回目录的直接子项,包含 file 与 directory;缺失目录返回空集合,文件不能作为列举前缀。stat(key) 可识别文件与目录;内存后端在写入嵌套文件时建立目录,删除最后一个文件不会删除目录。delete() 只删除文件,缺失文件无操作;get() / getText() 的缺失结果为 undefined,读取目录失败。size / updatedAt 是后端可提供的 metadata,不保证存在。

Node 后端在创建时固定 root(省略时使用当时的 cwd)。路径检查限制字面路径,不承诺防御宿主在 root 下放置的符号链接;文件树由宿主控制。自定义后端需实现同一目录与完成语义。readonly 在实际 put() / delete() 边界拒绝操作并给出 PersistenceError.code === 'READONLY',不依赖调用方先执行 preflight()。

保存配置和运行策略

Host 分别通过 configRecords 与 state 指定启动值及可选文档存储。两者不依赖安装 Persistence 服务,已有存储只需提供 HostDocumentStorage 的 getText()、put() 和 stat():

import { createHost } from '@pluxel/host'
import { createNodePersistenceBackend } from '@pluxel/services/persistence'

const files = createNodePersistenceBackend({ root: './data' })
const host = await createHost({
	plugins: [MyPlugin],
	configRecords: {
		storage: files.namespace('config'),
		initial: [{ owner, config: { endpoint: 'https://example.com' } }],
	},
	state: {
		storage: files.namespace('runtime-state'),
		initial: { autoStart: [owner] },
	},
})

省略 storage 时只用内存。提供 storage 时默认 mode: 'writable';mode: 'readonly' 读取已有文档并拒绝写入,文档不存在时保留 initial 且不创建文件。已有文档优先于启动种子,Host 返回前会完成两个存储的准备。配置保留 v3、运行策略保留 v5 文档格式,不在应用重新打包时覆盖已有数据。

文档存储是借用的:Host 关闭时等待自己的写入、清理定时器并报告 flush 失败;不会调用外部 backend 的 close。共享 backend 的创建和关闭由应用或对应服务拥有。put() 必须在完整文档提交后才 resolve,并在 { atomic: true } 时提供旧文档或新文档的完整替换语义;不能用“请求已入队”冒充写入成功。

Host 应用解析器从本次 startup.env 读取显式 envBindings,文件输入来自 fileBindings。普通配置优先级为基础对象/文件 < 管理保存值 < env,合并后由 schema 校验;env 控制的路径只读且不落盘。Vault env/file 绑定是整记录只读。底层 createHost() 不隐式读取进程环境。

选择后端

可写记录使用加密后端,并显式安装 Persistence:

services: [
	persistence('./data/persistence'),
	vault({
		deployIdentity: startup.env.PLUXEL_VAULT_DEPLOY_IDENTITY,
	}),
]

只有部署凭据时选择 vault({ backend: 'bindings' })。它不依赖 Persistence,不创建密钥或磁盘文件;没有绑定的记录不存在,所有写入拒绝。servicesPreset(startup, options) 显式从该 startup 环境读取部署解锁身份,options.vault 可以覆盖后端和部署解锁身份。直接 vault() 不读取进程环境。

部署凭据

宿主在应用工厂中通过 envBindings 或 fileBindings 用 envBinding / fileBinding 把导出的凭据根 schema 绑定到输入。Host 先完成 schema 校验、准备服务,再安装绑定,最后启动插件。见应用入口。

import { defineHostApplication, envBinding } from '@pluxel/host'
import * as v from 'valibot'
import { MyPlugin } from './MyPlugin.ts'

const Credentials = v.object({ primary: v.object({ token: v.string() }) })

export default defineHostApplication(() => ({
	plugins: [MyPlugin],
	envBindings: [
		envBinding(MyPlugin, {
			vault: { schema: Credentials, mapping: { primary: { token: 'APP_TOKEN' } } },
		}),
	],
}))

若凭据由挂载的 JSON 文件提供,则在同一个应用入口中用 fileBindings: [fileBinding(MyPlugin, { vault: { schema: Credentials, paths: { primary: './credentials.json' } } })] 替换上面的 envBindings。同一记录只能绑定一个来源。

显式绑定的 env/file 是整条只读记录,绝不与已存 KV 拼接。缺失输入不会偷偷回退到旧凭据;移除绑定后才重新读取持久记录。部署输入不写入加密 snapshot。

导出的凭据根 schema 使用 v.object({ primary: CredentialSchema }) 声明固定记录,或 v.record(KeySchema, CredentialSchema) 声明账号记录。宿主显式导入这个 schema,传给绑定的 vault.schema,在 mapping / paths 中填写记录 key 和环境名/JSON 路径。它是本次 Host 的部署入口契约,修改它需要重新创建 Host;Plugin 热替换不会自动替换该契约。私有 KV 的写入仍由业务用例校验,不会自动成为部署入口。每个明确映射的环境名都必须存在;schema 的 optional 字段可以不映射。

密钥与备份

Vault snapshot 使用 version 2,包含结构化 KV 与每条记录的 revision;不支持的版本或缺少 revision 时启动失败。

加密后端使用 global/keys.age 包装数据密钥,global/state.enc 保存记录,global/blobs/ 保存独立 blob。security/identity.json 保存宿主身份。已有仓库无法解锁、损坏或缺失 snapshot 时启动失败,不作为空仓库覆盖。

Root 通过 VaultAdmin 管理解锁、宿主密钥和部署 recipients。rekey() 原子重写密钥 envelope,数据密钥与密文内容保持;失败保留先前可用数据。部署私钥由宿主显式输入,不放入 Plugin config、日志或 UI。

备份与回滚应在 Host 停止后整体复制 Persistence 的 vault namespace:包括 security/identity.json、global/keys.age、global/state.enc 和 global/blobs/。不要分别恢复不匹配的 key envelope 与数据文件;不支持跨进程 writer 或跨 config/Vault 事务。

可选管理页面与服务配套插件

Vault 管理页由同一个服务包的 @pluxel/services/plugins 入口提供,不需要额外安装 UI 插件包。 VaultAdminPlugin 是普通 Plugin:它使用 Vault 能力并发布 Workbench 页面,管理操作仍通过 Management 的已认证会话执行。

import { pluginNodeAddressOf } from '@pluxel/core'
import { defineHostApplication } from '@pluxel/host'
import { servicesPreset } from '@pluxel/services/preset'
import { VaultAdminPlugin } from '@pluxel/services/plugins'

export default defineHostApplication(async (startup) => ({
	name: 'vault-example',
	services: await servicesPreset(startup, { persistence: '.pluxel/persistence' }),
	plugins: [VaultAdminPlugin],
	state: { initial: { autoStart: [pluginNodeAddressOf(VaultAdminPlugin)] } },
}))

在正常 Host 开发或生产入口启动应用后,登录 Workbench 即可访问 Vault 页面。缺少 Vault 服务时插件启动失败;关闭 Workbench 时不发布页面,Vault 服务仍独立运行。页面撤回随 Plugin 停止,后端资源随 Host 服务关闭,不建立第二套生命周期。

第三方服务可以采用同样的结构:服务入口负责业务能力,一个显式 ./plugins 导出集中提供可选配套 Plugin;应用选择服务、插件与启动策略。基础服务入口不导入 /plugins,页面组件不在服务器入口求值。同一个 Plugin 只从一个公开入口具名导出,不能再从根入口重复转发。包内路径、构建和测试示例见 packages/services/src/plugins.ts 与 packages/services/tests/vault-plugin.test.ts。

Host logging plan

servicesPreset() 安装默认日志方案。自定义时通过它的 logging 选项传入完整 plan;自行组合的 Host 使用下文 logging(plan) 服务:

logging: {
	root: {
		profile: 'production',
		debugTopics: ['cache:lookup'],
	},
	sinks: {
		console: {
			kind: 'console',
			format: 'json',
			caller: false,
			timezone: 'utc',
		},
	},
	routes: {
		runtime: [{ sink: 'console', minLevel: 'info' }],
		plugins: [{ sink: 'console', minLevel: 'trace' }],
		debug: [{ sink: 'console', minLevel: 'trace' }],
		meta: [{ sink: 'console', minLevel: 'warning' }],
	},
}

plugins route 可以保持较低门槛,再由 O(1) Plugin policy 决定实际等级。静默输出使用空 sinks 和四个空 routes,仍保留 Context identity 和管理所有权。logging 是完整 plan 替换,不合并 preset 默认的 console/store。

部署与 Workbench store 选项见 配置插件宿主,仓库内部的遥测集成见 OpenTelemetry 预览,该包目前不供外部项目安装。

远程输出:原生 LogTape sink

Host 的 kind: 'logtape' 直接接受原生 Sink,保留 LogRecord、formatter 和 sink 的释放协议。Pluxel 只管理 Context 身份、路由、插件 policy 与安装寿命;需要自定义格式、脱敏或远程输出时,直接组合上游 sink,不另建 logger 或调用全局 configure()。

推荐用 @logtape/otel 对接 OTLP 后端。应用按需安装以下依赖;Pluxel 默认不加载网络 exporter:

vp add @logtape/otel@^2.3.10 @opentelemetry/sdk-logs@^0.222.0 @opentelemetry/resources@^2.11.0 @opentelemetry/exporter-logs-otlp-proto@^0.222.0

在启动环境中设置完整的 logs endpoint;VictoriaLogs 示例:

OTEL_SERVICE_NAME=orders
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://victorialogs:9428/insert/opentelemetry/v1/logs

下面是独立 Host 的完整装配。使用原生批量 processor;认证和超时沿用 exporter 的标准 OTEL_EXPORTER_OTLP_LOGS_HEADERS、OTEL_EXPORTER_OTLP_LOGS_TIMEOUT 环境变量。显式选择的 exporter 使用 HTTP/protobuf。

import { getOpenTelemetrySink } from '@logtape/otel'
import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-proto'
import { resourceFromAttributes } from '@opentelemetry/resources'
import { BatchLogRecordProcessor, LoggerProvider } from '@opentelemetry/sdk-logs'
import { createHost } from '@pluxel/host'
import { logging } from '@pluxel/services/logging'

const endpoint = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT
if (!endpoint) throw new Error('Set OTEL_EXPORTER_OTLP_LOGS_ENDPOINT before enabling remote logs')

const provider = new LoggerProvider({
	resource: resourceFromAttributes({
		'service.name': process.env.OTEL_SERVICE_NAME ?? 'pluxel-app',
	}),
	processors: [new BatchLogRecordProcessor({ exporter: new OTLPLogExporter({ url: endpoint }) })],
})
const sink = getOpenTelemetrySink({ loggerProvider: provider, diagnostics: true })
const host = await createHost({
	plugins: [],
	services: [
		logging({
			root: { profile: 'production' },
			sinks: {
				remote: { kind: 'logtape', label: 'OTLP', sink, caller: false },
				console: { kind: 'console', format: 'json', caller: false, timezone: 'utc' },
				memory: { kind: 'store', caller: false },
			},
			routes: {
				runtime: [
					{ sink: 'remote', minLevel: 'info' },
					{ sink: 'memory', minLevel: 'info' },
				],
				plugins: [
					{ sink: 'remote', minLevel: 'trace' },
					{ sink: 'memory', minLevel: 'trace' },
				],
				debug: [],
				meta: [{ sink: 'console', minLevel: 'warning' }],
			},
		}),
	],
}).catch(async (error) => {
	// Also cover failures before Logging takes ownership; SDK shutdown is idempotent.
	await provider.shutdown()
	throw error
})
try {
	host.ctx.logger.info('Host ready')
} finally {
	await host.close()
}

使用 servicesPreset() 时把同一个 plan 传给 logging 选项,替换上面的独立 service 装配;不要重复安装 Logging。memory 保留 Workbench/开发控制台的有界查询,diagnostics: true 将进程级 OTel SDK diagnostics 接入 LogTape,meta 输出到本地以便排查 exporter,不回送远程 sink。

  • 每个 Host 创建独立 sink/provider。Host 关闭先停止插件,再通过 LogTape async disposal 关闭 sink;此版本的 getOpenTelemetrySink 也会 shutdown 传入的 provider,不要传入其他组件仍在使用的共享 provider。
  • 运行中需要立即发送时使用原生 await provider.forceFlush()。host.ctx.logging.flush() 只刷新 store 与 policy,不是远程送达确认。批量队列、超时、重试及丢弃行为遵循 OTel SDK;没有持久队列或无损交付保证。
  • 原生 sink 保留 category(含插件身份)和结构化属性,默认把 Error 投影为 OTel exception 属性;需要保留 cause/自定义字段时使用上游 exceptionAttributes: 'raw'。后端 stream 字段选择稳定的 service/deployment 属性,不使用每次启动变化的 rootId 或 request ID。
  • 本方案不依赖 @pluxel/plugin-otel Plugin;该 Plugin 的 telemetry logger 与 ctx.logger 是不同入口。

默认示例显式使用 provider:上游自动创建模式使用逐条 processor,且未配置 endpoint 时会成为 no-op。更多参数直接查看 LogTape OTel sink、OTel SDK 和 VictoriaLogs OTLP 接入。

若部署已有采集器,直接使用 JSON console 或 JSONL file,由采集器转发即可。Pluxel 不提供供归档抓取的 HTTP 日志端点;Workbench store 的游标查询用于有界诊断,不承担可靠 pull 归档。

独立 Logging Host

import { createHost } from '@pluxel/host'
import { logging } from '@pluxel/services/logging'

const host = await createHost({
	plugins: [],
	services: [
		logging({
			root: { profile: 'application' },
			sinks: { memory: { kind: 'store', caller: false } },
			routes: {
				runtime: [{ sink: 'memory', minLevel: 'info' }],
				plugins: [{ sink: 'memory', minLevel: 'trace' }],
				debug: [],
				meta: [],
			},
		}),
	],
})

host.ctx.logger.info('Host ready')
host.ctx.logging.flushStores()
const recent = host.ctx.logging.stores.getOrCreate('default').tailWindow(100)
await host.close()

日志查询和 policy 都属于所选 Host 的 host.ctx.logging,不依赖 Management。 需要持久化 policy 时,把 createPluginLogPolicyStore(namespace) 作为 logging(plan, { policyStore }) 的选项传入;namespace 是借用资源,关闭由应用或其存储服务负责。 删除 fork 会在同一 Host 队列内清理日志 policy;写入失败时保留 fork,恢复存储后可重试。 同一进程仍只允许一个活动日志 Host,第二个安装失败不会影响第一个。

宿主安装与访问

Host 负责安装、绑定和关闭进程日志 owner。通过 host.ctx.logging 或控制台的 dev.ctx.require(Logging) 访问这一个 manager;控制台查询不创建第二个日志 owner。

在 Workbench 查看与归档

Workbench 使用同一份有界日志存储,通过已认证的 Management 会话查询与 follow,不增加 HTTP/SSE 日志 API。 日志携带 node address、reference 和可读标签;range 按传输预算分页,超大单条记录明确标记 payload 截断。 需要进程外归档时配置 file 或 OpenTelemetry sink;Plugin 的业务 HTTP 不负责暴露宿主日志。

独立 HTTP Host

安装 @pluxel/services 与 elysia@2.0.0-rc.0;Elysia 是 HTTP 服务的可选 peer,不会随其他服务安装。

当前 Elysia 已通过 TypeBox 1.4.2 的发布前 schema 编译回归;生成项目通过 catalog 统一版本。

elysia() 安装 generation-scoped Elysia application;生产 Node 接线统一使用 srvx。 listenElysia() 使用 Host 的请求分发,并拥有 listener 和 Host 的关闭;不需要重复传 handler。 可选 publicDir 在创建 listener 时固定(相对路径以当时 cwd 解析),只在非保留路径的业务 404 后服务编译资源和 SPA fallback。

import { createHost } from '@pluxel/host'
import { elysia } from '@pluxel/services/elysia'

const host = await createHost({ plugins: [MyRoutes], services: [elysia()] })
await host.startNode(MyRoutesAddress)
const listener = await listenElysia(host, { hostname: '127.0.0.1', port: 3000 })
// 应用结束时关闭 listener 与 Host。
await listener.close()

需要直接调用 Fetch 请求边界时,使用 /elysia 的 createElysiaHandler(host);调用者负责关闭 Host。 ElysiaApp 仅供 Plugin/Part 使用。底层 directory、endpoint/fallback 和 carrier 接线属于框架内部, 不提供第二套公共 server API 或可替换 adapter。Management 与 Workbench 复用同一请求分发。

宿主拥有 listener、port、process shutdown 和物理 server policy,部署 ingress、反向代理或平台拥有 TLS。Plugin 调用 application 的 listen() / stop() 会立即 失败;onStart() / onStop() 也会立即失败,因为 Elysia 2 rc.0 尚未公开供外部 carrier 驱动的 attach/detach epoch。Plugin 也不 调用 Server view 的 stop()、reload()、ref() 或 unref(),不选择 srvx/runtime adapter。srvx 的接入属于宿主 carrier 工作, 不是 Plugin 的第二套 Web 作者 API。

handler 取得的 server 是 generation-scoped、carrier-backed view。url、port、hostname 和 development 反映当前宿主 listener; id 是本 generation 内稳定的 virtual-server value,不是物理 listener identity。server.url 每次返回独立 URL,修改它不会重配 listener。Node carrier 还支持 server.requestIP(request) 读取该请求的远端 address、port 和 IP family;把其他来源或已经脱离当前 owner invocation 的 Request 传入会明确失败。

Application-private database

当 fixed catalog、schema 和部署都由同一团队维护时,把 schema、client、repositories、migration 和 connection lifecycle 放进普通 application-private package,例如 @app/database。这个 package 自己声明 ORM 和 driver dependency;不要只把依赖安装在 workspace root,再让子包隐式使用。

应用服务列表不安装 database(),使误用 ctx.require(Database) 的 Plugin 直接启动失败;production freezer 同时设置 managedDatabaseDrivers: [],避免把未使用的 PGlite 与 pg package 复制进发行物。两处配置分别约束运行时 capability 与构建闭包,必须保持一致。

内置 Plugin 优先消费 repository 或 application service。只有确实需要构造查询时才暴露 ORM client;不要让每个 Plugin 各自读取 DSN、创建 pool 或运行 migration。

整个应用依赖数据库

如果没有数据库,整个应用 就没有可运行的核心功能,数据库是 host-owned root resource:应用 prepare() 必须在 Plugin graph 启动前完成连接、migration 和必要 preflight;失败直接终止本次 host startup。成功实例按 root Context 绑定,关闭登记到 root effects,不能归属任一 consumer Plugin。

Application package 导出接收 Context 的 typed accessor,不把数据库投影成 ctx.database,也不使用进程级 module singleton:

// @app/database — application-private server module
import type { Context } from '@pluxel/core'
import { openDatabase, migrate, type AppDatabase, type DatabaseOptions } from './internal.js'

const active = new WeakMap<object, AppDatabase>()

export async function prepareAppDatabase(ctx: Context, options: DatabaseOptions): Promise<void> {
	const root = ctx.root
	if (active.has(root)) return
	const database = await openDatabase(options)

	try {
		await migrate(database)
		active.set(root, database)
		root.effects.defer(
			async () => {
				if (active.get(root) === database) active.delete(root)
				await database.close()
			},
			{ tag: 'AppDatabase', phase: 'shutdown' },
		)
	} catch (error) {
		if (active.get(root) === database) active.delete(root)
		await database.close()
		throw error
	}
}

export function appDatabaseFor(ctx: Context): AppDatabase {
	const database = active.get(ctx.root)
	if (!database) throw new Error('Application database has not been prepared')
	return database
}

appDatabaseFor(ctx) 的参数既保留 root 隔离和完整返回类型,也在调用点诚实表达 application-private dependency。不要用 declaration merging 增加 ctx.appDatabase;应用声明的泛型不能反向改变独立编译 Plugin 的 Context shape。

应用入口统一决定部署路径,同时供宿主 Persistence 和 application database 使用:

import { resolve } from 'node:path'
import { defineHostApplication } from '@pluxel/host'
import { prepareAppDatabase } from '@app/database'
import { standardServices } from '@pluxel/services'

function storagePaths({ env }) {
	// 部署时传入发行目录外的绝对路径。
	const root = resolve(env.APP_DATA_ROOT ?? './data')
	return {
		hostPersistence: resolve(root, 'runtime'),
		applicationDatabase: resolve(root, 'application.sqlite'),
	}
}

export default defineHostApplication((startup) => {
	return {
		name: 'application',
		plugins: [BillingPlugin, AuditPlugin],
		services: standardServices({ persistence: storagePaths(startup).hostPersistence }),
		async prepare({ host, startup }) {
			await prepareAppDatabase(host.ctx, {
				filename: storagePaths(startup).applicationDatabase,
			})
		},
	}
})

Plugin 在 init() 或之后同步取得已准备实例:

protected override init() {
	this.database = appDatabaseFor(this.ctx)
}

SQLite path、DSN、TLS 和 pool options 从 startup.env、bindings 或部署配置显式解析,不从 Context 猜测。Persistence 是 namespace/get/put 抽象,backend 可能是 memory、readonly 或 custom,不保证存在可打开的文件目录。

prepare() 不是通用 service lifecycle:这里只表达“数据库是整个应用的硬 readiness 前提”。数据库 package 负责领域初始化,root effects 负责 acquisition rollback、正常 stop 和 shutdown;consumer replacement 不能关闭共享实例。

只有部分 Plugin 依赖数据库

如果数据库失败时无关 Plugin 仍应运行,把数据库建模为 application-private provider Plugin,并让 consumer 通过 constructor 声明 required dependency。provider 在 init() 打开和迁移数据库,并立即把关闭登记到自己的 effects;provider failure 只阻塞 dependents。不要同时保留 root prepare() 和 provider Plugin 两套所有权。

这条路径可以使用 SQLite 或其他数据库,但应用必须自行负责 migration 并发、连接恢复、备份、durability 和 shutdown。不要把 application client 包装成 ctx.database,否则会让调用者误以为它具备 managed Plugin database 的 owner isolation 与 replacement 语义。

选择 native PostgreSQL 或 PGlite

部署或验证目标Backend
正式部署、持续用户数据或多个 Plugin 频繁访问数据库native PostgreSQL
本机开发、自动化测试PGlite
row lock、deadlock、pool exhaustion、连接中断必须验证 native PG
多进程并发 migration、advisory lock 和故障恢复必须验证 native PG
throughput、latency、容量规划或生产硬件性能验收必须使用目标 native PG

PGlite 是执行真实 PostgreSQL 语义的本地 backend,不是 query mock;它适合快速验证 schema、migration、CRUD、owner isolation 和 Plugin lifecycle。但是 Pluxel 会把共享 PGlite 上的所有 database operation 串行调度,因此一个 host 中的 Plugin 会共同受到单连接吞吐上限影响。不要用 PGlite benchmark 推断 native PostgreSQL 性能。

PGlite 的 data directory 只为本机工作流提供正常关闭后的便利重启,不是部署存储承诺。Pluxel 不以补充 filesystem flush 或 fault-injection 测试的方式把它升级为 production baseline;需要正式部署时使用 native PostgreSQL。资源受限时,应在目标设备上调低 PostgreSQL connection/pool budget 并实测,而不是把 PGlite 带入部署。

连接字符串、TLS、pool 与 PGlite data directory 是 host startup policy,不是 Plugin config。Plugin schema/query 不根据 backend 分支。

生产构建

把构建选项保存在 tsdown.config.ts:

import { defineConfig } from 'vite-plus/pack'
import { pluxel } from '@pluxel/rolldown'

export default defineConfig({
	entry: './src/app.ts',
	plugins: [pluxel()],
})

在应用的 vite.config.ts 中导入这份配置并设置 pack 字段,然后运行 vp pack。已有开发配置时保留其他字段:

import { defineConfig } from 'vite-plus'
import pack from './tsdown.config'

export default defineConfig({ pack })

@pluxel/rolldown 的 pluxel() 统一构建官方与自定义服务组合。生产 bootstrap 通过 @pluxel/host 启动同一应用,HTTP handler/listener 属于 @pluxel/services/elysia/*。 delivery 独立选择交付边界:默认 standalone 内联框架和固定插件,launcher 可选 node、fetch 或 host,不能声明 sources。modules 编译本包模块、保留真实包 imports 与公开 Plugin identity,并导出应用工厂;它不自动启动,也不接受 launcher/residualDependencies。资源 variant 与服务安装是独立决定。

modules 同时支持 package.json#imports:本包目标进入编译,外部目标保留包依赖;搬移输出后不依赖原来的源码映射。

有 sources 的应用(包括 create 模板)使用:

export default defineConfig({
	entry: './src/app.ts',
	plugins: [pluxel({ delivery: 'modules' })],
})

默认应用入口为 dist/app.mjs。若用 tsdown 的 outputOptions.entryFileNames 自定义命名,保留 .mjs 扩展名,并让启动路径对应实际产物;输出的 package.json 的 ./app export 与 pluxel-deployment.json 的 server.entry 会同步记录实际入口。

原生启动示例:

import { runHostApplication } from '@pluxel/host'
import { serviceSharedPackages } from '@pluxel/services/sources'

const host = await runHostApplication('./dist/app.mjs', {
	startup: { root: import.meta.dirname, mode: 'production', env: process.env, bindings: {} },
	sharedPackages: serviceSharedPackages,
})
let listener
try {
	const { listenElysia } = await import('@pluxel/services/elysia/node')
	listener = await listenElysia(host)
} catch (error) {
	const [cleanup] = await Promise.allSettled([host.close()])
	if (cleanup.status === 'rejected')
		throw new AggregateError([error, cleanup.reason], 'Listener startup and cleanup failed', {
			cause: error,
		})
	throw error
}
export const stop = listener.close

必须由全新 Node 进程的 launcher 在应用及其业务依赖闭包首次求值之前调用;服务运行入口也延后 import。入口先建立共享绑定,再加载默认工厂、固定 imports 和来源。已在 scope 外求值的 entry 与受控缓存中的绑定冲突会明确拒绝;Node 没有公开的 ESM cache 查询,不能证明此前普通依赖的完整闭包。预先导入这些依赖违反启动前置条件,不能事后重新绑定 token。

Core/Host 绑定启动器安装;sharedPackages 中的其他包相对 startup.root 解析,包括合法 exports,应用须声明所使用的共享包依赖。官方清单来自 @pluxel/services/sources,因此官方模板直接声明 Services、Workbench、Commands、Elysia、@sinclair/typebox、typebox 和 exact-mirror;不要依赖工作区 hoist 提供这些安装事实。实际安装版本及 importer 声明必须兼容,错误版本会拒绝;清单不安装服务。自定义服务可以追加包名。Workbench publisher 的 capnweb 由领域检查绑定到所选 Workbench,私有 RPC 保留自身解析。

交付和搬离工作区验证见发行物。Node 已有回归覆盖;其他平台需要独立验证网络与原生依赖,不能只凭 Fetch 类型兼容推定支持。

测试

插件测试统一使用 @pluxel/test 的 createTestHost(),按需显式组合服务。它们是隔离宿主,不能代表已经运行的开发应用。具体调用见测试插件。

最后更新于

本页目录