mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(plugin-cli): add standalone plugin development runtime
This commit is contained in:
@@ -11,6 +11,9 @@ apps/desktop/tsconfig.json text eol=lf
|
||||
apps/desktop/*.ts text eol=lf
|
||||
src-tauri/windows/nsis/**/*.nsi text eol=lf
|
||||
|
||||
# Keep plugin dev-host formatting checks consistent on Windows checkouts.
|
||||
plugins/sdk/dev-host/** text=auto eol=lf
|
||||
|
||||
# Keep tests available for review and CI without counting them as shipped code
|
||||
# in GitHub's repository language breakdown.
|
||||
packages/app-tests/** linguist-vendored
|
||||
|
||||
@@ -132,6 +132,11 @@ jobs:
|
||||
- name: Test Rust CLI
|
||||
run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
|
||||
|
||||
- name: Prepare and test plugin development runtime
|
||||
run: |
|
||||
npm ci --prefix plugins/sdk/dev-host
|
||||
npm test --prefix plugins/sdk/dev-host
|
||||
|
||||
- name: Test npm launcher
|
||||
run: npm --prefix packages/plugin-cli test
|
||||
|
||||
@@ -268,6 +273,9 @@ jobs:
|
||||
node-version: 22.13.0
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Prepare development runtime build dependencies
|
||||
run: npm ci --prefix plugins/sdk/dev-host
|
||||
|
||||
- name: Wait for platform packages
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Plugin development host
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.gitattributes'
|
||||
- 'plugins/sdk/cli/**'
|
||||
- 'plugins/sdk/dev-host/**'
|
||||
- 'packages/plugin-cli/**'
|
||||
- 'scripts/verify-plugin-cli-package.mjs'
|
||||
- '.github/workflows/plugin-dev-host.yml'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-24.04, macos-15, windows-2022]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
cache: false
|
||||
- run: npm ci --prefix plugins/sdk/dev-host
|
||||
- run: npm run lint --prefix plugins/sdk/dev-host
|
||||
- run: npm run fmt:check --prefix plugins/sdk/dev-host
|
||||
- run: npm test --prefix plugins/sdk/dev-host
|
||||
- run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
|
||||
- run: npm test --prefix packages/plugin-cli
|
||||
- run: node scripts/verify-plugin-cli-package.mjs
|
||||
env:
|
||||
DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'
|
||||
@@ -83,6 +83,7 @@ review_classified.json
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
/.dbx-dev/
|
||||
|
||||
|
||||
# Generated changelog data
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/dev-runtime/
|
||||
/sdk-root/
|
||||
@@ -20,8 +20,11 @@ dbx-plugin create my-plugin --template frontend
|
||||
dbx-plugin create my-rust-plugin --template rust
|
||||
dbx-plugin create my-go-plugin --template go
|
||||
dbx-plugin package my-plugin
|
||||
dbx-plugin dev --path my-plugin --port 5190
|
||||
```
|
||||
|
||||
The `dev` subcommand requires Node.js 22+ and runs the bundled browser development runtime, without DBX or source-tree dependencies. It supports frontend-only workbenches and Rust/Go backends, with JSONL or framed transport. Optional `[dev]` `ui_build` and `ui_watch` command arrays configure project builds. Development credentials are stored locally as plaintext under `.dbx-dev/` (or `--data-dir`). Other commands retain their existing Node requirements.
|
||||
|
||||
## 中文说明
|
||||
|
||||
`@dbx-app/plugin-cli` 提供预编译的 `dbx-plugin` 命令。安装时不会编译 CLI,也不需要克隆 DBX 源码仓库。
|
||||
@@ -38,3 +41,5 @@ npx @dbx-app/plugin-cli create my-plugin
|
||||
```
|
||||
|
||||
npm 主包会自动选择当前操作系统对应的预编译二进制,并携带匹配版本的 Rust 和 Go 插件 SDK。纯前端插件只需要 Node.js;只有插件自身包含 Rust 或 Go 后端时才需要对应语言的编译环境。
|
||||
|
||||
使用 `dbx-plugin dev --path my-plugin --port 5190` 可在浏览器运行通用插件开发环境,无需启动 DBX。仅 `dev` 要求 Node.js 22+;运行时及外壳已包含在 npm 包中。开发配置和凭据以明文保存在 `.dbx-dev/`,不要提交或打包该目录。
|
||||
|
||||
@@ -67,6 +67,11 @@ try {
|
||||
const binary = resolveBinary();
|
||||
const env = { ...process.env };
|
||||
delete env.DBX_PLUGIN_CLI_BINARY;
|
||||
if (process.argv[2] === "dev") {
|
||||
if (Number(process.versions.node.split(".")[0]) < 22) throw new Error("dbx-plugin dev requires Node.js 22+.");
|
||||
env.DBX_PLUGIN_NODE = env.DBX_PLUGIN_NODE || process.execPath;
|
||||
env.DBX_PLUGIN_DEV_RUNTIME = env.DBX_PLUGIN_DEV_RUNTIME || join(packageRoot, "dev-runtime", "runtime.mjs");
|
||||
}
|
||||
if (!env.DBX_PLUGIN_SDK_ROOT) {
|
||||
const sdkRoot = bundledSdkRoot();
|
||||
if (sdkRoot) env.DBX_PLUGIN_SDK_ROOT = sdkRoot;
|
||||
|
||||
@@ -22,14 +22,15 @@
|
||||
"files": [
|
||||
"bin",
|
||||
"README.md",
|
||||
"sdk-root"
|
||||
"sdk-root",
|
||||
"dev-runtime"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node --check bin/dbx-plugin.js && node --check scripts/stage-sdk.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"prepack": "node scripts/stage-sdk.mjs",
|
||||
"postpack": "node scripts/stage-sdk.mjs --clean",
|
||||
"prepack": "node scripts/stage-sdk.mjs && node scripts/stage-dev.mjs",
|
||||
"postpack": "node scripts/stage-sdk.mjs --clean && node scripts/stage-dev.mjs --clean",
|
||||
"prepublishOnly": "npm run build && npm test"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { cpSync, existsSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const root = resolve(packageRoot, '../../plugins/sdk/dev-host');
|
||||
const destination = resolve(packageRoot, 'dev-runtime');
|
||||
if (process.argv.includes('--clean')) {
|
||||
rmSync(destination, { recursive: true, force: true });
|
||||
} else {
|
||||
if (Number(process.versions.node.split('.')[0]) < 22) throw new Error('Building dev runtime requires Node.js 22+');
|
||||
if (!existsSync(resolve(root, 'node_modules/vite'))) throw new Error('Run npm ci --prefix plugins/sdk/dev-host before packing the CLI');
|
||||
const result = spawnSync(process.execPath, [resolve(root, 'build.mjs')], { stdio: 'inherit' });
|
||||
if (result.status !== 0) throw new Error('Development runtime build failed');
|
||||
rmSync(destination, { recursive: true, force: true });
|
||||
cpSync(resolve(root, 'dist'), destination, { recursive: true });
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
test('dev selects the bundled runtime and current Node without changing ordinary launcher behavior', () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'dbx-dev-launcher-'));
|
||||
try {
|
||||
writeFileSync(join(cwd, 'dev'), 'console.log(JSON.stringify({runtime:process.env.DBX_PLUGIN_DEV_RUNTIME,node:process.env.DBX_PLUGIN_NODE,args:process.argv.slice(2)}))');
|
||||
const invoke = extra => spawnSync(process.execPath, [join(root, 'bin/dbx-plugin.js'), 'dev', '--port', '5190'], {
|
||||
cwd, encoding: 'utf8', env: { ...process.env, DBX_PLUGIN_CLI_BINARY: process.execPath, DBX_PLUGIN_NODE: '', DBX_PLUGIN_DEV_RUNTIME: '', ...extra },
|
||||
});
|
||||
const result = invoke({}); assert.equal(result.status, 0, result.stderr);
|
||||
const data = JSON.parse(result.stdout); assert.equal(data.runtime, join(root, 'dev-runtime/runtime.mjs')); assert.equal(data.node, process.execPath); assert.deepEqual(data.args, ['--port','5190']);
|
||||
const explicit = invoke({ DBX_PLUGIN_DEV_RUNTIME: '/custom/runtime.mjs', DBX_PLUGIN_NODE: '/custom/node' });
|
||||
assert.equal(JSON.parse(explicit.stdout).runtime, '/custom/runtime.mjs');
|
||||
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -155,6 +155,50 @@ dbx-plugin-demo/
|
||||
|
||||
## 5. 打包未签名开发包
|
||||
|
||||
### 打包前:使用独立浏览器开发环境
|
||||
|
||||
Agent 可通过只读接口获取调试日志,无需浏览器会话:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://127.0.0.1:5190/api/diagnostics?after=0&limit=100'
|
||||
```
|
||||
|
||||
端口使用实际启动值。增量查询传入上次返回的 `nextAfter`(作为 `after`)与 `instanceId`,可增加 `level=error` 筛选错误。`hasMore` 表示还有下一页,`reset` 表示实例或游标重置,`truncated` 表示旧日志已被覆盖。日志沿用界面脱敏规则,仅保留内存中最近 500 条。详见 [Agent 调试接口](sdk/dev-host/README.md#agent-diagnostics-api)。
|
||||
|
||||
语言按钮支持中文与英文,同时切换调试外壳、日志面板和插件语言;插件名称与字段标签使用 Manifest 本地化。已有插件页面在确认后自动重载,取消则保持原语言。用户保存的连接名称与业务数据不翻译。
|
||||
|
||||
顶部“自动重载”默认关闭。启用后,UI 输出更新会自动刷新所有插件页面;Rust/Go 后端源码修改会自动构建并重启。编译型前端仍需配置 `ui_watch`。已保存连接配置不会丢失,但后端重启后需重新连接;页面草稿与进行中的操作可能丢失。构建失败不会启动旧产物,重启调试服务后此开关恢复关闭。
|
||||
|
||||
配置 `ui_watch` 后,监听命令必须在每次构建成功且产物全部写入后,向标准输出打印独立一行 `DBX_UI_BUILD_SUCCESS`,调试服务才会通知页面重载。请接入构建工具的成功回调,不要在失败或无条件退出回调中发送;仅修改输出文件不会触发重载。未配置 `ui_watch` 的静态页面仍按文件变化重载。
|
||||
|
||||
浏览器刷新或关闭后,旧页面的工作台会保留 30 秒,允许事件流短暂断开后重连;超时后回收工作台,并断开没有其他页面使用的连接。已保存的连接配置不受影响。
|
||||
|
||||
使用包含 `dev` 子命令的 CLI,可不启动 DBX 进行前后端联调:
|
||||
|
||||
```bash
|
||||
dbx-plugin dev --path /path/to/my-plugin --port 5190
|
||||
```
|
||||
|
||||
`--path` 默认为当前目录,端口占用时自动选择空闲端口。仅此子命令要求 Node.js 22+。纯前端插件直接打开工作台;Rust/Go 插件根据 `[backend]` 配置构建并启动后端,兼容 JSONL 和 framed v1。
|
||||
|
||||
可在 `dbx-plugin.toml` 配置前端构建命令:
|
||||
|
||||
```toml
|
||||
[dev]
|
||||
ui_build = ["npm", "run", "build"]
|
||||
ui_watch = ["npm", "run", "build:watch"]
|
||||
```
|
||||
|
||||
命令按参数数组执行,不经过 shell;未配置时使用已有 UI。先自行安装插件依赖,工具不会猜测框架或自动安装。前端重新构建后手动重载页面,后端改动后点击重建并重新连接。
|
||||
|
||||
点击“重载页面”右侧的“调试”,底部显示最近 500 条构建、后端状态、RPC 和请求校验日志,包含端口、项目与请求路径、耗时,以及可展开的 JSON 入参和出参。支持级别筛选、清空视图和自动滚动,终端同步输出 `[dbx-dev]` 日志。密码、令牌及 Manifest 声明的敏感字段脱敏,二进制内容省略,长内容截断;普通业务内容仍可见,仅用于本地开发。重启服务后历史清空。
|
||||
|
||||
开发环境提供声明式连接表单、工作台 Tab、RPC、事件、资源读取和主题切换。普通配置与凭据以本地明文形式保存在 `.dbx-dev/`,默认忽略提交,可通过 `--data-dir` 更换。它不读取 DBX 用户数据,也不替代真实宿主的安装、Secret Store 或生命周期验收。
|
||||
|
||||
源码构建和支持范围见[开发运行时说明](sdk/dev-host/README.md)。
|
||||
|
||||
### 构建安装包
|
||||
|
||||
进入插件目录并打包:
|
||||
|
||||
```bash
|
||||
@@ -316,6 +360,7 @@ cargo run --release \
|
||||
# 查看帮助
|
||||
dbx-plugin --help
|
||||
dbx-plugin create --help
|
||||
dbx-plugin dev --help
|
||||
dbx-plugin package --help
|
||||
dbx-plugin keygen --help
|
||||
|
||||
@@ -341,6 +386,25 @@ dbx-plugin keygen company.plugins.release
|
||||
NO_COLOR=1 dbx-plugin --help
|
||||
```
|
||||
|
||||
### 独立调试插件
|
||||
|
||||
无需启动 DBX,使用 Node.js 22+ 在浏览器中调试真实插件前后端:
|
||||
|
||||
```bash
|
||||
# 在插件目录启动,默认端口 5190
|
||||
dbx-plugin dev
|
||||
|
||||
# 指定插件目录和端口
|
||||
dbx-plugin dev --path /path/to/my-plugin --port 5190
|
||||
|
||||
# 指定开发配置存储目录
|
||||
dbx-plugin dev --path /path/to/my-plugin --data-dir /path/to/dev-data
|
||||
```
|
||||
|
||||
启动后打开终端输出的本地地址,按 `Ctrl+C` 停止。端口占用时自动选择空闲端口。纯前端插件无需后端;Rust/Go 插件会根据项目配置构建并启动 Sidecar,需先安装对应工具链及插件依赖。
|
||||
|
||||
连接配置与凭据默认以明文保存在插件目录的 `.dbx-dev/` 中。前端构建监听通过 `[dev].ui_watch` 配置,自动重载默认关闭,可在页面中开启。完整配置与调试方式见[独立浏览器开发环境](#打包前使用独立浏览器开发环境)。
|
||||
|
||||
## 11. 常见问题
|
||||
|
||||
### `dbx-plugin: command not found`
|
||||
|
||||
@@ -9,6 +9,7 @@ npm install --global @dbx-app/plugin-cli
|
||||
|
||||
dbx-plugin create
|
||||
cd my-plugin
|
||||
dbx-plugin dev --port 5190
|
||||
dbx-plugin package .
|
||||
```
|
||||
|
||||
@@ -20,6 +21,12 @@ npx @dbx-app/plugin-cli create my-plugin
|
||||
|
||||
The npm launcher selects the matching macOS, Linux, or Windows binary and bundles the matching Rust and Go plugin SDK sources. Building the CLI from a DBX checkout is only necessary while developing the CLI itself.
|
||||
|
||||
## Browser development
|
||||
|
||||
`dbx-plugin dev --path /path/to/plugin --port 5190` runs a local browser development host without starting DBX. The npm package includes the prebuilt runtime; only `dev` requires Node.js 22+. Frontend-only plugins need no sidecar; Rust/Go projects are built from `[backend]` configuration. JSONL and framed v1 are supported.
|
||||
|
||||
Optional `[dev]` `ui_build` and `ui_watch` argument arrays configure UI builds. Without them, existing UI assets are loaded and watched. The command does not install project dependencies. Development data, including plaintext credentials, defaults to `.dbx-dev/`; use `--data-dir` to override it. See [runtime documentation](../dev-host/README.md) for supported APIs, source builds and limitations.
|
||||
|
||||
## Project templates
|
||||
|
||||
`dbx-plugin create` offers three templates:
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
use super::{canonical_directory, executable_name, sdk_root_from_environment, value_after, ProjectConfig};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Component, Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct DevConfig {
|
||||
#[serde(default)]
|
||||
ui_build: Vec<String>,
|
||||
#[serde(default)]
|
||||
ui_watch: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Options {
|
||||
path: PathBuf,
|
||||
port: u16,
|
||||
data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn parse(arguments: &[String]) -> Result<Options, String> {
|
||||
let mut options = Options { path: PathBuf::from("."), port: 5190, data_dir: None };
|
||||
let mut index = 0;
|
||||
while index < arguments.len() {
|
||||
match arguments[index].as_str() {
|
||||
"--path" => options.path = PathBuf::from(value_after(arguments, &mut index)?),
|
||||
"--port" => {
|
||||
options.port =
|
||||
value_after(arguments, &mut index)?.parse().map_err(|_| "--port must be between 0 and 65535")?
|
||||
}
|
||||
"--data-dir" => options.data_dir = Some(PathBuf::from(value_after(arguments, &mut index)?)),
|
||||
other => return Err(format!("Unknown dev argument '{other}'")),
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn relative_path(path: &Path) -> Result<(), String> {
|
||||
if path.as_os_str().is_empty() || path.components().any(|p| !matches!(p, Component::Normal(_) | Component::CurDir))
|
||||
{
|
||||
return Err(format!("Expected a project-relative path: {}", path.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_config(options: &Options, sdk_root: Option<&Path>) -> Result<Value, String> {
|
||||
let project = canonical_directory(&options.path)?;
|
||||
let config: ProjectConfig = toml::from_str(
|
||||
&fs::read_to_string(project.join("dbx-plugin.toml"))
|
||||
.map_err(|e| format!("Cannot read dbx-plugin.toml: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Invalid dbx-plugin.toml: {e}"))?;
|
||||
if config.schema_version != 1 {
|
||||
return Err("Unsupported project configuration version".into());
|
||||
}
|
||||
let manifest: Value = serde_json::from_str(
|
||||
&fs::read_to_string(project.join("manifest.json")).map_err(|e| format!("Cannot read manifest.json: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Invalid manifest.json: {e}"))?;
|
||||
let entry =
|
||||
manifest.pointer("/entrypoints/ui/entry").and_then(Value::as_str).ok_or("dev requires a UI entrypoint")?;
|
||||
relative_path(Path::new(entry))?;
|
||||
let ui_root = manifest.pointer("/entrypoints/ui/root").and_then(Value::as_str).unwrap_or("ui");
|
||||
relative_path(Path::new(ui_root))?;
|
||||
if !Path::new(entry).starts_with(ui_root) {
|
||||
return Err("UI entry must be inside its declared root".into());
|
||||
}
|
||||
for command in [&config.dev.ui_build, &config.dev.ui_watch] {
|
||||
if command.first().is_some_and(|c| c.trim().is_empty()) {
|
||||
return Err("dev build commands must start with an executable".into());
|
||||
}
|
||||
}
|
||||
let data_dir = match &options.data_dir {
|
||||
Some(path) if path.is_absolute() => path.clone(),
|
||||
Some(path) => project.join(path),
|
||||
None => project.join(".dbx-dev"),
|
||||
};
|
||||
let mut launch = json!({"project": project, "dataDir": data_dir, "port": options.port,
|
||||
"commands": {"ui": config.dev.ui_build, "watch": config.dev.ui_watch}});
|
||||
let declared_backend = manifest.pointer("/entrypoints/backend").is_some_and(|v| !v.is_null());
|
||||
if let Some(backend) = config.backend {
|
||||
if !declared_backend {
|
||||
return Err("Project declares a backend but manifest does not".into());
|
||||
}
|
||||
relative_path(&backend.directory)?;
|
||||
if backend.binary.is_empty()
|
||||
|| matches!(backend.binary.as_str(), "." | "..")
|
||||
|| backend.binary.contains(['/', '\\'])
|
||||
{
|
||||
return Err("Backend binary must be a filename".into());
|
||||
}
|
||||
let directory = project.join(&backend.directory);
|
||||
launch["backendWatch"] = json!(directory);
|
||||
let (binary, command) = match backend.language.as_str() {
|
||||
"rust" => {
|
||||
let cargo_manifest = directory.join("Cargo.toml");
|
||||
let cargo: toml::Value = toml::from_str(
|
||||
&fs::read_to_string(&cargo_manifest).map_err(|e| format!("Cannot read backend Cargo.toml: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Invalid backend Cargo.toml: {e}"))?;
|
||||
let target = data_dir.join("rust-target");
|
||||
let mut args = vec![
|
||||
"build".to_string(),
|
||||
"--manifest-path".into(),
|
||||
cargo_manifest.to_string_lossy().into_owned(),
|
||||
"--target-dir".into(),
|
||||
target.to_string_lossy().into_owned(),
|
||||
];
|
||||
if directory.join("Cargo.lock").is_file() {
|
||||
args.push("--locked".into());
|
||||
}
|
||||
// A crates.io patch is inappropriate for explicitly pinned Git or path dependencies.
|
||||
let dependency = cargo.get("dependencies").and_then(|d| d.get("dbx-plugin-sdk"));
|
||||
let explicit_source = dependency.is_some_and(|d| d.get("git").is_some() || d.get("path").is_some());
|
||||
if let Some(root) = sdk_root.filter(|_| dependency.is_some() && !explicit_source) {
|
||||
let sdk = root.join("plugins/sdk/rust/dbx-plugin-sdk");
|
||||
if !sdk.join("Cargo.toml").is_file() {
|
||||
return Err("Rust SDK root is invalid".into());
|
||||
}
|
||||
args.extend([
|
||||
"--config".into(),
|
||||
format!(
|
||||
"patch.crates-io.dbx-plugin-sdk.path={}",
|
||||
serde_json::to_string(&sdk.to_string_lossy()).map_err(|e| e.to_string())?
|
||||
),
|
||||
]);
|
||||
}
|
||||
(
|
||||
target.join("debug").join(executable_name(&backend.binary)),
|
||||
json!({"command":"cargo", "args":args, "cwd":project}),
|
||||
)
|
||||
}
|
||||
"go" => {
|
||||
if !directory.join("go.mod").is_file() {
|
||||
return Err("Go backend is missing go.mod".into());
|
||||
}
|
||||
let binary = data_dir.join("bin").join(executable_name(&backend.binary));
|
||||
let mut command = json!({"command":"go", "args":["build","-trimpath","-o",binary,"."],"cwd":directory});
|
||||
if let Some(root) = sdk_root {
|
||||
let sdk = root.join("plugins/sdk/go/dbx-plugin-sdk");
|
||||
if !sdk.join("go.mod").is_file() {
|
||||
return Err("Go SDK root is invalid".into());
|
||||
}
|
||||
// The runtime materializes this workfile inside its private data directory.
|
||||
command["goWorkspace"] = json!([directory, sdk]);
|
||||
}
|
||||
(binary, command)
|
||||
}
|
||||
_ => return Err("dev supports Rust and Go backends".into()),
|
||||
};
|
||||
launch["backend"] = json!(binary);
|
||||
launch["commands"]["backend"] = command;
|
||||
} else if declared_backend {
|
||||
return Err("Manifest backend requires [backend] configuration".into());
|
||||
}
|
||||
Ok(launch)
|
||||
}
|
||||
|
||||
pub(super) fn run(arguments: Vec<String>) -> Result<(), String> {
|
||||
if arguments.iter().any(|a| a == "--help" || a == "-h") {
|
||||
println!("Run a plugin without starting DBX (Node.js 22+)\n\nUsage: dbx-plugin dev [--path DIR] [--port PORT] [--data-dir DIR]\n\n --path DIR Plugin project (default: current directory)\n --port PORT Loopback port (default: 5190; occupied ports fall back to a free port)\n --data-dir DIR Development data (default: <project>/.dbx-dev)\n\nOptional [dev] ui_build and ui_watch arrays configure UI commands.\nDevelopment credentials are stored locally as plaintext.\nDBX_PLUGIN_DEV_RUNTIME overrides the runtime entrypoint; DBX_PLUGIN_NODE selects Node.");
|
||||
return Ok(());
|
||||
}
|
||||
let options = parse(&arguments)?;
|
||||
let sdk_root = sdk_root_from_environment()?;
|
||||
let launch = build_config(&options, sdk_root.as_deref())?;
|
||||
let runtime = std::env::var_os("DBX_PLUGIN_DEV_RUNTIME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../dev-host/dist/runtime.mjs"));
|
||||
if !runtime.is_file() {
|
||||
return Err("Development runtime is missing. Install @dbx-app/plugin-cli, or run 'npm ci --prefix plugins/sdk/dev-host && npm run build --prefix plugins/sdk/dev-host' in a DBX checkout and set DBX_PLUGIN_DEV_RUNTIME to dist/runtime.mjs.".into());
|
||||
}
|
||||
let node = std::env::var_os("DBX_PLUGIN_NODE").unwrap_or_else(|| "node".into());
|
||||
let version = Command::new(&node)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|_| "dev requires Node.js 22+; install Node or set DBX_PLUGIN_NODE")?;
|
||||
let major = String::from_utf8_lossy(&version.stdout)
|
||||
.trim()
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|v| v.parse::<u32>().ok());
|
||||
if !version.status.success() || !major.is_some_and(|v| v >= 22) {
|
||||
return Err("dev requires Node.js 22+".into());
|
||||
}
|
||||
let mut command = Command::new(node);
|
||||
command.arg(runtime).arg("--config").arg(serde_json::to_string(&launch).map_err(|e| e.to_string())?);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
Err(format!("Cannot launch development runtime: {}", command.exec()))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let status = command.status().map_err(|e| format!("Cannot launch development runtime: {e}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Development runtime exited with {status}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn options_and_invalid_arguments() {
|
||||
assert_eq!(parse(&[]).unwrap(), Options { path: ".".into(), port: 5190, data_dir: None });
|
||||
assert_eq!(parse(&["--port".into(), "0".into(), "--path".into(), "space path".into()]).unwrap().port, 0);
|
||||
for args in [vec!["--port", "65536"], vec!["--port", "no"], vec!["--path"], vec!["--unknown"]] {
|
||||
assert!(parse(&args.into_iter().map(String::from).collect::<Vec<_>>()).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn frontend_needs_no_backend_and_dev_arrays_are_typed() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
fs::write(root.path().join("manifest.json"), r#"{"entrypoints":{"ui":{"root":"ui","entry":"ui/index.html"}}}"#)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.path().join("dbx-plugin.toml"),
|
||||
"schema_version=1\n[package]\ninclude=[\"ui\"]\n[dev]\nui_build=[\"npm\",\"run\",\"build\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let options = Options { path: root.path().into(), port: 5190, data_dir: None };
|
||||
let config = build_config(&options, None).unwrap();
|
||||
assert!(config.get("backend").is_none());
|
||||
assert_eq!(config["commands"]["ui"][0], "npm");
|
||||
fs::write(
|
||||
root.path().join("dbx-plugin.toml"),
|
||||
"schema_version=1\n[package]\ninclude=[]\n[dev]\nui_build=\"npm run build\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(build_config(&options, None).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn paths_cannot_escape_project() {
|
||||
for path in ["../ui", "/tmp/ui", ""] {
|
||||
assert!(relative_path(Path::new(path)).is_err());
|
||||
}
|
||||
assert!(relative_path(Path::new("ui/index.html")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ use std::process::{Command, Stdio};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
mod dev;
|
||||
|
||||
const CLI_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const SDK_VERSION: &str = "0.1.0";
|
||||
const ANSI_RESET: &str = "\x1b[0m";
|
||||
@@ -193,6 +195,8 @@ struct ProjectConfig {
|
||||
#[serde(default)]
|
||||
backend: Option<BackendConfig>,
|
||||
package: PackageConfig,
|
||||
#[serde(default)]
|
||||
dev: dev::DevConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -295,6 +299,7 @@ where
|
||||
match arguments.next().as_deref() {
|
||||
Some("create") => run_create(arguments.collect()),
|
||||
Some("package") => run_package(arguments.collect()),
|
||||
Some("dev") => dev::run(arguments.collect()),
|
||||
Some("keygen") => run_keygen(arguments.collect()),
|
||||
Some("--version" | "-V" | "version") => {
|
||||
println!("{} {}", styled_stdout("dbx-plugin", ANSI_ACCENT), styled_stdout(CLI_VERSION, ANSI_MUTED));
|
||||
@@ -839,6 +844,9 @@ pub fn package_project(options: &PackageOptions) -> Result<(PathBuf, PathBuf), S
|
||||
}
|
||||
for include in &config.package.include {
|
||||
validate_relative_path(include, "package include")?;
|
||||
if include.components().any(|part| part.as_os_str() == ".dbx-dev") {
|
||||
return Err("Package input cannot contain .dbx-dev development data".to_owned());
|
||||
}
|
||||
}
|
||||
let manifest_path = project.join("manifest.json");
|
||||
let manifest_raw = fs::read(&manifest_path).map_err(|error| format!("Failed to read manifest.json: {error}"))?;
|
||||
@@ -1084,6 +1092,9 @@ fn run_command(command: &mut Command, label: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn copy_path(source: &Path, destination: &Path) -> Result<(), String> {
|
||||
if source.file_name().is_some_and(|name| name == ".dbx-dev") {
|
||||
return Err("Package input cannot contain .dbx-dev development data".to_owned());
|
||||
}
|
||||
let metadata = fs::symlink_metadata(source).map_err(|error| error.to_string())?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(format!("Package input cannot contain symbolic link {}", source.display()));
|
||||
@@ -1383,6 +1394,10 @@ fn print_usage() {
|
||||
println!("\n{}", styled_stdout("Commands:", ANSI_PROMPT));
|
||||
println!(" {} Create a frontend-only, Rust, or Go plugin project", styled_stdout("create", ANSI_SUCCESS));
|
||||
println!(" {} Build a .dbxp package and artifact metadata", styled_stdout("package", ANSI_SUCCESS));
|
||||
println!(
|
||||
" {} Run a plugin in the local browser development host (Node.js 22+)",
|
||||
styled_stdout("dev", ANSI_SUCCESS)
|
||||
);
|
||||
println!(" {} Generate an Ed25519 repository signing key", styled_stdout("keygen", ANSI_SUCCESS));
|
||||
println!(" {} Print the CLI version", styled_stdout("version", ANSI_SUCCESS));
|
||||
println!("\n{}", styled_stdout("Examples:", ANSI_PROMPT));
|
||||
@@ -1474,6 +1489,17 @@ mod tests {
|
||||
CreateOptions, PackageOptions, ProjectTemplate, ANSI_ACCENT,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn rejects_development_data_in_package_inputs() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let input = root.path().join("ui");
|
||||
std::fs::create_dir_all(input.join(".dbx-dev")).unwrap();
|
||||
std::fs::write(input.join(".dbx-dev/connections.json"), "private").unwrap();
|
||||
let output = root.path().join("stage");
|
||||
assert!(super::copy_path(&input, &output).unwrap_err().contains(".dbx-dev"));
|
||||
assert!(!output.join(".dbx-dev/connections.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enables_colors_only_for_supported_terminal_modes() {
|
||||
assert!(color_enabled_with(true, false, Some("xterm-256color"), None, None));
|
||||
|
||||
@@ -6,6 +6,16 @@ Generated by `dbx-plugin create` with the **{{TEMPLATE_LABEL}}** template.
|
||||
|
||||
## Develop
|
||||
|
||||
With Node.js 22+ and the backend compiler installed, run the browser development host without DBX:
|
||||
|
||||
```bash
|
||||
dbx-plugin dev --path . --port 5190
|
||||
```
|
||||
|
||||
The CLI builds the backend and loads the declared UI. Optional `[dev]` `ui_build` and `ui_watch` command arrays configure frontend builds. Development credentials are stored as local plaintext in `.dbx-dev/`; keep that directory out of commits and packages. Use the real DBX host for final integration testing.
|
||||
|
||||
Build an installable candidate:
|
||||
|
||||
```bash
|
||||
dbx-plugin package .
|
||||
```
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/dist/
|
||||
/.dbx-dev/
|
||||
.dbx-repository-signing-key.env
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
@@ -6,7 +6,15 @@ Generated by `dbx-plugin create` with the **{{TEMPLATE_LABEL}}** template. This
|
||||
|
||||
## Develop
|
||||
|
||||
Edit `ui/index.html`, then package the plugin:
|
||||
Edit `ui/index.html` and run the browser development host with Node.js 22+:
|
||||
|
||||
```bash
|
||||
dbx-plugin dev --path . --port 5190
|
||||
```
|
||||
|
||||
No backend or DBX application is required. Open this plugin's workbench from the development shell; reload the page after edits. `.dbx-dev/` stores local development data and must not be committed or packaged. Use the real DBX host for final integration testing.
|
||||
|
||||
Then package the plugin:
|
||||
|
||||
```bash
|
||||
dbx-plugin package .
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/node_modules/
|
||||
/dist/
|
||||
@@ -0,0 +1,120 @@
|
||||
# Plugin development runtime
|
||||
|
||||
Generic browser development host for `dbx-plugin dev`. It loads declared workbenches and runs optional Rust/Go sidecars without starting the DBX desktop application.
|
||||
|
||||
```bash
|
||||
dbx-plugin dev --path /path/to/plugin --port 5190
|
||||
```
|
||||
|
||||
Requires Node.js 22+. Pure frontend projects do not need Rust or Go. Native projects need their own compiler and dependencies. The npm CLI bundles this runtime and a prebuilt UI; consumers do not install Vite, Vue, or build the development shell.
|
||||
|
||||
## Build from source
|
||||
|
||||
From the DBX repository:
|
||||
|
||||
```bash
|
||||
npm ci --prefix plugins/sdk/dev-host
|
||||
npm run build --prefix plugins/sdk/dev-host
|
||||
cargo run --manifest-path plugins/sdk/cli/Cargo.toml -- dev --path /path/to/plugin
|
||||
```
|
||||
|
||||
The Rust CLI locates `dist/runtime.mjs` relative to its source checkout. For a standalone native CLI, set `DBX_PLUGIN_DEV_RUNTIME` to the built entrypoint. `DBX_PLUGIN_NODE` selects Node. The npm launcher supplies both paths automatically.
|
||||
|
||||
## Project configuration
|
||||
|
||||
The CLI reads `manifest.json` and `dbx-plugin.toml`. Existing `[backend]` metadata selects Rust or Go and the executable name. Rust uses a cached debug target directory; Go uses a cached output directory. Explicit Git/path Rust SDK dependencies are not replaced by a crates.io patch.
|
||||
|
||||
Optional UI commands in `dbx-plugin.toml`:
|
||||
|
||||
```toml
|
||||
[dev]
|
||||
ui_build = ["npm", "run", "build"]
|
||||
ui_watch = ["npm", "run", "build:watch"]
|
||||
```
|
||||
|
||||
Commands are executable/argument arrays, run in the plugin directory without a shell. No framework detection or automatic dependency installation occurs. Without these options, existing UI assets are served and watched. Manifest or runtime changes require restarting `dev`.
|
||||
|
||||
When `ui_watch` is configured, its stdout must emit a standalone `DBX_UI_BUILD_SUCCESS` line after each successful, fully written build. Only this signal triggers automatic UI reload; partial output and failed builds do not. Add the signal to your build tool's successful completion hook (not an unconditional exit hook). Without `ui_watch`, static UI files retain debounced output watching. This protocol is independent of the frontend framework.
|
||||
|
||||
`--path` defaults to the current directory. `--port` defaults to 5190 and falls back to an available port if occupied; `0` explicitly requests an available port. `--data-dir` defaults to `<project>/.dbx-dev/`. Packaging rejects `.dbx-dev` inputs, including nested directories. Custom data directories must stay outside package include paths and the UI resource root, and be excluded from source control.
|
||||
|
||||
## Workbenches and connections
|
||||
|
||||
Workbench contributions can be opened without a connection, including frontend-only plugins. Connection providers generate forms from declared fields, defaults, options and bindings. Port zero remains valid at the framework layer; plugins validate their own connection semantics.
|
||||
|
||||
Connection testing, connecting and disconnecting use the standard lifecycle methods. Custom RPC methods and results are forwarded without interpreting their business meaning. Workbench tabs are keyed by contribution and optional connection ID, retain their iframe when switched, and reuse an existing tab when opened again. Closing the last tab for a connection disconnects it after confirmation.
|
||||
|
||||
Lifecycle replies with `success: false` are failures, matching the real host. Browser pages have independent workbench ownership. After a page's event stream disconnects, its frames remain for a 30-second reconnect grace period, then are removed; connections still used by another page remain open. Saved connection configuration is unaffected. Manifest localization is performed only by the shell; bootstrap returns one unmodified Manifest.
|
||||
|
||||
Import connection configurations using the UI's JSON file picker:
|
||||
|
||||
```json
|
||||
{
|
||||
"connections": [
|
||||
{
|
||||
"providerId": "example.connection",
|
||||
"values": { "display_name": "Example", "host": "localhost", "port": 0 },
|
||||
"readOnly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Field names come from the plugin manifest, not the runtime. Imported records get new IDs. There are no built-in protocol presets, filesystem RPCs or plugin-specific adapters.
|
||||
|
||||
## Supported boundary
|
||||
|
||||
- Host API 1.0 subset: `ready`, `context`, `locale`, `theme`, `request`, `invoke`, `notify`, `onInit`, `onContext`, `onEvent`, `onBinary`, `sendBinary`, resource reads and `openWorkbench`.
|
||||
- Backend transports: default `stdio-jsonl` and explicit `stdio-framed`, protocol version 1. Initialization verifies plugin identity and version. Binary channels require framed transport.
|
||||
- Permissions: event, binary and workbench navigation permissions are enforced. Unimplemented methods, such as `host.openFilesystem`, return errors. Native connection actions, query-result contributions and the DBX component kit are not emulated.
|
||||
- JSON bridge parameters: 2 MiB. UI binary messages: 8 MiB. Sidecar JSON: 8 MiB. Sidecar binary: 64 MiB. Explicit bridge timeouts are clamped to 1–120000 ms, matching the host baseline.
|
||||
- UI resources must be inline or loaded through `readAssetUrl`; relative module URLs are not a replacement for the declared asset bridge. The iframe uses `sandbox="allow-scripts"`, restrictive CSP and pure-data context snapshots. Direct networking is not enabled by the development runtime.
|
||||
|
||||
UI rebuilds are applied by explicit reload to preserve drafts. Backend rebuilds disconnect sessions; failure does not fall back to an old binary. A timeout does not mean a write was cancelled, and requests are never automatically replayed. Reconnect after restarting the backend; reload the plugin page when needed.
|
||||
|
||||
## Data and isolation
|
||||
|
||||
**自动重载** is off by default and applies to all browsers connected to this server. Enabling it confirms possible draft loss. Stable UI output changes reload all open plugin frames; compiled UIs still require `[dev].ui_watch`. Backend `.rs`/`.go` files and Cargo/Go module files within the configured backend directory trigger a debounced, serialized rebuild/restart. Generated `target`, `.dbx-dev`, `vendor` and `node_modules` directories are ignored. Saved connections persist, but reconnect after a backend restart. Build failure leaves the backend stopped; writes are never replayed. Turning the option off cancels pending work, not a build already started. Restarting the dev server resets the option to off. This is reload/restart, not state-preserving HMR.
|
||||
|
||||
The language button to the left of **调试** switches both the development shell and plugin locale between `zh-CN` and `en`. Shell controls, dialogs, diagnostic labels and manifest-localized contributions update together. Saved connection names and RPC data are not translated. With an active page, it confirms possible draft loss, then reloads that page automatically; cancellation leaves the language unchanged. Other open frames receive the standard `env` message. New pages use the selected locale. Plugin content must provide its own translations.
|
||||
|
||||
The **调试** button to the right of **重载页面** opens a bottom panel with the latest 500 entries, level filtering, local view clearing and automatic scrolling. Entries also print to the terminal with a `[dbx-dev]` prefix. Logs include the listening port, project/UI/backend paths, HTTP routes/statuses, RPC IDs/methods/durations, expandable JSON parameters/results, structured error data, events and session rejection reasons. Password/token/credential fields and manifest-declared secret fields are redacted recursively; binary/base64 content is omitted and large/deep values are truncated. Ordinary business values remain visible: use development data only. Raw backend error messages are excluded. History is memory-only and resets on restart.
|
||||
|
||||
The server listens only on `127.0.0.1`. Host, Origin, browser session, CSRF, resource traversal and symlink boundaries are checked. Frame source and generation are checked before routing messages, so responses from an old document cannot resolve new requests.
|
||||
|
||||
Development configurations, including credentials, are stored as plaintext in `.dbx-dev/connections.json`. Directory/file permissions are 0700/0600 where supported. Credentials are excluded from list summaries and iframe context; diagnostics redact recognized sensitive fields as described above. Explicit editing returns form values to the local development shell. Sidecar stderr is consumed without forwarding it to logs because arbitrary plugins may log credentials.
|
||||
|
||||
This is not an OS sandbox: sidecars run with the current user's privileges. It does not read the DBX profile or emulate Keychain, signing, installation, production lifecycle guarantees or desktop tab restoration. Validate those in the real host.
|
||||
|
||||
## Architecture and tests
|
||||
|
||||
### Agent diagnostics API
|
||||
|
||||
Local agents can read the same redacted in-memory diagnostics without browser cookies:
|
||||
|
||||
```bash
|
||||
curl -sS \
|
||||
'http://127.0.0.1:5190/api/diagnostics?after=0&limit=100'
|
||||
```
|
||||
|
||||
Only `GET` is supported. Use the actual port printed by `dev`. No custom header or browser session is required; the URL can be opened directly in a browser. Host/Origin checks remain enabled and no CORS access is granted.
|
||||
|
||||
Optional query parameters: `after` (exclusive numeric entry ID, default 0), `limit` (1–500, default 100), `level` (`debug`, `info`, `error`; exact match), and `instanceId` (from the previous response). The JSON response includes `entries`, `nextAfter`, `hasMore`, `instanceId`, `reset`, `truncated`, `oldestId`, `latestId`, `plugin`, `backendState`, and `port`.
|
||||
|
||||
Pass `nextAfter` and `instanceId` on the next poll, retaining the same filter. `hasMore` means fetch the next page immediately; otherwise poll at a modest interval, for example once per second. `reset` means the server instance changed or the cursor exceeds current history; the response already starts from available history. `truncated` means older entries were dropped from the 500-entry ring. Changing filters should start a fresh cursor. Polling does not create diagnostic entries. History is not persistent, and this endpoint cannot invoke plugin operations.
|
||||
|
||||
- `cli/src/dev.rs` (sibling crate): CLI arguments, configuration validation, build specifications and runtime location.
|
||||
- `runtime.mjs`: build/watch processes, signal cleanup and runtime startup.
|
||||
- `server.mjs`: HTTP/SSE sessions, declarative lifecycle and workbench routing.
|
||||
- `sidecar.mjs`: JSONL/framed codecs, concurrent request correlation, events, timeouts and process cleanup.
|
||||
- `connections.mjs`, `assets.mjs`: typed bindings, atomic configuration writes and confined resource access.
|
||||
- `browser-bridge.mjs`, `ui/`: sandbox API, forms, tabs, themes and reload controls.
|
||||
|
||||
```bash
|
||||
npm test --prefix plugins/sdk/dev-host
|
||||
cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
|
||||
npm test --prefix packages/plugin-cli
|
||||
DBX_PLUGIN_CLI_VERIFY_NATIVE=1 node scripts/verify-plugin-cli-package.mjs
|
||||
```
|
||||
|
||||
The package verifier packs and installs the CLI into a temporary directory, exercises official frontend/Rust/Go templates, and starts their development runtimes without source-tree runtime dependencies. Runtime unit tests use an unrelated echo sidecar. Third-party plugins are external acceptance samples, not production runtime dependencies.
|
||||
@@ -0,0 +1,13 @@
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import { resolve, relative, isAbsolute, extname } from "node:path";
|
||||
export async function readAsset(root, path) {
|
||||
if (typeof path !== "string" || !path || path.includes("\0") || isAbsolute(path)) throw new Error("Invalid asset path");
|
||||
const base = await realpath(root),
|
||||
file = await realpath(resolve(base, path));
|
||||
const rel = relative(base, file);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Asset is outside UI root");
|
||||
const info = await stat(file);
|
||||
if (!info.isFile() || info.size > 8 * 1024 * 1024) throw new Error("Asset exceeds limit or is not a file");
|
||||
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".woff2": "font/woff2" };
|
||||
return { dataBase64: (await readFile(file)).toString("base64"), contentType: types[extname(file)] || "application/octet-stream" };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Coalesce edits, serialize builds, and remember edits made during a build.
|
||||
export class AutoReload {
|
||||
enabled = false;
|
||||
running = false;
|
||||
pending = false;
|
||||
timer;
|
||||
constructor(action, delay = 500) {
|
||||
this.action = action;
|
||||
this.delay = delay;
|
||||
}
|
||||
enable(value) {
|
||||
this.enabled = value;
|
||||
if (!value) {
|
||||
clearTimeout(this.timer);
|
||||
this.pending = false;
|
||||
}
|
||||
}
|
||||
changed() {
|
||||
if (!this.enabled) return;
|
||||
this.pending = true;
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => void this.flush(), this.delay);
|
||||
}
|
||||
async flush() {
|
||||
if (!this.enabled || !this.pending || this.running) return;
|
||||
this.pending = false;
|
||||
this.running = true;
|
||||
try {
|
||||
await this.action();
|
||||
} finally {
|
||||
this.running = false;
|
||||
if (this.pending && this.enabled) this.changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// This function is serialized into the sandbox. It must not capture module state.
|
||||
function installBridge(channel) {
|
||||
let sequence = 0,
|
||||
context = {},
|
||||
locale = "zh-CN",
|
||||
theme;
|
||||
const pending = new Map(),
|
||||
listeners = { context: new Set(), init: new Set(), event: new Set(), binary: new Set() };
|
||||
let resolveReady;
|
||||
const ready = new Promise((resolve) => {
|
||||
resolveReady = resolve;
|
||||
});
|
||||
let initialized = false;
|
||||
const readyTimer = setInterval(() => {
|
||||
if (!initialized) parent.postMessage({ source: "dbx-plugin", version: 1, channel, type: "ready" }, "*");
|
||||
}, 200);
|
||||
const encode = (input) => {
|
||||
const data = input instanceof Uint8Array ? input : new Uint8Array(input);
|
||||
let result = "";
|
||||
for (let i = 0; i < data.length; i += 8192) result += String.fromCharCode(...data.subarray(i, i + 8192));
|
||||
return btoa(result);
|
||||
};
|
||||
const decode = (value) => Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
|
||||
const request = (method, params) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = ++sequence;
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("Mock host request timed out"));
|
||||
}, 310000);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
parent.postMessage({ source: "dbx-plugin", version: 1, channel, type: "request", id, method, params }, "*");
|
||||
});
|
||||
const listen = (kind, callback) => {
|
||||
listeners[kind].add(callback);
|
||||
return () => listeners[kind].delete(callback);
|
||||
};
|
||||
function applyTheme(value) {
|
||||
if (!value) return;
|
||||
theme = value;
|
||||
document.documentElement.dataset.dbxTheme = value.appearance;
|
||||
for (const [name, token] of Object.entries(value.tokens || {})) {
|
||||
if (/^--[a-z0-9-]+$/i.test(name) && typeof token === "string") document.documentElement.style.setProperty(name, token);
|
||||
}
|
||||
}
|
||||
window.dbxPlugin = Object.freeze({
|
||||
ready,
|
||||
get context() {
|
||||
return context;
|
||||
},
|
||||
get locale() {
|
||||
return locale;
|
||||
},
|
||||
get theme() {
|
||||
return theme;
|
||||
},
|
||||
request,
|
||||
invoke: (method, params, options = {}) => request("backend.invoke", { method, params, timeoutMs: options.timeoutMs }),
|
||||
notify: (method, params) => request("backend.notify", { method, params }),
|
||||
sendBinary: (channel, data) => request("backend.sendBinary", { channel, dataBase64: typeof data === "string" ? data : encode(data) }),
|
||||
readAsset: (path) => request("ui.readAsset", { path }),
|
||||
readAssetUrl: async (path) => {
|
||||
const asset = await request("ui.readAsset", { path });
|
||||
return URL.createObjectURL(new Blob([decode(asset.dataBase64)], { type: asset.contentType }));
|
||||
},
|
||||
openWorkbench: (contributionId, context) => request("host.openWorkbench", { contributionId, context }),
|
||||
openFilesystem: (providerId, context) => request("host.openFilesystem", { providerId, context }),
|
||||
onContext: (fn) => listen("context", fn),
|
||||
onEvent: (fn) => listen("event", fn),
|
||||
onBinary: (fn) => listen("binary", fn),
|
||||
onInit: (fn) => {
|
||||
const off = listen("init", fn);
|
||||
if (initialized) fn(context);
|
||||
return off;
|
||||
},
|
||||
encodeBase64: encode,
|
||||
decodeBase64: decode,
|
||||
});
|
||||
addEventListener("message", (event) => {
|
||||
const m = event.data;
|
||||
if (event.source !== parent || m?.source !== "dbx-host" || m.channel !== channel || m.version !== 1) return;
|
||||
if (m.type === "init") {
|
||||
initialized = true;
|
||||
clearInterval(readyTimer);
|
||||
context = m.context || {};
|
||||
locale = m.locale || "zh-CN";
|
||||
applyTheme(m.theme);
|
||||
resolveReady(context);
|
||||
for (const fn of listeners.init) fn(context);
|
||||
dispatchEvent(new CustomEvent("dbx-plugin-init", { detail: m }));
|
||||
}
|
||||
if (m.type === "context") {
|
||||
context = m.context || {};
|
||||
for (const fn of listeners.context) fn(context);
|
||||
dispatchEvent(new CustomEvent("dbx-plugin-context", { detail: context }));
|
||||
}
|
||||
if (m.type === "env") {
|
||||
locale = m.locale || locale;
|
||||
applyTheme(m.theme);
|
||||
for (const fn of listeners.event) fn(m);
|
||||
dispatchEvent(new CustomEvent("dbx-plugin-env", { detail: m }));
|
||||
}
|
||||
if (m.type === "event") {
|
||||
for (const fn of listeners.event) fn(m);
|
||||
dispatchEvent(new CustomEvent("dbx-plugin-event", { detail: m }));
|
||||
}
|
||||
if (m.type === "binary") {
|
||||
const payload = { channel: m.binaryChannel, data: decode(m.dataBase64) };
|
||||
for (const fn of listeners.binary) fn(payload);
|
||||
dispatchEvent(new CustomEvent("dbx-plugin-binary", { detail: payload }));
|
||||
}
|
||||
if (m.type === "response") {
|
||||
const waiter = pending.get(m.id);
|
||||
if (!waiter) return;
|
||||
pending.delete(m.id);
|
||||
clearTimeout(waiter.timer);
|
||||
if (m.error) waiter.reject(Object.assign(new Error(m.error.message || "Host request failed"), { code: m.error.code, data: m.error.data }));
|
||||
else waiter.resolve(m.result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function sandboxDocument(html, channel) {
|
||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; media-src data: blob:; connect-src 'none';">`;
|
||||
const bootstrap = `<script>(${installBridge.toString()})(${JSON.stringify(channel)});</script>`;
|
||||
if (/<head\b[^>]*>/i.test(html)) return html.replace(/<head\b[^>]*>/i, (match) => match + csp + bootstrap);
|
||||
return `<!doctype html><html><head>${csp}${bootstrap}</head><body>${html}</body></html>`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { build as viteBuild } from "vite";
|
||||
import { build } from "esbuild";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwind from "@tailwindcss/vite";
|
||||
import { viteSingleFile } from "vite-plugin-singlefile";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
await viteBuild({ configFile: false, root: resolve(root, "ui"), plugins: [vue(), tailwind(), viteSingleFile()], build: { outDir: resolve(root, "dist/ui"), emptyOutDir: true, target: "es2022" } });
|
||||
await build({ entryPoints: [resolve(root, "runtime.mjs")], outfile: resolve(root, "dist/runtime.mjs"), bundle: true, platform: "node", format: "esm", target: "node22", banner: { js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);" } });
|
||||
@@ -0,0 +1,106 @@
|
||||
import { mkdir, readFile, writeFile, rename, chmod, lstat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export function providerFor(manifest, id) {
|
||||
const provider = manifest.contributions?.find((c) => c.type === "connection-provider" && c.id === id);
|
||||
if (!provider) throw new Error("Unknown connection provider");
|
||||
return provider;
|
||||
}
|
||||
export function binding(field) {
|
||||
return field.binding || (field.type === "password" ? "secret" : "config");
|
||||
}
|
||||
export function validateRecord(manifest, record, required = true) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) throw new Error("Invalid connection record");
|
||||
if (record.id !== undefined && (typeof record.id !== "string" || !/^[a-zA-Z0-9_.-]{1,128}$/.test(record.id))) throw new Error("Invalid connection ID");
|
||||
const provider = providerFor(manifest, record.providerId);
|
||||
if (!record.values || typeof record.values !== "object" || Array.isArray(record.values)) throw new Error("Invalid connection fields");
|
||||
if (Object.keys(record.values).some((key) => !provider.fields.some((f) => f.key === key))) throw new Error("Unknown connection field");
|
||||
const values = {};
|
||||
for (const f of provider.fields) {
|
||||
const value = record.values[f.key] ?? f.default;
|
||||
const empty = value === undefined || value === null || value === "";
|
||||
if (empty) {
|
||||
if (required && f.required) throw new Error(`Missing field: ${f.key}`);
|
||||
continue;
|
||||
}
|
||||
if (f.type === "number" ? typeof value !== "number" || !Number.isFinite(value) : f.type === "boolean" ? typeof value !== "boolean" : typeof value !== "string") {
|
||||
throw new Error(`Invalid field type: ${f.key}`);
|
||||
}
|
||||
if (f.type === "select" && !f.options?.some((option) => option.value === value)) throw new Error(`Invalid option: ${f.key}`);
|
||||
if (binding(f) === "port" && (!Number.isInteger(value) || value < 0 || value > 65535)) throw new Error("Port must be between 0 and 65535");
|
||||
Object.defineProperty(values, f.key, { enumerable: true, configurable: true, writable: true, value });
|
||||
}
|
||||
if (record.readOnly !== undefined && typeof record.readOnly !== "boolean") throw new Error("Invalid read-only value");
|
||||
return { id: record.id || randomUUID(), providerId: provider.id, values, readOnly: record.readOnly || false };
|
||||
}
|
||||
export function lifecyclePayload(manifest, record) {
|
||||
const provider = providerFor(manifest, record.providerId);
|
||||
const c = {
|
||||
id: record.id,
|
||||
db_type: "plugin",
|
||||
plugin_id: manifest.id,
|
||||
plugin_connection_provider: provider.id,
|
||||
plugin_connection_type: provider.database_type,
|
||||
name: provider.label,
|
||||
host: "",
|
||||
port: 0,
|
||||
username: "",
|
||||
external_config: {},
|
||||
connection_secrets: {},
|
||||
read_only: record.readOnly,
|
||||
query_timeout_secs: 60,
|
||||
idle_timeout_secs: 60,
|
||||
};
|
||||
for (const f of provider.fields) {
|
||||
const value = record.values[f.key] ?? f.default;
|
||||
if (value === undefined || value === null) continue;
|
||||
const target = binding(f);
|
||||
if (target === "config") Object.defineProperty(c.external_config, f.key, { value, enumerable: true });
|
||||
else if (target === "secret") Object.defineProperty(c.connection_secrets, f.key, { value: String(value), enumerable: true });
|
||||
else if (["name", "host", "port", "username", "password", "database"].includes(target)) c[target] = value;
|
||||
else throw new Error("Unsupported field binding");
|
||||
}
|
||||
return { provider: { id: provider.id, databaseType: provider.database_type }, connection: c, runtime: null };
|
||||
}
|
||||
export function summary(manifest, record) {
|
||||
const provider = providerFor(manifest, record.providerId);
|
||||
const name = provider.fields.find((f) => binding(f) === "name");
|
||||
return { id: record.id, providerId: record.providerId, name: name ? record.values[name.key] || provider.label : provider.label, label: provider.label };
|
||||
}
|
||||
|
||||
export class ConnectionStore {
|
||||
records = [];
|
||||
constructor(directory, manifest) {
|
||||
this.directory = directory;
|
||||
this.manifest = manifest;
|
||||
this.file = join(directory, "connections.json");
|
||||
}
|
||||
async load() {
|
||||
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
||||
if ((await lstat(this.directory)).isSymbolicLink()) throw new Error("Data directory cannot be a symlink");
|
||||
await chmod(this.directory, 0o700);
|
||||
try {
|
||||
if ((await lstat(this.file)).isSymbolicLink()) throw new Error("Connection store cannot be a symlink");
|
||||
const stored = JSON.parse(await readFile(this.file, "utf8"));
|
||||
if (stored.pluginId !== this.manifest.id || stored.version !== 1 || !Array.isArray(stored.connections)) throw new Error("Invalid connection store");
|
||||
this.records = stored.connections.map((r) => validateRecord(this.manifest, r, false));
|
||||
if (new Set(this.records.map((r) => r.id)).size !== this.records.length) throw new Error("Duplicate connection IDs");
|
||||
await chmod(this.file, 0o600);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
get(id) {
|
||||
const record = this.records.find((r) => r.id === id);
|
||||
if (!record) throw new Error("Connection not found");
|
||||
return structuredClone(record);
|
||||
}
|
||||
async replace(records) {
|
||||
const next = records.map((r) => validateRecord(this.manifest, r, false));
|
||||
const temporary = join(this.directory, `.connections-${randomUUID()}.tmp`);
|
||||
await writeFile(temporary, JSON.stringify({ version: 1, pluginId: this.manifest.id, connections: next }, null, 2), { mode: 0o600, flag: "wx" });
|
||||
await rename(temporary, this.file);
|
||||
this.records = next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
const sensitive = /password|passwd|secret|token|authorization|cookie|credential|private.?key|access.?key|api.?key|csrf/i;
|
||||
export function redact(value, secretKeys = new Set()) {
|
||||
let budget = 8192,
|
||||
nodes = 0;
|
||||
const seen = new WeakSet();
|
||||
function visit(v, key = "", depth = 0) {
|
||||
if (sensitive.test(key) || secretKeys.has(key) || key === "headers" || key === "channel") return "[REDACTED]";
|
||||
if (/base64|binary|^html$/i.test(key)) return "[content omitted]";
|
||||
if (++nodes > 150 || budget <= 0 || depth > 6) return "[truncated]";
|
||||
if (typeof v === "string") {
|
||||
const limit = Math.min(2048, budget),
|
||||
source = v.slice(0, limit);
|
||||
if (/-----BEGIN [^-]*PRIVATE KEY-----/.test(source)) return "[REDACTED]";
|
||||
const safe = source.replace(/(\b[a-z][a-z0-9+.-]{0,30}:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@").replace(/([?&](?:[^=&\s]*(?:token|key|password|secret)[^=&\s]*)=)[^&#\s]*/gi, "$1[REDACTED]");
|
||||
budget -= source.length;
|
||||
return safe + (v.length > limit ? " [truncated]" : "");
|
||||
}
|
||||
if (v === null || typeof v === "boolean" || typeof v === "number") return v;
|
||||
if (typeof v !== "object") return String(v);
|
||||
if (seen.has(v)) return "[circular]";
|
||||
seen.add(v);
|
||||
if (Array.isArray(v))
|
||||
return v
|
||||
.slice(0, 40)
|
||||
.map((item) => visit(item, "", depth + 1))
|
||||
.concat(v.length > 40 ? ["[truncated]"] : []);
|
||||
return Object.fromEntries(
|
||||
Object.entries(v)
|
||||
.slice(0, 40)
|
||||
.map(([k, item]) => [k.slice(0, 128), visit(item, k, depth + 1)]),
|
||||
);
|
||||
}
|
||||
return visit(value);
|
||||
}
|
||||
|
||||
export class Diagnostics extends EventEmitter {
|
||||
instanceId = randomUUID();
|
||||
entries = [];
|
||||
sequence = 0;
|
||||
secretKeys = new Set();
|
||||
context = {};
|
||||
constructor(output = (line) => console.log(line)) {
|
||||
super();
|
||||
this.output = output;
|
||||
}
|
||||
record(level, category, message, details = {}) {
|
||||
const safe = { ...this.context };
|
||||
for (const key of ["requestId", "status", "durationMs", "bytes", "port", "exitCode"]) {
|
||||
if (Number.isFinite(details[key])) safe[key] = details[key];
|
||||
}
|
||||
for (const key of ["method", "state", "reason", "transport"]) {
|
||||
if (typeof details[key] === "string" && /^[A-Za-z0-9._:/-]{1,256}$/.test(details[key])) safe[key] = details[key];
|
||||
}
|
||||
for (const key of ["path", "project", "uiRoot", "backend", "params", "result", "error"]) {
|
||||
if (details[key] !== undefined) safe[key] = redact(details[key], this.secretKeys);
|
||||
}
|
||||
const entry = { id: ++this.sequence, time: new Date().toISOString(), level, category, message, details: safe };
|
||||
this.entries.push(entry);
|
||||
if (this.entries.length > 500) this.entries.shift();
|
||||
this.output(`[dbx-dev] ${JSON.stringify(entry)}`);
|
||||
this.emit("entry", entry);
|
||||
return entry;
|
||||
}
|
||||
snapshot() {
|
||||
return structuredClone(this.entries);
|
||||
}
|
||||
query(params) {
|
||||
for (const key of params.keys()) if (!["after", "limit", "level", "instanceId"].includes(key) || params.getAll(key).length !== 1) throw new Error("Invalid diagnostics query");
|
||||
const integer = (key, fallback, max) => {
|
||||
const raw = params.get(key);
|
||||
if (raw === null) return fallback;
|
||||
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(Number(raw)) || Number(raw) > max) throw new Error(`Invalid ${key}`);
|
||||
return Number(raw);
|
||||
};
|
||||
const after = integer("after", 0, Number.MAX_SAFE_INTEGER),
|
||||
limit = integer("limit", 100, 500);
|
||||
const level = params.get("level");
|
||||
if (limit < 1 || (level && !["debug", "info", "error"].includes(level))) throw new Error("Invalid diagnostics filter");
|
||||
const reset = !!(params.get("instanceId") && params.get("instanceId") !== this.instanceId) || after > this.sequence;
|
||||
const cursor = reset ? 0 : after,
|
||||
oldestId = this.entries[0]?.id || 0;
|
||||
const matched = this.entries.filter((e) => e.id > cursor && (!level || e.level === level));
|
||||
const entries = matched.slice(0, limit),
|
||||
hasMore = matched.length > limit;
|
||||
return { instanceId: this.instanceId, entries: structuredClone(entries), nextAfter: hasMore ? entries.at(-1).id : this.sequence, hasMore, reset, truncated: oldestId > cursor + 1, oldestId, latestId: this.sequence };
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
Generated
+3543
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@dbx-app/plugin-dev-host-source",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"lint": "oxlint --vue-plugin --deny-warnings *.mjs ui tests",
|
||||
"fmt": "oxfmt \"*.mjs\" \"ui/**/*.{js,vue,css,html}\" \"tests/**/*.mjs\"",
|
||||
"fmt:check": "oxfmt --check \"*.mjs\" \"ui/**/*.{js,vue,css,html}\" \"tests/**/*.mjs\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"daisyui": "^5.1.10",
|
||||
"esbuild": "^0.25.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"lucide-vue-next": "^0.468.0",
|
||||
"oxfmt": "^0.53.0",
|
||||
"oxlint": "^1.68.0",
|
||||
"semver": "^7.7.2",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"vite": "^6.1.0",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
// Unix children must be spawned detached so their process group belongs to us.
|
||||
export async function stopProcessTree(child) {
|
||||
if (!child?.pid) return;
|
||||
if (process.platform === "win32") {
|
||||
await new Promise((resolve) => {
|
||||
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
||||
killer.once("error", resolve);
|
||||
killer.once("close", resolve);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const signal = (value) => {
|
||||
try {
|
||||
process.kill(-child.pid, value);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// macOS can return EPERM for a group containing only reparented zombies.
|
||||
if (error.code === "ESRCH" || (value === 0 && error.code === "EPERM")) return false;
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
if (!signal("SIGTERM")) return;
|
||||
// A leader exiting does not imply that its descendants have exited.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await delay(50);
|
||||
if (!signal(0)) return;
|
||||
}
|
||||
signal("SIGKILL");
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdir, writeFile, chmod } from "node:fs/promises";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { createMockHost } from "./server.mjs";
|
||||
import { Diagnostics } from "./diagnostics.mjs";
|
||||
import { stopProcessTree } from "./process-tree.mjs";
|
||||
|
||||
export async function startDevelopment(options) {
|
||||
const diagnostics = options.diagnostics || new Diagnostics();
|
||||
if (Number(process.versions.node.split(".")[0]) < 22) throw new Error("dbx-plugin dev requires Node.js 22+");
|
||||
const project = resolve(options.project),
|
||||
dataDir = resolve(options.dataDir || resolve(project, ".dbx-dev"));
|
||||
await mkdir(dataDir, { recursive: true, mode: 0o700 });
|
||||
await chmod(dataDir, 0o700);
|
||||
await mkdir(resolve(dataDir, "bin"), { recursive: true });
|
||||
const children = new Set();
|
||||
let host,
|
||||
stopping = false;
|
||||
async function spawnCommand(spec, watch = false) {
|
||||
if (stopping) throw new Error("Development host is stopping");
|
||||
if (Array.isArray(spec)) spec = { command: spec[0], args: spec.slice(1), cwd: project };
|
||||
if (!spec?.command || !Array.isArray(spec.args) || spec.args.some((a) => typeof a !== "string")) throw new Error("Invalid build command");
|
||||
const env = { ...process.env };
|
||||
if (spec.goWorkspace) {
|
||||
const work = resolve(dataDir, "go.work");
|
||||
await writeFile(work, `go 1.22\n\nuse (\n${spec.goWorkspace.map((p) => "\t" + JSON.stringify(p)).join("\n")}\n)\n`, { mode: 0o600 });
|
||||
env.GOWORK = work;
|
||||
}
|
||||
let command = spec.command === "node" ? process.execPath : spec.command;
|
||||
let args = spec.args;
|
||||
// Windows npm launchers are batch files; invoke npm's JS entrypoint through Node.
|
||||
if (process.platform === "win32" && command === "npm") {
|
||||
const npm = process.env.npm_execpath || resolve(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js");
|
||||
command = process.execPath;
|
||||
args = [npm, ...args];
|
||||
}
|
||||
if (stopping) throw new Error("Development host is stopping");
|
||||
const child = spawn(command, args, { cwd: spec.cwd || project, env, stdio: ["ignore", watch ? "pipe" : "inherit", "inherit"], detached: process.platform !== "win32" });
|
||||
children.add(child);
|
||||
child.once("exit", () => {
|
||||
child.cleanup = stopProcessTree(child);
|
||||
child.cleanup.then(
|
||||
() => children.delete(child),
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
async function run(spec) {
|
||||
diagnostics.record("info", "build", "构建命令开始");
|
||||
const child = await spawnCommand(spec);
|
||||
await new Promise((accept, reject) => {
|
||||
child.once("error", () => reject(new Error("Cannot start build command")));
|
||||
child.once("exit", (code) => (code === 0 ? accept() : reject(new Error(`Build failed (exit ${code})`))));
|
||||
}).catch((error) => {
|
||||
diagnostics.record("error", "build", "构建命令失败");
|
||||
throw error;
|
||||
});
|
||||
diagnostics.record("info", "build", "构建命令完成");
|
||||
if (stopping) throw new Error("Development host is stopping");
|
||||
}
|
||||
async function stopChildren() {
|
||||
await Promise.all([...children].map((child) => child.cleanup || stopProcessTree(child)));
|
||||
}
|
||||
const stop = async () => {
|
||||
if (stopping) return;
|
||||
stopping = true;
|
||||
process.removeListener("SIGINT", onSignal);
|
||||
process.removeListener("SIGTERM", onSignal);
|
||||
await stopChildren();
|
||||
await host?.close();
|
||||
};
|
||||
const onSignal = () => void stop();
|
||||
process.once("SIGINT", onSignal);
|
||||
process.once("SIGTERM", onSignal);
|
||||
try {
|
||||
const commands = options.commands || {};
|
||||
if (commands.backend) await run(commands.backend);
|
||||
if (commands.ui?.length) await run(commands.ui);
|
||||
if (stopping) throw new Error("Development host is stopping");
|
||||
host = await createMockHost({
|
||||
...options,
|
||||
project,
|
||||
dataDir,
|
||||
diagnostics,
|
||||
uiBuildSignals: Boolean(commands.watch?.length),
|
||||
shellHtml: options.shellHtml || resolve(dirname(fileURLToPath(import.meta.url)), "ui/index.html"),
|
||||
buildBackend: commands.backend ? () => run(commands.backend) : undefined,
|
||||
});
|
||||
if (stopping) {
|
||||
await host.close();
|
||||
throw new Error("Development host is stopping");
|
||||
}
|
||||
if (commands.watch?.length) {
|
||||
const watcher = await spawnCommand(commands.watch, true);
|
||||
let line = "",
|
||||
oversized = false;
|
||||
watcher.stdout.setEncoding("utf8");
|
||||
watcher.stdout.on("data", (chunk) => {
|
||||
process.stdout.write(chunk);
|
||||
for (const character of chunk) {
|
||||
if (character === "\n") {
|
||||
if (!oversized && line.replace(/\r$/, "") === "DBX_UI_BUILD_SUCCESS") {
|
||||
void host.uiBuilt().catch(() => diagnostics.record("error", "build", "前端构建产物不可读取"));
|
||||
}
|
||||
line = "";
|
||||
oversized = false;
|
||||
} else if (line.length < 1024) line += character;
|
||||
else oversized = true;
|
||||
}
|
||||
});
|
||||
watcher.on("error", () => diagnostics.record("error", "build", "前端构建监听启动失败"));
|
||||
watcher.on("exit", (code) => {
|
||||
if (!stopping) diagnostics.record("error", "build", "前端构建监听已退出", { exitCode: code });
|
||||
});
|
||||
}
|
||||
console.log(`Plugin dev host: ${host.origin}`);
|
||||
console.log("Development credentials are stored locally as plaintext in the configured data directory.");
|
||||
return { ...host, close: stop };
|
||||
} catch (error) {
|
||||
await stop();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
try {
|
||||
if (process.argv[2] !== "--config" || !process.argv[3]) throw new Error("Start this runtime using dbx-plugin dev");
|
||||
await startDevelopment(JSON.parse(process.argv[3]));
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import http from "node:http";
|
||||
import { randomUUID, randomBytes } from "node:crypto";
|
||||
import { readFile, realpath, watch } from "node:fs/promises";
|
||||
import { resolve, relative, isAbsolute, sep } from "node:path";
|
||||
import semver from "semver";
|
||||
import { Sidecar, protocolName } from "./sidecar.mjs";
|
||||
import { ConnectionStore, lifecyclePayload, providerFor, summary, validateRecord } from "./connections.mjs";
|
||||
import { readAsset } from "./assets.mjs";
|
||||
import { sandboxDocument } from "./browser-bridge.mjs";
|
||||
import { Diagnostics } from "./diagnostics.mjs";
|
||||
import { AutoReload } from "./auto-reload.mjs";
|
||||
|
||||
const BRIDGE_LIMIT = 2 * 1024 * 1024,
|
||||
UI_BINARY_LIMIT = 8 * 1024 * 1024;
|
||||
function jsonSize(value) {
|
||||
return Buffer.byteLength(JSON.stringify(value) ?? "null");
|
||||
}
|
||||
function requirePermission(manifest, permission) {
|
||||
if (!manifest.permissions?.includes(permission)) throw new Error(`Plugin permission required: ${permission}`);
|
||||
}
|
||||
function ensureSucceeded(result) {
|
||||
if (result?.success === false) throw new Error(result.message || "Plugin connection operation failed");
|
||||
}
|
||||
function pageId(value = "legacy") {
|
||||
if (typeof value !== "string" || !/^[a-zA-Z0-9-]{1,64}$/.test(value)) throw new Error("Invalid page ID");
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function createMockHost(options) {
|
||||
const diagnostics = options.diagnostics || new Diagnostics();
|
||||
const project = resolve(options.project),
|
||||
manifest = JSON.parse(await readFile(resolve(project, "manifest.json"), "utf8"));
|
||||
for (const contribution of manifest.contributions || [])
|
||||
for (const field of contribution.fields || []) {
|
||||
if (field.type === "password" || ["password", "secret"].includes(field.binding)) diagnostics.secretKeys.add(field.key);
|
||||
}
|
||||
if (manifest.manifest_version !== undefined && manifest.manifest_version !== 1) throw new Error("Unsupported manifest version");
|
||||
// DBX uses Rust semver requirements, whose comparator separators include commas.
|
||||
if (manifest.engines?.host_api && !semver.satisfies("1.0.0", manifest.engines.host_api.replaceAll(",", " "))) throw new Error("Plugin does not support Host API 1.0.0");
|
||||
const backendEntry = manifest.entrypoints?.backend;
|
||||
const transport = backendEntry?.transport || "stdio-jsonl";
|
||||
if (backendEntry && (!["stdio-framed", "stdio-jsonl"].includes(transport) || !(backendEntry.protocol_versions || [1]).includes(1))) throw new Error("Unsupported backend transport or protocol version");
|
||||
const declaredRoot = resolve(project, manifest.entrypoints.ui.root || "ui");
|
||||
const uiRoot = resolve(project, options.uiRoot || declaredRoot);
|
||||
const entry = relative(declaredRoot, resolve(project, manifest.entrypoints.ui.entry));
|
||||
await readAsset(uiRoot, entry);
|
||||
const store = new ConnectionStore(resolve(options.dataDir), manifest);
|
||||
await store.load();
|
||||
const dataRelative = relative(await realpath(uiRoot), await realpath(store.directory));
|
||||
if (!dataRelative || (!isAbsolute(dataRelative) && dataRelative !== ".." && !dataRelative.startsWith(`..${sep}`))) {
|
||||
throw new Error("Development data directory must be outside the UI resource root");
|
||||
}
|
||||
if (backendEntry && !options.backend) throw new Error("Backend executable is required");
|
||||
const sidecar = new Sidecar({ executable: backendEntry ? resolve(project, options.backend) : undefined, args: options.backendArgs || [], cwd: project, manifest, transport });
|
||||
const connected = new Set(),
|
||||
frames = new Map(),
|
||||
sessions = new Map(),
|
||||
streams = new Set();
|
||||
const pages = new Map();
|
||||
let revision = 0,
|
||||
restarting = false,
|
||||
closing = false,
|
||||
origin,
|
||||
cookieName;
|
||||
let lifecycleQueue = Promise.resolve();
|
||||
const serialize = (action) => {
|
||||
const task = lifecycleQueue.then(action);
|
||||
lifecycleQueue = task.catch(() => {});
|
||||
return task;
|
||||
};
|
||||
let autoReload = false;
|
||||
const backendReload = new AutoReload(async () => {
|
||||
try {
|
||||
await serialize(async () => {
|
||||
if (autoReload && !closing) await restartBackend();
|
||||
});
|
||||
} catch {
|
||||
broadcast("auto-reload-error", {});
|
||||
}
|
||||
});
|
||||
const broadcast = (type, payload) => {
|
||||
const message = `data: ${JSON.stringify({ type, ...payload })}\n\n`;
|
||||
for (const stream of streams) {
|
||||
if (stream.writableLength > 16 * 1024 * 1024) {
|
||||
stream.destroy();
|
||||
streams.delete(stream);
|
||||
} else stream.write(message);
|
||||
}
|
||||
};
|
||||
const onDiagnostic = (entry) => broadcast("diagnostic", { entry });
|
||||
diagnostics.on("entry", onDiagnostic);
|
||||
sidecar.on("diagnostic", (event) => diagnostics.record(event.level, "rpc", event.message, event.details));
|
||||
sidecar.on("status", (state) => diagnostics.record(state === "failed" ? "error" : "info", "backend", "后端状态变化", { state, transport }));
|
||||
sidecar.on("event", () => diagnostics.record("debug", "event", "收到后端事件"));
|
||||
sidecar.on("binary", (event) => diagnostics.record("debug", "binary", "收到二进制帧", { bytes: Buffer.byteLength(event.dataBase64, "base64") }));
|
||||
sidecar.on("status", (state) => {
|
||||
if (state !== "ready") connected.clear();
|
||||
broadcast("status", { state });
|
||||
});
|
||||
sidecar.on("event", (event) => {
|
||||
if (manifest.permissions?.includes("host.events")) broadcast("event", event);
|
||||
});
|
||||
sidecar.on("binary", (event) => {
|
||||
if (manifest.permissions?.includes("host.binary") && event.dataBase64.length <= (UI_BINARY_LIMIT / 3) * 4 + 4) broadcast("binary", event);
|
||||
});
|
||||
try {
|
||||
await sidecar.start();
|
||||
} catch (error) {
|
||||
diagnostics.removeListener("entry", onDiagnostic);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const listing = () => store.records.map((r) => ({ ...summary(manifest, r), connected: connected.has(r.id) }));
|
||||
async function disconnect(id) {
|
||||
if (connected.has(id) && sidecar.state === "ready") {
|
||||
ensureSucceeded(await sidecar.request("connection/disconnect", lifecyclePayload(manifest, store.get(id)), 10000));
|
||||
}
|
||||
connected.delete(id);
|
||||
}
|
||||
async function connect(record) {
|
||||
if (restarting || sidecar.state !== "ready") throw new Error("Backend is not ready; restart it first");
|
||||
const provider = providerFor(manifest, record.providerId);
|
||||
if (!provider.capabilities?.includes("connect")) throw new Error("Provider does not support connect");
|
||||
validateRecord(manifest, record);
|
||||
if (!connected.has(record.id)) {
|
||||
ensureSucceeded(await sidecar.request("connection/connect", lifecyclePayload(manifest, record), 60000));
|
||||
connected.add(record.id);
|
||||
}
|
||||
}
|
||||
async function openFrame(session, connectionId, contributionId, suppliedContext) {
|
||||
const record = connectionId ? store.get(connectionId) : undefined;
|
||||
const provider = record ? providerFor(manifest, record.providerId) : undefined;
|
||||
const contribution = manifest.contributions.find((c) => c.id === (contributionId || provider?.workbench) && c.type === "workbench");
|
||||
if (!contribution) throw new Error("Unknown workbench contribution");
|
||||
if (record) await connect(record);
|
||||
const context = record ? { ...suppliedContext, connectionId: record.id, providerId: provider.id, connectionType: provider.database_type } : { ...suppliedContext };
|
||||
const name = record ? summary(manifest, record).name : contribution.label;
|
||||
if (jsonSize(context) > BRIDGE_LIMIT) throw new Error("Context exceeds limit");
|
||||
const existing = [...frames.values()].find((f) => f.session === session.id && f.page === session.page && f.connectionId === connectionId && f.contributionId === contribution.id);
|
||||
if (existing) {
|
||||
existing.context = context;
|
||||
existing.name = name;
|
||||
return expose(existing);
|
||||
}
|
||||
const frame = { id: randomUUID(), channel: randomUUID(), session: session.id, page: session.page, connectionId, contributionId: contribution.id, context, name };
|
||||
frames.set(frame.id, frame);
|
||||
return expose(frame);
|
||||
}
|
||||
function expose(frame) {
|
||||
const { session: _session, page: _page, ...safe } = frame;
|
||||
return structuredClone(safe);
|
||||
}
|
||||
function frameFor(session, id) {
|
||||
const f = frames.get(id);
|
||||
if (!f || f.session !== session.id || f.page !== session.page) throw new Error("Unknown workbench frame");
|
||||
return f;
|
||||
}
|
||||
async function bridge(session, input) {
|
||||
const frame = frameFor(session, input.frameId),
|
||||
p = input.params || {};
|
||||
if (input.channel !== frame.channel) throw new Error("Stale workbench generation");
|
||||
if (input.method !== "backend.sendBinary" && jsonSize(p) > BRIDGE_LIMIT) throw new Error("Bridge request exceeds 2 MiB");
|
||||
switch (input.method) {
|
||||
case "host.getContext":
|
||||
return structuredClone(frame.context);
|
||||
case "backend.invoke": {
|
||||
if (p.timeoutMs !== undefined && (typeof p.timeoutMs !== "number" || !Number.isFinite(p.timeoutMs))) throw new Error("Invalid request timeout");
|
||||
const timeout = p.timeoutMs === undefined ? 30000 : Math.min(120000, Math.max(1, Math.round(p.timeoutMs)));
|
||||
return sidecar.request(protocolName(p.method), p.params ?? null, timeout);
|
||||
}
|
||||
case "backend.notify":
|
||||
await sidecar.notify(protocolName(p.method), p.params ?? null);
|
||||
return null;
|
||||
case "backend.sendBinary": {
|
||||
requirePermission(manifest, "host.binary");
|
||||
if (typeof p.dataBase64 !== "string" || p.dataBase64.length > Math.ceil(UI_BINARY_LIMIT / 3) * 4) throw new Error("Invalid base64 or binary exceeds limit");
|
||||
const data = Buffer.from(p.dataBase64, "base64");
|
||||
if (data.toString("base64") !== p.dataBase64) throw new Error("Invalid base64");
|
||||
if (data.length > UI_BINARY_LIMIT) throw new Error("Bridge binary exceeds 8 MiB");
|
||||
await sidecar.sendBinary(p.channel, data);
|
||||
return null;
|
||||
}
|
||||
case "ui.readAsset": {
|
||||
const prefix = (manifest.entrypoints.ui.root || "ui").replace(/\/$/, "") + "/";
|
||||
const path = typeof p.path === "string" && p.path.startsWith(prefix) ? p.path.slice(prefix.length) : p.path;
|
||||
return readAsset(uiRoot, path);
|
||||
}
|
||||
case "host.openWorkbench": {
|
||||
requirePermission(manifest, "host.workbench");
|
||||
return { mockHostOpenFrame: await serialize(() => openFrame(session, p.context?.connectionId || frame.connectionId, p.contributionId, p.context)) };
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported mock host method: ${input.method}`);
|
||||
}
|
||||
}
|
||||
async function api(session, route, p) {
|
||||
if (route === "/api/bridge") return bridge(session, p);
|
||||
if (route === "/api/frame-document") {
|
||||
const f = frameFor(session, p.frameId);
|
||||
const asset = await readAsset(uiRoot, entry);
|
||||
f.channel = randomUUID();
|
||||
return { ...expose(f), html: sandboxDocument(Buffer.from(asset.dataBase64, "base64").toString("utf8"), f.channel) };
|
||||
}
|
||||
return serialize(async () => {
|
||||
switch (route) {
|
||||
case "/api/auto-reload": {
|
||||
if (typeof p.enabled !== "boolean") throw new Error("Invalid automatic reload setting");
|
||||
autoReload = p.enabled;
|
||||
backendReload.enable(autoReload);
|
||||
broadcast("auto-reload", { enabled: autoReload });
|
||||
diagnostics.record("info", "build", autoReload ? "自动重载已启用" : "自动重载已关闭");
|
||||
return { enabled: autoReload };
|
||||
}
|
||||
case "/api/workbenches/open":
|
||||
return { frame: await openFrame(session, undefined, p.contributionId) };
|
||||
case "/api/connections/edit":
|
||||
return store.get(p.id);
|
||||
case "/api/connections/save": {
|
||||
const existing = p.id ? store.get(p.id) : undefined;
|
||||
const record = validateRecord(manifest, p);
|
||||
if (existing && existing.providerId !== record.providerId) throw new Error("Create a new connection to change its provider");
|
||||
if (existing) await disconnect(existing.id);
|
||||
await store.replace([...store.records.filter((r) => r.id !== record.id), record]);
|
||||
return { connections: listing(), id: record.id };
|
||||
}
|
||||
case "/api/connections/test": {
|
||||
const record = validateRecord(manifest, { ...p, id: randomUUID() });
|
||||
if (!providerFor(manifest, record.providerId).capabilities?.includes("test")) throw new Error("Provider does not support test");
|
||||
ensureSucceeded(await sidecar.request("connection/test", lifecyclePayload(manifest, record), 60000));
|
||||
return { message: "连接测试成功" };
|
||||
}
|
||||
case "/api/connections/connect":
|
||||
return { frame: await openFrame(session, p.id), connections: listing() };
|
||||
case "/api/connections/disconnect":
|
||||
await disconnect(p.id);
|
||||
return { connections: listing() };
|
||||
case "/api/connections/delete": {
|
||||
store.get(p.id);
|
||||
await disconnect(p.id);
|
||||
await store.replace(store.records.filter((r) => r.id !== p.id));
|
||||
for (const f of frames.values()) if (f.connectionId === p.id) frames.delete(f.id);
|
||||
broadcast("frames-removed", { connectionId: p.id });
|
||||
return { connections: listing() };
|
||||
}
|
||||
case "/api/connections/import": {
|
||||
if (!Array.isArray(p.connections) || p.connections.length > 100) throw new Error("Invalid connection import");
|
||||
const imported = p.connections.map((r) => validateRecord(manifest, { ...r, id: randomUUID() }));
|
||||
await store.replace([...store.records, ...imported]);
|
||||
return { connections: listing() };
|
||||
}
|
||||
case "/api/frames/close": {
|
||||
const f = frameFor(session, p.id);
|
||||
if (f.connectionId && ![...frames.values()].some((other) => other.id !== f.id && other.connectionId === f.connectionId)) await disconnect(f.connectionId);
|
||||
frames.delete(f.id);
|
||||
return { connections: listing() };
|
||||
}
|
||||
case "/api/backend/restart":
|
||||
return restartBackend();
|
||||
default:
|
||||
throw new Error("Unknown mock host endpoint");
|
||||
}
|
||||
});
|
||||
}
|
||||
async function restartBackend() {
|
||||
restarting = true;
|
||||
diagnostics.record("info", "build", "开始重建后端");
|
||||
try {
|
||||
for (const id of connected) await disconnect(id).catch(() => connected.delete(id));
|
||||
await sidecar.stop();
|
||||
if (options.buildBackend) await options.buildBackend();
|
||||
await sidecar.start();
|
||||
diagnostics.record("info", "build", "后端重建完成");
|
||||
return { connections: listing(), state: sidecar.state };
|
||||
} catch (error) {
|
||||
diagnostics.record("error", "build", "后端重建失败");
|
||||
throw error;
|
||||
} finally {
|
||||
restarting = false;
|
||||
}
|
||||
}
|
||||
const server = http.createServer(async (request, response) => {
|
||||
const started = performance.now();
|
||||
let route = "unknown",
|
||||
rejection;
|
||||
response.on("finish", () => {
|
||||
if (route === "diagnostics" || route === "events" || (route === "bootstrap" && response.statusCode < 400)) return;
|
||||
diagnostics.record(response.statusCode >= 400 ? "error" : "debug", "http", "HTTP 请求完成", {
|
||||
method: route,
|
||||
path: route === "unknown" ? "[unknown route]" : route.startsWith("/") ? route : `/api/${route}`,
|
||||
status: response.statusCode,
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
reason: rejection,
|
||||
});
|
||||
});
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
response.setHeader("Referrer-Policy", "no-referrer");
|
||||
try {
|
||||
if (request.headers.host !== new URL(origin).host) {
|
||||
rejection = "invalid-host";
|
||||
return respond(response, 403, { error: { message: "Invalid Host" } });
|
||||
}
|
||||
if (request.headers["sec-fetch-site"] === "cross-site" || (request.headers.origin && request.headers.origin !== origin)) {
|
||||
rejection = "invalid-origin";
|
||||
return respond(response, 403, { error: { message: "Invalid Origin" } });
|
||||
}
|
||||
const path = new URL(request.url, origin).pathname;
|
||||
const known = [
|
||||
"/",
|
||||
"/api/bootstrap",
|
||||
"/api/events",
|
||||
"/api/bridge",
|
||||
"/api/frame-document",
|
||||
"/api/workbenches/open",
|
||||
"/api/connections/edit",
|
||||
"/api/connections/save",
|
||||
"/api/connections/test",
|
||||
"/api/connections/connect",
|
||||
"/api/connections/disconnect",
|
||||
"/api/connections/delete",
|
||||
"/api/connections/import",
|
||||
"/api/frames/close",
|
||||
"/api/backend/restart",
|
||||
];
|
||||
route = known.includes(path) ? path.replace("/api/", "") || "/" : "unknown";
|
||||
if (path === "/api/diagnostics") {
|
||||
route = "diagnostics";
|
||||
if (request.method !== "GET") return respond(response, 405, { error: { message: "Diagnostics is read-only; use GET" } });
|
||||
return respond(response, 200, { ...diagnostics.query(new URL(request.url, origin).searchParams), plugin: { id: manifest.id, version: manifest.version }, backendState: sidecar.state, port: server.address().port });
|
||||
}
|
||||
const cookie = (request.headers.cookie || "")
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(`${cookieName}=`))
|
||||
?.slice(cookieName.length + 1);
|
||||
let session = sessions.get(cookie);
|
||||
if (path === "/api/bootstrap" && request.method === "GET") {
|
||||
if (!session) {
|
||||
if (sessions.size >= 128) throw new Error("Too many browser sessions");
|
||||
session = { id: randomBytes(24).toString("hex"), csrf: randomBytes(24).toString("hex") };
|
||||
sessions.set(session.id, session);
|
||||
response.setHeader("Set-Cookie", `${cookieName}=${session.id}; HttpOnly; SameSite=Strict; Path=/`);
|
||||
}
|
||||
return respond(response, 200, { manifest, connections: listing(), csrf: session.csrf, state: sidecar.state, revision, autoReload, diagnostics: diagnostics.snapshot() });
|
||||
}
|
||||
if (path === "/api/events" && request.method === "GET" && session) {
|
||||
const page = pageId(new URL(request.url, origin).searchParams.get("page") || undefined);
|
||||
const key = `${session.id}:${page}`;
|
||||
let lease = pages.get(key);
|
||||
if (!lease) {
|
||||
lease = { count: 0 };
|
||||
pages.set(key, lease);
|
||||
}
|
||||
clearTimeout(lease.timer);
|
||||
lease.count++;
|
||||
response.writeHead(200, { "Content-Type": "text/event-stream", Connection: "keep-alive" });
|
||||
response.write(`data: ${JSON.stringify({ type: "page-frames", ids: [...frames.values()].filter((f) => f.session === session.id && f.page === page).map((f) => f.id) })}\n\n`);
|
||||
response.write(`data: ${JSON.stringify({ type: "status", state: sidecar.state })}\n\n`);
|
||||
streams.add(response);
|
||||
response.write(`data: ${JSON.stringify({ type: "auto-reload", enabled: autoReload })}\n\n`);
|
||||
response.write(`data: ${JSON.stringify({ type: "diagnostic-history", entries: diagnostics.snapshot() })}\n\n`);
|
||||
const heartbeat = setInterval(() => response.write(": heartbeat\n\n"), 20000);
|
||||
response.on("close", () => {
|
||||
streams.delete(response);
|
||||
clearInterval(heartbeat);
|
||||
if (--lease.count || closing) return;
|
||||
// Keep frames during EventSource reconnects; retire abandoned browser pages.
|
||||
lease.timer = setTimeout(() => {
|
||||
void serialize(async () => {
|
||||
if (closing || lease.count || pages.get(key) !== lease) return;
|
||||
pages.delete(key);
|
||||
const ids = new Set();
|
||||
for (const f of frames.values()) {
|
||||
if (f.session !== session.id || f.page !== page) continue;
|
||||
frames.delete(f.id);
|
||||
if (f.connectionId) ids.add(f.connectionId);
|
||||
}
|
||||
for (const id of ids) {
|
||||
if ([...frames.values()].some((f) => f.connectionId === id)) continue;
|
||||
try {
|
||||
await disconnect(id);
|
||||
} catch {
|
||||
diagnostics.record("error", "backend", "页面回收时断开连接失败");
|
||||
}
|
||||
}
|
||||
broadcast("connections", { connections: listing() });
|
||||
});
|
||||
}, options.pageGraceMs ?? 30000);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path.startsWith("/api/")) {
|
||||
if (request.method !== "POST" || !session || request.headers.origin !== origin || request.headers["x-mock-csrf"] !== session.csrf || !request.headers["content-type"]?.startsWith("application/json")) {
|
||||
rejection = request.method !== "POST" ? "invalid-method" : !session ? "missing-or-expired-session" : request.headers.origin !== origin ? "missing-origin" : request.headers["x-mock-csrf"] !== session.csrf ? "csrf-mismatch" : "invalid-content-type";
|
||||
return respond(response, 403, { error: { message: "Invalid browser session or request" } });
|
||||
}
|
||||
let bytes = 0;
|
||||
const chunks = [];
|
||||
for await (const chunk of request) {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 12 * 1024 * 1024) throw new Error("HTTP payload exceeds limit");
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
const page = pageId(request.headers["x-dbx-page"]);
|
||||
return respond(response, 200, { value: await api({ ...session, page }, path, body) });
|
||||
}
|
||||
if (path === "/" && request.method === "GET") {
|
||||
response.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
response.setHeader(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'unsafe-inline' blob:; style-src 'self' 'unsafe-inline' blob:; img-src 'self' data: blob:; font-src 'self' data: blob:; media-src 'self' data: blob:; frame-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
||||
);
|
||||
return response.end(await readFile(options.shellHtml));
|
||||
}
|
||||
respond(response, 404, { error: { message: "Not found" } });
|
||||
} catch (error) {
|
||||
const rpc = error.rpc;
|
||||
rejection = rpc ? "backend-error" : error.message?.startsWith("Plugin permission required:") ? "permission-denied" : error.message?.startsWith("Unsupported mock host method:") ? "unsupported-method" : error.message === "Stale workbench generation" ? "stale-frame" : "operation-failed";
|
||||
// Do not reflect raw filesystem/process errors, paths or request bodies.
|
||||
const message = error.code ? "Local development service operation failed" : error.message;
|
||||
respond(response, 400, { error: { message, ...(rpc ? { code: rpc.code, data: rpc.data } : {}) } });
|
||||
}
|
||||
});
|
||||
server.requestTimeout = 320000;
|
||||
try {
|
||||
await new Promise((accept, reject) => {
|
||||
server.once("error", (error) => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", accept);
|
||||
} else reject(error);
|
||||
});
|
||||
server.listen(options.port ?? 5190, "127.0.0.1", accept);
|
||||
});
|
||||
} catch (error) {
|
||||
await sidecar.stop();
|
||||
throw error;
|
||||
}
|
||||
origin = `http://127.0.0.1:${server.address().port}`;
|
||||
// Cookies are scoped by host, not port; concurrent development hosts need distinct names.
|
||||
cookieName = `dbx_dev_session_${server.address().port}`;
|
||||
diagnostics.context = { port: server.address().port };
|
||||
diagnostics.record("info", "server", "调试服务已启动", { port: server.address().port, project, uiRoot, backend: options.backend });
|
||||
const watchStop = new AbortController();
|
||||
if (options.backendWatch && options.buildBackend)
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const event of watch(options.backendWatch, { signal: watchStop.signal, recursive: true })) {
|
||||
const path = String(event.filename || "").replaceAll("\\", "/");
|
||||
if (path.split("/").some((p) => ["target", ".git", ".dbx-dev", "node_modules", "vendor"].includes(p))) continue;
|
||||
if (/\.(rs|go)$|(^|\/)(Cargo\.toml|Cargo\.lock|build\.rs|go\.mod|go\.sum)$/.test(path)) backendReload.changed();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") {
|
||||
diagnostics.record("error", "build", "后端源码监听已停止");
|
||||
broadcast("auto-reload-error", {});
|
||||
}
|
||||
}
|
||||
})();
|
||||
let debounce;
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const _event of watch(uiRoot, { signal: watchStop.signal, recursive: true })) {
|
||||
if (options.uiBuildSignals) continue;
|
||||
clearTimeout(debounce);
|
||||
debounce = setTimeout(async () => {
|
||||
try {
|
||||
await readAsset(uiRoot, entry);
|
||||
broadcast("ui-rebuilt", { revision: ++revision });
|
||||
} catch {
|
||||
broadcast("watch-error", {});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") broadcast("watch-error", {});
|
||||
}
|
||||
})();
|
||||
return {
|
||||
origin,
|
||||
server,
|
||||
sidecar,
|
||||
store,
|
||||
diagnostics,
|
||||
async uiBuilt() {
|
||||
await readAsset(uiRoot, entry);
|
||||
broadcast("ui-rebuilt", { revision: ++revision });
|
||||
},
|
||||
async close() {
|
||||
if (closing) return;
|
||||
closing = true;
|
||||
for (const lease of pages.values()) clearTimeout(lease.timer);
|
||||
pages.clear();
|
||||
backendReload.enable(false);
|
||||
watchStop.abort();
|
||||
clearTimeout(debounce);
|
||||
for (const stream of streams) stream.end();
|
||||
streams.clear();
|
||||
server.close();
|
||||
await serialize(async () => {
|
||||
for (const id of connected) await disconnect(id).catch(() => {});
|
||||
await sidecar.stop();
|
||||
});
|
||||
server.closeAllConnections();
|
||||
diagnostics.record("info", "server", "调试服务已停止");
|
||||
diagnostics.removeListener("entry", onDiagnostic);
|
||||
},
|
||||
};
|
||||
}
|
||||
function respond(response, code, value) {
|
||||
if (response.headersSent || response.destroyed) return;
|
||||
response.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { spawn } from "node:child_process";
|
||||
import { stopProcessTree } from "./process-tree.mjs";
|
||||
|
||||
export const JSON_LIMIT = 8 * 1024 * 1024;
|
||||
export const BINARY_LIMIT = 64 * 1024 * 1024;
|
||||
export function protocolName(value) {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value)) throw new Error("Invalid protocol name");
|
||||
return value;
|
||||
}
|
||||
export function frame(kind, payload) {
|
||||
const header = Buffer.alloc(5);
|
||||
header[0] = kind;
|
||||
header.writeUInt32BE(payload.length, 1);
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
export function binaryPayload(channel, data) {
|
||||
const name = Buffer.from(protocolName(channel));
|
||||
if (data.length > BINARY_LIMIT) throw new Error("Binary payload exceeds limit");
|
||||
const header = Buffer.alloc(2);
|
||||
header.writeUInt16BE(name.length);
|
||||
return Buffer.concat([header, name, data]);
|
||||
}
|
||||
|
||||
export class FrameDecoder {
|
||||
buffer = Buffer.alloc(0);
|
||||
constructor(onFrame) {
|
||||
this.onFrame = onFrame;
|
||||
}
|
||||
push(chunk) {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
while (this.buffer.length >= 5) {
|
||||
const kind = this.buffer[0],
|
||||
length = this.buffer.readUInt32BE(1);
|
||||
if (![0, 1].includes(kind)) throw new Error("Unknown frame kind");
|
||||
if (length > (kind === 0 ? JSON_LIMIT : BINARY_LIMIT + 1024)) throw new Error("Frame exceeds limit");
|
||||
if (this.buffer.length < length + 5) return;
|
||||
const payload = this.buffer.subarray(5, length + 5);
|
||||
this.buffer = this.buffer.subarray(length + 5);
|
||||
if (kind === 0) this.onFrame({ kind, message: JSON.parse(payload.toString("utf8")) });
|
||||
else {
|
||||
if (payload.length < 2) throw new Error("Invalid binary frame");
|
||||
const size = payload.readUInt16BE(0);
|
||||
if (!size || size + 2 > payload.length) throw new Error("Invalid binary channel");
|
||||
const channel = protocolName(new TextDecoder("utf-8", { fatal: true }).decode(payload.subarray(2, 2 + size)));
|
||||
this.onFrame({ kind, channel, data: payload.subarray(2 + size) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class JsonLineDecoder {
|
||||
buffer = Buffer.alloc(0);
|
||||
constructor(onFrame) {
|
||||
this.onFrame = onFrame;
|
||||
}
|
||||
push(chunk) {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
let newline;
|
||||
while ((newline = this.buffer.indexOf(10)) !== -1) {
|
||||
if (newline > JSON_LIMIT) throw new Error("JSON line exceeds limit");
|
||||
const line = this.buffer.subarray(0, newline).toString("utf8").trim();
|
||||
this.buffer = this.buffer.subarray(newline + 1);
|
||||
if (line) this.onFrame({ kind: 0, message: JSON.parse(line) });
|
||||
}
|
||||
if (this.buffer.length > JSON_LIMIT) throw new Error("JSON line exceeds limit");
|
||||
}
|
||||
}
|
||||
|
||||
export class Sidecar extends EventEmitter {
|
||||
pending = new Map();
|
||||
sequence = 0;
|
||||
state = "stopped";
|
||||
constructor({ executable, args = [], cwd, manifest, transport }) {
|
||||
super();
|
||||
Object.assign(this, { executable, args, cwd, manifest });
|
||||
this.transport = transport || manifest.entrypoints?.backend?.transport || "stdio-jsonl";
|
||||
}
|
||||
async start() {
|
||||
if (!this.executable) {
|
||||
this.state = "frontend";
|
||||
this.emit("status", this.state);
|
||||
return null;
|
||||
}
|
||||
if (this.child) throw new Error("Sidecar is already running");
|
||||
this.state = "starting";
|
||||
this.emit("status", this.state);
|
||||
const child = spawn(this.executable, this.args, { cwd: this.cwd, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
||||
this.child = child;
|
||||
const Decoder = this.transport === "stdio-framed" ? FrameDecoder : JsonLineDecoder;
|
||||
const decoder = new Decoder((packet) => {
|
||||
if (packet.kind === 1) return this.emit("binary", { channel: packet.channel, dataBase64: packet.data.toString("base64") });
|
||||
const message = packet.message;
|
||||
if (message.jsonrpc !== "2.0") throw new Error("Invalid JSON-RPC message");
|
||||
if (message.id !== undefined) {
|
||||
const waiter = this.pending.get(message.id);
|
||||
if (!waiter) return;
|
||||
this.pending.delete(message.id);
|
||||
clearTimeout(waiter.timer);
|
||||
if (message.error) waiter.reject(Object.assign(new Error(message.error.message || "Sidecar error"), { rpc: message.error }));
|
||||
else waiter.resolve(message.result);
|
||||
} else if (message.method) this.emit("event", { method: protocolName(message.method), params: message.params });
|
||||
});
|
||||
child.stdout.on("data", (chunk) => {
|
||||
try {
|
||||
decoder.push(chunk);
|
||||
} catch {
|
||||
this.emit("diagnostic", { level: "error", message: "后端协议解析失败", details: { reason: "invalid-frame" } });
|
||||
this.fail("Invalid sidecar frame");
|
||||
child.kill();
|
||||
}
|
||||
});
|
||||
// Sidecar stderr may contain credentials. Consume it without copying it into HTTP or logs.
|
||||
child.stderr.resume();
|
||||
child.on("error", () => this.fail("Cannot start sidecar executable"));
|
||||
child.stdin.on("error", () => this.fail("Sidecar input closed"));
|
||||
child.on("exit", (code) => {
|
||||
this.emit("diagnostic", { level: this.state === "stopping" ? "info" : "error", message: "后端进程退出", details: { exitCode: code } });
|
||||
this.child = undefined;
|
||||
this.cleanup = stopProcessTree(child);
|
||||
if (this.state !== "stopping") this.fail("Sidecar exited");
|
||||
else {
|
||||
this.state = "stopped";
|
||||
this.emit("status", this.state);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const info = await this.request("plugin/initialize", { host: { protocolVersions: [1] } }, 10000);
|
||||
if (info?.protocolVersion !== 1 || info?.plugin?.id !== this.manifest.id || info?.plugin?.version !== this.manifest.version) {
|
||||
throw new Error("Sidecar identity or protocol does not match manifest");
|
||||
}
|
||||
this.state = "ready";
|
||||
this.emit("status", this.state);
|
||||
return info;
|
||||
} catch (error) {
|
||||
await this.stop();
|
||||
this.fail(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
fail(message, state = "failed") {
|
||||
this.state = state;
|
||||
for (const waiter of this.pending.values()) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error(message));
|
||||
}
|
||||
this.pending.clear();
|
||||
this.emit("status", this.state);
|
||||
}
|
||||
write(kind, payload) {
|
||||
if (!this.executable) return Promise.reject(new Error("This plugin has no backend"));
|
||||
if (kind === 1 && this.transport !== "stdio-framed") return Promise.reject(new Error("Binary channels require stdio-framed transport"));
|
||||
if (!this.child || !["starting", "ready"].includes(this.state)) return Promise.reject(new Error("Sidecar is not ready"));
|
||||
if (kind === 0 && payload.length > JSON_LIMIT) return Promise.reject(new Error("JSON payload exceeds limit"));
|
||||
if (this.child.stdin.writableLength > 16 * 1024 * 1024) return Promise.reject(new Error("Sidecar input is busy"));
|
||||
const output = this.transport === "stdio-framed" ? frame(kind, payload) : Buffer.concat([payload, Buffer.from("\n")]);
|
||||
return new Promise((resolve, reject) => this.child.stdin.write(output, (error) => (error ? reject(new Error("Sidecar write failed")) : resolve())));
|
||||
}
|
||||
request(method, params = null, timeoutMs = 30000) {
|
||||
protocolName(method);
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 300000) throw new Error("Invalid request timeout");
|
||||
if (this.pending.size >= 256) return Promise.reject(new Error("Too many pending requests"));
|
||||
const id = ++this.sequence;
|
||||
const started = performance.now();
|
||||
this.emit("diagnostic", { level: "debug", message: "RPC 开始", details: { requestId: id, method, transport: this.transport, params } });
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error("Sidecar request timed out; operation may still be running"));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.write(0, Buffer.from(JSON.stringify({ jsonrpc: "2.0", id, method, params }))).catch((error) => {
|
||||
clearTimeout(timer);
|
||||
this.pending.delete(id);
|
||||
reject(error);
|
||||
});
|
||||
}).then(
|
||||
(result) => {
|
||||
this.emit("diagnostic", { level: "debug", message: "RPC 完成", details: { requestId: id, method, result, durationMs: Math.round(performance.now() - started) } });
|
||||
return result;
|
||||
},
|
||||
(error) => {
|
||||
const reason = error.message.startsWith("Sidecar request timed out") ? "timeout" : error.rpc ? "backend-error" : "transport-error";
|
||||
this.emit("diagnostic", { level: "error", message: "RPC 失败", details: { requestId: id, method, reason, error: error.rpc ? { code: error.rpc.code, data: error.rpc.data } : { reason }, durationMs: Math.round(performance.now() - started) } });
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
notify(method, params) {
|
||||
return this.write(0, Buffer.from(JSON.stringify({ jsonrpc: "2.0", method: protocolName(method), params })));
|
||||
}
|
||||
sendBinary(channel, data) {
|
||||
return this.write(1, binaryPayload(channel, data));
|
||||
}
|
||||
async stop() {
|
||||
const child = this.child;
|
||||
if (!child) {
|
||||
await this.cleanup;
|
||||
return;
|
||||
}
|
||||
if (!child.pid) {
|
||||
this.child = undefined;
|
||||
this.state = "stopped";
|
||||
return;
|
||||
}
|
||||
this.fail("Sidecar stopped", "stopping");
|
||||
// Keep the Windows leader alive until taskkill has discovered its descendants.
|
||||
if (process.platform !== "win32") child.stdin.end();
|
||||
await stopProcessTree(child);
|
||||
await this.cleanup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { AutoReload } from "../auto-reload.mjs";
|
||||
|
||||
test("auto reload defaults off, coalesces edits and queues edits during builds", async () => {
|
||||
let calls = 0,
|
||||
release;
|
||||
const scheduler = new AutoReload(async () => {
|
||||
calls++;
|
||||
await new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
}, 10000);
|
||||
scheduler.changed();
|
||||
await scheduler.flush();
|
||||
assert.equal(calls, 0);
|
||||
scheduler.enable(true);
|
||||
scheduler.changed();
|
||||
scheduler.changed();
|
||||
const first = scheduler.flush();
|
||||
assert.equal(calls, 1);
|
||||
scheduler.changed();
|
||||
await scheduler.flush();
|
||||
assert.equal(calls, 1);
|
||||
release();
|
||||
await first;
|
||||
const second = scheduler.flush();
|
||||
assert.equal(calls, 2);
|
||||
release();
|
||||
await second;
|
||||
scheduler.changed();
|
||||
scheduler.enable(false);
|
||||
await scheduler.flush();
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { reactive } from "vue";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { sandboxDocument } from "../browser-bridge.mjs";
|
||||
import { hostMessage } from "../ui/messages.js";
|
||||
|
||||
test("bridge delivers host-compatible environment, lifecycle and binary events", async (t) => {
|
||||
const dom = new JSDOM(sandboxDocument("<head></head>", "test"), { runScripts: "dangerously" });
|
||||
t.after(() => dom.window.close());
|
||||
const w = dom.window;
|
||||
const send = (payload) => w.dispatchEvent(new w.MessageEvent("message", { source: w, data: hostMessage("test", payload) }));
|
||||
const events = [];
|
||||
for (const type of ["init", "context", "env", "event", "binary"]) w.addEventListener(`dbx-plugin-${type}`, (event) => events.push(event));
|
||||
let initialized = 0,
|
||||
notifications = 0,
|
||||
binary;
|
||||
w.dbxPlugin.onInit(() => initialized++);
|
||||
w.dbxPlugin.onEvent(() => notifications++);
|
||||
w.dbxPlugin.onBinary((value) => {
|
||||
binary = value;
|
||||
});
|
||||
send({ type: "init", context: { value: 1 } });
|
||||
await w.dbxPlugin.ready;
|
||||
assert.equal(initialized, 1);
|
||||
w.dbxPlugin.onInit(() => initialized++);
|
||||
assert.equal(initialized, 2);
|
||||
send({ type: "context", context: { value: 2 } });
|
||||
send({ type: "env", locale: "en" });
|
||||
send({ type: "event", method: "arbitrary/event" });
|
||||
send({ type: "binary", binaryChannel: "bytes", dataBase64: "AP8=" });
|
||||
assert.equal(notifications, 2);
|
||||
assert.ok(binary.data instanceof w.Uint8Array);
|
||||
assert.deepEqual([...binary.data], [0, 255]);
|
||||
assert.equal(binary.channel, "bytes");
|
||||
assert.equal(events.length, 5);
|
||||
assert.equal(events[1].detail.value, 2);
|
||||
});
|
||||
|
||||
test("host messages snapshot nested reactive context and permissions before postMessage", () => {
|
||||
const source = reactive({ context: { connectionId: "one" }, permissions: ["host.events"] });
|
||||
const message = hostMessage("generation", { type: "init", ...source });
|
||||
assert.doesNotThrow(() => structuredClone(message));
|
||||
source.context.connectionId = "two";
|
||||
source.permissions.push("host.binary");
|
||||
assert.equal(message.context.connectionId, "one");
|
||||
assert.deepEqual(message.permissions, ["host.events"]);
|
||||
});
|
||||
test("sandbox bootstrap handles initialization, generation filtering and context listeners", async () => {
|
||||
const dom = new JSDOM(sandboxDocument("<html><head></head><body></body></html>", "generation"), { runScripts: "dangerously" });
|
||||
const w = dom.window,
|
||||
send = (value) => w.dispatchEvent(new w.MessageEvent("message", { source: w, data: value }));
|
||||
send(hostMessage("generation", { type: "init", context: { connectionId: "one" }, locale: "zh-CN" }));
|
||||
await w.dbxPlugin.ready;
|
||||
assert.equal(w.dbxPlugin.context.connectionId, "one");
|
||||
assert.equal(w.dbxPlugin.locale, "zh-CN");
|
||||
send(hostMessage("old", { type: "env", locale: "ja" }));
|
||||
assert.equal(w.dbxPlugin.locale, "zh-CN");
|
||||
send(hostMessage("generation", { type: "env", locale: "en" }));
|
||||
assert.equal(w.dbxPlugin.locale, "en");
|
||||
send(hostMessage("generation", { type: "env", theme: { appearance: "dark" } }));
|
||||
assert.equal(w.dbxPlugin.locale, "en");
|
||||
assert.equal(w.dbxPlugin.context.connectionId, "one");
|
||||
let calls = 0;
|
||||
const off = w.dbxPlugin.onContext(() => calls++);
|
||||
send(hostMessage("old", { type: "context", context: { connectionId: "bad" } }));
|
||||
assert.equal(w.dbxPlugin.context.connectionId, "one");
|
||||
send(hostMessage("generation", { type: "context", context: { connectionId: "two" } }));
|
||||
assert.equal(calls, 1);
|
||||
off();
|
||||
send(hostMessage("generation", { type: "context", context: { connectionId: "three" } }));
|
||||
assert.equal(calls, 1);
|
||||
dom.window.close();
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Diagnostics, redact } from "../diagnostics.mjs";
|
||||
|
||||
test("diagnostics bound history and serialize only allowed metadata", () => {
|
||||
const lines = [],
|
||||
log = new Diagnostics((line) => lines.push(line));
|
||||
let emitted;
|
||||
log.on("entry", (entry) => {
|
||||
emitted = entry;
|
||||
});
|
||||
for (let n = 0; n < 505; n++)
|
||||
log.record("debug", "rpc", "RPC 完成", {
|
||||
requestId: n,
|
||||
durationMs: 3,
|
||||
method: "example/echo",
|
||||
params: { password: "private-value" },
|
||||
result: { count: 3, access_token: "private-value" },
|
||||
cookie: "private-value",
|
||||
});
|
||||
const entries = log.snapshot();
|
||||
assert.equal(entries.length, 500);
|
||||
assert.equal(entries[0].id, 6);
|
||||
assert.equal(emitted.id, 505);
|
||||
assert.equal(
|
||||
lines.some((line) => line.includes("private-value")),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(entries[0].details, { requestId: 5, durationMs: 3, method: "example/echo", params: { password: "[REDACTED]" }, result: { count: 3, access_token: "[REDACTED]" } });
|
||||
entries[0].details.method = "changed";
|
||||
assert.equal(log.snapshot()[0].details.method, "example/echo");
|
||||
});
|
||||
|
||||
test("payload diagnostics retain types and paths while redacting nested secrets and bounding content", () => {
|
||||
const input = { path: "/folder/test.txt", port: 9000, enabled: false, nested: [{ credential: "private", custom: "private" }], url: "https://user:private@example.com/path?token=private", dataBase64: "private", text: "a".repeat(10000) };
|
||||
const safe = redact(input, new Set(["custom"]));
|
||||
assert.equal(safe.path, input.path);
|
||||
assert.equal(safe.port, 9000);
|
||||
assert.equal(safe.enabled, false);
|
||||
assert.equal(JSON.stringify(safe).includes("private"), false);
|
||||
assert.match(safe.text, /truncated/);
|
||||
assert.ok(safe.text.length < 2100);
|
||||
assert.equal(input.nested[0].custom, "private");
|
||||
const cycle = {};
|
||||
cycle.self = cycle;
|
||||
assert.equal(redact(cycle).self, "[circular]");
|
||||
});
|
||||
|
||||
test("diagnostic queries paginate, filter and detect overwritten history and restarts", () => {
|
||||
const log = new Diagnostics(() => {});
|
||||
for (let i = 0; i < 503; i++) log.record(i % 2 ? "error" : "info", "rpc", "RPC 完成");
|
||||
const first = log.query(new URLSearchParams("limit=2&level=error"));
|
||||
assert.equal(first.entries.length, 2);
|
||||
assert.equal(first.hasMore, true);
|
||||
assert.equal(first.truncated, true);
|
||||
const next = log.query(new URLSearchParams(`after=${first.nextAfter}&level=error&limit=500`));
|
||||
assert.ok(next.entries.every((e) => e.id > first.nextAfter && e.level === "error"));
|
||||
assert.equal(next.nextAfter, 503);
|
||||
assert.equal(next.hasMore, false);
|
||||
assert.equal(log.query(new URLSearchParams("after=503")).entries.length, 0);
|
||||
assert.equal(log.query(new URLSearchParams("after=503&instanceId=old")).reset, true);
|
||||
for (const query of ["limit=0", "after=-1", "limit=501", "level=unknown", "after=1&after=2", "token=secret"]) assert.throws(() => log.query(new URLSearchParams(query)));
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FrameDecoder, JsonLineDecoder, frame, binaryPayload } from "../sidecar.mjs";
|
||||
import { spawn } from "node:child_process";
|
||||
const jsonl = process.argv.includes("--jsonl");
|
||||
const send = (message) => {
|
||||
const bytes = Buffer.from(JSON.stringify({ jsonrpc: "2.0", ...message }));
|
||||
process.stdout.write(jsonl ? Buffer.concat([bytes, Buffer.from("\n")]) : frame(0, bytes));
|
||||
};
|
||||
const decoder = new (jsonl ? JsonLineDecoder : FrameDecoder)((packet) => {
|
||||
if (packet.kind === 1) return process.stdout.write(frame(1, binaryPayload(packet.channel, packet.data)));
|
||||
const { id, method, params } = packet.message;
|
||||
if (method === "plugin/initialize") return send({ id, result: { protocolVersion: 1, plugin: { id: "example.echo", version: "1.0.0" } } });
|
||||
if (method === "die") return process.exit(1);
|
||||
if (method === "spawn-child") {
|
||||
const child = spawn(process.execPath, ["-e", 'process.on("SIGTERM", () => {}); process.on("SIGHUP", () => {}); setInterval(() => {}, 1000)'], { stdio: "ignore" });
|
||||
child.unref();
|
||||
return send({ id, result: child.pid });
|
||||
}
|
||||
if (method === "emit") return send({ method: "example/event", params });
|
||||
if (method === "fail") return send({ id, error: { code: -32000, message: "Example failure", data: { category: "example" } } });
|
||||
if (method === "slow") return setTimeout(() => send({ id, result: params }), 80);
|
||||
if (id !== undefined) send({ id, result: method.startsWith("connection/") ? { success: true } : params });
|
||||
});
|
||||
process.stdin.on("data", (data) => decoder.push(data));
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { english, localizeManifest, translate } from "../ui/i18n.js";
|
||||
|
||||
test("shell labels and diagnostics translate without altering arbitrary business data", async () => {
|
||||
for (const file of ["App.vue", "DebugPanel.vue"]) {
|
||||
const source = await readFile(new URL(`../ui/${file}`, import.meta.url), "utf8");
|
||||
for (const match of source.matchAll(/\bt\('([^']+)'/g)) {
|
||||
assert.ok(english[match[1]], `Missing English label: ${match[1]}`);
|
||||
}
|
||||
}
|
||||
assert.equal(translate("en", "RPC 完成"), "RPC completed");
|
||||
assert.equal(translate("zh-CN", "RPC 完成"), "RPC 完成");
|
||||
assert.equal(translate("en", "自定义连接"), "自定义连接");
|
||||
assert.match(translate("en", "删除连接“{name}”?其页面将关闭,未保存修改会丢失。", { name: "My DB" }), /My DB/);
|
||||
});
|
||||
|
||||
test("manifest localization switches from original labels and preserves defaults", () => {
|
||||
const source = {
|
||||
name: "Example",
|
||||
contributions: [{ id: "main", label: "Page", fields: [{ key: "mode", label: "Mode", default: "one", options: [{ value: "one", label: "One" }] }] }],
|
||||
localizations: { "zh-CN": { name: "示例", contributions: { main: { label: "页面", fields: { mode: { label: "模式", options: { one: "一" } } } } } } },
|
||||
};
|
||||
const chinese = localizeManifest(source, "zh-CN");
|
||||
assert.equal(chinese.name, "示例");
|
||||
assert.equal(chinese.contributions[0].fields[0].options[0].label, "一");
|
||||
assert.equal(chinese.contributions[0].fields[0].default, "one");
|
||||
assert.equal(localizeManifest(source, "en").contributions[0].label, "Page");
|
||||
assert.equal(source.name, "Example");
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { startDevelopment } from "../runtime.mjs";
|
||||
import { Diagnostics } from "../diagnostics.mjs";
|
||||
|
||||
async function project(t) {
|
||||
const root = await mkdtemp(join(tmpdir(), "dbx-dev-runtime-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
await mkdir(join(root, "ui"));
|
||||
await writeFile(join(root, "ui/index.html"), "<html><head></head><body>Test</body></html>");
|
||||
await writeFile(
|
||||
join(root, "manifest.json"),
|
||||
JSON.stringify({
|
||||
manifest_version: 1,
|
||||
id: "example.frontend",
|
||||
version: "1.0.0",
|
||||
name: "Example",
|
||||
entrypoints: { ui: { root: "ui", entry: "ui/index.html" } },
|
||||
contributions: [],
|
||||
permissions: [],
|
||||
}),
|
||||
);
|
||||
return { project: root, port: 0, shellHtml: join(root, "ui/index.html"), diagnostics: new Diagnostics(() => {}) };
|
||||
}
|
||||
|
||||
test("failed build does not start a host or retain signal listeners", async (t) => {
|
||||
const options = await project(t);
|
||||
const before = process.listenerCount("SIGTERM");
|
||||
await assert.rejects(
|
||||
startDevelopment({
|
||||
...options,
|
||||
commands: {
|
||||
backend: {
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(7)"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
/Build failed/,
|
||||
);
|
||||
assert.equal(process.listenerCount("SIGTERM"), before);
|
||||
});
|
||||
|
||||
test("development credentials cannot be placed inside UI resources", async (t) => {
|
||||
const options = await project(t);
|
||||
await assert.rejects(startDevelopment({ ...options, dataDir: join(options.project, "ui", "private") }), /outside the UI/);
|
||||
});
|
||||
|
||||
test("closing development host terminates its UI watcher and releases its port", async (t) => {
|
||||
const options = await project(t),
|
||||
pidFile = join(options.project, "watcher.pid");
|
||||
const host = await startDevelopment({ ...options, commands: { watch: [process.execPath, "-e", 'require("node:fs").writeFileSync(process.argv[1], String(process.pid)); setInterval(() => {}, 1000)', pidFile] } });
|
||||
t.after(() => host.close());
|
||||
let pid;
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
try {
|
||||
pid = Number(await readFile(pidFile, "utf8"));
|
||||
break;
|
||||
} catch {
|
||||
await delay(20);
|
||||
}
|
||||
}
|
||||
assert.ok(pid, "watcher started");
|
||||
await host.close();
|
||||
assert.throws(() => process.kill(pid, 0), { code: "ESRCH" });
|
||||
await assert.rejects(fetch(host.origin));
|
||||
});
|
||||
|
||||
test("watcher success markers trigger reload only after a complete line", async (t) => {
|
||||
const options = await project(t);
|
||||
const host = await startDevelopment({ ...options, commands: { watch: [process.execPath, "-e", 'process.stdout.write("x".repeat(1100)+"DBX_UI_BUILD_SUCCESS\\n"); process.stdout.write("DBX_UI_BUILD_"); setTimeout(() => process.stdout.write("SUCCESS\\n"), 100); setInterval(() => {}, 1000)'] } });
|
||||
t.after(() => host.close());
|
||||
let revision = 0;
|
||||
for (let i = 0; i < 100 && !revision; i++) {
|
||||
await delay(20);
|
||||
revision = (await (await fetch(`${host.origin}/api/bootstrap`)).json()).revision;
|
||||
}
|
||||
assert.equal(revision, 1);
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm, stat, symlink } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import http from "node:http";
|
||||
import { createMockHost } from "../server.mjs";
|
||||
import { ConnectionStore, lifecyclePayload, validateRecord } from "../connections.mjs";
|
||||
import { readAsset } from "../assets.mjs";
|
||||
import { Diagnostics } from "../diagnostics.mjs";
|
||||
|
||||
const manifest = {
|
||||
id: "example.echo",
|
||||
version: "1.0.0",
|
||||
name: "Generic echo",
|
||||
engines: { host_api: ">=1.0.0, <2.0.0" },
|
||||
permissions: ["host.events", "host.binary"],
|
||||
entrypoints: { backend: { transport: "stdio-framed", protocol_versions: [1] }, ui: { root: "ui", entry: "ui/index.html" } },
|
||||
contributions: [
|
||||
{
|
||||
type: "connection-provider",
|
||||
id: "example.connection",
|
||||
database_type: "example",
|
||||
label: "Example",
|
||||
workbench: "example.main",
|
||||
capabilities: ["test", "connect", "disconnect"],
|
||||
fields: [
|
||||
{ key: "name", type: "text", binding: "name", required: true },
|
||||
{ key: "endpoint", type: "text", binding: "config", required: true },
|
||||
{ key: "count", type: "number", binding: "config" },
|
||||
{ key: "enabled", type: "boolean", binding: "config" },
|
||||
{ key: "password", type: "password", binding: "secret" },
|
||||
],
|
||||
},
|
||||
{ type: "workbench", id: "example.main", label: "Main" },
|
||||
],
|
||||
};
|
||||
const values = { name: "Example connection", endpoint: "test-endpoint", count: 7, enabled: true, password: "test-password" };
|
||||
test("lifecycle failures do not report success or discard a live connection", async (t) => {
|
||||
const { host, request } = await fixture(t);
|
||||
const original = host.sidecar.request.bind(host.sidecar);
|
||||
let fail;
|
||||
host.sidecar.request = (method, ...args) => (method === fail ? Promise.resolve({ success: false, message: "Expected failure" }) : original(method, ...args));
|
||||
fail = "connection/test";
|
||||
assert.equal((await request("connections/test", { providerId: "example.connection", values })).status, 400);
|
||||
const saved = (await request("connections/save", { providerId: "example.connection", values })).value;
|
||||
fail = "connection/connect";
|
||||
assert.equal((await request("connections/connect", { id: saved.id })).status, 400);
|
||||
const snapshot = async () => (await (await fetch(`${host.origin}/api/bootstrap`)).json()).connections;
|
||||
assert.equal((await snapshot())[0].connected, false);
|
||||
fail = undefined;
|
||||
const frame = (await request("connections/connect", { id: saved.id })).value.frame;
|
||||
const document = (await request("frame-document", { frameId: frame.id })).value;
|
||||
fail = "custom/operation";
|
||||
const result = await request("bridge", { frameId: frame.id, channel: document.channel, method: "backend.invoke", params: { method: fail } });
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.value.success, false, "custom RPC results remain transparent");
|
||||
fail = "connection/disconnect";
|
||||
assert.equal((await request("frames/close", { id: frame.id })).status, 400);
|
||||
assert.equal((await snapshot())[0].connected, true);
|
||||
fail = undefined;
|
||||
assert.equal((await request("frames/close", { id: frame.id })).value.connections[0].connected, false);
|
||||
});
|
||||
|
||||
test("page reconnects preserve frames, abandoned pages release their last connection", async (t) => {
|
||||
const { host, request, headers } = await fixture(t, false, { pageGraceMs: 150 });
|
||||
const page = { "X-DBX-Page": "browser-page" };
|
||||
const events = async () => {
|
||||
const response = await fetch(`${host.origin}/api/events?page=browser-page`, { headers });
|
||||
const reader = response.body.getReader();
|
||||
await reader.read();
|
||||
t.after(() => reader.cancel());
|
||||
return reader;
|
||||
};
|
||||
const first = await events();
|
||||
const saved = (await request("connections/save", { providerId: "example.connection", values }, page)).value;
|
||||
const opened = (await request("connections/connect", { id: saved.id }, page)).value.frame;
|
||||
await first.cancel();
|
||||
const second = await events();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
assert.equal((await request("frame-document", { frameId: opened.id }, page)).status, 200);
|
||||
await second.cancel();
|
||||
let connected = true;
|
||||
for (let i = 0; i < 100 && connected; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
connected = (await (await fetch(`${host.origin}/api/bootstrap`)).json()).connections[0].connected;
|
||||
}
|
||||
assert.equal(connected, false);
|
||||
assert.equal((await request("frame-document", { frameId: opened.id }, page)).status, 400);
|
||||
assert.equal(host.store.get(saved.id).values.name, values.name, "saved configuration survives page cleanup");
|
||||
});
|
||||
test("pages sharing a cookie have independent workbench generations", async (t) => {
|
||||
const { request } = await fixture(t, true);
|
||||
const a = { "X-DBX-Page": "page-a" },
|
||||
b = { "X-DBX-Page": "page-b" };
|
||||
const open = (headers) => request("workbenches/open", { contributionId: "example.main" }, headers);
|
||||
const first = (await open(a)).value.frame;
|
||||
const second = (await open(b)).value.frame;
|
||||
assert.notEqual(first.id, second.id);
|
||||
assert.equal((await open(a)).value.frame.id, first.id);
|
||||
const document = (await request("frame-document", { frameId: first.id }, a)).value;
|
||||
await request("frame-document", { frameId: second.id }, b);
|
||||
assert.equal((await request("frame-document", { frameId: first.id }, b)).status, 400);
|
||||
const result = await request("bridge", { frameId: first.id, channel: document.channel, method: "host.getContext" }, a);
|
||||
assert.equal(result.status, 200, JSON.stringify(result));
|
||||
});
|
||||
|
||||
test("compiled UI output waits for a successful build signal", async (t) => {
|
||||
const { host, root } = await fixture(t, true, { uiBuildSignals: true });
|
||||
const revision = async () => (await (await fetch(`${host.origin}/api/bootstrap`)).json()).revision;
|
||||
await writeFile(join(root, "ui/index.html"), "<head></head>Partial output");
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.equal(await revision(), 0);
|
||||
await host.uiBuilt();
|
||||
assert.equal(await revision(), 1);
|
||||
});
|
||||
test("refresh cleanup does not disconnect a connection still used by another page", async (t) => {
|
||||
const { host, request, headers } = await fixture(t, false, { pageGraceMs: 50 });
|
||||
const response = await fetch(`${host.origin}/api/events?page=old`, { headers });
|
||||
const reader = response.body.getReader();
|
||||
t.after(() => reader.cancel());
|
||||
await reader.read();
|
||||
const saved = (await request("connections/save", { providerId: "example.connection", values })).value;
|
||||
const old = (await request("connections/connect", { id: saved.id }, { "X-DBX-Page": "old" })).value.frame;
|
||||
const fresh = (await request("connections/connect", { id: saved.id }, { "X-DBX-Page": "fresh" })).value.frame;
|
||||
await reader.cancel();
|
||||
let status = 200;
|
||||
for (let i = 0; i < 100 && status === 200; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
status = (await request("frame-document", { frameId: old.id }, { "X-DBX-Page": "old" })).status;
|
||||
}
|
||||
assert.equal(status, 400);
|
||||
const bootstrap = await (await fetch(`${host.origin}/api/bootstrap`)).json();
|
||||
assert.equal(bootstrap.connections[0].connected, true);
|
||||
assert.deepEqual(bootstrap.manifest, manifest);
|
||||
assert.equal(Object.hasOwn(bootstrap, "rawManifest"), false);
|
||||
const closed = await request("frames/close", { id: fresh.id }, { "X-DBX-Page": "fresh" });
|
||||
assert.equal(closed.value.connections[0].connected, false);
|
||||
});
|
||||
async function fixture(t, frontend = false, overrides = {}) {
|
||||
const root = await mkdtemp(join(tmpdir(), "dbx-mock-test-"));
|
||||
await mkdir(join(root, "ui"));
|
||||
await writeFile(join(root, "ui/index.html"), "<html><head></head><body>Test</body></html>");
|
||||
const actual = frontend ? { ...manifest, entrypoints: { ui: manifest.entrypoints.ui }, contributions: manifest.contributions.filter((c) => c.type === "workbench") } : manifest;
|
||||
await writeFile(join(root, "manifest.json"), JSON.stringify(actual));
|
||||
const host = await createMockHost({
|
||||
project: root,
|
||||
uiRoot: "ui",
|
||||
backend: process.execPath,
|
||||
diagnostics: new Diagnostics(() => {}),
|
||||
backendArgs: [fileURLToPath(new URL("./echo-sidecar.mjs", import.meta.url))],
|
||||
dataDir: join(root, "data"),
|
||||
shellHtml: join(root, "ui/index.html"),
|
||||
port: 0,
|
||||
backendWatch: root,
|
||||
buildBackend: async () => {
|
||||
throw new Error("Intentional build failure");
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
t.after(async () => {
|
||||
await host.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const response = await fetch(`${host.origin}/api/bootstrap`),
|
||||
bootstrap = await response.json();
|
||||
const cookie = response.headers.get("set-cookie").split(";")[0];
|
||||
const headers = { Cookie: cookie, Origin: host.origin, "X-Mock-Csrf": bootstrap.csrf, "Content-Type": "application/json" };
|
||||
const request = async (path, data = {}, overrides = {}) => {
|
||||
const response = await fetch(`${host.origin}/api/${path}`, { method: "POST", headers: { ...headers, ...overrides }, body: JSON.stringify(data) });
|
||||
return { status: response.status, ...(await response.json()) };
|
||||
};
|
||||
return { root, host, request, headers };
|
||||
}
|
||||
test("hosts on different ports keep independent browser cookies", async (t) => {
|
||||
const first = await fixture(t, true),
|
||||
second = await fixture(t, true);
|
||||
const jar = new Map();
|
||||
const sessions = [];
|
||||
for (const host of [first.host, second.host]) {
|
||||
const response = await fetch(`${host.origin}/api/bootstrap`, { headers: { Cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; ") } });
|
||||
const [key, value] = response.headers.get("set-cookie").split(";")[0].split("=");
|
||||
jar.set(key, value);
|
||||
sessions.push(await response.json());
|
||||
}
|
||||
for (const [index, host] of [first.host, second.host].entries()) {
|
||||
const response = await fetch(`${host.origin}/api/workbenches/open`, {
|
||||
method: "POST",
|
||||
headers: { Cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "), Origin: host.origin, "X-Mock-Csrf": sessions[index].csrf, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contributionId: "example.main" }),
|
||||
});
|
||||
assert.equal(response.status, 200, JSON.stringify(await response.json()));
|
||||
}
|
||||
});
|
||||
test("diagnostics distinguish session failures and exclude connection credentials", async (t) => {
|
||||
const { host, request } = await fixture(t);
|
||||
const saved = await request("connections/save", { providerId: "example.connection", values });
|
||||
await request("connections/connect", { id: saved.value.id });
|
||||
const denied = await request("connections/save", { providerId: "example.connection", values }, { "X-Mock-Csrf": "secret-token" });
|
||||
assert.equal(denied.status, 403);
|
||||
const entries = host.diagnostics.snapshot();
|
||||
assert.ok(entries.some((e) => e.details.reason === "csrf-mismatch" && e.details.status === 403));
|
||||
assert.ok(entries.some((e) => e.details.method === "plugin/initialize" && e.message === "RPC 完成"));
|
||||
assert.ok(entries.some((e) => e.details.method === "connections/save" && e.details.status === 200));
|
||||
const text = JSON.stringify(entries);
|
||||
for (const secret of ["test-password", "secret-token"]) assert.equal(text.includes(secret), false);
|
||||
assert.ok(text.includes("test-endpoint"));
|
||||
assert.ok(entries.some((e) => e.details.port === Number(new URL(host.origin).port)));
|
||||
const response = await fetch(`${host.origin}/api/bootstrap`);
|
||||
assert.deepEqual((await response.json()).diagnostics, entries);
|
||||
});
|
||||
test("source watching is opt-in and automatic restarts preserve saved connections", async (t) => {
|
||||
let builds = 0;
|
||||
const { root, host, request } = await fixture(t, false, {
|
||||
buildBackend: async () => {
|
||||
builds++;
|
||||
},
|
||||
});
|
||||
const saved = await request("connections/save", { providerId: "example.connection", values });
|
||||
await writeFile(join(root, "example.rs"), "// disabled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.equal(builds, 0);
|
||||
assert.equal((await request("auto-reload", { enabled: true })).value.enabled, true);
|
||||
await writeFile(join(root, "example.rs"), "// enabled");
|
||||
for (let i = 0; i < 100 && !host.diagnostics.snapshot().some((e) => e.message === "后端重建完成"); i++) await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(builds, 1);
|
||||
assert.equal(host.sidecar.state, "ready");
|
||||
assert.equal(host.store.get(saved.value.id).values.name, values.name);
|
||||
await request("auto-reload", { enabled: false });
|
||||
await writeFile(join(root, "example.rs"), "// disabled again");
|
||||
await new Promise((resolve) => setTimeout(resolve, 650));
|
||||
assert.equal(builds, 1);
|
||||
});
|
||||
test("agent diagnostics is read-only, bounded and does not log its own polling", async (t) => {
|
||||
const { host } = await fixture(t, true);
|
||||
const url = `${host.origin}/api/diagnostics`,
|
||||
headers = {};
|
||||
const first = await (await fetch(url, { headers })).json();
|
||||
assert.ok(first.entries.length);
|
||||
assert.equal(first.plugin.id, manifest.id);
|
||||
const next = await (await fetch(`${url}?after=${first.nextAfter}`, { headers })).json();
|
||||
assert.deepEqual(next.entries, []);
|
||||
assert.equal(next.nextAfter, first.nextAfter);
|
||||
assert.equal((await fetch(url)).status, 200);
|
||||
assert.equal((await fetch(url, { headers, method: "POST" })).status, 405);
|
||||
assert.equal((await fetch(url, { headers: { ...headers, Origin: "https://other.test" } })).status, 403);
|
||||
assert.equal((await fetch(`${url}?limit=501`, { headers })).status, 400);
|
||||
});
|
||||
test("configuration mapping preserves typed external config and separates secrets", () => {
|
||||
const record = validateRecord(manifest, { providerId: "example.connection", values });
|
||||
const c = lifecyclePayload(manifest, record).connection;
|
||||
assert.deepEqual(c.external_config, { endpoint: "test-endpoint", count: 7, enabled: true });
|
||||
assert.deepEqual(c.connection_secrets, { password: "test-password" });
|
||||
assert.throws(() => validateRecord(manifest, { providerId: record.providerId, values: { ...values, count: "7" } }), /type/);
|
||||
assert.throws(() => validateRecord(manifest, { providerId: record.providerId, values: { ...values, endpoint: "" } }), /Missing/);
|
||||
});
|
||||
|
||||
test("a frontend-only workbench opens without a connection or sidecar", async (t) => {
|
||||
const { host, request } = await fixture(t, true);
|
||||
assert.equal(host.sidecar.state, "frontend");
|
||||
assert.equal(host.sidecar.child, undefined);
|
||||
const opened = await request("workbenches/open", { contributionId: "example.main" });
|
||||
assert.equal(opened.status, 200);
|
||||
assert.deepEqual(opened.value.frame.context, {});
|
||||
assert.equal((await request("workbenches/open", { contributionId: "example.main" })).value.frame.id, opened.value.frame.id);
|
||||
const f = (await request("frame-document", { frameId: opened.value.frame.id })).value;
|
||||
const rpc = await request("bridge", { frameId: f.id, channel: f.channel, method: "backend.invoke", params: { method: "echo" } });
|
||||
assert.equal(rpc.status, 400);
|
||||
assert.match(rpc.error.message, /no backend/);
|
||||
assert.equal((await request("frames/close", { id: f.id })).status, 200);
|
||||
});
|
||||
test("save, reload, iframe isolation, generic RPC and close lifecycle", async (t) => {
|
||||
const { root, host, request } = await fixture(t);
|
||||
const saved = await request("connections/save", { providerId: "example.connection", values });
|
||||
assert.equal(saved.status, 200);
|
||||
const id = saved.value.id;
|
||||
assert.equal(JSON.stringify(saved).includes("test-password"), false);
|
||||
const stored = new ConnectionStore(join(root, "data"), manifest);
|
||||
await stored.load();
|
||||
assert.deepEqual(stored.get(id).values, values);
|
||||
if (process.platform !== "win32") {
|
||||
assert.equal((await stat(join(root, "data"))).mode & 0o777, 0o700);
|
||||
assert.equal((await stat(join(root, "data/connections.json"))).mode & 0o777, 0o600);
|
||||
}
|
||||
const opened = await request("connections/connect", { id });
|
||||
const f = opened.value.frame;
|
||||
assert.equal((await request("connections/connect", { id })).value.frame.id, f.id);
|
||||
assert.deepEqual(f.context, { connectionId: id, providerId: "example.connection", connectionType: "example" });
|
||||
assert.equal(JSON.stringify(f).includes("test-password"), false);
|
||||
const document = (await request("frame-document", { frameId: f.id })).value;
|
||||
assert.match(document.html, /Content-Security-Policy/);
|
||||
assert.match(document.html, /window.dbxPlugin/);
|
||||
const call = (method, params) => request("bridge", { frameId: f.id, channel: document.channel, method, params });
|
||||
assert.deepEqual((await call("backend.invoke", { method: "echo", params: { anyBusiness: 123 } })).value, { anyBusiness: 123 });
|
||||
assert.equal((await call("host.openWorkbench", { contributionId: "example.main" })).status, 400);
|
||||
assert.equal((await call("host.openFilesystem", {})).status, 400);
|
||||
assert.equal((await call("backend.invoke", { method: "echo", params: "x".repeat(2 * 1024 * 1024) })).status, 400);
|
||||
assert.equal((await request("bridge", { frameId: f.id, channel: "old", method: "host.getContext" })).status, 400);
|
||||
await request("connections/save", { id, providerId: "example.connection", values: { ...values, name: "Renamed connection" } });
|
||||
const renamed = (await request("connections/connect", { id })).value.frame;
|
||||
assert.equal(renamed.id, f.id);
|
||||
assert.equal(renamed.name, "Renamed connection");
|
||||
assert.equal((await request("frames/close", { id: f.id })).value.connections[0].connected, false);
|
||||
const failedBuild = await request("backend/restart");
|
||||
assert.equal(failedBuild.status, 400);
|
||||
assert.equal(host.sidecar.state, "stopped");
|
||||
});
|
||||
test("loopback endpoint rejects cross-origin, forged Host, CSRF and unowned frames", async (t) => {
|
||||
const { host, request, headers } = await fixture(t);
|
||||
assert.equal((await request("connections/save", {}, { Origin: "https://evil.example" })).status, 403);
|
||||
assert.equal((await request("connections/save", {}, { "X-Mock-Csrf": "wrong" })).status, 403);
|
||||
const bad = await new Promise((resolve, reject) => {
|
||||
const request = http.get(`${host.origin}/api/bootstrap`, { headers: { Host: "evil.example" } }, (response) => {
|
||||
response.resume();
|
||||
resolve(response.statusCode);
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
assert.equal(bad, 403);
|
||||
const saved = await request("connections/save", { providerId: "example.connection", values });
|
||||
const f = (await request("connections/connect", { id: saved.value.id })).value.frame;
|
||||
const other = await fetch(`${host.origin}/api/bootstrap`);
|
||||
const boot = await other.json();
|
||||
assert.equal((await request("frame-document", { frameId: f.id }, { ...headers, Cookie: other.headers.get("set-cookie").split(";")[0], "X-Mock-Csrf": boot.csrf })).status, 400);
|
||||
});
|
||||
test("asset reader rejects traversal and symlinks outside the UI root", async (t) => {
|
||||
const { root } = await fixture(t);
|
||||
await writeFile(join(root, "secret"), "test-secret");
|
||||
await symlink(join(root, "secret"), join(root, "ui/link"));
|
||||
await assert.rejects(readAsset(join(root, "ui"), "../secret"), /outside/);
|
||||
await assert.rejects(readAsset(join(root, "ui"), "link"), /outside/);
|
||||
assert.equal(Buffer.from((await readAsset(join(root, "ui"), "index.html")).dataBase64, "base64").toString(), await readFile(join(root, "ui/index.html"), "utf8"));
|
||||
});
|
||||
|
||||
test("UI root overrides and port collision fallback work without changing the plugin manifest", async (t) => {
|
||||
const { root, host } = await fixture(t);
|
||||
await mkdir(join(root, "alternate-ui"));
|
||||
await writeFile(join(root, "alternate-ui/index.html"), "<html>Alternate output</html>");
|
||||
const second = await createMockHost({
|
||||
project: root,
|
||||
uiRoot: "alternate-ui",
|
||||
backend: process.execPath,
|
||||
backendArgs: [fileURLToPath(new URL("./echo-sidecar.mjs", import.meta.url))],
|
||||
dataDir: join(root, "second-data"),
|
||||
shellHtml: join(root, "alternate-ui/index.html"),
|
||||
port: Number(new URL(host.origin).port),
|
||||
});
|
||||
try {
|
||||
assert.notEqual(second.origin, host.origin);
|
||||
assert.equal((await fetch(second.origin)).status, 200);
|
||||
} finally {
|
||||
await second.close();
|
||||
}
|
||||
await writeFile(join(root, "manifest.json"), JSON.stringify({ ...manifest, engines: { host_api: ">=2.0.0" } }));
|
||||
await assert.rejects(createMockHost({ project: root }), /does not support Host API/);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { once } from "node:events";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { FrameDecoder, JsonLineDecoder, frame, binaryPayload, Sidecar, JSON_LIMIT } from "../sidecar.mjs";
|
||||
|
||||
const config = { executable: process.execPath, args: [fileURLToPath(new URL("./echo-sidecar.mjs", import.meta.url))], transport: "stdio-framed", manifest: { id: "example.echo", version: "1.0.0" } };
|
||||
test("sidecar shutdown terminates descendants even after the leader exits", async (t) => {
|
||||
const sidecar = new Sidecar(config);
|
||||
t.after(() => sidecar.stop());
|
||||
await sidecar.start();
|
||||
const pid = await sidecar.request("spawn-child");
|
||||
t.after(() => {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {}
|
||||
});
|
||||
await delay(100);
|
||||
await sidecar.stop();
|
||||
let alive = true;
|
||||
for (let i = 0; i < 100 && alive; i++) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
await delay(20);
|
||||
} catch {
|
||||
alive = false;
|
||||
}
|
||||
}
|
||||
assert.equal(alive, false, "sidecar descendant must be terminated");
|
||||
});
|
||||
test("decoder handles split headers, payloads, concatenated frames and binary channels", () => {
|
||||
const got = [],
|
||||
decoder = new FrameDecoder((packet) => got.push(packet));
|
||||
const input = Buffer.concat([frame(0, Buffer.from('{"result":1}')), frame(1, binaryPayload("test/bytes", Buffer.from([0, 255, 13])))]);
|
||||
for (const byte of input) decoder.push(Buffer.from([byte]));
|
||||
assert.equal(got[0].message.result, 1);
|
||||
assert.deepEqual(got[1].data, Buffer.from([0, 255, 13]));
|
||||
assert.equal(decoder.buffer.length, 0);
|
||||
});
|
||||
test("decoder rejects oversized and malformed frames before allocating payloads", () => {
|
||||
const header = Buffer.alloc(5);
|
||||
header.writeUInt32BE(JSON_LIMIT + 1, 1);
|
||||
assert.throws(() => new FrameDecoder(() => {}).push(header), /limit/);
|
||||
assert.throws(() => new FrameDecoder(() => {}).push(frame(9, Buffer.alloc(0))), /kind/);
|
||||
assert.throws(() => new FrameDecoder(() => {}).push(frame(1, Buffer.from([0, 20]))), /channel/);
|
||||
});
|
||||
test("generic sidecar correlates out-of-order replies and preserves structured errors", async (t) => {
|
||||
const sidecar = new Sidecar(config);
|
||||
t.after(() => sidecar.stop());
|
||||
await sidecar.start();
|
||||
const order = [];
|
||||
const a = sidecar.request("slow", { a: 1 }).then((value) => {
|
||||
order.push("slow");
|
||||
return value;
|
||||
});
|
||||
const b = sidecar.request("echo", { b: 2 }).then((value) => {
|
||||
order.push("fast");
|
||||
return value;
|
||||
});
|
||||
assert.deepEqual(await b, { b: 2 });
|
||||
assert.deepEqual(await a, { a: 1 });
|
||||
assert.deepEqual(order, ["fast", "slow"]);
|
||||
await assert.rejects(sidecar.request("fail"), (e) => e.rpc.data.category === "example");
|
||||
});
|
||||
test("events, binary, timeout and process death are observable without business RPC knowledge", async (t) => {
|
||||
const sidecar = new Sidecar(config);
|
||||
t.after(() => sidecar.stop());
|
||||
await sidecar.start();
|
||||
const event = once(sidecar, "event");
|
||||
await sidecar.notify("emit", { event: true });
|
||||
assert.equal((await event)[0].params.event, true);
|
||||
const binary = once(sidecar, "binary");
|
||||
await sidecar.sendBinary("channel", Buffer.from("bytes"));
|
||||
assert.equal((await binary)[0].dataBase64, Buffer.from("bytes").toString("base64"));
|
||||
await assert.rejects(sidecar.request("slow", {}, 5), /timed out/);
|
||||
const slow = sidecar.request("slow");
|
||||
const slowAssertion = assert.rejects(slow, /exited/);
|
||||
await sidecar.notify("die");
|
||||
await slowAssertion;
|
||||
assert.equal(sidecar.pending.size, 0);
|
||||
});
|
||||
test("handshake rejects wrong identity and missing executables terminate promptly", async () => {
|
||||
const wrong = new Sidecar({ ...config, manifest: { id: "wrong", version: "1.0.0" } });
|
||||
await assert.rejects(wrong.start(), /identity/);
|
||||
const missing = new Sidecar({ ...config, executable: "/definitely/missing/mock-sidecar" });
|
||||
await assert.rejects(missing.start(), /start/);
|
||||
await missing.stop();
|
||||
});
|
||||
|
||||
test("JSONL supports split lines, concurrent RPC, events and rejects binary channels", async (t) => {
|
||||
const packets = [],
|
||||
decoder = new JsonLineDecoder((packet) => packets.push(packet));
|
||||
decoder.push(Buffer.from('{"a":'));
|
||||
decoder.push(Buffer.from('1}\n\n{"b":2}\n'));
|
||||
assert.equal(packets.length, 2);
|
||||
assert.equal(packets[1].message.b, 2);
|
||||
assert.throws(() => new JsonLineDecoder(() => {}).push(Buffer.alloc(JSON_LIMIT + 1, 65)), /limit/);
|
||||
const sidecar = new Sidecar({ ...config, transport: "stdio-jsonl", args: [...config.args, "--jsonl"] });
|
||||
t.after(() => sidecar.stop());
|
||||
await sidecar.start();
|
||||
assert.deepEqual(await sidecar.request("echo", { arbitrary: "value" }), { arbitrary: "value" });
|
||||
const event = once(sidecar, "event");
|
||||
await sidecar.notify("emit", { ok: true });
|
||||
assert.equal((await event)[0].params.ok, true);
|
||||
await assert.rejects(sidecar.sendBinary("bytes", Buffer.from("data")), /framed/);
|
||||
});
|
||||
@@ -0,0 +1,442 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Plus, Upload, Plug, Unplug, Pencil, Trash2, X, RefreshCw, Sun, Moon, Terminal, Settings2, Languages } from "lucide-vue-next";
|
||||
import { hostMessage } from "./messages.js";
|
||||
import DebugPanel from "./DebugPanel.vue";
|
||||
import { localizeManifest, translate } from "./i18n.js";
|
||||
const rawManifest = ref(),
|
||||
connections = ref([]),
|
||||
frames = ref([]),
|
||||
active = ref(""),
|
||||
state = ref("starting");
|
||||
const error = ref(""),
|
||||
notice = ref(""),
|
||||
busy = ref(false),
|
||||
rebuilt = ref(false),
|
||||
dark = ref(false);
|
||||
const debugOpen = ref(false),
|
||||
diagnostics = ref([]);
|
||||
const locale = ref("zh-CN");
|
||||
const pageId = crypto.randomUUID();
|
||||
const t = (text, values) => translate(locale.value, text, values);
|
||||
const manifest = computed(() => localizeManifest(rawManifest.value ? JSON.parse(JSON.stringify(rawManifest.value)) : undefined, locale.value));
|
||||
const frameName = (frame) => (frame.connectionId ? frame.name : manifest.value?.contributions.find((c) => c.id === frame.contributionId)?.label || frame.name);
|
||||
watch(
|
||||
locale,
|
||||
(value) => {
|
||||
window.document.documentElement.lang = value;
|
||||
window.document.title = t("插件调试");
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const autoReload = ref(false),
|
||||
pendingReload = ref(false);
|
||||
async function toggleAutoReload(event) {
|
||||
event.target.checked = autoReload.value;
|
||||
await run(async () => {
|
||||
if (!autoReload.value && !(await ask("启用自动重载?所有打开的插件页面会在更新后刷新,未保存修改将丢失;后端源码变化会重建并断开连接。此设置影响同一调试服务的所有浏览器页面。"))) return;
|
||||
autoReload.value = (await api("auto-reload", { enabled: !autoReload.value })).enabled;
|
||||
});
|
||||
}
|
||||
watch([busy, autoReload, pendingReload], async () => {
|
||||
if (!autoReload.value) {
|
||||
pendingReload.value = false;
|
||||
return;
|
||||
}
|
||||
if (busy.value || !pendingReload.value) return;
|
||||
pendingReload.value = false;
|
||||
await run(async () => {
|
||||
for (const frame of frames.value) Object.assign(frame, await api("frame-document", { frameId: frame.id }));
|
||||
rebuilt.value = false;
|
||||
});
|
||||
});
|
||||
async function changeLocale() {
|
||||
await run(async () => {
|
||||
const current = frames.value.find((frame) => frame.id === active.value);
|
||||
if (current && !(await ask("切换语言并重载当前页面?未保存修改将丢失。"))) return;
|
||||
const document = current ? await api("frame-document", { frameId: current.id }) : undefined;
|
||||
locale.value = locale.value === "zh-CN" ? "en" : "zh-CN";
|
||||
for (const frame of frames.value) post(frame, { type: "env", locale: locale.value });
|
||||
if (current) {
|
||||
Object.assign(current, document);
|
||||
rebuilt.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
function mergeDiagnostics(entries) {
|
||||
diagnostics.value = [...new Map([...diagnostics.value, ...entries].map((e) => [e.id, e])).values()].sort((a, b) => a.id - b.id).slice(-500);
|
||||
}
|
||||
const editor = ref(),
|
||||
askDialog = ref(),
|
||||
importing = ref(),
|
||||
question = ref(""),
|
||||
draft = ref(),
|
||||
editing = ref(false);
|
||||
const windows = new Map();
|
||||
let csrf, stream, finishAsk;
|
||||
const providers = computed(() => manifest.value?.contributions.filter((c) => c.type === "connection-provider") || []);
|
||||
const workbenches = computed(() => manifest.value?.contributions.filter((c) => c.type === "workbench") || []);
|
||||
const provider = computed(() => providers.value.find((p) => p.id === draft.value?.providerId));
|
||||
const themes = () => ({
|
||||
appearance: dark.value ? "dark" : "light",
|
||||
tokens: dark.value
|
||||
? {
|
||||
"--color-background": "#18181b",
|
||||
"--color-foreground": "#f4f4f5",
|
||||
"--color-muted": "#27272a",
|
||||
"--color-border": "#3f3f46",
|
||||
"--color-primary": "#60a5fa",
|
||||
}
|
||||
: { "--color-background": "#ffffff", "--color-foreground": "#27272a", "--color-muted": "#f4f4f5", "--color-border": "#e4e4e7", "--color-primary": "#2563eb" },
|
||||
});
|
||||
function post(frame, message) {
|
||||
const snapshot = hostMessage(frame.channel, message);
|
||||
windows.get(frame.id)?.contentWindow?.postMessage(snapshot, "*");
|
||||
}
|
||||
function init(frame) {
|
||||
post(frame, { type: "init", context: JSON.parse(JSON.stringify(frame.context)), locale: locale.value, theme: themes(), permissions: manifest.value.permissions || [] });
|
||||
}
|
||||
function theme() {
|
||||
dark.value = !dark.value;
|
||||
document.documentElement.dataset.theme = dark.value ? "dark" : "light";
|
||||
for (const f of frames.value) post(f, { type: "env", theme: themes() });
|
||||
}
|
||||
async function api(path, params = {}) {
|
||||
const response = await fetch(`/api/${path}`, { method: "POST", headers: { "Content-Type": "application/json", "X-Mock-Csrf": csrf, "X-DBX-Page": pageId }, body: JSON.stringify(params) });
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw Object.assign(new Error(result.error?.message || "请求失败"), { code: result.error?.code, data: result.error?.data });
|
||||
return result.value;
|
||||
}
|
||||
async function run(action) {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function ask(message) {
|
||||
question.value = message;
|
||||
const promise = new Promise((resolve) => {
|
||||
finishAsk = resolve;
|
||||
});
|
||||
await nextTick();
|
||||
askDialog.value.showModal();
|
||||
return promise;
|
||||
}
|
||||
function answer(yes) {
|
||||
askDialog.value.close();
|
||||
finishAsk?.(yes);
|
||||
finishAsk = undefined;
|
||||
}
|
||||
async function bootstrap() {
|
||||
const response = await fetch("/api/bootstrap");
|
||||
if (!response.ok) throw new Error("无法读取模拟宿主状态");
|
||||
const data = await response.json();
|
||||
csrf = data.csrf;
|
||||
rawManifest.value = data.manifest;
|
||||
connections.value = data.connections;
|
||||
state.value = data.state;
|
||||
diagnostics.value = data.diagnostics || [];
|
||||
autoReload.value = data.autoReload || false;
|
||||
}
|
||||
function newDraft() {
|
||||
draft.value = { providerId: providers.value[0]?.id, values: {}, readOnly: false };
|
||||
editing.value = false;
|
||||
defaults();
|
||||
editor.value.showModal();
|
||||
}
|
||||
function defaults() {
|
||||
draft.value.values = Object.fromEntries((provider.value?.fields || []).filter((f) => f.default !== undefined).map((f) => [f.key, f.default]));
|
||||
}
|
||||
async function edit(record) {
|
||||
await run(async () => {
|
||||
draft.value = await api("connections/edit", { id: record.id });
|
||||
editing.value = true;
|
||||
editor.value.showModal();
|
||||
});
|
||||
}
|
||||
async function save() {
|
||||
await run(async () => {
|
||||
const result = await api("connections/save", draft.value);
|
||||
connections.value = result.connections;
|
||||
for (const f of frames.value) if (f.connectionId === result.id) f.name = result.connections.find((c) => c.id === result.id)?.name || f.name;
|
||||
editor.value.close();
|
||||
draft.value = undefined;
|
||||
notice.value = "连接已保存";
|
||||
});
|
||||
}
|
||||
async function test() {
|
||||
await run(async () => {
|
||||
await api("connections/test", draft.value);
|
||||
notice.value = "连接测试成功";
|
||||
});
|
||||
}
|
||||
async function addFrame(frame) {
|
||||
const previous = frames.value.find((f) => f.id === frame.id);
|
||||
if (previous) {
|
||||
previous.context = frame.context;
|
||||
previous.name = frame.name;
|
||||
post(previous, { type: "context", context: frame.context });
|
||||
} else {
|
||||
const document = await api("frame-document", { frameId: frame.id });
|
||||
frames.value.push(document);
|
||||
}
|
||||
active.value = frame.id;
|
||||
}
|
||||
async function connect(record) {
|
||||
await run(async () => {
|
||||
const result = await api("connections/connect", { id: record.id });
|
||||
connections.value = result.connections;
|
||||
await addFrame(result.frame);
|
||||
});
|
||||
}
|
||||
async function openWorkbench(contribution) {
|
||||
await run(async () => {
|
||||
await addFrame((await api("workbenches/open", { contributionId: contribution.id })).frame);
|
||||
});
|
||||
}
|
||||
async function disconnect(record) {
|
||||
await run(async () => {
|
||||
if (!(await ask("断开连接将中断该连接正在进行的操作。继续?"))) return;
|
||||
connections.value = (await api("connections/disconnect", { id: record.id })).connections;
|
||||
});
|
||||
}
|
||||
async function remove(record) {
|
||||
await run(async () => {
|
||||
if (!(await ask(t("删除连接“{name}”?其页面将关闭,未保存修改会丢失。", { name: record.name })))) return;
|
||||
connections.value = (await api("connections/delete", { id: record.id })).connections;
|
||||
removeFrames(record.id);
|
||||
});
|
||||
}
|
||||
function removeFrames(connectionId) {
|
||||
for (const f of frames.value.filter((f) => f.connectionId === connectionId)) windows.delete(f.id);
|
||||
frames.value = frames.value.filter((f) => f.connectionId !== connectionId);
|
||||
if (!frames.value.some((f) => f.id === active.value)) active.value = frames.value[0]?.id || "";
|
||||
}
|
||||
async function closeFrame(frame) {
|
||||
await run(async () => {
|
||||
if (!(await ask("关闭此页面?未保存修改将丢失;最后一个关联页面关闭后会断开连接。"))) return;
|
||||
connections.value = (await api("frames/close", { id: frame.id })).connections;
|
||||
windows.delete(frame.id);
|
||||
frames.value = frames.value.filter((f) => f.id !== frame.id);
|
||||
if (active.value === frame.id) active.value = frames.value[0]?.id || "";
|
||||
});
|
||||
}
|
||||
async function reload() {
|
||||
await run(async () => {
|
||||
const f = frames.value.find((f) => f.id === active.value);
|
||||
if (!f) return;
|
||||
if (!(await ask("重载当前页面?未保存修改将丢失。"))) return;
|
||||
const document = await api("frame-document", { frameId: f.id });
|
||||
Object.assign(f, document);
|
||||
rebuilt.value = false;
|
||||
});
|
||||
}
|
||||
async function restart() {
|
||||
await run(async () => {
|
||||
if (!(await ask("重建并重启后端将断开所有连接、中断传输。页面不会自动重载,写操作不会自动重试。继续?"))) return;
|
||||
state.value = "building";
|
||||
const result = await api("backend/restart");
|
||||
connections.value = result.connections;
|
||||
state.value = result.state;
|
||||
notice.value = "后端已启动,请重新连接";
|
||||
});
|
||||
}
|
||||
async function importFile(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
await run(async () => {
|
||||
if (file.size > 2 * 1024 * 1024) throw new Error("导入文件超过 2 MiB");
|
||||
const data = JSON.parse(await file.text());
|
||||
connections.value = (await api("connections/import", data)).connections;
|
||||
});
|
||||
event.target.value = "";
|
||||
}
|
||||
async function onMessage(event) {
|
||||
const m = event.data;
|
||||
const f = frames.value.find((f) => windows.get(f.id)?.contentWindow === event.source);
|
||||
if (!f || m?.source !== "dbx-plugin" || m.version !== 1 || m.channel !== f.channel) return;
|
||||
if (m.type === "ready") {
|
||||
init(f);
|
||||
return;
|
||||
}
|
||||
if (m.type !== "request") return;
|
||||
const channel = f.channel;
|
||||
try {
|
||||
const result = await api("bridge", { frameId: f.id, channel, method: m.method, params: m.params });
|
||||
if (f.channel !== channel || !windows.has(f.id)) return;
|
||||
if (m.method === "host.openWorkbench" && result?.mockHostOpenFrame) {
|
||||
await addFrame(result.mockHostOpenFrame);
|
||||
post(f, { type: "response", id: m.id, result: null });
|
||||
} else post(f, { type: "response", id: m.id, result });
|
||||
} catch (e) {
|
||||
if (f.channel === channel) post(f, { type: "response", id: m.id, error: { message: e.message, code: e.code, data: e.data } });
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
window.addEventListener("message", onMessage);
|
||||
await run(async () => {
|
||||
await bootstrap();
|
||||
stream = new EventSource(`/api/events?page=${pageId}`);
|
||||
stream.onmessage = (event) => {
|
||||
const m = JSON.parse(event.data);
|
||||
if (m.type === "connections") connections.value = m.connections;
|
||||
if (m.type === "page-frames") {
|
||||
for (const f of frames.value) if (!m.ids.includes(f.id)) windows.delete(f.id);
|
||||
frames.value = frames.value.filter((f) => m.ids.includes(f.id));
|
||||
if (!frames.value.some((f) => f.id === active.value)) active.value = frames.value[0]?.id || "";
|
||||
}
|
||||
if (m.type === "diagnostic") mergeDiagnostics([m.entry]);
|
||||
if (m.type === "diagnostic-history") mergeDiagnostics(m.entries);
|
||||
if (m.type === "status") {
|
||||
state.value = m.state;
|
||||
if (m.state !== "ready") connections.value = connections.value.map((c) => ({ ...c, connected: false }));
|
||||
}
|
||||
if (m.type === "ui-rebuilt") {
|
||||
rebuilt.value = true;
|
||||
if (autoReload.value) pendingReload.value = true;
|
||||
}
|
||||
if (m.type === "auto-reload") autoReload.value = m.enabled;
|
||||
if (m.type === "auto-reload-error") error.value = "自动重载失败,请查看调试日志;修复源码后可重试";
|
||||
if (m.type === "frames-removed") removeFrames(m.connectionId);
|
||||
if (m.type === "watch-error") error.value = "页面构建监听已停止,请重启调试服务";
|
||||
if (m.type === "event" || m.type === "binary") for (const f of frames.value) post(f, { ...m, binaryChannel: m.channel });
|
||||
};
|
||||
stream.onerror = () => {
|
||||
error.value = "调试服务连接中断,正在重新连接";
|
||||
};
|
||||
});
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
stream?.close();
|
||||
window.removeEventListener("message", onMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flex h-full flex-col bg-base-100 text-base-content">
|
||||
<header class="flex min-h-14 flex-wrap items-center gap-3 border-b border-base-300 px-4 py-2">
|
||||
<Terminal :size="20" class="text-success" />
|
||||
<h1 class="text-sm font-semibold">{{ t("插件调试") }}</h1>
|
||||
<span class="mr-auto text-xs text-base-content/60">{{ manifest?.name }}</span>
|
||||
<span class="text-xs" :class="['ready', 'frontend'].includes(state) ? 'text-success' : 'text-warning'" role="status">{{
|
||||
t({ frontend: "纯前端", ready: "后端运行中", building: "正在构建", starting: "正在启动", stopped: "后端已停止", stopping: "正在停止", failed: "后端异常" }[state] || state)
|
||||
}}</span>
|
||||
<button v-if="manifest?.entrypoints?.backend" class="btn btn-sm" :disabled="busy" @click="restart"><RefreshCw :size="14" />{{ t("重建后端") }}</button>
|
||||
<button class="btn btn-sm shrink-0" :class="rebuilt ? 'text-info' : ''" :disabled="busy || !active" @click="reload"><RefreshCw :size="14" />{{ t(rebuilt ? "有新构建 · 重载页面" : "重载页面") }}</button>
|
||||
<button class="btn btn-sm w-28 shrink-0" :disabled="busy" :aria-label="t('切换插件语言')" :title="t('切换插件语言')" @click="changeLocale"><Languages :size="14" />{{ t(locale === "zh-CN" ? "中文" : "English") }}</button>
|
||||
<button class="btn btn-sm" :class="debugOpen ? 'btn-active' : ''" :aria-expanded="debugOpen" aria-controls="debug-panel" @click="debugOpen = !debugOpen"><Terminal :size="14" />{{ t("调试") }}</button>
|
||||
<label class="flex shrink-0 items-center gap-2 text-xs"><input type="checkbox" class="toggle toggle-xs" :aria-label="t('自动重载')" :checked="autoReload" :disabled="busy" @change="toggleAutoReload" />{{ t("自动重载") }}</label>
|
||||
<button class="btn btn-sm btn-square btn-ghost" :aria-label="t(dark ? '浅色主题' : '深色主题')" :title="t(dark ? '浅色主题' : '深色主题')" @click="theme"><Sun v-if="dark" :size="16" /><Moon v-else :size="16" /></button>
|
||||
</header>
|
||||
<div v-if="error && !draft" role="alert" class="flex gap-2 border-b border-error/30 bg-error/10 px-4 py-2 text-sm text-error">
|
||||
<span class="min-w-0 flex-1 break-words">{{ t(error) }}</span
|
||||
><button class="btn btn-xs btn-square btn-ghost" :aria-label="t('关闭错误')" @click="error = ''"><X :size="14" /></button>
|
||||
</div>
|
||||
<div v-if="notice && !draft" role="status" class="border-b border-base-300 px-4 py-2 text-xs text-success">{{ t(notice) }}</div>
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<aside class="flex w-56 shrink-0 flex-col border-r border-base-300 bg-base-200 max-sm:w-40" :aria-label="t('连接列表')">
|
||||
<div v-if="workbenches.length" class="border-b border-base-300 p-2">
|
||||
<h2 class="px-1 py-2 text-xs font-semibold">{{ t("工作台") }}</h2>
|
||||
<button v-for="workbench in workbenches" :key="workbench.id" class="btn btn-sm btn-ghost w-full justify-start" :title="workbench.label" :disabled="busy" @click="openWorkbench(workbench)">
|
||||
<Terminal :size="14" /><span class="truncate">{{ workbench.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 border-b border-base-300 p-2">
|
||||
<h2 class="mr-auto text-xs font-semibold">{{ t("连接") }}</h2>
|
||||
<button class="btn btn-sm btn-square btn-ghost" :aria-label="t('新建连接')" :title="t('新建连接')" :disabled="busy || !providers.length" @click="newDraft"><Plus :size="16" /></button
|
||||
><button class="btn btn-sm btn-square btn-ghost" :aria-label="t('导入连接')" :title="t('导入连接')" :disabled="busy" @click="importing.click()"><Upload :size="16" /></button><input ref="importing" type="file" accept="application/json,.json" hidden @change="importFile" />
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-auto p-2">
|
||||
<div v-for="c in connections" :key="c.id" class="mb-1 border-b border-base-300 pb-2" :data-connection="c.id">
|
||||
<button class="flex w-full min-w-0 items-center gap-2 py-2 text-left text-sm" :title="c.name" :disabled="busy || state !== 'ready'" @click="connect(c)">
|
||||
<Plug :size="15" class="shrink-0" :class="c.connected ? 'text-success' : 'text-base-content/40'" /><span class="truncate">{{ c.name }}</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="mr-auto truncate text-xs text-base-content/60">{{ providers.find((p) => p.id === c.providerId)?.label || c.label }}</span
|
||||
><button class="btn btn-xs btn-square btn-ghost" :aria-label="t('编辑') + ' ' + c.name" :title="t('编辑')" :disabled="busy" @click="edit(c)"><Pencil :size="13" /></button
|
||||
><button class="btn btn-xs btn-square btn-ghost" :aria-label="t('断开') + ' ' + c.name" :title="t('断开')" :disabled="busy || !c.connected" @click="disconnect(c)"><Unplug :size="13" /></button
|
||||
><button class="btn btn-xs btn-square btn-ghost" :aria-label="t('删除') + ' ' + c.name" :title="t('删除')" :disabled="busy" @click="remove(c)"><Trash2 :size="13" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!connections.length" class="py-8 text-center text-xs text-base-content/50">{{ t("暂无连接") }}</p>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="flex min-h-0 min-w-0 flex-1 flex-col" :aria-label="t('工作台')">
|
||||
<div class="flex min-h-10 items-center border-b border-base-300">
|
||||
<div class="flex min-w-0 flex-1 overflow-x-auto" role="tablist">
|
||||
<div v-for="f in frames" :key="f.id" class="flex shrink-0 items-center border-r border-base-300" :class="active === f.id ? 'bg-base-200 border-b-2 border-b-info' : ''">
|
||||
<button role="tab" class="max-w-44 truncate px-3 py-2 text-xs" :aria-selected="active === f.id" :title="frameName(f)" @click="active = f.id">{{ frameName(f) }}</button
|
||||
><button class="btn btn-xs btn-square btn-ghost mr-1" :aria-label="t('关闭') + ' ' + frameName(f)" :disabled="busy" @click="closeFrame(f)"><X :size="12" /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<iframe v-for="f in frames" v-show="active === f.id" :key="f.id" :ref="(el) => (el ? windows.set(f.id, el) : windows.delete(f.id))" :srcdoc="f.html" sandbox="allow-scripts" referrerpolicy="no-referrer" :title="frameName(f)" class="absolute inset-0" @load="init(f)" />
|
||||
<div v-if="!frames.length" class="grid h-full place-items-center text-sm text-base-content/50">
|
||||
<div class="flex items-center gap-2"><Settings2 :size="18" />{{ t("尚未打开工作台") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<DebugPanel v-if="debugOpen" :entries="diagnostics" :locale="locale" @close="debugOpen = false" />
|
||||
<dialog ref="editor" class="modal" @cancel="draft = undefined">
|
||||
<form v-if="draft" class="modal-box max-w-xl rounded-lg" @submit.prevent="save">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold">{{ t(editing ? "编辑连接" : "新建连接") }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-square btn-ghost"
|
||||
:aria-label="t('关闭表单')"
|
||||
:disabled="busy"
|
||||
@click="
|
||||
editor.close();
|
||||
draft = undefined;
|
||||
"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<label class="mt-4 block text-xs"
|
||||
>{{ t("连接类型")
|
||||
}}<select v-model="draft.providerId" class="select select-sm mt-1 w-full" :disabled="editing || busy" @change="defaults">
|
||||
<option v-for="p in providers" :key="p.id" :value="p.id">{{ p.label }}</option>
|
||||
</select></label
|
||||
>
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<label v-for="field in provider?.fields || []" :key="field.key" class="min-w-0 text-xs" :class="field.type === 'textarea' ? 'sm:col-span-2' : ''"
|
||||
>{{ field.label }}{{ field.required ? " *" : "" }}
|
||||
<select v-if="field.type === 'select'" v-model="draft.values[field.key]" class="select select-sm mt-1 w-full" :aria-label="field.label" :required="field.required" :disabled="busy">
|
||||
<option v-for="option in field.options" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<input v-else-if="field.type === 'boolean'" v-model="draft.values[field.key]" type="checkbox" class="checkbox checkbox-sm ml-2" :aria-label="field.label" :disabled="busy" />
|
||||
<textarea v-else-if="field.type === 'textarea'" v-model="draft.values[field.key]" class="textarea mt-1 w-full" :aria-label="field.label" :required="field.required" :disabled="busy" />
|
||||
<input v-else-if="field.type === 'number'" v-model.number="draft.values[field.key]" type="number" class="input input-sm mt-1 w-full" :aria-label="field.label" :required="field.required" :disabled="busy" />
|
||||
<input v-else v-model="draft.values[field.key]" :type="field.type === 'password' ? 'password' : 'text'" class="input input-sm mt-1 w-full" :aria-label="field.label" :required="field.required" :disabled="busy" autocomplete="off" />
|
||||
<span v-if="field.description" class="mt-1 block text-xs text-base-content/50">{{ field.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="mt-4 flex items-center gap-2 text-xs"><input v-model="draft.readOnly" type="checkbox" class="checkbox checkbox-sm" :disabled="busy" />{{ t("只读连接") }}</label>
|
||||
<p v-if="error" role="alert" class="mt-3 break-words text-sm text-error">{{ t(error) }}</p>
|
||||
<p v-if="notice" role="status" class="mt-3 text-sm text-success">{{ t(notice) }}</p>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-sm" :disabled="busy || state !== 'ready' || !provider?.capabilities?.includes('test')" @click="test">{{ t("测试连接") }}</button><button class="btn btn-sm" :disabled="busy">{{ t("保存") }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog ref="askDialog" class="modal" @cancel.prevent="answer(false)">
|
||||
<div class="modal-box max-w-sm rounded-lg">
|
||||
<p class="text-sm">{{ t(question) }}</p>
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-sm" @click="answer(false)">{{ t("取消") }}</button><button class="btn btn-sm" @click="answer(true)">{{ t("确认") }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { ArrowDownToLine, Trash2, X } from "lucide-vue-next";
|
||||
import { translate } from "./i18n.js";
|
||||
const props = defineProps({ entries: { type: Array, default: () => [] }, locale: { type: String, default: "zh-CN" } });
|
||||
const t = (text) => translate(props.locale, text);
|
||||
defineEmits(["close"]);
|
||||
const level = ref("all"),
|
||||
follow = ref(true),
|
||||
cleared = ref(0),
|
||||
viewport = ref();
|
||||
const visible = computed(() => props.entries.filter((e) => e.id > cleared.value && (level.value === "all" || e.level === level.value)));
|
||||
function clear() {
|
||||
cleared.value = props.entries.at(-1)?.id || 0;
|
||||
}
|
||||
watch(
|
||||
[visible, follow],
|
||||
async () => {
|
||||
if (follow.value) {
|
||||
await nextTick();
|
||||
viewport.value?.scrollTo({ top: viewport.value.scrollHeight });
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="debug-panel" :aria-label="t('调试信息')" class="flex h-64 max-h-[40vh] min-h-0 shrink-0 flex-col border-t border-base-300 bg-base-200">
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b border-base-300 px-3 py-2">
|
||||
<h2 class="mr-auto text-xs font-semibold">
|
||||
{{ t("调试信息") }}<span class="ml-2 font-normal text-base-content/60">{{ visible.length }} / 500</span>
|
||||
</h2>
|
||||
<select v-model="level" :aria-label="t('日志级别')" class="select select-xs w-32">
|
||||
<option value="all">{{ t("全部级别") }}</option>
|
||||
<option value="error">{{ t("错误") }}</option>
|
||||
<option value="info">{{ t("信息") }}</option>
|
||||
<option value="debug">{{ t("调试") }}</option>
|
||||
</select>
|
||||
<button class="btn btn-xs btn-square btn-ghost" :class="follow ? 'btn-active' : ''" :aria-pressed="follow" :aria-label="t('自动滚动')" :title="t('自动滚动')" @click="follow = !follow"><ArrowDownToLine :size="14" /></button>
|
||||
<button class="btn btn-xs btn-square btn-ghost" :aria-label="t('清空调试信息')" :title="t('清空当前视图')" @click="clear"><Trash2 :size="14" /></button>
|
||||
<button class="btn btn-xs btn-square btn-ghost" :aria-label="t('关闭调试信息')" :title="t('关闭调试信息')" @click="$emit('close')"><X :size="14" /></button>
|
||||
</div>
|
||||
<div ref="viewport" class="min-h-0 flex-1 overflow-auto px-3 py-1 font-mono text-xs" role="log" aria-live="off" tabindex="0">
|
||||
<div v-for="entry in visible" :key="entry.id" class="flex flex-wrap gap-x-3 border-b border-base-300/50 py-1.5" :class="entry.level === 'error' ? 'text-error' : ''">
|
||||
<time class="shrink-0 text-base-content/60" :datetime="entry.time">{{ new Date(entry.time).toLocaleTimeString(locale, { hour12: false }) }}</time>
|
||||
<span class="w-12 shrink-0 uppercase">{{ entry.level }}</span>
|
||||
<span class="w-14 shrink-0 text-base-content/60">{{ entry.category }}</span>
|
||||
<div class="min-w-0 flex-1 break-words">
|
||||
<span>{{ t(entry.message) }}</span>
|
||||
<span class="ml-2 text-base-content/60">{{
|
||||
Object.entries(entry.details)
|
||||
.filter(([k]) => !["params", "result", "error"].includes(k))
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(" · ")
|
||||
}}</span>
|
||||
<details v-for="key in ['params', 'result', 'error'].filter((k) => k in entry.details)" :key="key" class="mt-1">
|
||||
<summary class="cursor-pointer text-info">{{ t({ params: "入参", result: "出参", error: "错误详情" }[key]) }}</summary>
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all text-base-content">{{ JSON.stringify(entry.details[key], null, 2) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!visible.length" class="py-4 text-center text-base-content/50">{{ t("暂无调试记录") }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,118 @@
|
||||
export const english = {
|
||||
插件调试: "Plugin Debug",
|
||||
纯前端: "Frontend only",
|
||||
后端运行中: "Backend running",
|
||||
正在构建: "Building",
|
||||
正在启动: "Starting",
|
||||
后端已停止: "Backend stopped",
|
||||
正在停止: "Stopping",
|
||||
后端异常: "Backend failed",
|
||||
重建后端: "Rebuild backend",
|
||||
重载页面: "Reload page",
|
||||
"有新构建 · 重载页面": "New build · Reload page",
|
||||
调试: "Debug",
|
||||
自动重载: "Auto reload",
|
||||
切换插件语言: "Switch language",
|
||||
浅色主题: "Light theme",
|
||||
深色主题: "Dark theme",
|
||||
关闭错误: "Dismiss error",
|
||||
连接列表: "Connections",
|
||||
工作台: "Workbench",
|
||||
连接: "Connections",
|
||||
新建连接: "New connection",
|
||||
导入连接: "Import connections",
|
||||
编辑: "Edit",
|
||||
断开: "Disconnect",
|
||||
删除: "Delete",
|
||||
关闭: "Close",
|
||||
暂无连接: "No connections",
|
||||
尚未打开工作台: "No workbench open",
|
||||
编辑连接: "Edit connection",
|
||||
关闭表单: "Close form",
|
||||
连接类型: "Connection type",
|
||||
只读连接: "Read-only connection",
|
||||
测试连接: "Test connection",
|
||||
保存: "Save",
|
||||
取消: "Cancel",
|
||||
确认: "Confirm",
|
||||
调试信息: "Diagnostics",
|
||||
日志级别: "Log level",
|
||||
全部级别: "All levels",
|
||||
错误: "Error",
|
||||
信息: "Info",
|
||||
自动滚动: "Auto scroll",
|
||||
清空调试信息: "Clear diagnostics",
|
||||
清空当前视图: "Clear current view",
|
||||
关闭调试信息: "Close diagnostics",
|
||||
入参: "Parameters",
|
||||
出参: "Result",
|
||||
错误详情: "Error details",
|
||||
暂无调试记录: "No diagnostic entries",
|
||||
"启用自动重载?所有打开的插件页面会在更新后刷新,未保存修改将丢失;后端源码变化会重建并断开连接。此设置影响同一调试服务的所有浏览器页面。":
|
||||
"Enable auto reload? Updated plugin pages will reload and unsaved changes will be lost. Backend changes rebuild and disconnect sessions. This affects all browsers using this development server.",
|
||||
"切换语言并重载当前页面?未保存修改将丢失。": "Switch language and reload the current page? Unsaved changes will be lost.",
|
||||
"断开连接将中断该连接正在进行的操作。继续?": "Disconnecting will interrupt operations on this connection. Continue?",
|
||||
"删除连接“{name}”?其页面将关闭,未保存修改会丢失。": 'Delete connection "{name}"? Its pages will close and unsaved changes will be lost.',
|
||||
"关闭此页面?未保存修改将丢失;最后一个关联页面关闭后会断开连接。": "Close this page? Unsaved changes will be lost. Closing the last associated page disconnects the connection.",
|
||||
"重载当前页面?未保存修改将丢失。": "Reload the current page? Unsaved changes will be lost.",
|
||||
"重建并重启后端将断开所有连接、中断传输。页面不会自动重载,写操作不会自动重试。继续?": "Rebuild and restart the backend? All connections and transfers will be interrupted. Pages will not reload automatically and writes will not be retried.",
|
||||
请求失败: "Request failed",
|
||||
无法读取模拟宿主状态: "Unable to load development host state",
|
||||
连接已保存: "Connection saved",
|
||||
连接测试成功: "Connection test succeeded",
|
||||
"后端已启动,请重新连接": "Backend started. Reconnect to continue.",
|
||||
"导入文件超过 2 MiB": "Import exceeds 2 MiB",
|
||||
"自动重载失败,请查看调试日志;修复源码后可重试": "Auto reload failed. Check diagnostics and fix the source to retry.",
|
||||
"页面构建监听已停止,请重启调试服务": "UI build watcher stopped. Restart the development server.",
|
||||
"调试服务连接中断,正在重新连接": "Connection to the development server lost. Reconnecting.",
|
||||
构建命令开始: "Build command started",
|
||||
构建命令完成: "Build command completed",
|
||||
构建命令失败: "Build command failed",
|
||||
后端状态变化: "Backend state changed",
|
||||
后端进程退出: "Backend process exited",
|
||||
后端协议解析失败: "Backend protocol parsing failed",
|
||||
"RPC 开始": "RPC started",
|
||||
"RPC 完成": "RPC completed",
|
||||
"RPC 失败": "RPC failed",
|
||||
"HTTP 请求完成": "HTTP request completed",
|
||||
收到后端事件: "Backend event received",
|
||||
收到二进制帧: "Binary frame received",
|
||||
开始重建后端: "Backend rebuild started",
|
||||
后端重建完成: "Backend rebuild completed",
|
||||
后端重建失败: "Backend rebuild failed",
|
||||
调试服务已启动: "Development server started",
|
||||
调试服务已停止: "Development server stopped",
|
||||
前端构建监听启动失败: "UI watcher failed to start",
|
||||
前端构建监听已退出: "UI watcher exited",
|
||||
页面回收时断开连接失败: "Failed to disconnect an abandoned page's connection",
|
||||
后端源码监听已停止: "Backend source watcher stopped",
|
||||
自动重载已启用: "Auto reload enabled",
|
||||
自动重载已关闭: "Auto reload disabled",
|
||||
};
|
||||
|
||||
export function translate(locale, text, values = {}) {
|
||||
const translated = locale === "en" ? english[text] || text : text;
|
||||
return String(translated ?? "").replace(/\{(\w+)\}/g, (match, key) => values[key] ?? match);
|
||||
}
|
||||
|
||||
export function localizeManifest(source, locale) {
|
||||
if (!source) return source;
|
||||
const result = structuredClone(source),
|
||||
translation = source.localizations?.[locale];
|
||||
if (!translation) return result;
|
||||
result.name = translation.name || result.name;
|
||||
for (const contribution of result.contributions || []) {
|
||||
const item = translation.contributions?.[contribution.id];
|
||||
if (!item) continue;
|
||||
contribution.label = item.label || contribution.label;
|
||||
contribution.description = item.description || contribution.description;
|
||||
for (const field of contribution.fields || []) {
|
||||
const localized = item.fields?.[field.key];
|
||||
if (!localized) continue;
|
||||
field.label = localized.label || field.label;
|
||||
field.description = localized.description || field.description;
|
||||
for (const option of field.options || []) option.label = localized.options?.[option.value] || option.label;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>插件调试</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="./main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,3 @@
|
||||
export function hostMessage(channel, message) {
|
||||
return JSON.parse(JSON.stringify({ ...message, source: "dbx-host", version: 1, channel }));
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui" {
|
||||
themes:
|
||||
light --default,
|
||||
dark;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
:root {
|
||||
font:
|
||||
14px -apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
letter-spacing: 0;
|
||||
--radius-box: 0.5rem;
|
||||
--radius-field: 0.25rem;
|
||||
}
|
||||
* {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
button:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--color-info);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: var(--color-base-100);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawnSync, spawn } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
copyFileSync,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join, resolve } from "node:path";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
|
||||
const repositoryRoot = resolve(import.meta.dirname, "..");
|
||||
const platformPackages = {
|
||||
@@ -85,10 +85,10 @@ try {
|
||||
throw new Error(`Unexpected installed CLI version output: ${version.stdout.trim()}`);
|
||||
}
|
||||
|
||||
verifyTemplate(command, installDirectory, "frontend");
|
||||
await verifyTemplate(command, installDirectory, "frontend");
|
||||
if (process.env.DBX_PLUGIN_CLI_VERIFY_NATIVE === "1") {
|
||||
verifyTemplate(command, installDirectory, "rust");
|
||||
if (commandAvailable("go")) verifyTemplate(command, installDirectory, "go");
|
||||
await verifyTemplate(command, installDirectory, "rust");
|
||||
if (commandAvailable("go")) await verifyTemplate(command, installDirectory, "go");
|
||||
}
|
||||
|
||||
const installedManifest = JSON.parse(
|
||||
@@ -103,7 +103,7 @@ try {
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function verifyTemplate(command, workingDirectory, template) {
|
||||
async function verifyTemplate(command, workingDirectory, template) {
|
||||
const project = join(workingDirectory, `${template}-plugin`);
|
||||
run(command, [
|
||||
"create",
|
||||
@@ -125,6 +125,60 @@ function verifyTemplate(command, workingDirectory, template) {
|
||||
if (packages.length !== 1 || !existsSync(join(project, "dist", packages[0]))) {
|
||||
throw new Error(`Expected one ${template} .dbxp package, found ${packages.join(", ")}.`);
|
||||
}
|
||||
await verifyDev(workingDirectory, project, template);
|
||||
}
|
||||
|
||||
async function verifyDev(workingDirectory, project, template) {
|
||||
const packageRoot = join(workingDirectory, 'node_modules/@dbx-app/plugin-cli');
|
||||
if (!existsSync(join(packageRoot, 'dev-runtime/runtime.mjs')) || !existsSync(join(packageRoot, 'dev-runtime/ui/index.html'))) throw new Error('Packed CLI has no dev runtime');
|
||||
const child = spawn(process.execPath, [join(packageRoot, 'bin/dbx-plugin.js'), 'dev', '--path', project, '--port', '0'], {
|
||||
cwd: workingDirectory, env: { ...process.env, DBX_PLUGIN_DEV_RUNTIME: '', DBX_PLUGIN_NODE: '', DBX_PLUGIN_SDK_ROOT: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32',
|
||||
});
|
||||
let output = '';
|
||||
child.stderr.on('data', chunk => { output = (output + chunk).slice(-64000); });
|
||||
try {
|
||||
const origin = await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('Installed dev runtime did not start')), 180000);
|
||||
child.on('error', error => { clearTimeout(timer); reject(error); });
|
||||
child.on('exit', code => { clearTimeout(timer); reject(new Error(`Installed dev exited ${code}: ${output}`)); });
|
||||
child.stdout.on('data', chunk => { output = (output + chunk).slice(-64000); const url = /Plugin dev host: (http:\/\/127\.0\.0\.1:\d+)/.exec(output)?.[1]; if (url) { clearTimeout(timer); resolve(url); } });
|
||||
});
|
||||
const bootstrap = await fetch(`${origin}/api/bootstrap`), boot = await bootstrap.json();
|
||||
const cookie = bootstrap.headers.get('set-cookie').split(';')[0];
|
||||
const post = async (path, body) => {
|
||||
const response = await fetch(`${origin}/api/${path}`, { method: 'POST', headers: { Cookie: cookie, Origin: origin, 'Content-Type': 'application/json', 'X-Mock-Csrf': boot.csrf }, body: JSON.stringify(body) });
|
||||
const result = await response.json(); if (!response.ok) throw new Error(JSON.stringify(result)); return result.value;
|
||||
};
|
||||
const workbench = boot.manifest.contributions.find(c => c.type === 'workbench');
|
||||
let opened;
|
||||
if (template === 'frontend') opened = await post('workbenches/open', { contributionId: workbench.id });
|
||||
else {
|
||||
const provider = boot.manifest.contributions.find(c => c.type === 'connection-provider');
|
||||
const record = { providerId: provider.id, values: Object.fromEntries(provider.fields.filter(f => f.default !== undefined).map(f => [f.key, f.default])) };
|
||||
await post('connections/test', record);
|
||||
const saved = await post('connections/save', record); opened = await post('connections/connect', { id: saved.id });
|
||||
}
|
||||
const frame = await post('frame-document', { frameId: opened.frame.id });
|
||||
const call = (method, params) => post('bridge', { frameId: frame.id, channel: frame.channel, method, params });
|
||||
await call('host.getContext', {});
|
||||
if (template !== 'frontend') {
|
||||
const method = readFileSync(join(project, 'ui/index.html'), 'utf8').match(/\.invoke\("([^\"]+)"/)?.[1];
|
||||
if (!method) throw new Error('Native template has no invoke example');
|
||||
const result = await call('backend.invoke', { method, params: { connectionId: frame.context.connectionId } });
|
||||
if (result.language !== template || !result.ok) throw new Error('Native dev RPC returned unexpected result');
|
||||
}
|
||||
await post('frames/close', { id: frame.id });
|
||||
console.log(`Verified installed ${template} dev runtime without source-tree dependencies.`);
|
||||
} finally {
|
||||
if (child.exitCode === null && !child.signalCode) {
|
||||
await new Promise(resolve => {
|
||||
const timer = setTimeout(() => { if (process.platform === 'win32') spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F']); else { try { process.kill(-child.pid, 'SIGKILL'); } catch {} } }, 7000);
|
||||
child.once('close', () => { clearTimeout(timer); resolve(); });
|
||||
if (process.platform === 'win32') spawn('taskkill', ['/pid', String(child.pid), '/T', '/F']); else process.kill(-child.pid, 'SIGTERM');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function commandAvailable(command) {
|
||||
@@ -133,6 +187,13 @@ function commandAvailable(command) {
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
if (process.platform === "win32" && command === "npm") {
|
||||
args = [process.env.npm_execpath || join(dirname(process.execPath), "node_modules/npm/bin/npm-cli.js"), ...args];
|
||||
command = process.execPath;
|
||||
} else if (process.platform === "win32" && command.endsWith("dbx-plugin.cmd")) {
|
||||
args = [join(dirname(command), "../@dbx-app/plugin-cli/bin/dbx-plugin.js"), ...args];
|
||||
command = process.execPath;
|
||||
}
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repositoryRoot,
|
||||
encoding: "utf8",
|
||||
|
||||
Reference in New Issue
Block a user