Compare commits

...

5 Commits

Author SHA1 Message Date
xfy
eaabb87108 docs(code-runner): AGENTS.md 补充别名归一化与 bun 镜像说明
Some checks failed
CI / check (push) Has been cancelled
CI / build (push) Has been cancelled
Code Runner API 层描述补充 normalize_lang 别名归一化机制:
- parse_fence_info 在 markdown 渲染期把别名(js→node, ts→bun 等)换成 canonical
- start_exec/start_exec_stream 在 LANGUAGES.get 前显式 normalize_lang

Runner images 段落补 yggdrasil-runner-bun,记录三个踩坑点:
- bun 不在 Alpine 3.18 仓库,用 bun.sh 脚本(自动选 musl 变体)
- bun 的 musl 二进制是 C++,需 libstdc++ + libgcc 运行时
- BUN_INSTALL 必须 export,否则 curl|bash 管道右侧读不到
2026-07-21 15:47:39 +08:00
xfy
ca676a6146 feat(docker): 新增 yggdrasil-runner-bun 沙箱镜像
bun 在 Alpine 3.18 官方仓库不可用,用 bun.sh 安装脚本(多架构、自动检测
/etc/alpine-release 选 musl 变体)。踩坑三处:

1. BUN_INSTALL 必须对管道两侧生效——「VAR=x cmd | bash」只给左侧设变量,
   bash 侧读不到会 fallback 到 $HOME/.bun。改用 export。
2. bun 的 musl 二进制是 C++ 写的,依赖 libstdc++ + libgcc(musl libc 不带
   C++ 运行时),缺失时报 _ZNSt18condition_variable... symbol not found。
   这两个是运行时依赖,apk del 不得删除。
3. 安装路径固定到 /opt/bun + symlink /usr/local/bin/bun,避免依赖 $HOME
   (runner 用户 $HOME 可能不可写)。

本地验证通过:
- bun --version → 1.3.14
- runner 用户(1000:1000) + 只读根 + tmpfs(/code mode=1777, /tmp exec) 下
  成功执行含 interface/类型注解/循环的 TypeScript 代码。

build-runners.sh 加 bun 构建步骤;deploy-to-linux SKILL.md 的 build 循环、
docker save 列表、docker tag 列表、verify 循环全部补 bun(5→6 个镜像)。
2026-07-21 15:44:38 +08:00
xfy
52e5ee2736 feat(admin): 代码试运行沙箱加 bun 语言按钮
SUPPORTED_LANGS 追加 'bun'(仅 canonical key,不展示别名以免按钮拥挤),
default_source 加 bun 分支——示例用 TypeScript 类型注解(:string / :number)
体现 bun 跑 TS 的语言特性。

aliases(js/ts/rs 等)经 normalize_lang 归一即命中此处某项,按钮无需重复。
2026-07-21 15:24:41 +08:00
xfy
864c813c6d feat(code-runner): 高亮层补 bun 别名、CodeMirror 加 TS 模式
syntect 高亮别名表(highlight.rs)加 ('bun','ts'):bun 运行器跑的是
TypeScript,归一化后用 ts 扩展名命中 TypeScript.sublime-syntax。

CodeMirror buildLanguageExtension(codemirror-editor/editor.ts)新增
bun/typescript/ts 分支 → javascript({ typescript: true }),让 bun 代码块
在阅读器侧编辑器获得 TS 类型感知高亮。归一化后 CodeRunner 实际只传
canonical key,但保留别名分支作防御(CodeMirror 也在 SQL 控制台等场景用)。

两边都补了测试:highlight_code_resolves_bun_alias_to_typescript 锁定
'bun' 与 'ts' 输出一致;CodeMirror 经 tsc --noEmit + vite build 通过。
2026-07-21 15:23:47 +08:00
xfy
22d226d4e8 feat(code-runner): 添加语言别名归一化与 bun 运行器注册
新增 normalize_lang 别名表,把读者/作者习惯的语言简写归一到 canonical key:
- js / javascript → node
- rs              → rust
- ts / typescript → bun

归一化在 markdown 渲染期(parse_fence_info)即完成,使 data-lang、
ExecRequest.language、ExecResult.language、LANGUAGES.get 全程只见到
canonical 名(python/node/go/rust/bun),避免前端用别名调 StartExec 时
查表落空。execute.rs 的 start_exec / start_exec_stream 在 spawn 前显式
normalize_lang,保证 ExecResult.language 回显 canonical。

同时注册 bun 语言(canonical):镜像 yggdrasil-runner-bun,run_cmd
'bun run /code/main.ts',默认资源与 node 对齐(256MB/5s)。Dockerfile
与 build-runners.sh 跟进。

测试覆盖三处协同契约:languages.rs 的 normalize_lang 别名表、
execute.rs 的 validate 接受别名、markdown.rs 的 data-lang 归一化。
2026-07-21 15:21:58 +08:00
11 changed files with 301 additions and 23 deletions

View File

@ -117,7 +117,7 @@ docker buildx build --platform linux/amd64 --load -t localhost/yggdrasil:latest
- 首次约 15-30 分钟Rosetta 下 cargo 全量编译),有 buildkit 缓存后分钟级 - 首次约 15-30 分钟Rosetta 下 cargo 全量编译),有 buildkit 缓存后分钟级
- 产物 `localhost/yggdrasil:latest`scratch 运行时层约 16MB - 产物 `localhost/yggdrasil:latest`scratch 运行时层约 16MB
### 5 个 Code Runner 沙箱镜像 ### 6 个 Code Runner 沙箱镜像
runner Dockerfile `FROM yggdrasil-runner-base:latest`(无 `localhost/` 前缀),必须**先建 base 再建子镜像** runner Dockerfile `FROM yggdrasil-runner-base:latest`(无 `localhost/` 前缀),必须**先建 base 再建子镜像**
@ -127,8 +127,8 @@ docker buildx build --platform linux/amd64 --load \
-t localhost/yggdrasil-runner-base:latest docker/runner-base -t localhost/yggdrasil-runner-base:latest docker/runner-base
# 2. 再 tag 无前缀名,让子镜像 FROM 能解析 # 2. 再 tag 无前缀名,让子镜像 FROM 能解析
docker tag localhost/yggdrasil-runner-base:latest yggdrasil-runner-base:latest docker tag localhost/yggdrasil-runner-base:latest yggdrasil-runner-base:latest
# 3. 4 个子镜像(它们 FROM yggdrasil-runner-base:latest) # 3. 5 个子镜像(它们 FROM yggdrasil-runner-base:latest)
for img in python node go rust; do for img in python node go rust bun; do
docker buildx build --platform linux/amd64 --load \ docker buildx build --platform linux/amd64 --load \
-t localhost/yggdrasil-runner-$img:latest docker/runner-$img -t localhost/yggdrasil-runner-$img:latest docker/runner-$img
docker tag localhost/yggdrasil-runner-$img:latest yggdrasil-runner-$img:latest docker tag localhost/yggdrasil-runner-$img:latest yggdrasil-runner-$img:latest
@ -140,7 +140,7 @@ done
### 构建验证 ### 构建验证
```bash ```bash
for img in yggdrasil yggdrasil-runner-base yggdrasil-runner-python yggdrasil-runner-node yggdrasil-runner-go yggdrasil-runner-rust; do for img in yggdrasil yggdrasil-runner-base yggdrasil-runner-python yggdrasil-runner-node yggdrasil-runner-go yggdrasil-runner-rust yggdrasil-runner-bun; do
docker image inspect localhost/$img:latest --format "$img: {{.Architecture}} manifests={{.Manifests}}" docker image inspect localhost/$img:latest --format "$img: {{.Architecture}} manifests={{.Manifests}}"
done done
# 期望: 每行 amd64 且 manifests=[](单平台,非 manifest list) # 期望: 每行 amd64 且 manifests=[](单平台,非 manifest list)
@ -157,6 +157,7 @@ docker save \
localhost/yggdrasil-runner-node:latest \ localhost/yggdrasil-runner-node:latest \
localhost/yggdrasil-runner-go:latest \ localhost/yggdrasil-runner-go:latest \
localhost/yggdrasil-runner-rust:latest \ localhost/yggdrasil-runner-rust:latest \
localhost/yggdrasil-runner-bun:latest \
-o /tmp/yggdrasil-runners.tar -o /tmp/yggdrasil-runners.tar
gzip -f /tmp/yggdrasil-app.tar /tmp/yggdrasil-runners.tar gzip -f /tmp/yggdrasil-app.tar /tmp/yggdrasil-runners.tar
@ -184,6 +185,7 @@ ssh <host> 'docker tag localhost/yggdrasil-runner-python:latest yggdrasil-runner
ssh <host> 'docker tag localhost/yggdrasil-runner-node:latest yggdrasil-runner-node:latest' ssh <host> 'docker tag localhost/yggdrasil-runner-node:latest yggdrasil-runner-node:latest'
ssh <host> 'docker tag localhost/yggdrasil-runner-go:latest yggdrasil-runner-go:latest' ssh <host> 'docker tag localhost/yggdrasil-runner-go:latest yggdrasil-runner-go:latest'
ssh <host> 'docker tag localhost/yggdrasil-runner-rust:latest yggdrasil-runner-rust:latest' ssh <host> 'docker tag localhost/yggdrasil-runner-rust:latest yggdrasil-runner-rust:latest'
ssh <host> 'docker tag localhost/yggdrasil-runner-bun:latest yggdrasil-runner-bun:latest'
``` ```
验证短名可解析: 验证短名可解析:

View File

@ -181,14 +181,14 @@ src/infra/ — Docker execution layer + runner config (server-only); see
Readers can execute fenced code blocks in isolated Docker containers; authors get a `/admin/runner` trial sandbox. Three-layer architecture, all Docker interaction gated behind `#[cfg(feature = "server")]`: Readers can execute fenced code blocks in isolated Docker containers; authors get a `/admin/runner` trial sandbox. Three-layer architecture, all Docker interaction gated behind `#[cfg(feature = "server")]`:
- **Execution layer** (`src/infra/docker.rs`, server-only): `bollard` client over Unix socket (`DOCKER_CLIENT` LazyLock). `run_in_container` creates a read-only-rootfs container (tmpfs `/code`,`/tmp`,`/run`; cpu/memory/pids/ulimits cap; `cap_drop=ALL`; `no-new-privileges`; non-root `1000:1000`; `network_mode` none/bridge), injects source via stdin, waits with timeout, captures stdout/stderr (truncated to `output_bytes`), inspects OOM, force-removes via `ContainerGuard` Drop. `src/infra/runner_config.rs` (`RUNNER_CONFIG`) reads all `CODE_RUNNER_*` env vars; `clamp_limits` AND-merges request overrides × language `allow_network` × global switch. - **Execution layer** (`src/infra/docker.rs`, server-only): `bollard` client over Unix socket (`DOCKER_CLIENT` LazyLock). `run_in_container` creates a read-only-rootfs container (tmpfs `/code`,`/tmp`,`/run`; cpu/memory/pids/ulimits cap; `cap_drop=ALL`; `no-new-privileges`; non-root `1000:1000`; `network_mode` none/bridge), injects source via stdin, waits with timeout, captures stdout/stderr (truncated to `output_bytes`), inspects OOM, force-removes via `ContainerGuard` Drop. `src/infra/runner_config.rs` (`RUNNER_CONFIG`) reads all `CODE_RUNNER_*` env vars; `clamp_limits` AND-merges request overrides × language `allow_network` × global switch.
- **API layer** (`src/api/code_runner/`): shared `ExecRequest`/`ExecResult`/`ExecStatus`/`ExecTask` structs (compile on both targets); `progress.rs` = DashMap task registry + `gc_old_tasks`; `languages.rs` = `LANGUAGES` registry + `parse_fence_info`; `execute.rs` = `StartExec`/`GetExecResult` server functions (double rate-limit → whitelist → size check → enqueue → `tokio::spawn` + `RUNNER_SEMAPHORE` concurrency cap → clamp → run_in_container). System errors are sanitized to 「系统暂时不可用」 for anonymous callers; full errors in server logs only. - **API layer** (`src/api/code_runner/`): shared `ExecRequest`/`ExecResult`/`ExecStatus`/`ExecTask` structs (compile on both targets); `progress.rs` = DashMap task registry + `gc_old_tasks`; `languages.rs` = `LANGUAGES` registry + `parse_fence_info` + `normalize_lang` (alias normalization: `js`/`javascript``node`, `rs``rust`, `ts`/`typescript``bun` — done in `parse_fence_info` so `data-lang`/`ExecRequest.language`/`LANGUAGES.get` only ever see canonical keys `python`/`node`/`go`/`rust`/`bun`); `execute.rs` = `StartExec`/`GetExecResult` server functions (double rate-limit → whitelist → size check → enqueue → `tokio::spawn` + `RUNNER_SEMAPHORE` concurrency cap → clamp → run_in_container). `start_exec`/`start_exec_stream` call `normalize_lang(&req.language)` before `LANGUAGES.get` so aliases survive the wire. System errors are sanitized to 「系统暂时不可用」 for anonymous callers; full errors in server logs only.
- **Markdown/render layer**: a fenced block ` ```python runnable {...overrides} ` renders to `<pre data-runnable="true" data-lang="python" data-overrides="..." data-source="...">` (sanitizer whitelists these 4 attrs on `<pre>`). `PostContent` (`src/components/post/post_content.rs`) splits `content_html` into `Html`/`Runnable` fragments so each runnable block renders as a real `<CodeRunner>` vdom element (no manual DOM mutation → no hydration conflict). `CodeRunner` component polls `GetExecResult` via WASM-friendly `sleep_ms`. Output is rendered into an xterm.js terminal instance (`src/xterm_bridge.rs` + `libs/xterm-terminal`, mirroring the codemirror bridge pattern) — stdout/stderr streamed via SSE, with full-buffer `writeAll` as a polling fallback. - **Markdown/render layer**: a fenced block ` ```python runnable {...overrides} ` renders to `<pre data-runnable="true" data-lang="python" data-overrides="..." data-source="...">` (sanitizer whitelists these 4 attrs on `<pre>`). `PostContent` (`src/components/post/post_content.rs`) splits `content_html` into `Html`/`Runnable` fragments so each runnable block renders as a real `<CodeRunner>` vdom element (no manual DOM mutation → no hydration conflict). `CodeRunner` component polls `GetExecResult` via WASM-friendly `sleep_ms`. Output is rendered into an xterm.js terminal instance (`src/xterm_bridge.rs` + `libs/xterm-terminal`, mirroring the codemirror bridge pattern) — stdout/stderr streamed via SSE, with full-buffer `writeAll` as a polling fallback.
**Critical WASM-visibility rule**: `code_runner/execute.rs` is **not** cfg-gated (server functions must be visible to the client), but every server-only `use`/static inside it is individually `#[cfg(feature = "server")]`. The shared `use` of `ExecRequest`/`ExecTask` (used in signatures) stays ungated. `code_runner/languages.rs` and `code_runner/progress.rs` (pure server helpers) are wholly gated. Mirrors the `posts/` module convention. **Critical WASM-visibility rule**: `code_runner/execute.rs` is **not** cfg-gated (server functions must be visible to the client), but every server-only `use`/static inside it is individually `#[cfg(feature = "server")]`. The shared `use` of `ExecRequest`/`ExecTask` (used in signatures) stays ungated. `code_runner/languages.rs` and `code_runner/progress.rs` (pure server helpers) are wholly gated. Mirrors the `posts/` module convention.
**Governor 0.8 caveat**: `Quota::per_day` does not exist; `CODE_EXEC_DAILY_LIMITER` uses `Quota::with_period(24h).allow_burst(daily)`. `RATE_LIMIT_CODE_EXEC_PER_SEC` must be an integer (governor's `per_second` takes `NonZeroU32`; decimals in `.env` fall back to the default 1). **Governor 0.8 caveat**: `Quota::per_day` does not exist; `CODE_EXEC_DAILY_LIMITER` uses `Quota::with_period(24h).allow_burst(daily)`. `RATE_LIMIT_CODE_EXEC_PER_SEC` must be an integer (governor's `per_second` takes `NonZeroU32`; decimals in `.env` fall back to the default 1).
**Runner images** (`docker/`): `build-runners.sh` builds `yggdrasil-runner-base``yggdrasil-runner-python``yggdrasil-runner-node``yggdrasil-runner-go``yggdrasil-runner-rust`; tags must match `LANGUAGES` image fields. Python image symlinks `python``python3` to match `run_cmd`. Go image redirects `GOCACHE`/`GOTMPDIR`/`GOPATH` to `/tmp` (read-only rootfs makes `$HOME/.cache` unwritable). Rust image ships a `/usr/local/bin/run-rust.sh` wrapper because `rustc` compile + run is two steps and `docker.rs` injects `run_cmd` via `exec``exec A && B` would replace the shell and never reach `B`, so the two steps are encapsulated in the wrapper. `runner.toml` files are image self-descriptions only — runtime config is the Rust `LANGUAGES` registry + `CODE_RUNNER_*` env, not parsed from toml. **Runner images** (`docker/`): `build-runners.sh` builds `yggdrasil-runner-base``yggdrasil-runner-python``yggdrasil-runner-node``yggdrasil-runner-go``yggdrasil-runner-rust``yggdrasil-runner-bun`; tags must match `LANGUAGES` image fields. Python image symlinks `python``python3` to match `run_cmd`. Go image redirects `GOCACHE`/`GOTMPDIR`/`GOPATH` to `/tmp` (read-only rootfs makes `$HOME/.cache` unwritable). Rust image ships a `/usr/local/bin/run-rust.sh` wrapper because `rustc` compile + run is two steps and `docker.rs` injects `run_cmd` via `exec``exec A && B` would replace the shell and never reach `B`, so the two steps are encapsulated in the wrapper. Bun image uses the official `bun.sh/install` script (bun isn't in Alpine 3.18 repos; the script auto-detects `/etc/alpine-release` and picks the musl variant) and must `apk add libstdc++ libgcc` — bun's musl binary is C++ and needs the C++ runtime that musl libc alone doesn't provide; `BUN_INSTALL=/opt/bun` is `export`-ed so the pipe `curl | bash` reads it on both sides (a bare `VAR=x curl | bash` only sets it for curl, making bash fall back to `$HOME/.bun`). `runner.toml` files are image self-descriptions only — runtime config is the Rust `LANGUAGES` registry + `CODE_RUNNER_*` env, not parsed from toml.
## Frontend Lib Subprojects ## Frontend Lib Subprojects

View File

@ -1,13 +1,14 @@
#!/bin/sh #!/bin/sh
# 构建代码运行器所需的沙箱镜像base → python / node / go / rust # 构建代码运行器所需的沙箱镜像base → python / node / go / rust / bun
# #
# 依赖:本机已安装 docker。构建顺序固定python/node/go/rust 均为 FROM base # 依赖:本机已安装 docker。构建顺序固定python/node/go/rust/bun 均为 FROM base
# 镜像 tag 与 src/api/code_runner/languages.rs 的注册项严格对应: # 镜像 tag 与 src/api/code_runner/languages.rs 的注册项严格对应:
# yggdrasil-runner-base:latest # yggdrasil-runner-base:latest
# yggdrasil-runner-python:latest # yggdrasil-runner-python:latest
# yggdrasil-runner-node:latest # yggdrasil-runner-node:latest
# yggdrasil-runner-go:latest # yggdrasil-runner-go:latest
# yggdrasil-runner-rust:latest # yggdrasil-runner-rust:latest
# yggdrasil-runner-bun:latest
set -e set -e
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
@ -27,6 +28,9 @@ docker build -t yggdrasil-runner-go:latest "$SCRIPT_DIR/runner-go"
echo "==> Building yggdrasil-runner-rust:latest" echo "==> Building yggdrasil-runner-rust:latest"
docker build -t yggdrasil-runner-rust:latest "$SCRIPT_DIR/runner-rust" docker build -t yggdrasil-runner-rust:latest "$SCRIPT_DIR/runner-rust"
echo "==> Building yggdrasil-runner-bun:latest"
docker build -t yggdrasil-runner-bun:latest "$SCRIPT_DIR/runner-bun"
echo "==> Done. Images:" echo "==> Done. Images:"
docker images --filter "reference=yggdrasil-runner-*" \ docker images --filter "reference=yggdrasil-runner-*" \
--format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}"

View File

@ -0,0 +1,25 @@
# Bun 运行沙箱镜像。
# 与 src/api/code_runner/languages.rs 的 bun 注册项对齐:
# image: yggdrasil-runner-bun:latest
# run_cmd: bun run /code/main.ts
#
# bun 在 Alpine 3.18 的官方仓库里没有,用 bun.sh 官方安装脚本(多架构、
# 自动检测 /etc/alpine-release 选 musl 变体。bun 的 musl 二进制是 C++ 写的,
# 运行时依赖 libstdc++ + libgccmusl 只带 libc不带 C++ 运行时),必须保留。
# 安装期另需 unzip + bash + curl安装后卸载 unzip/curl 减小镜像。
FROM yggdrasil-runner-base:latest
# libstdc++ / libgcc 是 bun 运行时依赖不能删curl/unzip/bash 仅供安装期。
# BUN_INSTALL 用 export 对管道两侧生效——「VAR=x cmd | bash」只给左侧设变量
# bash 侧读不到会 fallback 到 $HOME/.bun。指定到 /opt/bun 避免依赖 $HOME
# runner 用户 $HOME 可能不可写或不存在)。
RUN apk add --no-cache curl unzip bash libstdc++ libgcc \
&& export BUN_INSTALL=/opt/bun \
&& curl -fsSL https://bun.sh/install | bash \
&& ln -s /opt/bun/bin/bun /usr/local/bin/bun \
&& apk del curl unzip \
&& bun --version
COPY runner.toml /runner.toml
USER runner

View File

@ -0,0 +1,15 @@
# Bun runner 镜像的运行时默认值(参考用)。
# 实际生效配置由 Rust 侧 LANGUAGES 注册表 + CODE_RUNNER_* 环境变量决定(见
# src/api/code_runner/languages.rs、src/infra/runner_config.rs本文件仅作为
# 镜像构建产物的自描述,便于运维核对镜像与代码的契约一致。
language = "bun"
extension = "ts"
run_command = "bun run /code/main.ts"
allow_network = false
[default_limits]
cpu_cores = 1.0
memory_mb = 256
timeout_secs = 5
output_bytes = 1048576
allow_network = false

View File

@ -107,9 +107,14 @@ export class EditorOptions {
/** /**
* CodeMirror Extension * CodeMirror Extension
* - `python` @codemirror/lang-python * - `python` @codemirror/lang-python
* - `node` / `javascript` / `js` @codemirror/lang-javascript * - `node` / `javascript` / `js` @codemirror/lang-javascriptJS
* - `bun` / `typescript` / `ts` @codemirror/lang-javascriptTypeScript
* - `sql` / @codemirror/lang-sqlPostgreSQL + schema * - `sql` / @codemirror/lang-sqlPostgreSQL + schema
* *
* src/api/code_runner/languages.rs::normalize_langCodeRunner
* canonical keynode/bun/python/go/rust
* CodeMirror SQL
*
* SQL 使 schema SQL schema SQL * SQL 使 schema SQL schema SQL
*/ */
function buildLanguageExtension(lang: string, schema: SqlSchema): Extension { function buildLanguageExtension(lang: string, schema: SqlSchema): Extension {
@ -120,6 +125,10 @@ function buildLanguageExtension(lang: string, schema: SqlSchema): Extension {
if (normalized === 'node' || normalized === 'javascript' || normalized === 'js') { if (normalized === 'node' || normalized === 'javascript' || normalized === 'js') {
return javascript(); return javascript();
} }
if (normalized === 'bun' || normalized === 'typescript' || normalized === 'ts') {
// bun 跑 TypeScript编辑器用 TS 模式获得类型感知高亮(接口/类型注解等)。
return javascript({ typescript: true });
}
// sql / 缺省:保留原有 PostgreSQL + schema 补全行为 // sql / 缺省:保留原有 PostgreSQL + schema 补全行为
return sql({ return sql({
dialect: PostgreSQL, dialect: PostgreSQL,

View File

@ -26,7 +26,7 @@ use crate::api::code_runner::{ExecResult, ExecStatus};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user; use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::code_runner::languages::{is_supported_lang, LANGUAGES}; use crate::api::code_runner::languages::{is_supported_lang, normalize_lang, LANGUAGES};
#[cfg(feature = "server")] #[cfg(feature = "server")]
use crate::api::code_runner::progress::{ use crate::api::code_runner::progress::{
gc_old_tasks, insert_task, update_task_result, update_task_stage, StreamEntry, EXEC_STREAMS, gc_old_tasks, insert_task, update_task_result, update_task_stage, StreamEntry, EXEC_STREAMS,
@ -115,7 +115,9 @@ pub async fn start_exec(req: ExecRequest) -> Result<String, ServerFnError> {
// 后台执行:信号量限并发 → clamp_limits → run_in_container // 后台执行:信号量限并发 → clamp_limits → run_in_container
let task_id_clone = task_id.clone(); let task_id_clone = task_id.clone();
let lang_key = req.language.clone(); // 归一化为 canonical keyjs→node / ts→bun / rs→rust确保 LANGUAGES.get 命中、
// ExecResult.language 回显 canonical与 markdown 渲染期的 data-lang 一致)。
let lang_key = normalize_lang(&req.language);
tokio::spawn(async move { tokio::spawn(async move {
let sem = &*RUNNER_SEMAPHORE; let sem = &*RUNNER_SEMAPHORE;
@ -243,7 +245,8 @@ pub async fn start_exec_stream(req: ExecRequest) -> Result<String, ServerFnError
gc_old_tasks(); gc_old_tasks();
let task_id_clone = task_id.clone(); let task_id_clone = task_id.clone();
let lang_key = req.language.clone(); // 同 start_exec归一化别名LANGUAGES.get 用 canonical key。
let lang_key = normalize_lang(&req.language);
tokio::spawn(async move { tokio::spawn(async move {
let sem = &*RUNNER_SEMAPHORE; let sem = &*RUNNER_SEMAPHORE;
@ -361,8 +364,8 @@ mod tests {
#[test] #[test]
fn validate_accepts_registered_language() { fn validate_accepts_registered_language() {
// python/node/go/rust 默认注册,应放行。 // python/node/go/rust/bun 默认注册,应放行。
for lang in ["python", "node", "go", "rust"] { for lang in ["python", "node", "go", "rust", "bun"] {
assert!( assert!(
validate_exec_request(&req(lang, "x")).is_ok(), validate_exec_request(&req(lang, "x")).is_ok(),
"{lang} 应被支持" "{lang} 应被支持"
@ -370,6 +373,19 @@ mod tests {
} }
} }
#[test]
fn validate_accepts_aliases() {
// 别名经 is_supported_lang 内的 normalize_lang 归一后命中注册表,应放行。
// 锁定该契约:前端 CodeRunner 可能带着别名(如历史渲染的 data-lang="js"
// 调 StartExec校验链不能因别名拒绝。
for lang in ["js", "javascript", "rs", "ts", "typescript"] {
assert!(
validate_exec_request(&req(lang, "x")).is_ok(),
"别名 {lang} 应被支持"
);
}
}
#[test] #[test]
fn validate_language_is_case_and_whitespace_insensitive() { fn validate_language_is_case_and_whitespace_insensitive() {
// is_supported_lang 内部 trim+lowercase校验链应透传该容忍。 // is_supported_lang 内部 trim+lowercase校验链应透传该容忍。

View File

@ -6,6 +6,12 @@
//! //!
//! 实际可用语言默认即注册表里的全部;若设置了 `CODE_RUNNER_LANGUAGES` //! 实际可用语言默认即注册表里的全部;若设置了 `CODE_RUNNER_LANGUAGES`
//! 环境变量,则进一步收窄到该白名单内([`is_supported_lang`])。 //! 环境变量,则进一步收窄到该白名单内([`is_supported_lang`])。
//!
//! **别名归一化**[`normalize_lang`] 把 `js`/`javascript` 归一为 `node`、
//! `rs` 归一为 `rust`、`ts`/`typescript` 归一为 `bun`。归一化在 [`parse_fence_info`]
//! 与 [`is_supported_lang`] 内完成markdown 渲染期就把别名换成 canonical key
//! 下游 `data-lang` / `ExecRequest.language` / `LANGUAGES.get` 只见到 canonical
//! 名(`python`/`node`/`go`/`rust`/`bun`),不再出现别名,避免查表落空。
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::LazyLock; use std::sync::LazyLock;
@ -107,32 +113,86 @@ pub static LANGUAGES: LazyLock<HashMap<String, LanguageDef>> = LazyLock::new(||
}, },
); );
// bun原生 TypeScript 运行器(无需 tsc 预编译)。`ts`/`typescript` 别名
// 归一到 `bun`(见 LANG_ALIASES。bun 镜像用官方安装脚本装在 alpine 上,
// 单步 `bun run /code/main.ts` 直接执行,无需 wrapper。
// 默认资源与 node 对齐JS 运行时开销相近256MB/5s 足够。
m.insert(
"bun".to_string(),
LanguageDef {
image: "yggdrasil-runner-bun:latest".to_string(),
run_cmd: "bun run /code/main.ts".to_string(),
extension: "ts".to_string(),
default_limits: ResourceLimits {
cpu_cores: 1.0,
memory_mb: 256,
timeout_secs: 5,
output_bytes: 1_048_576,
allow_network: false,
},
allow_network: false,
},
);
m m
}); });
/// 是否支持该语言:必须在 LANGUAGES 注册表中存在。 /// 语言别名 → canonical key 映射。
/// 若设置了 `CODE_RUNNER_LANGUAGES`,还需同时在该白名单内(用于收窄可用语言); ///
/// 未设置则注册表里的语言全部放行。 /// canonical key`python`/`node`/`go`/`rust`/`bun`)本身不在表里——它们原样
pub fn is_supported_lang(lang: &str) -> bool { /// 通过。仅收录读者/作者习惯的简写:`js`/`javascript`→`node`、`rs`→`rust`、
/// `ts`/`typescript`→`bun`。比较大小写不敏感(见 [`normalize_lang`])。
const LANG_ALIASES: &[(&str, &str)] = &[
("js", "node"),
("javascript", "node"),
("rs", "rust"),
("ts", "bun"),
("typescript", "bun"),
];
/// 把语言标识归一化为 `LANGUAGES` 注册表里的 canonical key。
///
/// 步骤trim → lowercase → 查 [`LANG_ALIASES`],命中返回映射值,未命中返回
/// trim+lowercase 后的原值(保证 canonical 名与未注册字符串都能被调用方处理)。
///
/// 归一化是 [`is_supported_lang`] / [`parse_fence_info`] 的前置步骤,也是
/// `execute.rs` 在 `LANGUAGES.get` 前必须调用的——否则用别名查表会落空。
pub fn normalize_lang(lang: &str) -> String {
let clean = lang.trim().to_lowercase(); let clean = lang.trim().to_lowercase();
LANGUAGES.contains_key(&clean) for &(from, to) in LANG_ALIASES {
if clean == from {
return to.to_string();
}
}
clean
}
/// 是否支持该语言:先 [`normalize_lang`] 归一化,再查 `LANGUAGES` 注册表。
/// 若设置了 `CODE_RUNNER_LANGUAGES`,还需同时在该白名单内(用于收窄可用语言,
/// 白名单用 canonical key 比较——运维写 `node` 而非 `js`);未设置则全部放行。
pub fn is_supported_lang(lang: &str) -> bool {
let canonical = normalize_lang(lang);
LANGUAGES.contains_key(&canonical)
&& RUNNER_CONFIG && RUNNER_CONFIG
.languages .languages
.as_ref() .as_ref()
.is_none_or(|list| list.iter().any(|l| l == &clean)) .is_none_or(|list| list.iter().any(|l| l == &canonical))
} }
/// 解析围栏代码块的 info string。 /// 解析围栏代码块的 info string。
/// ///
/// 格式:`<lang> [runnable|run] [ {<ResourceLimits JSON>} ]` /// 格式:`<lang> [runnable|run] [ {<ResourceLimits JSON>} ]`
/// ///
/// 返回 `(lang, runnable, overrides)`。未知 token 静默忽略JSON 解析失败时 overrides 为 None。 /// 返回 `(lang, runnable, overrides)`。`lang` 已经过 [`normalize_lang`] 归一化
/// `js`→`node`、`ts`→`bun` 等),下游 markdown 渲染 / ExecRequest.language /
/// `LANGUAGES.get` 都拿到 canonical key。未知 token 静默忽略JSON 解析失败时
/// overrides 为 None。
pub fn parse_fence_info(info: &str) -> (String, bool, Option<ResourceLimits>) { pub fn parse_fence_info(info: &str) -> (String, bool, Option<ResourceLimits>) {
let tokens: Vec<&str> = info.split_whitespace().collect(); let tokens: Vec<&str> = info.split_whitespace().collect();
if tokens.is_empty() { if tokens.is_empty() {
return ("".to_string(), false, None); return ("".to_string(), false, None);
} }
let lang = tokens[0].trim().to_lowercase(); let lang = normalize_lang(tokens[0]);
let mut runnable = false; let mut runnable = false;
let mut overrides = None; let mut overrides = None;
@ -229,4 +289,87 @@ mod tests {
assert!(is_supported_lang(" Python ")); assert!(is_supported_lang(" Python "));
assert!(is_supported_lang("NODE")); assert!(is_supported_lang("NODE"));
} }
// ---- 别名归一化normalize_lang / parse_fence_info / is_supported_lang 三处协同)----
#[test]
fn normalize_lang_canonical_passthrough() {
// canonical key 原样通过(仅 trim+lowercase
assert_eq!(normalize_lang("python"), "python");
assert_eq!(normalize_lang("node"), "node");
assert_eq!(normalize_lang("go"), "go");
assert_eq!(normalize_lang("rust"), "rust");
assert_eq!(normalize_lang("bun"), "bun");
}
#[test]
fn normalize_lang_aliases() {
// 别名 → canonical。
assert_eq!(normalize_lang("js"), "node");
assert_eq!(normalize_lang("javascript"), "node");
assert_eq!(normalize_lang("rs"), "rust");
assert_eq!(normalize_lang("ts"), "bun");
assert_eq!(normalize_lang("typescript"), "bun");
}
#[test]
fn normalize_lang_case_and_whitespace_insensitive() {
// 输入容忍:大小写、首尾空白。匹配 is_supported_lang 的既有契约。
assert_eq!(normalize_lang(" JS "), "node");
assert_eq!(normalize_lang("JavaScript"), "node");
assert_eq!(normalize_lang("\tRS\t"), "rust");
assert_eq!(normalize_lang("TypeScript"), "bun");
}
#[test]
fn normalize_lang_unregistered_passthrough() {
// 未在别名表、未在注册表的语言原样返回trim+lowercase 后),
// 由调用方决定是否拒绝。normalize_lang 不做存在性判断。
assert_eq!(normalize_lang("ruby"), "ruby");
assert_eq!(normalize_lang("Brainfuck"), "brainfuck");
assert_eq!(normalize_lang(""), "");
}
#[test]
fn is_supported_lang_accepts_aliases() {
// 别名经 normalize_lang 归一后命中注册表,应放行。
assert!(is_supported_lang("js"));
assert!(is_supported_lang("javascript"));
assert!(is_supported_lang("rs"));
assert!(is_supported_lang("ts"));
assert!(is_supported_lang("typescript"));
}
#[test]
fn is_supported_lang_accepts_bun_canonical() {
// bun 作为新增 canonical key默认放行。
assert!(is_supported_lang("bun"));
}
#[test]
fn parse_fence_info_normalizes_alias_to_canonical() {
// 关键契约parse_fence_info 返回的 lang 是 canonical key
// 下游 markdown.rs 的 data-lang / class="language-{lang}" 直接用此值。
let (lang, runnable, _) = parse_fence_info("js runnable");
assert_eq!(lang, "node");
assert!(runnable);
let (lang, _, _) = parse_fence_info("typescript runnable");
assert_eq!(lang, "bun");
let (lang, _, _) = parse_fence_info("rs runnable");
assert_eq!(lang, "rust");
// 大小写不敏感。
let (lang, _, _) = parse_fence_info("JavaScript runnable");
assert_eq!(lang, "node");
}
#[test]
fn parse_fence_info_bun_canonical_runs_as_ts() {
// bun 自身是 canonicalrunnable 块以 bun 执行。
let (lang, runnable, _) = parse_fence_info("bun runnable");
assert_eq!(lang, "bun");
assert!(runnable);
}
} }

View File

@ -950,6 +950,47 @@ console.log(1)
); );
} }
#[test]
fn render_markdown_runnable_block_alias_normalized_to_canonical() {
// 关键契约parse_fence_info 在 markdown 渲染期就把别名归一为 canonical key
// 故 `js runnable` 渲染出的 data-lang 是 "node"(而非 "js")。
// 这保证阅读器 CodeRunner 拿到的 language 是 canonicalStartExec 的
// LANGUAGES.get 能直接命中,无需前端再做别名映射。
let cases = [
("js", "node"),
("javascript", "node"),
("rs", "rust"),
("ts", "bun"),
("typescript", "bun"),
// 大小写不敏感。
("JavaScript", "node"),
("TypeScript", "bun"),
];
for (alias, canonical) in cases {
let src = format!("```{alias} runnable\nconsole.log(1)\n```");
let result = render_markdown_enhanced(&src);
assert!(
result.html.contains(&format!(r#"data-lang="{canonical}""#)),
"别名 {alias} 应归一为 data-lang={canonical}, got: {}",
result.html
);
// 不应残留原始别名作为 data-lang。
let bad = format!(r#"data-lang="{alias}""#);
assert!(
!result.html.contains(&bad),
"data-lang 不应保留别名 {alias}, got: {}",
result.html
);
}
}
#[test]
fn render_markdown_runnable_block_bun_canonical() {
// bun 自身是 canonical不是别名runnable 块以 bun 执行。
let result = render_markdown_enhanced("```bun runnable\nconsole.log('hi')\n```");
assert!(result.html.contains(r#"data-lang="bun""#), "got: {}", result.html);
}
#[test] #[test]
fn render_markdown_runnable_marker_on_unsupported_lang_ignored() { fn render_markdown_runnable_marker_on_unsupported_lang_ignored() {
// 语言不在白名单(rust 已在白名单,改用 ruby)runnable 标记被忽略,输出普通代码块。 // 语言不在白名单(rust 已在白名单,改用 ruby)runnable 标记被忽略,输出普通代码块。

View File

@ -85,6 +85,8 @@ pub mod server {
("js", "js"), ("js", "js"),
("javascript", "js"), ("javascript", "js"),
("typescript", "ts"), ("typescript", "ts"),
// bun 运行器跑的是 TypeScript归一化后用 ts 语法高亮。
("bun", "ts"),
("py", "py"), ("py", "py"),
("python", "py"), ("python", "py"),
("rb", "rb"), ("rb", "rb"),
@ -486,4 +488,21 @@ const message = ref('Hello Vue!')
// 大写标识经别名表 eq_ignore_ascii_case 回退,输出须与小写一致。 // 大写标识经别名表 eq_ignore_ascii_case 回退,输出须与小写一致。
assert_eq!(by_alias, by_upper); assert_eq!(by_alias, by_upper);
} }
#[test]
fn highlight_code_resolves_bun_alias_to_typescript() {
// bun 运行器跑 TypeScript别名表把 "bun" 归一为 "ts" 扩展名。
// 用类型注解TS 特有语法)验证命中的是 TypeScript 语法而非纯 JS。
let code = "const x: number = 1;";
let result = highlight_code(code, Some("bun"));
// 不应回退纯文本(纯文本无 <span> 高亮 span
assert!(
result.contains("<span"),
"bun 别名应触发语法高亮, got: {}",
result
);
// 与直接传 ts 的输出一致——别名表正确归一。
let by_ts = highlight_code(code, Some("ts"));
assert_eq!(result, by_ts);
}
} }

View File

@ -13,7 +13,9 @@ use crate::components::ui::{ADMIN_CARD_CLASS, BTN_PRIMARY_SM};
use crate::infra::runner_config::ResourceLimits; use crate::infra::runner_config::ResourceLimits;
/// 受支持的语言集合(与 LANGUAGES 注册表 / CODE_RUNNER_LANGUAGES 对齐)。 /// 受支持的语言集合(与 LANGUAGES 注册表 / CODE_RUNNER_LANGUAGES 对齐)。
const SUPPORTED_LANGS: &[&str] = &["python", "node", "go", "rust"]; /// 仅列 canonical key别名js/ts/rs 等)经 normalize_lang 归一到此处某项,
/// 按钮不重复展示别名,避免选择拥挤。
const SUPPORTED_LANGS: &[&str] = &["python", "node", "go", "rust", "bun"];
/// 默认示例源码(按语言)。 /// 默认示例源码(按语言)。
fn default_source(lang: &str) -> String { fn default_source(lang: &str) -> String {
@ -22,6 +24,8 @@ fn default_source(lang: &str) -> String {
"node" => "console.log('Hello from author sandbox');\n[0,1,2].forEach(i => console.log(`line ${i}`));\n".to_string(), "node" => "console.log('Hello from author sandbox');\n[0,1,2].forEach(i => console.log(`line ${i}`));\n".to_string(),
"go" => "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello from author sandbox\")\n\tfor i := 0; i < 3; i++ {\n\t\tfmt.Printf(\"line %d\\n\", i)\n\t}\n}\n".to_string(), "go" => "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"Hello from author sandbox\")\n\tfor i := 0; i < 3; i++ {\n\t\tfmt.Printf(\"line %d\\n\", i)\n\t}\n}\n".to_string(),
"rust" => "fn main() {\n println!(\"Hello from author sandbox\");\n for i in 0..3 {\n println!(\"line {}\", i);\n }\n}\n".to_string(), "rust" => "fn main() {\n println!(\"Hello from author sandbox\");\n for i in 0..3 {\n println!(\"line {}\", i);\n }\n}\n".to_string(),
// bun 跑 TypeScript示例用 TS 类型注解体现语言特性。
"bun" => "const greeting: string = 'Hello from author sandbox';\nconsole.log(greeting);\n[0, 1, 2].forEach((i: number) => console.log(`line ${i}`));\n".to_string(),
_ => String::new(), _ => String::new(),
} }
} }