运行时能力

插件 HTTP

在插件中添加 HTTP API、webhook 和 WebSocket,并验证路由与热更新行为。

要给插件增加 API 或 webhook,在 init() 中通过 this.ctx.elysia 声明路由即可。宿主负责监听端口,关闭 Workbench 也不影响业务 HTTP。

下面的插件可以加入 快速开始 创建的应用。ctx.elysia 使用 Elysia 2 原生 API,路由路径就是最终 URL,不会自动添加插件名前缀。

最小路由


@({ : 'Orders' })
export class  extends  {
	protected override () {
		this..
			.('/health', () => ({ : true }))
			.('/orders/:id', ({  }) => this.(.))
	}

	private (: string):  {
		return {  }
	}
}

OrdersPlugin 加入宿主清单与自动启动项,启动应用后,用终端显示的 origin 请求 /health,应得到 {"ok":true};请求 /orders/42 应得到 {"id":"42"}。若返回 404,先确认插件已运行、请求发往同一宿主端口。

这里的真实地址就是 /health/orders/:id。需要共同前缀时直接使用 Elysia 的 group()

this.ctx.elysia.group('/orders', (app) =>
	app
		.get('/health', () => ({ ok: true }))
		.post('/webhooks/payment', ({ body }) => this.acceptPayment(body)),
)

最终地址是 /orders/health/orders/webhooks/payment/__pluxel 是宿主 control plane 的保留 namespace,Plugin 不能在其中声明路由。路径与访问策略正交:公开路径不会自动获得匿名访问,鉴权、签名校验、租户判断、CORS 和速率限制仍由 明确的 Elysia plugin、业务 handler 或宿主策略实现。

fork 或多个 Plugin 若要同时提供 HTTP,必须声明彼此不冲突的最终路径。不要从 Plugin identity 或 fork id 猜测 URL;路径本身 就是产品 contract。

与产品 SPA 和 Workbench 共用 origin

Runtime 只保留 /__pluxel;不会强制业务路由使用 /api 或其他前缀。同一 listener 上的请求按以下边界仲裁:

/__pluxel/**                 -> Runtime / Workbench
匹配 Plugin HTTP/WS route    -> owning Plugin generation
Workbench document path     -> Workbench shell
其余 navigation             -> 产品 SPA fallback

因此 Plugin 显式声明 GET /settings 时会优先于产品 SPA 的 /settings。这是产品选择的最终路径所有权,不是 Runtime 可以从两个 独立 Router 自动判定的冲突。应用可以约定 /api/webhooks 等首段来降低误用,但 Pluxel 不把团队惯例升级为框架限制;真正需要 提供独立产品页面的 Plugin 也可以拥有明确的 mount point,并自行配置该前端的 Router basename 与 asset base。

Workbench contribution 不通过业务 HTTP route 抢占产品页面。Shell 在 workbench.uiBasePath 下组织其逻辑路由,框架 API、session、 federation artifact 与 Shell asset 均留在 /__pluxel/**。包含产品 SPA 的 host 通常使用 workbench.uiBasePath: '/__pluxel/workbench'

直接使用 Elysia 能力

ctx.elysia 是上游 Elysia instance,不是 facade 或 Proxy。schema、model、macro、hook、guard、derive、resolve、error handler、 cookie、stream 和普通 function plugin 都按 Elysia 2 API 使用。需要这些 API 的 Plugin package 直接依赖 elysia@pluxel/runtime 不重新导出 Elysia 或它的官方 plugin。

发布给其他宿主使用的 Plugin package 应把宿主支持的 Elysia 精确版本同时声明为 peerDependenciesdevDependencies:peer 保证运行时复用宿主 singleton,dev dependency 则供本 package 编译、测试和编辑器解析。不要把 Elysia 打进 Plugin bundle,也不要让 Plugin 自带另一份 runtime copy。当前 Runtime 锁定 2.0.0-beta.7,对应声明为:

{
	"peerDependencies": {
		"elysia": "2.0.0-beta.7"
	},
	"devDependencies": {
		"elysia": "2.0.0-beta.7"
	}
}

应用仓库内的 private Plugin 也必须通过 workspace catalog 解析到同一个版本;不能用宽范围产生第二份 Elysia。这个约束同时覆盖 elysia 根入口和 elysia/websocket 等 subpath。

静态构建与动态开发都复用宿主的同一份 Elysia,包含其公开子入口。源码关联可能产生不同的 pnpm 物理路径,但插件仍须声明上述精确版本;当前加载流程不会替作者验证所有版本范围。

Elysia 2 的 route schema 位于 handler 之前:

import { t } from 'elysia'

this.ctx.elysia.post(
	'/orders',
	{
		body: t.Object({
			customerId: t.String({ minLength: 1 }),
			items: t.Array(t.String(), { minItems: 1 }),
		}),
	},
	async ({ body, status }) => {
		const order = await this.createOrder(body)
		return status(201, order)
	},
)

在 HTTP 边界校验 params、query、headers 和 body。HTTP DTO 是外部 contract;不要直接返回数据库 row、Plugin instance、 Context、Error object 或带 credential 的 SDK response。

可复用的业务路由写成普通 Elysia function plugin,不需要 Pluxel adapter:

import type { Elysia } from 'elysia'

function ordersApi(service: OrdersService) {
	return (app: Elysia) =>
		app.group('/orders', (app) =>
			app.get('/:id', ({ params }) => service.find(params.id)),
		)
}

protected override init() {
	this.ctx.elysia.use(ordersApi(this.orders))
}

WebSocket

Node production carrier、static Vite 和 dynamic Vite 已通过真实 listener 验证 Elysia 2 的基础业务 WebSocket。作者仍直接使用 Elysia,不需要 Pluxel WebSocket facade:

import { websocket } from 'elysia/websocket'

this.ctx.elysia.use(websocket()).ws('/events', {
	open(socket) {
		socket.subscribe('orders')
	},
	message(socket, message) {
		socket.send({ type: 'echo', message })
	},
	close(socket) {
		socket.unsubscribe('orders')
	},
})

当前 Node 路线支持基础 open / message / close dispatch、send 和 pub/sub。同名 topic 会自动限定在 owning Plugin generation, 不会跨 owner 广播。replacement 或 owner stop 会先 abort connection owner signal,再以 1012 Service Restart 关闭该 owner 的 socket; 其他 owner 的连接不受影响。若 transport 没有回报 close,宿主会在有界等待后终止连接并释放 generation lease。

Vite 始终先保留自己的 HMR protocol/path,只有非 HMR 且命中当前 Elysia .ws() route 的 upgrade 才进入业务 carrier。

PluginPart 与 Elysia scope

同一 generation 的 root Plugin 和所有 Part 共享相同的 application identity。Part 可以直接注册路由:

class MetricsPart extends PluginPart<OrdersPlugin> {
	protected override init() {
		this.ctx.elysia.get('/orders/metrics', () => this.snapshot())
	}
}

这也表示 Part 不是 Elysia semantic isolation boundary。root 与 Part 按实际 children-before-owner 注册顺序共同组成一个 app,hook、 model、macro、store 和 named plugin dedupe 遵循 Elysia 在该 app 内的规则。需要独立路径治理、hook 隔离、启停或撤销的组成应成为 真正 Plugin。

不同 Plugin generation 则各自拥有独立 app。一个 Plugin 的 global hook、store 或 error handler 不会因为宿主组合顺序作用到另一个 Plugin 或 control plane。

Finalization 与生命周期

Plugin 和所有 Part 的 init() 成功后,Runtime 会等待 lazy Elysia modules,检查 route inventory,再调用 Elysia 2 自己的 app.compile() 固化 application。compile 会 seal 同一个 instance;generation running 后继续增加 route、hook、store 或 decorator 会由 Elysia 2 fail-fast。

作者不需要保存 publication handle。配置或源码变化建立新 generation;不要在 running generation 内原地改 route tree。finalization 失败不会发布部分路由;成功 contribution 与 Core running projection 一起提交。Plugin stop、replacement 或 rollback 后, 旧 generation 不再接收新请求。

请求进入 app 前会取得 owner generation lease。返回 streaming Response 时,lease 延伸到 body close、cancel 或 error;generation 停止会 abort handler 看到的 request.signal,并等待已经接纳的 response settle。无法响应 signal 的任意 JavaScript 仍受宿主 drain timeout 约束,Runtime 不会假装能同步终止它。

长期 background task 不应挂在某个 HTTP request Promise 上。把它建模为 owner-bound worker/queue,再让 endpoint 只提交任务或查询状态。

挂载已有 Fetch application

已有 WinterTC-style Fetch application 使用 Elysia 原生 mount()

this.ctx.elysia.mount('/legacy', (request) => legacyRouter.fetch(request))

请求仍从整个 generation contribution 的 admission 与 cleanup 边界进入;Pluxel 不再定义另一套 Fetch boundary 或 mount handle。

按所有权组织大型路由

URL 层级是产品协议,Plugin 依赖图是生命周期协议;两者可以长得相似,但不能互相代替。先决定谁应当随谁启动、失败、替换和撤销, 再决定 path。按下面的信号选择 route owner:

需求组织方式
一组 route 共享发布版本、鉴权、hook 和撤销边界一个 Plugin 拥有 Elysia app,内部用普通 function plugin 拆模块
webhook、WebSocket 或 API 需要独立启停、失败隔离或 HMR对应能力成为独立 Plugin,直接使用自己的 ctx.elysia
provider、adapter 等动态集合只贡献状态或处理器数据一个 Plugin 拥有固定 ingress,其他 Plugin 向 typed registry 注册数据
Plugin 只有领域能力,没有入站 HTTP不读取 ctx.elysia,保持 Elysia application 严格惰性

固定产品 API 由 ingress 依赖领域能力

对于已知且需要一起发布的 route tree,让 HTTP ingress Plugin 通过 constructor 依赖领域 Plugin,再在自己的 app 中组合普通 Elysia function plugin。不要让领域 Plugin 反向依赖 ingress,也不要把 ingress 的 app 暴露给其他 Plugin 修改:

function roomRoutes(rooms: Rooms) {
	return (app: Elysia) =>
		app.get('/rooms', () => rooms.list()).get('/rooms/:id', ({ params }) => rooms.get(params.id))
}

function queueRoutes(queue: Queue) {
	return (app: Elysia) =>
		app
			.get('/queues/:id', ({ params }) => queue.list(params.id))
			.post('/queues/:id/items', ({ params, body }) => queue.add(params.id, body))
}

@Plugin()
export class MusicApiPlugin extends BasePlugin {
	constructor(
		private readonly rooms: Rooms,
		private readonly queue: Queue,
	) {
		super()
	}

	protected override init() {
		this.ctx.elysia.group('/music/api', (app) =>
			app.use(roomRoutes(this.rooms)).use(queueRoutes(this.queue)),
		)
	}
}

这些 function plugin 没有独立 Plugin identity、config 或 lifecycle;它们只是同一个 Elysia owner 内的代码组织。修改任一模块会建立新的 MusicApiPlugin generation,并原子替换完整 route tree。不要为拆文件而创建更多 Pluxel Plugin。

动态扩展使用固定 ingress 与 registry

当 provider 集合可以动态出现、消失或 HMR replacement 时,不要让它们向一个已经运行的 app 追加 route。Elysia app 会在 Plugin 初始化后 compile/seal,跨 Plugin 修改还会失去 route ownership。让一个 Plugin 声明固定 route,动态 Plugin 只注册有明确清理语义的数据:

type DiagnosticSource = Readonly<{
	id: string
	snapshot(): Readonly<{
		status: 'healthy' | 'degraded' | 'unavailable'
		metrics: Readonly<Record<string, string | number>>
	}>
}>

@Plugin()
export class DiagnosticsPlugin extends BasePlugin {
	private readonly sources = new DiagnosticSourceRegistry()

	registerSource(source: DiagnosticSource): () => void {
		return this.sources.register(source)
	}

	protected override init() {
		this.ctx.elysia.group('/music', (app) => app.get('/diagnostics', () => this.sources.snapshot()))
	}
}

贡献者通过普通 Plugin 依赖或可选 definePluginRef() 调用 registerSource(),并把返回的 disposer 交给当前 generation effects:

this.plugins.use(Diagnostics, (diagnostics) =>
	diagnostics.registerSource({
		id: 'platform.voice',
		snapshot: () => this.currentDiagnostics(),
	}),
)

plugins.use() 同时建立 lifecycle edge:Diagnostics provider 出现、消失或 replacement 时,调用它的 consumer 会重启。若 consumer 本身拥有不应被诊断系统 HMR 打断的长连接、播放器或其他昂贵资源,把 contribution 放在已有的产品集成 Plugin;没有合适 集成层时,再建立一个只拥有这条跨域集成 lifecycle 的小 Plugin。它通过 constructor 依赖业务能力,并可选依赖 ingress:

const Diagnostics = definePluginRef<DiagnosticsPlugin>()

@Plugin()
export class VoiceProductIntegrationPlugin extends BasePlugin {
	constructor(private readonly voice: VoiceGatewayPlugin) {
		super()
	}

	protected override init() {
		this.plugins.use(Diagnostics, (diagnostics) =>
			diagnostics.registerSource(voiceDiagnosticSource(this.voice)),
		)
	}
}

这样 Diagnostics replacement 只重启轻量集成层,不会反向重启 VoiceGatewayPlugin。只有当重启业务 Plugin 本来就是正确语义时, 才在业务 Plugin 本体声明 optional integration;该业务 Plugin 自己拥有的 command registration、临时状态和其他 effects 也会一起重建。 如果这些资源同样不能中断,就使用示例中的专用小型集成 Plugin。不要用 ambient event handshake 隐藏这条真实依赖。

这样 contributor replacement 只撤销旧 source 并注册新 source;固定 ingress、鉴权和 schema 不发生 late mutation。source ID、重复注册、 snapshot 上限、错误隔离与 disposer 幂等性属于 registry contract。请求期 snapshot 应读取已经拥有的有界内存状态,不执行平台探测; 需要异步采集时由 owner 调度并缓存 snapshot。聚合层必须按公开 DTO 逐字段投影并运行时校验,不用对象 spread 把 contributor 的额外字段 带到 HTTP 边界;TypeScript 类型不能阻止 token、连接地址、内部 Error 或第三方 SDK 对象意外进入运行时对象。固定 ingress 还应直接用 Elysia 原生 response schema 声明完整 HTTP DTO,让运行时响应校验、序列化和 OpenAPI 继续只有一个上游契约。

异步聚合还要单独记录 source-set revision:采集中发生注册或撤销时,丢弃已失效结果,再从最新 source set 重算;普通领域事件和周期采样 应合并 refresh demand,不能持续使正在进行的有效采集失效。要求 source 集合精确的 endpoint 只等待当前 refresh barrier;普通领域事实 采用事件触发与周期采样的最终一致语义,纯读取本身不标记新变化或重新采样。事件订阅可以稍后收到新 snapshot,但不能在撤销后重新发布 旧 generation 的 contribution。

以下模式会破坏边界,应改用上面的 owner 或 registry:

  • featurePlugin -> apiPlugin.elysia.get(...):依赖方向倒置,route 无法随 contributor 精确撤销;
  • 自定义 registerRoute() / mountRoute():重新制造一套弱于 Elysia 的作者 API;
  • 运行中向 app 追加 route:越过 native compile/seal 与 generation publication;
  • 用 standalone new Elysia() 拼接 Pluxel Plugin:server lifecycle、hook 和 WebSocket owner 可能分裂;
  • 只为共享 path prefix 建立核心 Plugin:group() 或普通常量已经能表达 namespace,prefix 本身不是 lifecycle。

宿主拥有 listener、port、process shutdown 和物理 server policy,部署 ingress、反向代理或平台拥有 TLS。Plugin 调用 application 的 listen() / stop() 会立即 失败;setup() / cleanup() 也会立即失败,因为 Elysia 2 beta.7 尚未公开供外部 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。urlporthostnamedevelopment 反映当前宿主 listener; id 是本 generation 内稳定的 virtual-server value,不是物理 listener identity。server.url 每次返回独立 URL,修改它不会重配 listener。Node carrier 还支持 server.requestIP(request) 读取该请求的远端 address、port 和 IP family;把其他来源或已经脱离当前 owner invocation 的 Request 传入会明确失败。

当前 Elysia 2 与 carrier 边界

Runtime 当前锁定 Elysia 2.0.0-beta.7。已经验证并作为当前 contract 的是 Fetch HTTP route、普通 Elysia composition、native compile/seal、atomic generation publication、stream lease、owner withdrawal,以及上述三条 Node listener 路线的基础业务 WebSocket。 以下能力仍不能按“所有 runtime 上完整等同原生 Elysia server”使用:

  • Elysia setup() / cleanup() 尚无公开 external attach/detach runner。直接注册会 fail-fast;把带隐藏 lifecycle callback 的 standalone Elysia instance 再通过 .use() 合并也不属于受支持路径。Pluxel 不读取 beta private fields 自行模拟。
  • 目前只有 Node production、static Vite 和 dynamic Vite carrier 完成 conformance;尚无第二个 Bun、Deno 或 Worker carrier,因此 “portable application seam”不等于已经证明跨平台 transport parity。
  • crossws 的 portable socket API 尚不能实现 Elysia socket 的主动 pong()。不同 runtime 的 send 返回值、backpressure 和 buffered byte 语义也尚未完成精确对齐,不应据此编写跨 runtime 流控协议。
  • Elysia application-level WebSocket tuning 尚未完整投影到共享 carrier,例如全部 payload、compression、idle timeout 和 transport tuning 不能视为每个 Plugin 独立拥有的设置。
  • Runtime 会拒绝相同 method 与相同声明 path 的跨 owner 冲突,也会拒绝 /__pluxel;但 beta 的 public inventory 尚不足以证明所有 canonical-equivalent pattern 都能与 Elysia matcher 完全一致地预检,例如仅参数名不同的 pattern。当前应给每个业务 API 使用明确、 唯一的首段 namespace,并用真实请求覆盖边界。

这些限制属于 Elysia/carrier seam,不会通过增加 Pluxel Web wrapper 来掩盖。Node 已验证范围内可以使用业务 WebSocket;依赖上述 portable parity 或 tuning 的应用应等待对应 conformance 完成。

错误边界

区分可预期的请求错误与 lifecycle 失败:

情况处理
参数、权限或状态冲突使用 Elysia status/Response 返回明确 4xx DTO
上游超时或临时不可用返回领域定义的 5xx/503,并保留 server log cause
未知请求异常交给 Elysia error boundary,向外返回不含敏感信息的通用错误
reserved path、route conflict 等generation start failed;不发布该 contribution
Plugin 根本无法继续提供能力触发 lifecycle failure/restart,而不是持续提供半失效的 HTTP 500

不要把 stack、token、内部文件路径或完整上游 response body 直接返回客户端。

测试

Runtime test host 的 host.http.fetch() 会经过真实 directory、generation admission 和 sealed Elysia app,但不打开端口:

const response = await host.http.fetch(new URL('/orders/42', host.http.origin))

至少验证:

  • success 和 validation failure;
  • 最终产品 path;
  • Plugin remove/replacement 后旧 route 返回 404;
  • stream cancellation 与 owner stop(若 handler 返回 stream);
  • auth/signature failure 不泄露内部错误;
  • Workbench disabled 时业务 route 仍工作。

host.http.fetch() 不执行 HTTP Upgrade,也不证明真实 listener disconnect、WebSocket close code、backpressure 或 HMR arbitration。需要这些 carrier 能力时必须使用 Node production、static Vite 或 dynamic Vite 对应的 ephemeral real-listener integration test;不能用普通 Fetch response 代替。完整 test host 配置见测试 Pluxel 插件。 出站请求可以使用官方 Wretch Plugin 或领域 HTTP client,不要与入站 Elysia application ownership 混在一起。

效率模型

未读取 ctx.elysia 的 generation 不创建 Elysia app。已发布请求先由 immutable business directory 选择 owner contribution,再进入该 owner 的 sealed Elysia app;这是一次有意识的两级 routing,用来隔离跨 Plugin global hook、store、plugin dedupe 与 generation withdrawal。

目前没有同场 benchmark 可以证明这条路径“接近裸 Elysia”或给出跨机器延迟承诺。后续 benchmark 应比较 plain Elysia 与 1/10/100/1000 contribution 的 directory delegate、snapshot build、stream drain;如果两级 lookup 成为主要瓶颈,应优化内部 dispatcher,而不是把 route index 或 carrier tuning 变成 Plugin API。

最后更新于

本页目录