Skip to content

feat(server): webserver standalone mode#1665

Merged
IceyLiu merged 80 commits intomainfrom
zynx/feat/webserver-standalone
Mar 24, 2026
Merged

feat(server): webserver standalone mode#1665
IceyLiu merged 80 commits intomainfrom
zynx/feat/webserver-standalone

Conversation

@piorpua
Copy link
Copy Markdown
Contributor

@piorpua piorpua commented Mar 24, 2026

Summary

  • Enable AionUi to run as a standalone web server (without Electron), supporting Docker deployment
  • Introduce ISqliteDriver abstraction with BunSqliteDriver and BetterSqlite3Driver for runtime detection
  • Add IPlatformServices DI layer to decouple platform-specific APIs from core logic

Changes

Core Architecture

  • feat(platform): Introduce IPlatformServices interface and NodePlatformServices / ElectronPlatformServices implementations
  • feat(database): Add ISqliteDriver, BunSqliteDriver, BetterSqlite3Driver, and createDriver factory with runtime detection
  • refactor(database): Migrate AionUIDatabase to static async factory; update all getDatabase() call sites

Standalone Server

  • feat(server): Add standalone server entry point (src/server.ts) and Dockerfile
  • feat(server): Switch server runtime to Bun with bun:sqlite driver
  • feat(server): Add standalone renderer build (vite.renderer.config.ts) without Electron dependency
  • feat(server): Enable bridges in standalone mode: cronBridge, mcpBridge, notificationBridge, pptPreviewBridge, fsBridge, applicationBridgeCore
  • feat(server): Extract initBridgeStandalone — bridge init without Electron-only bridges
  • feat(adapter): Add shared registry for WebSocket broadcasters and bridge emitter

Config Migration

  • feat(config): Add configMigration module for Electron → server config import
  • feat(config): Wire config migration into initStorage startup sequence
  • fix(config): Guard migration to only run outside Electron (process.versions.electron)

Bug Fixes

  • fix(storage): Guard Electron-only calls; support DATA_DIR env var
  • fix(server): Fix blank page, cookie import, and data persistence in standalone mode
  • fix(server): Fix WebSocket upgrade, ACP detection, and renderer serving
  • fix(server): Fix Gemini worker IPC and WASM loading in standalone mode
  • fix(utils): Reimplement hasElectronAppPath via process.versions.electron

Test Plan

  • Run bun run server and verify renderer loads in browser
  • Verify chat, model selection, and conversation persistence work in standalone mode
  • Run bun run test — all unit tests pass
  • Build Docker image and verify server starts correctly
  • Verify config migration imports Electron config on first standalone launch
  • Run prek run --from-ref origin/main --to-ref HEAD — no lint/format issues

zynx added 30 commits March 20, 2026 12:38
Add a dependency-injection abstraction over Electron-specific APIs so the
standalone WebServer can run in pure Node.js / Docker without Electron.

New files:
- src/common/platform/IPlatformServices.ts   – interface definitions
- src/common/platform/index.ts               – register/get singleton
- src/common/platform/NodePlatformServices.ts  – Node.js implementation
- src/common/platform/ElectronPlatformServices.ts – Electron wrapper
- src/common/platform/register-node.ts       – side-effect entry for server.ts
- src/common/platform/register-electron.ts   – side-effect entry for process/index.ts
- src/common/electronSafe.ts                 – null-safe shim (marked @internal)
- tests/unit/platform/platformRegistry.test.ts
- tests/unit/platform/NodePlatformServices.test.ts

Migrated call sites:
- appEnv.ts, utils.ts, initStorage.ts, staticRoutes.ts, McpProtocol.ts,
  extensions/constants.ts → getPlatformServices().paths.*
- ForkTask.ts → getPlatformServices().worker.fork (drops utilityProcess)
- CronService.ts → getPlatformServices().power.{preventSleep,allowSleep}
- notificationBridge.ts → getPlatformServices().notification.send
  (removes Electron-only click/failed/close handlers and setMainWindow)

Updated tests: appEnv, BaseAgentManagerDecouple, cronService, tray,
applicationBridge, mainWindowLifecycle, vitest.setup
- conversionService: use electronSafe shim for BrowserWindow (allowed consumer)
- authRoutes: update verifyQRTokenDirect import path to webuiQR module
Resolve conflicts in initStorage.ts and utils.ts:
- utils.ts: keep platform DI imports (getPlatformServices) over direct electron import
- initStorage.ts: adopt ASAR unpack redirect from main, use getPlatformServices().paths.isPackaged() instead of app.isPackaged
…rm services

- configureChromium: add app.setPath('userData') alongside setName, since
  Electron 28+ no longer derives userData from app.getName() on macOS
- platform/index.ts: auto-register NodePlatformServices for utility processes
  (process.type !== 'browser') where app is unavailable
- ElectronPlatformServices: inject DATA_DIR env var when forking utility
  processes so NodePlatformServices resolves correct paths
- process/index.ts: restore configureChromium as first import so app name
  and userData path are set before any other module initializes
- Add server npm scripts using build-server.mjs (dev/prod/remote variants)
- Add scripts/build-server.mjs for standalone server build
- Add tsx dev dependency for TypeScript execution
- Add dist-server/ to .gitignore (build output)
- Add design docs for server scripts feature
…tformServices

Rollup does not bundle dynamic require() calls — only static imports are
inlined. The previous require("./NodePlatformServices") in getPlatformServices()
left a dangling relative reference in the output chunk that failed at runtime
with MODULE_NOT_FOUND. Replace with a static import so Rollup inlines the
class into the shared chunk.
…e.ts

Remove static import of getSystemDir from initStorage.ts in utils.ts to
eliminate the circular dependency causing module initialization failure in
esbuild-bundled server output. Add optional cacheDir parameter to
copyFilesToDirectory instead; callers that need temp-file cleanup pass
cacheDir explicitly.
…tion details

- Fix pragma() Bun adapter: three-case logic (setter/getter-array/getter-scalar)
- Fix BunSqliteDriver.run() to use db.run(sql, ...args) not db.query().run()
- Document getDatabase() async migration scope (~80 callers, includes sync sites)
- Acknowledge full exec() split scope: ~30+ call sites across migrations v1-v15
- Mark bun:sqlite as required external in build script (not optional)
- Add IStatement.run() lastInsertRowid field
- Add BunSqliteDriver test integration plan (bun test, test:bun script)
zynx added 24 commits March 23, 2026 11:28
…ver mode

- Add worker entry points (gemini, acp, codex, openclaw-gateway, nanobot) to
  build-server.mjs so BaseAgentManager can fork them via dist-server/<type>.js
- Fix pipe.ts to support both Electron (process.parentPort) and Node.js
  child_process.fork (process.on/send) IPC — workers were silently receiving
  no messages in server mode
- Add unhandledRejection handler in worker utils to prevent tree-sitter WASM
  stub errors from crashing the worker process
- Switch worker builds to wasmRuntimePlugin: copy tree-sitter WASM files to
  dist-server/wasm/ and load them via fs.readFileSync at runtime, eliminating
  the CompileError/abort noise and enabling shell syntax parsing
Replace getUserSkillsDir() and findBuiltinResourceDir() with
initStorage exports (getSkillsDir, getBuiltinSkillsDir) which
already abstract the platform via getPlatformServices().
Fixes skills hub showing 0 skills in standalone server mode.
systemInfo, updateSystemInfo, getPath are now shared between
Electron and standalone. Electron-only handlers (restart,
devtools, zoom, CDP) remain in applicationBridge.ts.
resolveBuiltinDir("src/skills") only works for packaged Electron builds
because viteStaticCopy maps src/process/resources/skills/** → skills/.
In development and standalone server mode the path src/skills does not
exist, so existsSync returned false and the copy was silently skipped.

Add a fallback chain after resolveBuiltinDir:
- Development: src/process/resources/skills/ (actual source path)
- Standalone production: dist-server/skills/ (copied by build-server.mjs)

Also update build-server.mjs to copy src/process/resources/skills/ →
dist-server/skills/ so the skills are available alongside the bundle.
… to prevent crash

Previously, getOrBuildTask was called with void before the conversation was
saved to the database, causing an unhandled promise rejection that crashed
the Bun process when changing workspace.
Resolved 8 conflicts:

- src/process/bridge/index.ts: added missing initPptPreviewBridge import/call/export from v1.8.33
- src/process/bridge/conversationBridge.ts: kept HEAD fix (getOrBuildTask moved after createWithMigration)
- src/process/bridge/fsBridge.ts: applied v1.8.33 error handling improvements (ENOENT→null for readFile/readFileBuffer, return '' for readBuiltinRule/readBuiltinSkill, try/catch for fetchRemoteImage, fs.mkdir before createZip writeFile)
- src/process/task/AcpAgentManager.ts: kept HEAD (standalone branch uses direct presetContext injection, not prepareFirstMessageWithSkillsIndex)
- src/process/utils/configureChromium.ts: kept HEAD (Electron 28+ specific userData path fix using dirname)
- src/process/utils/initStorage.ts: applied v1.8.33 (src/skills→src/process/resources/skills dev mapping, morph-ppt preset enabledByDefault)
- tests/unit/applicationBridge.test.ts: took v1.8.33 (simplified mocks with mockElectronApp helper, appData path support)
- tests/unit/fsBridge.skills.test.ts: took v1.8.33 (new tests for ENOENT, createZip parent dir, readBuiltinRule/Skill, fetchRemoteImage error handling)
Replace electron app.getPath('userData') with getPlatformServices().paths.getDataDir()
to remove the Electron dependency, then register initPptPreviewBridge in initBridgeStandalone.
Design and plan for migrating AI provider/MCP config from Electron
desktop app to standalone Node server on first startup, with optional
manual import via IMPORT_CONFIG_FROM env var.
…n standalone mode

- Initialize ExtensionRegistry and ChannelManager in server.ts main() so that
  channel plugins (Telegram, Lark, DingTalk) work in standalone server mode;
  previously enablePlugin always returned 'manager not initialized'
- Add ChannelManager.shutdown() to standalone server shutdown sequence
- Pass globalThis.fetch to grammY Bot constructor to avoid node-fetch@2 vs
  abort-controller AbortSignal instanceof mismatch in the bundled server build;
  native fetch (undici/Chromium) accepts abort-controller signals via duck-typing
- Register NodePlatformServices in acp worker entry point so that module-level
  calls to getPlatformServices() do not throw on startup in Node.js (non-Electron)
- fsBridge.skills.test.ts: add getSkillsDir/getBuiltinSkillsDir to
  initStorage mock; update builtinBase path to match getBuiltinSkillsDir()
- NodePlatformServices.test.ts: update assertions to match actual
  fallback paths (homedir/.aionui-server) and getAppPath (process.cwd())
- pptPreviewBridge.test.ts: mock @common/platform/index so
  checkForUpdate does not throw "Services not registered"
getUserExtensionsDir() and resolveStatesFile() were using
os.homedir() + getEnvAwareName('.aionui') directly, which caused
the standalone server and Electron dev app to share the same
~/.aionui-dev/extensions/ and extension-states.json paths.

Replace with getDataPath() which already handles per-environment
isolation: returns the CLI-safe symlink (~/.aionui-dev) on macOS
Electron and the isolated data dir (~/.aionui-server/aionui) in
standalone mode.
@piorpua
Copy link
Copy Markdown
Contributor Author

piorpua commented Mar 24, 2026

Code Review:feat(server): webserver standalone mode (#1665)

变更概述

本 PR 为 AionUi 引入独立 Web 服务器模式,允许不依赖 Electron 直接以 Docker 容器部署。核心改动包括:新增 IPlatformServices 依赖注入层(ElectronPlatformServices / NodePlatformServices)、ISqliteDriver SQLite 驱动抽象(BunSqliteDriver / BetterSqlite3Driver 运行时探测)、独立服务器入口 src/server.ts、桥接层适配(initBridgeStandalone),以及首次启动时从 Electron 桌面端迁移配置的 configMigration 模块,涵盖 renderer 独立构建和 Dockerfile。


方案评估

结论:✅ 方案合理

架构分层清晰,DI 层的引入有效解耦了平台相关代码,ISqliteDriver 的抽象方式与项目现有模式一致。registry.ts 的广播器注册机制解决了 Electron IPC 适配器与 WebSocket 适配器共存的问题,信号量处理也在 main() 之外正确注册以避免竞态。方案没有发现根本性的设计缺陷。


问题清单

🟠 HIGH — Dockerfile 未设置 DATA_DIR 默认值,存在数据丢失风险

文件Dockerfile,第 20–34 行

问题代码

ENV PORT=3000
ENV NODE_ENV=production
ENV ALLOW_REMOTE=true

VOLUME ["/data"]
EXPOSE 3000

CMD ["bun", "dist-server/server.mjs"]

问题说明
VOLUME ["/data"] 声明了数据卷挂载点,但 DATA_DIR 环境变量未设置默认值。NodePlatformServices 的逻辑是:

getDataDir: () => process.env.DATA_DIR ?? path.join(os.homedir(), '.aionui-server'),

若用户未手动传入 -e DATA_DIR=/data,数据将写入容器内部 ~/.aionui-server不在 /data 卷内,容器重启后数据全部丢失。VOLUME 声明形同虚设,给用户造成已有持久化的误导。

修复建议

ENV PORT=3000
ENV NODE_ENV=production
ENV ALLOW_REMOTE=true
ENV DATA_DIR=/data          # ← 添加:默认写入 VOLUME 路径

VOLUME ["/data"]
EXPOSE 3000

🔵 LOW — configMigration.ts 中存在 6 处 no-await-in-loop lint 警告

文件src/process/utils/configMigration.ts,第 103、110、115、156、164、169 行

问题代码(以 migrateFromElectronConfig 为例):

for (const key of MIGRATABLE_KEYS) {
  const existing = await configStore.get(key).catch(...)  // ← lint warning
  if (existing !== undefined && existing !== null) continue;
  await configStore.set(key, ...)  // ← lint warning
}

问题说明
Oxlint 报告 6 处 no-await-in-loop 警告(Warning 级别)。虽然此处"先读后写"的顺序语义是正确且有意为之的(不能将所有 get 并发后再 set,因为需要判断 existing),但 lint 警告会出现在 CI 输出中,需要明确标注意图或重构。

修复建议(添加 eslint-disable 注释以明确意图):

// Sequential read-before-write is intentional: each key is checked individually
// before writing to avoid overwriting user config with migrated data.
// eslint-disable-next-line no-await-in-loop
const existing = await configStore.get(key).catch((): undefined => undefined);
if (existing !== undefined && existing !== null) continue;
// eslint-disable-next-line no-await-in-loop
await configStore.set(key, sourceValue as IConfigStorageRefer[typeof key]);

🔵 LOW — ElectronPlatformServices.tspostMessage 误触发 lint 规则

文件src/common/platform/ElectronPlatformServices.ts,第 8–10 行

问题代码

postMessage(message: unknown): void {
  this.up.postMessage(message);
}

问题说明
unicorn/require-post-message-target-origin 要求为所有 postMessage 调用添加 targetOrigin 参数,但该规则针对的是 Window.postMessage()。此处调用的是 Electron UtilityProcess.postMessage(message, transfer?),接口签名完全不同,不存在 targetOrigin 参数。这是 lint 规则的误判,但会产生噪音警告。

修复建议

postMessage(message: unknown): void {
  // eslint-disable-next-line unicorn/require-post-message-target-origin
  this.up.postMessage(message);
}

汇总

# 严重级别 文件 问题
1 🟠 HIGH Dockerfile:29 DATA_DIR 未默认为 /data,Docker 部署数据不持久化
2 🔵 LOW src/process/utils/configMigration.ts:103–169 6 处 no-await-in-loop lint 警告,需注释说明意图
3 🔵 LOW src/common/platform/ElectronPlatformServices.ts:9 误触发 require-post-message-target-origin lint 警告

结论

⚠️ 有条件批准 — 存在一个 Dockerfile 部署配置问题(HIGH),会导致 Docker 环境下用户数据在容器重启后丢失,建议在正式发布前修复。两处 LOW 问题不影响功能,可在后续 PR 处理。


本报告由本地 pr-review skill 生成,包含完整项目上下文,无截断限制。

Without a default, data was written to ~/.aionui-server inside the
container instead of the declared VOLUME /data, causing data loss on
container restart.

Review follow-up for #1665
@piorpua
Copy link
Copy Markdown
Contributor Author

piorpua commented Mar 24, 2026

PR Fix 验证报告

原始 PR: #1665
修复方式: 直接推送到 `zynx/feat/webserver-standalone`

# 严重级别 文件 问题 修复方式 状态
1 🟠 HIGH `Dockerfile:29` `DATA_DIR` 未默认为 `/data`,Docker 部署数据不持久化 新增 `ENV DATA_DIR=/data`,更新挂载注释 ✅ 已修复
2 🔵 LOW `src/process/utils/configMigration.ts:103–169` 6 处 `no-await-in-loop` lint 警告 ⏭️ 跳过
3 🔵 LOW `src/common/platform/ElectronPlatformServices.ts:9` 误触发 `require-post-message-target-origin` lint 警告 ⏭️ 跳过

总结: ✅ 已修复 1 个 | ⏭️ 跳过 2 个

@IceyLiu IceyLiu merged commit 1ccecb8 into main Mar 24, 2026
15 of 17 checks passed
@piorpua piorpua deleted the zynx/feat/webserver-standalone branch March 24, 2026 08:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants