diff --git a/.codebuddy/skills/pagx/SKILL.md b/.codebuddy/skills/pagx/SKILL.md index 5cc0c9b9a8..89a09e17bc 100644 --- a/.codebuddy/skills/pagx/SKILL.md +++ b/.codebuddy/skills/pagx/SKILL.md @@ -46,7 +46,7 @@ relevant reference: Before running any `pagx` command, ensure it is installed and meets the minimum version: ```bash -PAGX_MIN="0.4.33" +PAGX_MIN="0.4.39" if ! command -v pagx &>/dev/null; then npm install -g @libpag/pagx elif [ "$(printf '%s\n' "$PAGX_MIN" "$(pagx -v | awk '{print $2}')" | sort -V | head -1)" != "$PAGX_MIN" ]; then diff --git a/cli/npm/bin/pagx.js b/cli/npm/bin/pagx.js index 762066340a..e08492bf17 100644 --- a/cli/npm/bin/pagx.js +++ b/cli/npm/bin/pagx.js @@ -68,6 +68,42 @@ if (!process.env.PAGX_HTML_SNAPSHOT_BIN) { } } +// Intercept the preview sub-command and delegate to pagx-preview (a Node.js-based +// HTTP server + MCP service). The preview module lives under preview/ at the package +// root so it can be bundled and published alongside the native binary. +if (process.argv[2] === 'preview') { + const previewEntry = path.join(__dirname, '..', 'preview', 'src', 'cli.js'); + if (!fs.existsSync(previewEntry)) { + console.error('pagx: preview command not available. Preview module not found.'); + process.exit(1); + } + const { spawn } = require('child_process'); + const child = spawn('node', [previewEntry, ...process.argv.slice(3)], { stdio: 'inherit' }); + child.on('exit', (code) => process.exit(code != null ? code : 1)); + return; +} + +// Inject the preview sub-command into the native binary's --help output. The native binary is +// unaware of preview (it lives in the Node.js wrapper), so we run its help, then splice a preview +// line under the "Commands:" header. Any future native command changes flow through untouched; +// only the preview line is maintained here. +if ((process.argv[2] === '--help' || process.argv[2] === '-h') && process.argv.length === 3) { + const { spawnSync } = require('child_process'); + const result = spawnSync(binPath, ['--help'], { encoding: 'utf8' }); + const lines = result.stdout.split('\n'); + const output = lines + .map((line) => { + if (line.trim() === 'Commands:') { + return line + '\n preview Preview a PAGX file in the browser with live reload'; + } + return line; + }) + .join('\n'); + process.stdout.write(output); + if (result.stderr) process.stderr.write(result.stderr); + process.exit(result.status || 0); +} + try { execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" }); } catch (e) { diff --git a/cli/npm/package.json b/cli/npm/package.json index 4d7bba3d1e..0fb40d305b 100644 --- a/cli/npm/package.json +++ b/cli/npm/package.json @@ -1,6 +1,6 @@ { "name": "@libpag/pagx", - "version": "0.4.33", + "version": "0.4.39", "description": "PAGX command-line tool for validation, rendering, optimization and formatting", "license": "Apache-2.0", "keywords": [ @@ -42,9 +42,15 @@ "x64", "arm64" ], + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "chokidar": "^3.6.0", + "express": "^4.21.1" + }, "files": [ "bin/", "html-snapshot/", + "preview/", "README.md" ] } diff --git a/playground/pagx-preview/.gitignore b/playground/pagx-preview/.gitignore new file mode 100644 index 0000000000..b005297daf --- /dev/null +++ b/playground/pagx-preview/.gitignore @@ -0,0 +1,14 @@ +# Dependencies +node_modules/ + +# Logs +*.log + +# Packing output +*.tgz + +# Generated/copied build artifacts: pagx-viewer wasm+glue, pagx-player bundle, MCP ext bundle, +# and the MCP widget bundle. Kept in a single directory outside static/ (mirrors pagx-playground's +# wasm-mt/ layout) and never committed, so the source tree stays free of heavy binaries. These are +# regenerated on demand via `npm run build` and on publish via the prepack hook. +wasm/ diff --git a/playground/pagx-preview/.npmignore b/playground/pagx-preview/.npmignore new file mode 100644 index 0000000000..52898d1b73 --- /dev/null +++ b/playground/pagx-preview/.npmignore @@ -0,0 +1,8 @@ +# Never ship source maps or wasm debug symbols in the published package — they only bloat the +# tarball (the player map alone is 2MB+). This is a safety net on top of prebuild.js, which +# already skips copying maps on release builds. +*.map +*.wasm.symbols + +# Local packing output. +*.tgz diff --git a/playground/pagx-preview/README.md b/playground/pagx-preview/README.md new file mode 100644 index 0000000000..c1ca0fa7de --- /dev/null +++ b/playground/pagx-preview/README.md @@ -0,0 +1,269 @@ +# PAGX Preview + +Local live-reload preview server for rendering and debugging [PAGX](https://pag.io/pagx/latest/) animation files. Supports automatic refresh on file changes and MCP integration for AI coding assistants. + +It is exposed as the `pagx preview` subcommand of [`@libpag/pagx`](https://www.npmjs.com/package/@libpag/pagx). End users invoke it as `pagx preview` through the `@libpag/pagx` CLI; this package can also run standalone as `pagx-preview`, a form used for local development and maintainer testing (see [Development](#development)). + +## Features + +- One-command startup; the browser opens the preview automatically +- Automatic file-change detection and live re-rendering (pushed over SSE) +- Persistent background daemon; multiple files share one server +- Drag and drop a `.pagx` file into the browser window to preview it +- Runs as an MCP server for AI coding assistants (CodeBuddy / Claude Desktop / VS Code Copilot) +- Automatic font download (NotoSansSC + NotoColorEmoji) + +## Requirements + +- Node.js ≥ 16.7 +- A built `pagx-viewer` (multi-threaded or single-threaded variant) + +## Quick Start + +```bash +# Install the main CLI globally (includes the preview subcommand) +npm install -g @libpag/pagx + +# Preview a file +pagx preview /path/to/animation.pagx + +# Preview another file (reuses the running server) +pagx preview /path/to/other.pagx + +# Stop the background server +pagx preview stop +``` + +## CLI Options + +| Option | Description | +|--------|-------------| +| `--port ` | Bind to a specific port (default: system-assigned) | +| `--host ` | Bind host (default: 127.0.0.1) | +| `--fonts ` | Override the fonts directory | +| `--no-open` | Do not open the browser automatically | +| `--foreground` | Run in the foreground (do not detach) | +| `--mcp` | Run as an MCP stdio server (see the MCP section below) | +| `--log` | Print the server log | +| `stop` | Stop the background server | + +## MCP Server Mode + +`pagx preview` can run as an MCP (Model Context Protocol) server, letting AI coding assistants preview `.pagx` files directly in conversation. + +### How it works + +When started with `--mcp`, the process communicates over stdin/stdout using the MCP protocol and automatically starts a local HTTP server for WASM rendering. The user does not need to start any service manually — the MCP client manages the process lifecycle. + +### Tools + +| Tool | Description | +|------|-------------| +| `preview_pagx` | **Default preview.** Loads the file and returns a session URL to open in an IDE webview panel or a browser; does not render an inline widget. | +| `preview_pagx_widget` | **Inline widget preview.** Renders the animation directly in the conversation (a small in-chat window). Use only when the user explicitly asks for an inline / small-window preview. | +| `reload_file` | Force a reload of the file from disk (file changes auto-reload; this is a manual trigger). | +| `get_document` | Return document information (dimensions, duration, etc.). | + +`preview_pagx` is the default tool because inline widget rendering is unreliable across desktop hosts (see [Known compatibility issues](#known-compatibility-issues)). Only `preview_pagx_widget` carries the MCP Apps UI resource (`ui://pagx-preview/main`) that lets a supporting host mount the inline iframe; `preview_pagx` deliberately omits it and therefore never triggers the problematic widget. + +### Platform deployment + +(Note: the MCP configuration differs between development/testing and the published form — see Development > Build and test a tarball locally, step 4.) + +#### CodeBuddy IDE + +Add to `~/.codebuddy/mcp.json`: + +```json +{ + "mcpServers": { + "pagx-preview": { + "command": "pagx", + "args": ["preview", "--mcp"] + } + } +} +``` + +#### Claude Desktop + +macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "pagx-preview": { + "command": "pagx", + "args": ["preview", "--mcp"] + } + } +} +``` + +#### VS Code GitHub Copilot + +Create `.vscode/mcp.json` in your project: + +```json +{ + "servers": { + "pagx-preview": { + "type": "http", + "url": "http://127.0.0.1:/mcp" + } + } +} +``` + +Note: Copilot uses HTTP transport; start the server manually first (`pagx preview --port `). + +### Known compatibility issues + +The inline widget (`preview_pagx_widget`) has been verified in the official [ext-apps basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) reference host, but inline MCP Apps support varies across desktop hosts: + +| Host | Inline widget status | +|------|------| +| Claude Desktop | The widget iframe may not be mounted by the host (known host-side bug) | +| CodeBuddy IDE | MCP Apps inline widgets are not yet supported | +| VS Code Copilot | Opens in Simple Browser (editor tab), not an inline widget | + +Therefore `preview_pagx` (open in an IDE webview panel / browser) is the default tool and `preview_pagx_widget` is optional. In all cases the session URL (`http://127.0.0.1:/session//`) provides a fully functional preview with live reload. + +## Development + +> The commands in this section target the **standalone** `pagx-preview` command (run via `npm link` or `node src/cli.js`), which is how this package is developed and tested in isolation. End users reach the same tool as `pagx preview` through the `@libpag/pagx` CLI. + +### First-time setup + +```bash +cd ../pagx-preview +npm install +npm run build # builds pagx-viewer (single-threaded) + pagx-player and stages artifacts +``` + +`npm run build` chains all steps: it first builds the upstream `pagx-viewer` (single-threaded `st` variant) and `pagx-player`, then copies the artifacts into `static/`. + +Build commands: + +| Command | Viewer variant | Use case | +| --- | --- | --- | +| `npm run build` | single-threaded (st) | Default. Works everywhere — MCP widget **and** browser preview. | +| `npm run build:release` | single-threaded (st), release | Same as above, optimized build. | +| `npm run build:mt` | multi-threaded (mt) | Browser preview only; faster rendering. | +| `npm run build:release:mt` | multi-threaded (mt), release | Same, optimized build. | + +The single-threaded (`st`) variant is the default: the MCP widget runs in a sandbox iframe without cross-origin isolation, where the `SharedArrayBuffer` that the multi-threaded build depends on is unavailable. The `mt` variant renders faster but only works in the plain browser preview (`pagx-preview file.pagx`, where the server sends the required COOP/COEP headers); inside an MCP host the widget falls back to opening the browser URL. + +If the upstream artifacts are already built (or the cloned repo ships them), run `npm run prebuild` to only copy them without rebuilding. `prebuild` automatically: +- Detects the viewer build variant (MT/ST) and copies wasm + glue to `static/viewer/` +- Copies the pagx-player ESM bundle to `static/player/` +- Copies the ext-apps SDK bundle to `static/ext/` +- Bundles the MCP widget with esbuild to `static/mcp-widget.bundle.js` + +### Run from source + +```bash +node src/cli.js /path/to/file.pagx # background +node src/cli.js --foreground /path/to/file.pagx # foreground +node src/cli.js --mcp # MCP mode +``` + +### Test with a global link + +```bash +npm link +pagx-preview /path/to/file.pagx +npm unlink -g @libpag/pagx-preview +``` + +### How end users get the package + +End users do not build or pack anything. Once published, the compiled artifacts (`static/viewer/*.wasm`, `static/player/*.js`, etc.) ship with the npm tarball, so a plain install pulls a ready-to-run command — exactly like the `@libpag/pagx` CLI: + +```bash +npm install -g @libpag/pagx-preview # downloads prebuilt artifacts; no compilation needed +``` + +Building (`npm run build`) is the maintainer step that regenerates the artifacts before a release. The local tarball test below is only for simulating a real install before the package is published to the npm registry. + +### Build and test a tarball locally + +Reproduces the full experience a user gets from `npm install`, using a local tarball. + +```bash +# 1. Build artifacts (release) and pack the tarball +cd ../pagx-preview +npm run build:release +npm pack --dry-run # inspect the file list that will be published +npm pack # writes libpag-pagx-preview-.tgz + +# 2. Install the tarball globally to simulate a published state +npm unlink -g @libpag/pagx-preview # remove any existing dev link first +npm install -g ./libpag-pagx-preview-.tgz + +# 3. Verify the CLI +pagx-preview --help +pagx-preview /path/to/file.pagx # opens a browser preview + +# 4. Verify the MCP server +# Note: this section targets the **standalone** pagx-preview command, whose MCP json differs +# from the end-user (published) form: +# - standalone dev/test: { "command": "pagx-preview", "args": ["--mcp"] } +# - end user (@libpag/pagx): { "command": "pagx", "args": ["preview", "--mcp"] } (see "Platform deployment" above) +# Point your MCP client (CodeBuddy / Claude Desktop) at the installed standalone command: +# { "mcpServers": { "pagx-preview": { "command": "pagx-preview", "args": ["--mcp"] } } } +# Then ask the assistant to preview a .pagx file. Or smoke-test stdio startup directly: +pagx-preview --mcp < /dev/null # should start, print nothing to stdout, and wait + +# 5. Cleanup +npm uninstall -g @libpag/pagx-preview +rm libpag-pagx-preview-.tgz +rm -rf ~/.pagx # clears cached fonts, log, and process lock (fresh-user state) +``` + +### Publish to npm + +> Distribution: this tool ships with `@libpag/pagx` as the `pagx preview` subcommand, so end users do not need to install this package separately. + +## Directory Structure + +``` +pagx-preview/ +├── src/ +│ ├── cli.js # CLI entry, argument parsing +│ ├── daemon.js # background process management, stdio MCP startup +│ ├── server/ +│ │ ├── index.js # Express HTTP server (SSE, static assets, MCP mount) +│ │ ├── session.js # file-watching session (chokidar) +│ │ ├── fonts.js # font resolution +│ │ ├── font-cache.js # lazy font download +│ │ └── lock.js # process lock +│ └── mcp/ +│ ├── server.js # MCP server construction (stdio + HTTP) +│ └── tools.js # MCP tools + resource definitions +├── static/ +│ ├── index.js / index.html / index.css # browser client +│ ├── viewer/ # pagx-viewer WASM + glue (generated by prebuild) +│ ├── player/ # pagx-player ESM bundle (generated by prebuild) +│ ├── ext/ # ext-apps SDK bundle (generated by prebuild) +│ ├── icons/ # playback control icons +│ ├── mcp-widget.js # MCP Apps widget source +│ ├── mcp-widget.html # MCP Apps widget HTML shell +│ └── mcp-widget.bundle.js # bundled widget (generated by prebuild) +├── scripts/ +│ ├── prebuild.js # artifact copy + bundle build +│ └── check-artifacts.js # pre-publish check +└── package.json +``` + +## Fonts + +On first run, NotoSansSC-Regular.otf + NotoColorEmoji.ttf are downloaded automatically to `~/.pagx/fonts/`. +Priority: `--fonts` argument > `PAGX_FONTS_DIR` environment variable > `~/.pagx/fonts/` > `resources/font/` in the libpag repo. + +Set `PAGX_FONTS_NO_AUTO_DOWNLOAD=1` to disable the automatic download. + +## License + +Apache-2.0 diff --git a/playground/pagx-preview/README.zh_CN.md b/playground/pagx-preview/README.zh_CN.md new file mode 100644 index 0000000000..3bfbd4657a --- /dev/null +++ b/playground/pagx-preview/README.zh_CN.md @@ -0,0 +1,282 @@ +# PAGX Preview + +本地实时预览服务器,用于 [PAGX](https://pag.io/pagx/latest/) 动画文件的渲染和调试。支持文件变更自动刷新、MCP 协议接入 AI 编码助手。 + +它作为 [`@libpag/pagx`](https://www.npmjs.com/package/@libpag/pagx) 的 `pagx preview` 子命令对外提供。终端用户通过 `@libpag/pagx` CLI 以 `pagx preview` 调用;本包也可作为独立命令 `pagx-preview` 运行,该形式用于本地开发和维护者测试(见[开发](#开发))。 + +## 功能 + +- 一行命令启动,浏览器自动打开预览 +- 文件修改自动检测、实时重新渲染(SSE 推送) +- 后台常驻守护进程,多文件复用同一服务 +- 支持拖放 `.pagx` 文件到浏览器窗口预览 +- 作为 MCP Server 接入 AI 编码助手(CodeBuddy / Claude Desktop / VS Code Copilot) +- 字体自动下载(NotoSansSC + NotoColorEmoji) + +## 依赖 + +- Node.js >= 16.7 +- 已构建的 `pagx-viewer`(多线程或单线程版本) + +## 快速开始 + +```bash +# 全局安装主 CLI(内置 preview 子命令) +npm install -g @libpag/pagx + +# 预览文件 +pagx preview /path/to/animation.pagx + +# 预览另一个文件(复用已有服务) +pagx preview /path/to/other.pagx + +# 停止后台服务 +pagx preview stop +``` + +## CLI 参数 + +| 参数 | 说明 | +|------|------| +| `--port ` | 指定端口(默认:系统分配) | +| `--host ` | 绑定地址(默认:127.0.0.1) | +| `--fonts ` | 指定字体目录 | +| `--no-open` | 不自动打开浏览器 | +| `--foreground` | 前台运行(不后台化) | +| `--mcp` | 作为 MCP stdio 服务器运行(见下方 MCP 章节) | +| `--log` | 查看服务日志 | +| `stop` | 停止后台服务 | + +## MCP Server 模式 + +`pagx preview` 可作为 MCP (Model Context Protocol) 服务器运行,让 AI 编码助手在对话中直接预览 `.pagx` 文件。 + +### 工作原理 + +使用 `--mcp` 启动时,进程通过 stdin/stdout 通信 MCP 协议,同时自动启动本地 HTTP 服务器用于 WASM 渲染。用户无需手动启动任何服务——MCP 客户端管理进程生命周期。 + +### 提供的工具 + +| 工具 | 说明 | +|------|------| +| `preview_pagx` | **默认预览。** 加载文件并返回 session URL,供在 IDE webview 面板或浏览器中打开;不渲染内联 widget。 | +| `preview_pagx_widget` | **内联小窗预览。** 直接在对话中渲染动画(对话内小窗)。仅当用户明确要求内联 /「小窗」预览时使用。 | +| `reload_file` | 强制从磁盘重新加载文件(文件变更会自动重载,这是手动触发)。 | +| `get_document` | 获取文档信息(尺寸、时长等)。 | + +`preview_pagx` 作为默认工具,因为内联 widget 在各桌面端宿主中的渲染并不可靠(见 +[已知兼容性问题](#已知兼容性问题))。只有 `preview_pagx_widget` 才携带 MCP Apps UI 资源 +(`ui://pagx-preview/main`),让支持的宿主挂载内联 iframe;`preview_pagx` 刻意不带它,因此永远 +不会触发有问题的 widget。 + +### 各平台配置 + +(请注意 开发调试和发布版本的mcp配置有所区别,详情请看 开发-本地打包测试-4) + +#### CodeBuddy IDE + +在 `~/.codebuddy/mcp.json` 中添加: + +```json +{ + "mcpServers": { + "pagx-preview": { + "command": "pagx", + "args": ["preview", "--mcp"] + } + } +} +``` + +#### Claude Desktop + +macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "pagx-preview": { + "command": "pagx", + "args": ["preview", "--mcp"] + } + } +} +``` + +#### VS Code GitHub Copilot + +在项目中创建 `.vscode/mcp.json`: + +```json +{ + "servers": { + "pagx-preview": { + "type": "http", + "url": "http://127.0.0.1:<端口>/mcp" + } + } +} +``` + +注意:Copilot 使用 HTTP 传输,需先手动启动服务(`pagx preview --port <端口> <文件>`)。 + +### 已知兼容性问题 + +内联 widget(`preview_pagx_widget`)已在官方 [ext-apps basic-host](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) 参考宿主中验证通过,但各桌面端宿主对内联 MCP Apps 的支持情况不一: + +| 宿主 | 内联 widget 状态 | +|------|------| +| Claude Desktop | widget iframe 可能不被宿主挂载(已知 host 侧 bug) | +| CodeBuddy IDE | 暂不支持 MCP Apps 内联 widget | +| VS Code Copilot | 在 Simple Browser(编辑器标签)中打开,而非内联 widget | + +因此 `preview_pagx`(在 IDE webview 面板 / 浏览器中打开)为默认工具,`preview_pagx_widget` 为 +可选。所有情况下,session URL(`http://127.0.0.1:<端口>/session//`)都能提供带实时重载的完整 +预览。 + +## 开发 + +> 本节命令针对**独立**的 `pagx-preview` 命令(通过 `npm link` 或 `node src/cli.js` 运行),这是本包 +> 单独开发和测试的方式。终端用户则通过 `@libpag/pagx` CLI 以 `pagx preview` 使用同一工具。 + +### 首次设置 + +```bash +cd ../pagx-preview +npm install +npm run build # 一键构建 pagx-viewer(单线程) + pagx-player,并拷贝产物 +``` + +`npm run build` 会串联完成全部步骤:先构建上游的 `pagx-viewer`(单线程 `st` 变体)和 +`pagx-player`,再把产物拷贝到 `static/`。 + +构建命令: + +| 命令 | viewer 变体 | 适用场景 | +| --- | --- | --- | +| `npm run build` | 单线程 (st) | 默认。全场景可用——MCP widget **和** 浏览器预览。 | +| `npm run build:release` | 单线程 (st),release | 同上,优化构建。 | +| `npm run build:mt` | 多线程 (mt) | 仅浏览器预览;渲染更快。 | +| `npm run build:release:mt` | 多线程 (mt),release | 同上,优化构建。 | + +默认用单线程(`st`)变体:MCP widget 运行在没有跨源隔离的沙箱 iframe 中,多线程版依赖的 +`SharedArrayBuffer` 在那里不可用。`mt` 变体渲染更快,但只在纯浏览器预览(`pagx-preview +file.pagx`,服务器会发送所需的 COOP/COEP 头)下可用;在 MCP 宿主中 widget 会回退到打开浏览器 +URL。 + +如果上游产物已经构建好(或克隆的仓库已自带产物),可以只运行 `npm run prebuild` 仅拷贝而不 +重新构建。`prebuild` 会自动: +- 检测 viewer 构建变体(MT/ST),复制 wasm + glue 到 `static/viewer/` +- 复制 pagx-player ESM bundle 到 `static/player/` +- 复制 ext-apps SDK bundle 到 `static/ext/` +- 用 esbuild 打包 MCP widget bundle 到 `static/mcp-widget.bundle.js` + +### 从源码运行 + +```bash +node src/cli.js /path/to/file.pagx # 后台 +node src/cli.js --foreground /path/to/file.pagx # 前台 +node src/cli.js --mcp # MCP 模式 +``` + +### 全局链接测试 + +```bash +npm link +pagx-preview /path/to/file.pagx +npm unlink -g @libpag/pagx-preview +``` + +### 用户如何获取 + +用户无需构建或打包。包发布后,编译产物(`static/viewer/*.wasm`、`static/player/*.js` 等)已经 +随 npm tarball 一起发出,直接安装即可拿到开箱即用的命令——和 `@libpag/pagx` CLI 一样: + +```bash +npm install -g @libpag/pagx-preview # 下载预编译产物,无需编译 +``` + +构建(`npm run build`)是维护者在发布前重新生成产物的步骤。下面的本地打包测试仅在包尚未发布到 +npm registry 前用于模拟真实安装。 + +### 本地打包测试 + +用本地 tarball 复现用户从 `npm install` 得到的完整效果。 + +```bash +# 1. 构建产物(发布版)并打包 tarball +cd ../pagx-preview +npm run build:release +npm pack --dry-run # 检查将要发布的文件列表 +npm pack # 生成 libpag-pagx-preview-.tgz + +# 2. 全局安装 tarball,模拟已发布状态 +npm unlink -g @libpag/pagx-preview # 先移除已有的开发链接 +npm install -g ./libpag-pagx-preview-.tgz + +# 3. 验证 CLI +pagx-preview --help +pagx-preview /path/to/file.pagx # 打开浏览器预览 + +# 4. 验证 MCP 服务 +# 注意:本节针对的是**独立** pagx-preview 命令,其 MCP json 与终端用户(发布形态)不同: +# - 独立开发/测试: { "command": "pagx-preview", "args": ["--mcp"] } +# - 终端用户(@libpag/pagx): { "command": "pagx", "args": ["preview", "--mcp"] }(见上文「各平台配置」) +# 在 MCP 客户端(CodeBuddy / Claude Desktop)中指向已安装的独立命令: +# { "mcpServers": { "pagx-preview": { "command": "pagx-preview", "args": ["--mcp"] } } } +# 然后让助手预览一个 .pagx 文件。也可直接冒烟测试 stdio 启动: +pagx-preview --mcp < /dev/null # 应正常启动、stdout 无输出并挂起等待 + +# 5. 清理 +npm uninstall -g @libpag/pagx-preview +rm libpag-pagx-preview-.tgz +rm -rf ~/.pagx # 清除缓存的字体、日志和进程锁(还原为全新用户状态) +``` + +### 发布到 npm + + +> 当前分发方式:本工具随 `@libpag/pagx` 以 `pagx preview` 子命令提供,终端用户无需单独安装本包。 + +## 目录结构 + +``` +pagx-preview/ +├── src/ +│ ├── cli.js # CLI 入口,参数解析 +│ ├── daemon.js # 后台进程管理、stdio MCP 启动 +│ ├── server/ +│ │ ├── index.js # Express HTTP 服务器(SSE、静态资源、MCP 挂载) +│ │ ├── session.js # 文件监听 session(chokidar) +│ │ ├── fonts.js # 字体查找 +│ │ ├── font-cache.js # 字体懒下载 +│ │ └── lock.js # 进程锁 +│ └── mcp/ +│ ├── server.js # MCP Server 构建(stdio + HTTP) +│ └── tools.js # MCP 工具 + 资源定义 +├── static/ +│ ├── index.js / index.html / index.css # 浏览器客户端 +│ ├── viewer/ # pagx-viewer WASM + glue(prebuild 生成) +│ ├── player/ # pagx-player ESM bundle(prebuild 生成) +│ ├── ext/ # ext-apps SDK bundle(prebuild 生成) +│ ├── icons/ # 播放控制图标 +│ ├── mcp-widget.js # MCP Apps widget 源码 +│ ├── mcp-widget.html # MCP Apps widget HTML 壳 +│ └── mcp-widget.bundle.js # 打包后的 widget(prebuild 生成) +├── scripts/ +│ ├── prebuild.js # 产物复制 + bundle 构建 +│ └── check-artifacts.js # 发布前检查 +└── package.json +``` + +## 字体 + +首次运行时自动下载 NotoSansSC-Regular.otf + NotoColorEmoji.ttf 到 `~/.pagx/fonts/`。 +优先级:`--fonts` 参数 > `PAGX_FONTS_DIR` 环境变量 > `~/.pagx/fonts/` > libpag 仓库内 `resources/font/`。 + +设置 `PAGX_FONTS_NO_AUTO_DOWNLOAD=1` 禁用自动下载。 + +## 许可证 + +Apache-2.0 diff --git a/playground/pagx-preview/package-lock.json b/playground/pagx-preview/package-lock.json new file mode 100644 index 0000000000..344fea03b1 --- /dev/null +++ b/playground/pagx-preview/package-lock.json @@ -0,0 +1,1765 @@ +{ + "name": "@libpag/pagx-preview", + "version": "0.1.0-alpha.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@libpag/pagx-preview", + "version": "0.1.0-alpha.1", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "chokidar": "^3.6.0", + "express": "^4.21.1" + }, + "bin": { + "pagx-preview": "src/cli.js" + }, + "devDependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.0" + }, + "engines": { + "node": ">=16.7.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://mirrors.tencent.com/npm/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.7.4", + "resolved": "https://mirrors.tencent.com/npm/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.4.tgz", + "integrity": "sha512-QQqysE549cf/Y0VabBmAACXhj92EhB3t8yVct2BHbkWiPTFA1S91EqTVjYXXcZEefXU0pmHcdObhsNMcomJIOQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://mirrors.tencent.com/npm/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://mirrors.tencent.com/npm/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://mirrors.tencent.com/npm/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://mirrors.tencent.com/npm/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://mirrors.tencent.com/npm/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://mirrors.tencent.com/npm/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://mirrors.tencent.com/npm/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://mirrors.tencent.com/npm/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://mirrors.tencent.com/npm/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://mirrors.tencent.com/npm/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://mirrors.tencent.com/npm/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://mirrors.tencent.com/npm/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://mirrors.tencent.com/npm/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://mirrors.tencent.com/npm/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://mirrors.tencent.com/npm/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://mirrors.tencent.com/npm/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://mirrors.tencent.com/npm/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://mirrors.tencent.com/npm/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://mirrors.tencent.com/npm/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://mirrors.tencent.com/npm/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://mirrors.tencent.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://mirrors.tencent.com/npm/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://mirrors.tencent.com/npm/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://mirrors.tencent.com/npm/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://mirrors.tencent.com/npm/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://mirrors.tencent.com/npm/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://mirrors.tencent.com/npm/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://mirrors.tencent.com/npm/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://mirrors.tencent.com/npm/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://mirrors.tencent.com/npm/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://mirrors.tencent.com/npm/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://mirrors.tencent.com/npm/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://mirrors.tencent.com/npm/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://mirrors.tencent.com/npm/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://mirrors.tencent.com/npm/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://mirrors.tencent.com/npm/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://mirrors.tencent.com/npm/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://mirrors.tencent.com/npm/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://mirrors.tencent.com/npm/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://mirrors.tencent.com/npm/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express-rate-limit/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://mirrors.tencent.com/npm/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://mirrors.tencent.com/npm/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://mirrors.tencent.com/npm/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://mirrors.tencent.com/npm/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://mirrors.tencent.com/npm/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://mirrors.tencent.com/npm/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://mirrors.tencent.com/npm/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://mirrors.tencent.com/npm/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://mirrors.tencent.com/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://mirrors.tencent.com/npm/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://mirrors.tencent.com/npm/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://mirrors.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://mirrors.tencent.com/npm/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.31", + "resolved": "https://mirrors.tencent.com/npm/hono/-/hono-4.12.31.tgz", + "integrity": "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://mirrors.tencent.com/npm/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://mirrors.tencent.com/npm/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://mirrors.tencent.com/npm/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://mirrors.tencent.com/npm/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://mirrors.tencent.com/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://mirrors.tencent.com/npm/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://mirrors.tencent.com/npm/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://mirrors.tencent.com/npm/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://mirrors.tencent.com/npm/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://mirrors.tencent.com/npm/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "node_modules/jose": { + "version": "6.2.4", + "resolved": "https://mirrors.tencent.com/npm/jose/-/jose-6.2.4.tgz", + "integrity": "sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://mirrors.tencent.com/npm/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://mirrors.tencent.com/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://mirrors.tencent.com/npm/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://mirrors.tencent.com/npm/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://mirrors.tencent.com/npm/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://mirrors.tencent.com/npm/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://mirrors.tencent.com/npm/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://mirrors.tencent.com/npm/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://mirrors.tencent.com/npm/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://mirrors.tencent.com/npm/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://mirrors.tencent.com/npm/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://mirrors.tencent.com/npm/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://mirrors.tencent.com/npm/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://mirrors.tencent.com/npm/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://mirrors.tencent.com/npm/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://mirrors.tencent.com/npm/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://mirrors.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://mirrors.tencent.com/npm/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://mirrors.tencent.com/npm/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://mirrors.tencent.com/npm/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://mirrors.tencent.com/npm/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://mirrors.tencent.com/npm/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://mirrors.tencent.com/npm/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://mirrors.tencent.com/npm/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://mirrors.tencent.com/npm/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://mirrors.tencent.com/npm/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://mirrors.tencent.com/npm/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://mirrors.tencent.com/npm/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://mirrors.tencent.com/npm/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://mirrors.tencent.com/npm/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://mirrors.tencent.com/npm/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://mirrors.tencent.com/npm/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://mirrors.tencent.com/npm/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://mirrors.tencent.com/npm/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://mirrors.tencent.com/npm/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://mirrors.tencent.com/npm/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://mirrors.tencent.com/npm/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://mirrors.tencent.com/npm/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://mirrors.tencent.com/npm/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/playground/pagx-preview/package.json b/playground/pagx-preview/package.json new file mode 100644 index 0000000000..b33013d973 --- /dev/null +++ b/playground/pagx-preview/package.json @@ -0,0 +1,59 @@ +{ + "name": "@libpag/pagx-preview", + "version": "0.1.0-alpha.1", + "description": "Local PAGX preview server with live reload. Run `pagx-preview file.pagx` to view a PAGX in the browser with automatic refresh on file changes.", + "license": "Apache-2.0", + "author": "Tencent", + "keywords": [ + "pagx", + "pag", + "libpag", + "preview", + "live-reload", + "animation" + ], + "repository": { + "type": "git", + "url": "https://github.com/Tencent/libpag", + "directory": "playground/pagx-preview" + }, + "bugs": { + "url": "https://github.com/Tencent/libpag/issues" + }, + "homepage": "https://github.com/Tencent/libpag#readme", + "type": "module", + "engines": { + "node": ">=16.7.0" + }, + "bin": { + "pagx-preview": "src/cli.js" + }, + "files": [ + "src/", + "static/", + "wasm/", + "scripts/check-artifacts.js", + "README.md" + ], + "scripts": { + "build": "node scripts/prebuild.js --build", + "build:release": "node scripts/prebuild.js --build --release", + "build:mt": "node scripts/prebuild.js --build --mt", + "build:release:mt": "node scripts/prebuild.js --build --release --mt", + "prebuild": "node scripts/prebuild.js", + "preview": "node src/cli.js", + "prepack": "node scripts/prebuild.js --build --release && node scripts/check-artifacts.js", + "prepublishOnly": "node scripts/check-artifacts.js" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.0.0", + "chokidar": "^3.6.0", + "express": "^4.21.1" + }, + "devDependencies": { + "@modelcontextprotocol/ext-apps": "^1.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/playground/pagx-preview/scripts/check-artifacts.js b/playground/pagx-preview/scripts/check-artifacts.js new file mode 100644 index 0000000000..4daf581eb3 --- /dev/null +++ b/playground/pagx-preview/scripts/check-artifacts.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +// Guard for `npm publish` / `npm pack`: verifies that prebuild has staged the pagx-viewer +// artifacts (into wasm/) and the source-side static assets, otherwise the published tarball +// would be broken. Mirrors the intent of cli/npm/scripts/check-binaries.js. + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const PKG_DIR = path.resolve(__dirname, '..'); + +const REQUIRED = [ + 'static/index.html', + 'static/index.css', + 'static/index.js', + 'static/mcp-widget.html', + 'static/mcp-widget.js', + 'wasm/viewer/info.json', + 'wasm/player/pagx-player.esm.js', + 'static/icons/play.png', + 'static/icons/pause.png', + 'static/icons/previous.png', + 'static/icons/next.png', +]; + +function main() { + if (process.env.PAGX_PREVIEW_SKIP_ARTIFACT_CHECK === '1') { + process.stderr.write('check-artifacts: skipped (PAGX_PREVIEW_SKIP_ARTIFACT_CHECK=1)\n'); + return; + } + + const missing = REQUIRED.filter((rel) => !fs.existsSync(path.join(PKG_DIR, rel))); + if (missing.length > 0) { + process.stderr.write('check-artifacts: ERROR: missing required artifacts:\n'); + for (const rel of missing) process.stderr.write(` ${rel}\n`); + process.stderr.write( + 'check-artifacts: run `npm run prebuild` from the pagx-preview directory ' + + 'and make sure pagx-viewer has been built first.\n' + ); + process.exit(1); + } + + // Extra: the wasm/glue files referenced by info.json must exist too. + const infoPath = path.join(PKG_DIR, 'wasm/viewer/info.json'); + const info = JSON.parse(fs.readFileSync(infoPath, 'utf8')); + for (const rel of [info.wasmFile, info.glueFile]) { + const abs = path.join(PKG_DIR, 'wasm/viewer', rel); + if (!fs.existsSync(abs)) { + process.stderr.write( + `check-artifacts: ERROR: info.json references ${rel} but the file is missing.\n` + ); + process.exit(1); + } + } + + process.stderr.write('check-artifacts: all required artifacts present.\n'); +} + +main(); diff --git a/playground/pagx-preview/scripts/prebuild.js b/playground/pagx-preview/scripts/prebuild.js new file mode 100644 index 0000000000..f55660be1b --- /dev/null +++ b/playground/pagx-preview/scripts/prebuild.js @@ -0,0 +1,289 @@ +#!/usr/bin/env node +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +// Detects which pagx-viewer build is available (multi-threaded or single-threaded), copies its +// artifacts into wasm/viewer/, and writes info.json so both the server (COOP/COEP headers) +// and the client (dynamic import path) know which variant is in use. Also copies the compiled +// pagx-player esm bundle into wasm/player/ so the client can import it without going through +// a bundler; pagx-player is workspace-local and has no npm publish target, so the preview owns +// the file copy the same way it owns the pagx-viewer copy. +// +// All generated/copied artifacts live under a single gitignored wasm/ directory (mirroring +// pagx-playground's wasm-mt/ layout) so the source-only static/ dir stays clean and no heavy +// binaries are committed. They are regenerated on demand via `npm run build` and on publish via +// the prepack hook. + +import fs from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PREVIEW_DIR = path.dirname(__dirname); +const VIEWER_DIR = path.resolve(PREVIEW_DIR, '../pagx-viewer'); +const PLAYER_DIR = path.resolve(PREVIEW_DIR, '../pagx-player'); +const VIEWER_LIB_DIR = path.join(VIEWER_DIR, 'lib'); +const PLAYER_LIB_DIR = path.join(PLAYER_DIR, 'lib'); +// Single gitignored directory holding every compiled/copied artifact (viewer, player, ext, and +// the MCP widget bundle). Kept out of static/ so the source tree stays free of build outputs. +const GENERATED_DIR = path.join(PREVIEW_DIR, 'wasm'); +const OUTPUT_DIR = path.join(GENERATED_DIR, 'viewer'); +const PLAYER_OUTPUT_DIR = path.join(GENERATED_DIR, 'player'); + +// (infix, humanLabel, isMultiThreaded). Order matters: MT is preferred when both are present +// so the preview matches pagx-playground's default flavor. +const VARIANTS = [ + { infix: '', label: 'multi-threaded', multiThreaded: true }, + { infix: '.st', label: 'single-threaded', multiThreaded: false }, +]; + +function detectVariant() { + for (const variant of VARIANTS) { + const wasm = path.join(VIEWER_LIB_DIR, `pagx-viewer${variant.infix}.wasm`); + const glue = path.join(VIEWER_LIB_DIR, `pagx-viewer${variant.infix}.esm.js`); + if (fs.existsSync(wasm) && fs.existsSync(glue)) { + return variant; + } + } + return null; +} + +function cleanOldArtifacts() { + if (!fs.existsSync(OUTPUT_DIR)) return; + for (const entry of fs.readdirSync(OUTPUT_DIR)) { + if (entry.startsWith('pagx-viewer') || entry === 'info.json') { + fs.unlinkSync(path.join(OUTPUT_DIR, entry)); + } + } +} + +function copyPagxPlayerArtifacts({ release } = {}) { + if (!fs.existsSync(PLAYER_LIB_DIR)) { + console.error('\npagx-preview prebuild: ERROR: pagx-player has not been built.'); + console.error(`Looked in: ${PLAYER_LIB_DIR}`); + console.error('Please build pagx-player first:'); + console.error(` cd ${path.dirname(PLAYER_LIB_DIR)} && npm install && npm run build\n`); + process.exit(1); + } + const bundleFile = 'pagx-player.esm.js'; + const src = path.join(PLAYER_LIB_DIR, bundleFile); + if (!fs.existsSync(src)) { + console.error(`\npagx-preview prebuild: ERROR: ${bundleFile} not found in ${PLAYER_LIB_DIR}.`); + console.error('Please rebuild pagx-player:'); + console.error(` cd ${path.dirname(PLAYER_LIB_DIR)} && npm run build\n`); + process.exit(1); + } + if (!fs.existsSync(PLAYER_OUTPUT_DIR)) { + fs.mkdirSync(PLAYER_OUTPUT_DIR, { recursive: true }); + } + // Clean previous copies so an older bundle can't shadow the newly built one. + for (const entry of fs.readdirSync(PLAYER_OUTPUT_DIR)) { + if (entry.startsWith('pagx-player')) { + fs.unlinkSync(path.join(PLAYER_OUTPUT_DIR, entry)); + } + } + fs.copyFileSync(src, path.join(PLAYER_OUTPUT_DIR, bundleFile)); + console.log(` Copied: ${bundleFile}`); + // Copy the source map only for debug builds (local development). The 2MB+ map is useless in a + // published/release package — it would only bloat the tarball — so release skips it. The + // .unlink loop above already removed any stale map from a previous debug run. + if (!release) { + const mapFile = bundleFile + '.map'; + const mapSrc = path.join(PLAYER_LIB_DIR, mapFile); + if (fs.existsSync(mapSrc)) { + fs.copyFileSync(mapSrc, path.join(PLAYER_OUTPUT_DIR, mapFile)); + console.log(` Copied: ${mapFile}`); + } + } +} + +// Copies the @modelcontextprotocol/ext-apps browser bundle (app-with-deps.js) into wasm/ext/ +// so the MCP widget HTML can import it from the same origin without depending on esm.sh. The +// bundle is self-contained (includes all transitive runtime deps) so no further bundling is +// needed. If the package is not installed (e.g. dev environment without MCP support), the copy +// is skipped with a warning — the widget will fail to load but the rest of pagx-preview works. +function copyExtAppsBundle() { + const EXT_OUTPUT_DIR = path.join(GENERATED_DIR, 'ext'); + // Try to locate the ext-apps package via node_modules resolution. The package may be hoisted + // to a parent node_modules in a monorepo / npm workspace setup, so we walk up from the preview + // dir rather than hard-coding a single path. + const candidatePaths = [ + path.join(PREVIEW_DIR, 'node_modules', '@modelcontextprotocol', 'ext-apps'), + path.join(path.dirname(PREVIEW_DIR), 'node_modules', '@modelcontextprotocol', 'ext-apps'), + path.join(path.dirname(path.dirname(PREVIEW_DIR)), 'node_modules', '@modelcontextprotocol', 'ext-apps'), + ]; + const pkgDir = candidatePaths.find((p) => fs.existsSync(path.join(p, 'package.json'))); + if (!pkgDir) { + console.log(' Skipped: @modelcontextprotocol/ext-apps not installed (MCP widget disabled)'); + return; + } + const src = path.join(pkgDir, 'dist', 'src', 'app-with-deps.js'); + if (!fs.existsSync(src)) { + console.log(` Skipped: ext-apps bundle not found at ${src}`); + return; + } + if (!fs.existsSync(EXT_OUTPUT_DIR)) { + fs.mkdirSync(EXT_OUTPUT_DIR, { recursive: true }); + } + fs.copyFileSync(src, path.join(EXT_OUTPUT_DIR, 'app-with-deps.js')); + console.log(' Copied: ext/app-with-deps.js'); +} + +// Builds the upstream pagx-viewer and pagx-player packages in place so a single +// `npm run build` from pagx-preview produces every artifact this script then copies. Without +// --build the script only detects + copies pre-built artifacts (the historical behavior), which +// is what `npm pack` / a clean checkout with committed artifacts relies on. +// +// The viewer defaults to the single-threaded (st) variant because the MCP widget runs inside a +// sandbox iframe with no cross-origin isolation, so SharedArrayBuffer (required by the +// multi-threaded wasm) is unavailable there — st is the only variant that works in every +// consumer. --mt opts into the multi-threaded build, which renders faster but only works in the +// plain browser preview (`pagx-preview file.pagx`), where the server can send COOP/COEP headers; +// in an MCP host the widget then falls back to the browser URL. +function buildUpstreamDependencies({ release, mt }) { + let viewerScript; + if (mt) { + viewerScript = release ? 'build:release' : 'build:debug'; + } else { + viewerScript = release ? 'build:release:st' : 'build:debug:st'; + } + const playerScript = release ? 'build:release' : 'build'; + + if (!fs.existsSync(VIEWER_DIR)) { + console.error(`\npagx-preview prebuild: ERROR: pagx-viewer not found at ${VIEWER_DIR}.`); + process.exit(1); + } + if (!fs.existsSync(PLAYER_DIR)) { + console.error(`\npagx-preview prebuild: ERROR: pagx-player not found at ${PLAYER_DIR}.`); + process.exit(1); + } + + // pagx-viewer's wasm build is guarded by a source-hash cache (.pagx-viewer.wasm.md5) that does + // not distinguish debug from release: after a debug build it will skip recompiling for a + // release run and reuse the ~100MB debug wasm (full DWARF info). Clean the viewer first on a + // release build so the wasm is actually recompiled with -O3 and stripped down to a few MB. + // Debug builds keep the cache to preserve fast incremental rebuilds during development. + if (release) { + console.log('pagx-preview prebuild: cleaning pagx-viewer (release: force wasm recompile)...'); + execSync('npm run clean', { cwd: VIEWER_DIR, stdio: 'inherit' }); + } + + console.log(`pagx-preview prebuild: building pagx-viewer (npm run ${viewerScript})...`); + execSync(`npm run ${viewerScript}`, { cwd: VIEWER_DIR, stdio: 'inherit' }); + + console.log(`pagx-preview prebuild: building pagx-player (npm run ${playerScript})...`); + execSync(`npm run ${playerScript}`, { cwd: PLAYER_DIR, stdio: 'inherit' }); +} + +function main() { + const args = process.argv.slice(2); + const doBuild = args.includes('--build'); + const release = args.includes('--release'); + const mt = args.includes('--mt'); + if (doBuild) { + buildUpstreamDependencies({ release, mt }); + } + + const variant = detectVariant(); + if (!variant) { + console.error('\npagx-preview prebuild: ERROR: no pagx-viewer build found.'); + console.error(`Looked in: ${VIEWER_LIB_DIR}`); + console.error('Build the upstream dependencies automatically:'); + console.error(` cd ${PREVIEW_DIR} && npm run build # debug`); + console.error(` cd ${PREVIEW_DIR} && npm run build:release # release`); + console.error('Or build pagx-viewer manually first (either variant works):'); + console.error(` cd ${VIEWER_DIR} && npm run build:debug # multi-threaded`); + console.error(` cd ${VIEWER_DIR} && npm run build:debug:st # single-threaded\n`); + process.exit(1); + } + + console.log(`pagx-preview prebuild: detected pagx-viewer (${variant.label}) build.`); + + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + cleanOldArtifacts(); + + const files = [`pagx-viewer${variant.infix}.wasm`, `pagx-viewer${variant.infix}.esm.js`]; + for (const file of files) { + fs.copyFileSync(path.join(VIEWER_LIB_DIR, file), path.join(OUTPUT_DIR, file)); + console.log(` Copied: ${file}`); + } + + // Consumed by both the server (to decide whether COOP/COEP headers are required) and the + // client (to dynamically import the correct glue file). + const info = { + variant: variant.multiThreaded ? 'mt' : 'st', + multiThreaded: variant.multiThreaded, + glueFile: `pagx-viewer${variant.infix}.esm.js`, + wasmFile: `pagx-viewer${variant.infix}.wasm`, + }; + fs.writeFileSync( + path.join(OUTPUT_DIR, 'info.json'), + JSON.stringify(info, null, 2) + '\n' + ); + console.log(` Wrote: info.json (${info.variant})`); + + copyPagxPlayerArtifacts({ release }); + copyExtAppsBundle(); + buildMcpWidgetBundle(); + + console.log('pagx-preview prebuild: done.'); +} + +// Builds the MCP widget bundle: a single minified ES module that inlines app-with-deps.js, +// pagx-player.esm.js, and mcp-widget.js. This bundle is base64-encoded and injected into the +// widget HTML at runtime by the readResource handler so the MCP Apps sandbox iframe does not +// need to load external scripts (which many hosts block). +function buildMcpWidgetBundle() { + const widgetSrc = path.join(PREVIEW_DIR, 'static', 'mcp-widget.js'); + if (!fs.existsSync(widgetSrc)) { + console.log(' Skipped: mcp-widget.js not found (MCP widget bundle not built)'); + return; + } + if (!fs.existsSync(GENERATED_DIR)) { + fs.mkdirSync(GENERATED_DIR, { recursive: true }); + } + // Create a temp entry inside GENERATED_DIR with imports rewritten relative to that dir (esbuild + // cannot resolve absolute /wasm/... URLs). The ext/ and player/ artifacts were copied into + // GENERATED_DIR earlier in this run, so './ext/...' / './player/...' resolve correctly. + const entryPath = path.join(GENERATED_DIR, '_mcp_bundle_entry.js'); + let src = fs.readFileSync(widgetSrc, 'utf8'); + src = src + .replace(/from '\/wasm\/ext\/app-with-deps\.js'/g, "from './ext/app-with-deps.js'") + .replace(/from '\/wasm\/player\/pagx-player\.esm\.js'/g, "from './player/pagx-player.esm.js'"); + fs.writeFileSync(entryPath, src); + try { + const outPath = path.join(GENERATED_DIR, 'mcp-widget.bundle.js'); + execSync(`npx esbuild "${entryPath}" --bundle --format=esm --outfile="${outPath}" --minify`, { + cwd: PREVIEW_DIR, + stdio: 'pipe', + }); + console.log(` Built: mcp-widget.bundle.js (${(fs.statSync(outPath).size / 1024).toFixed(0)} KB)`); + } catch (err) { + console.log(` Warning: mcp-widget.bundle.js build failed: ${err.message}`); + console.log(' The MCP widget will fall back to external script loading.'); + } finally { + if (fs.existsSync(entryPath)) fs.unlinkSync(entryPath); + } +} + +main(); diff --git a/playground/pagx-preview/src/cli.js b/playground/pagx-preview/src/cli.js new file mode 100755 index 0000000000..2532be972b --- /dev/null +++ b/playground/pagx-preview/src/cli.js @@ -0,0 +1,325 @@ +#!/usr/bin/env node +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import fs from 'fs'; +import http from 'http'; +import path from 'path'; +import { execFile } from 'child_process'; +import { fileURLToPath } from 'url'; +import { probeLiveServer, clearLock } from './server/lock.js'; +import { runServer, runStdioServer, spawnDaemon, stopDaemon, printLog, LOG_FILE } from './daemon.js'; + +const USAGE = `Usage: + pagx preview Preview a .pagx file (opens in the browser) + pagx preview --mcp Run as an MCP server for AI assistants (e.g. CodeBuddy) + pagx preview stop Stop the running background server + pagx preview --log Print the server log + pagx preview --help Show this help + +Options: + --mcp Run as a stdio MCP server so an AI assistant can open .pagx files and render an + inline preview widget. Add to CodeBuddy mcp.json: + { "mcpServers": { "pagx-preview": { "command": "pagx", "args": ["preview", "--mcp"] } } } + --log Print the server log file (${LOG_FILE}) and exit + -h, --help Show this help + +Commands: + stop Stop the background server (if any) +`; + +function parseArgs(argv) { + const args = { + port: 0, + host: '127.0.0.1', + // Default: open when stdout is a real TTY. Non-TTY invocations (piped stdout, IDE agents, + // CI) almost always want the URL emitted but no browser stealing focus. --no-open forces + // it off explicitly; --json also implies no-open. + open: process.stdout.isTTY === true, + json: false, + file: null, + fonts: null, + foreground: false, + // Run as a stdio MCP server instead of an HTTP preview daemon. Self-bootstrapping entry used + // by MCP clients (CodeBuddy) that spawn `pagx preview --mcp` on demand. + mcp: false, + // Internal-only: instructs cli.js to run the server directly in this process instead of + // spawning a daemon. Used by spawnDaemon() when it re-execs itself. + serverMode: false, + command: null, + }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '-h' || a === '--help') { + args.help = true; + } else if (a === '--no-open') { + args.open = false; + } else if (a === '--json') { + args.json = true; + args.open = false; + } else if (a === '--foreground') { + args.foreground = true; + } else if (a === '--mcp') { + args.mcp = true; + } else if (a === '--__server') { + args.serverMode = true; + } else if (a === '--log') { + args.command = 'log'; + } else if (a === '--port') { + const value = argv[++i]; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0 || parsed > 65535) { + throw new Error(`invalid --port value: ${value}`); + } + args.port = parsed; + } else if (a === '--host') { + args.host = argv[++i]; + } else if (a === '--fonts') { + args.fonts = argv[++i]; + } else if (a === 'stop' && args.file === null && args.command === null) { + args.command = 'stop'; + } else if (a.startsWith('-')) { + throw new Error(`unknown option: ${a}`); + } else if (args.file === null) { + args.file = a; + } else { + throw new Error(`unexpected argument: ${a}`); + } + } + return args; +} + +function openBrowser(url) { + if (process.platform === 'darwin') { + execFile('open', [url]); + } else if (process.platform === 'win32') { + execFile('cmd', ['/c', 'start', '', url]); + } else { + execFile('xdg-open', [url]); + } +} + +/** POST /sessions on the given running server to reuse or create a session. */ +function requestSession({ host, port }, filePath) { + return new Promise((resolve, reject) => { + const body = JSON.stringify({ file: filePath }); + const req = http.request( + { + host, + port, + method: 'POST', + path: '/sessions', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }, + timeout: 2000, + }, + (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + if (res.statusCode !== 200) { + reject(new Error(`server returned ${res.statusCode}: ${text}`)); + return; + } + try { + resolve(JSON.parse(text)); + } catch (err) { + reject(new Error(`invalid session response: ${err.message}`)); + } + }); + } + ); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('session request timed out')); + }); + req.write(body); + req.end(); + }); +} + +function sessionUrl(lock, sessionPath) { + const displayHost = lock.host === '0.0.0.0' ? 'localhost' : (lock.host || '127.0.0.1'); + return `http://${displayHost}:${lock.port}${sessionPath}`; +} + +export async function run(argv) { + let args; + try { + args = parseArgs(argv); + } catch (err) { + process.stderr.write(`pagx preview: ${err.message}\n\n${USAGE}`); + process.exit(2); + } + + if (args.help) { + process.stdout.write(USAGE); + process.exit(0); + } + + // Stdio MCP mode: keep this process in the foreground speaking MCP over stdin/stdout. No file + // argument is required — files are opened lazily through the preview_pagx tool. Handled before + // the file-required checks below so `pagx preview --mcp` works with no positional argument. + if (args.mcp) { + await runStdioServer({ port: args.port, host: args.host, fontsDir: args.fonts }); + return; + } + + // Internal server mode: the detached child re-executes this script with --__server so the + // actual event loop that hosts express + chokidar lives here. + if (args.serverMode) { + if (!args.file) { + process.stderr.write('pagx preview: internal error: --__server without file\n'); + process.exit(2); + } + const absoluteFile = path.resolve(args.file); + await runServer({ + entryFile: absoluteFile, + port: args.port, + host: args.host, + fontsDir: args.fonts, + }); + return; + } + + if (args.command === 'log') { + printLog(); + return; + } + if (args.command === 'stop') { + await stopDaemon(); + return; + } + + if (args.file === null) { + process.stdout.write(USAGE); + process.exit(2); + } + + const absoluteFile = path.resolve(args.file); + if (!fs.existsSync(absoluteFile)) { + process.stderr.write(`pagx preview: file not found: ${absoluteFile}\n`); + process.exit(1); + } + + // 1) Try to reuse an already-running server. + const existing = await probeLiveServer(); + if (existing) { + try { + const info = await requestSession(existing, absoluteFile); + const url = sessionUrl(existing, info.url); + if (args.json) { + emitJson({ url, pid: existing.pid, logFile: LOG_FILE, reused: true }); + return; + } + // Only auto-open the browser when there isn't already a live tab for this session. + // Chrome (and several other browsers) treat `open ` as "open a new tab" even + // when the URL is already displayed elsewhere, which piles up duplicate tabs on repeated + // pagx-preview invocations. When a tab is already active we surface the URL and rely on + // the user's browser being one Cmd+Tab away. + if (info.reused && info.hadActiveTab) { + process.stdout.write(`pagx preview: already open at ${url}\n`); + } else { + process.stdout.write(`pagx preview: ${url}\n`); + if (args.open) openBrowser(url); + } + return; + } catch (err) { + process.stderr.write( + `pagx preview: existing server unreachable (${err.message}); starting a new one\n` + ); + clearLock(); + } + } + + // 2) Foreground mode: run the server directly in this process. Useful for debugging or when + // the user wants Ctrl+C to control the server lifetime. + if (args.foreground) { + await runServer({ + entryFile: absoluteFile, + port: args.port, + host: args.host, + fontsDir: args.fonts, + }); + return; + } + + // 3) Default: spawn a detached background server, then hand back the shell prompt. + let lock; + try { + lock = await spawnDaemon({ + entryFile: absoluteFile, + port: args.port, + host: args.host, + fontsDir: args.fonts, + }); + } catch (err) { + process.stderr.write(`pagx preview: ${err.message}\n`); + process.exit(1); + } + + // The daemon just came up; ask it for the session URL so we can print it and open a browser. + let info; + try { + info = await requestSession(lock, absoluteFile); + } catch (err) { + process.stderr.write(`pagx preview: server started but session request failed: ${err.message}\n`); + process.exit(1); + } + + const url = sessionUrl(lock, info.url); + if (args.json) { + emitJson({ url, pid: lock.pid, logFile: LOG_FILE, reused: false }); + return; + } + process.stdout.write(`pagx preview: ${url}\n`); + process.stdout.write( + `pagx preview: server running in background (pid ${lock.pid}, log ${LOG_FILE})\n` + ); + process.stdout.write('pagx preview: run `pagx preview stop` to stop it.\n'); + if (args.open) openBrowser(url); +} + +// Emit a single-line JSON record on stdout. Kept as a one-liner (no trailing newline concerns +// beyond the write itself) so callers can pipe pagx-preview into `jq` or `JSON.parse(line)` +// without splitting on multiple messages. +function emitJson(payload) { + process.stdout.write(JSON.stringify(payload) + '\n'); +} + +// When executed directly (node src/cli.js ..., or via the bin symlink created by npm link / +// npm install -g), run immediately. Both sides are resolved through realpath so a symlinked +// entry (which is how npm exposes the bin script) still matches the module file. +function isDirectlyInvoked() { + const modulePath = fileURLToPath(import.meta.url); + const entry = process.argv[1]; + if (!entry) return false; + try { + return fs.realpathSync(entry) === fs.realpathSync(modulePath); + } catch (_) { + return path.resolve(entry) === modulePath; + } +} + +if (isDirectlyInvoked()) { + run(process.argv.slice(2)); +} diff --git a/playground/pagx-preview/src/daemon.js b/playground/pagx-preview/src/daemon.js new file mode 100644 index 0000000000..5f360d5d62 --- /dev/null +++ b/playground/pagx-preview/src/daemon.js @@ -0,0 +1,296 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +// Detached server helpers. +// +// A `pagx preview ` invocation is expected to return the shell prompt immediately after the +// server is reachable, so the user can keep issuing commands. We achieve this by re-executing +// this script with `--__server` (an internal-only flag) as a detached child process whose stdio +// is redirected to ~/.pagx/preview.log. The parent process waits until the lock file appears +// (indicating the child has bound its port) and then exits. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { spawn } from 'child_process'; +import { fileURLToPath } from 'url'; +import { readLock, writeLock, clearLock } from './server/lock.js'; +import { startServer } from './server/index.js'; +import { connectStdioMcpServer } from './mcp/server.js'; + +const HOME_DIR = path.join(os.homedir(), '.pagx'); +export const LOG_FILE = path.join(HOME_DIR, 'preview.log'); +const SPAWN_READY_TIMEOUT_MS = 3000; +const SPAWN_POLL_INTERVAL_MS = 50; + +/** Ensures ~/.pagx/ exists. */ +function ensureHomeDir() { + fs.mkdirSync(HOME_DIR, { recursive: true }); +} + +/** Truncates the log file so each daemon lifetime starts with a fresh log. */ +function truncateLog() { + ensureHomeDir(); + try { + fs.writeFileSync(LOG_FILE, ''); + } catch (_) { + // ignore + } +} + +/** Opens the log file for the child stdio. Returns a file descriptor suitable for spawn(). */ +function openLogFd() { + ensureHomeDir(); + // Append mode so the child can keep writing after the parent detaches; truncateLog() already + // reset the contents before we get here, so the log still starts empty on this lifetime. + return fs.openSync(LOG_FILE, 'a'); +} + +/** + * Runs the actual preview server in the current process. Used by: + * - The detached child spawned by spawnDaemon() below. + * - `pagx preview --foreground` for debugging. + */ +export async function runServer({ entryFile, port, host, fontsDir }) { + let server; + try { + server = await startServer({ entryFile, port, host, fontsDir }); + } catch (err) { + process.stderr.write(`pagx preview: failed to start server: ${err.message}\n`); + process.exit(1); + } + + try { + writeLock({ port: server.port, host: server.host, pid: process.pid }); + } catch (err) { + process.stderr.write(`pagx preview: warning: could not write lock file: ${err.message}\n`); + } + + process.stdout.write(`pagx preview: watching ${entryFile}\n`); + if (server.fontsDir) { + process.stdout.write(`pagx preview: fonts from ${server.fontsDir}\n`); + } else if (server.useLazyDownload) { + process.stdout.write('pagx preview: fonts downloading in background; text may render blank until ready\n'); + } else { + process.stderr.write( + 'pagx preview: no fonts directory found. Text glyphs will render blank. ' + + 'Pass --fonts or set PAGX_FONTS_DIR to a directory containing ' + + 'NotoSansSC-Regular.otf and NotoColorEmoji.ttf.\n' + ); + } + process.stdout.write(`pagx preview: ${server.url}\n`); + + let shuttingDown = false; + const shutdown = async (reason) => { + if (shuttingDown) { + process.stderr.write('pagx preview: force exit.\n'); + process.exit(1); + } + shuttingDown = true; + process.stdout.write(`\npagx preview: ${reason}, shutting down...\n`); + const forceTimer = setTimeout(() => { + process.stderr.write('pagx preview: graceful shutdown timed out, forcing exit.\n'); + process.exit(1); + }, 3000); + forceTimer.unref(); + try { + await server.close(); + } finally { + clearLock(); + process.exit(0); + } + }; + process.on('SIGINT', () => shutdown('received SIGINT')); + process.on('SIGTERM', () => shutdown('received SIGTERM')); + server.onIdle(() => shutdown('all tabs closed')); +} + +/** + * Runs pagx-preview as a self-bootstrapping stdio MCP server (`pagx preview --mcp`). This is the + * entry an MCP client (CodeBuddy) spawns on demand via a `command` config, so the user never has + * to start a server manually. + * + * Two things live in this single process: + * 1. An in-process preview HTTP server (for the widget iframe and the browser-openable webview). + * 2. An MCP server speaking JSON-RPC over stdin/stdout. + * They share the same session maps directly (no network hop). Process lifetime is owned by the MCP + * client through stdin: when the client disconnects, the transport closes and we tear everything + * down. Crucially we do NOT register server.onIdle() here — unlike the daemon, this server must + * stay alive even when no browser tab is open, otherwise the widget/webview would hit a dead port. + */ +export async function runStdioServer({ port, host, fontsDir }) { + // stdout is reserved for the MCP JSON-RPC framing (StdioServerTransport writes there directly). + // Redirect console logging to stderr so any stray log line can't corrupt the protocol stream. + // eslint-disable-next-line no-console + console.log = (...parts) => process.stderr.write(parts.join(' ') + '\n'); + // eslint-disable-next-line no-console + console.info = console.log; + + let server; + try { + server = await startServer({ entryFile: null, port, host, fontsDir }); + } catch (err) { + process.stderr.write(`pagx preview: failed to start server: ${err.message}\n`); + process.exit(1); + return; + } + process.stderr.write(`pagx preview: MCP stdio server ready (preview at ${server.url})\n`); + + const mcp = await connectStdioMcpServer({ + sessions: server.sessions, + dropSessions: server.dropSessions, + createOrGetSession: server.createOrGetSession, + staticDir: server.staticDir, + getServerBaseUrl: server.getServerBaseUrl, + }); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) process.exit(1); + shuttingDown = true; + try { + await mcp.close(); + } catch (_) { + // ignore + } + try { + await server.close(); + } catch (_) { + // ignore + } + process.exit(0); + }; + // The transport closes when the client disconnects stdin; mirror that into a full teardown. + mcp.onclose = shutdown; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +/** + * Spawns this same script as a detached child running the server, then waits until the child + * writes its lock file (or SPAWN_READY_TIMEOUT_MS elapses). + * + * Returns the lock payload on success, or throws with the log tail on failure. + */ +export async function spawnDaemon({ entryFile, port, host, fontsDir }) { + ensureHomeDir(); + clearLock(); + truncateLog(); + + const scriptPath = fileURLToPath(new URL('./cli.js', import.meta.url)); + const childArgs = [scriptPath, '--__server', entryFile]; + if (port) childArgs.push('--port', String(port)); + if (host) childArgs.push('--host', host); + if (fontsDir) childArgs.push('--fonts', fontsDir); + + const logFd = openLogFd(); + const child = spawn(process.execPath, childArgs, { + detached: true, + stdio: ['ignore', logFd, logFd], + // windowsHide keeps `cmd.exe` from popping a console window when the parent is a GUI shell. + windowsHide: true, + }); + // Break the parent<->child reference so the parent can exit while the child keeps running. + child.unref(); + + // Poll for the lock file. The child writes it after successfully binding its port; anything + // before that (missing wasm artifacts, bad --port, etc.) will show up in the log tail below. + const started = Date.now(); + let lastError = null; + child.on('error', (err) => { + lastError = err; + }); + + while (Date.now() - started < SPAWN_READY_TIMEOUT_MS) { + const lock = readLock(); + if (lock && lock.pid === child.pid) return lock; + if (child.exitCode !== null) break; + await sleep(SPAWN_POLL_INTERVAL_MS); + } + + // Give the child one last chance in case the exit happened right before we sampled. + const finalLock = readLock(); + if (finalLock && finalLock.pid === child.pid) return finalLock; + + const tail = readLogTail(20); + const err = new Error( + `daemon failed to start${lastError ? `: ${lastError.message}` : ''}\n` + + (tail ? `\n---- last 20 log lines ----\n${tail}` : '') + ); + throw err; +} + +/** Returns the last N lines of the log file, or an empty string if unreadable. */ +export function readLogTail(lines = 40) { + try { + const text = fs.readFileSync(LOG_FILE, 'utf8'); + const arr = text.split(/\r?\n/); + return arr.slice(-lines - 1).join('\n'); + } catch (_) { + return ''; + } +} + +/** Sends SIGTERM to the daemon process referenced by the lock file. */ +export async function stopDaemon() { + const lock = readLock(); + if (!lock) { + process.stdout.write('pagx preview: no running server.\n'); + return; + } + try { + process.kill(lock.pid, 'SIGTERM'); + } catch (err) { + if (err.code === 'ESRCH') { + clearLock(); + process.stdout.write('pagx preview: server was not running (stale lock cleared).\n'); + return; + } + process.stderr.write(`pagx preview: failed to stop server (pid ${lock.pid}): ${err.message}\n`); + process.exit(1); + } + + // Wait a beat for the graceful shutdown to remove the lock file. Not strictly required, but + // gives useful feedback that the peer actually acted on our SIGTERM. + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + if (!readLock()) { + process.stdout.write(`pagx preview: stopped (pid ${lock.pid}).\n`); + return; + } + await sleep(50); + } + process.stdout.write( + `pagx preview: sent SIGTERM to pid ${lock.pid}; lock still present (shutdown in progress).\n` + ); +} + +/** Prints the log file to stdout. */ +export function printLog() { + if (!fs.existsSync(LOG_FILE)) { + process.stdout.write('pagx preview: no log file yet.\n'); + return; + } + const data = fs.readFileSync(LOG_FILE, 'utf8'); + process.stdout.write(data); + if (!data.endsWith('\n')) process.stdout.write('\n'); +} + +function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/playground/pagx-preview/src/mcp/server.js b/playground/pagx-preview/src/mcp/server.js new file mode 100644 index 0000000000..55931e2d3a --- /dev/null +++ b/playground/pagx-preview/src/mcp/server.js @@ -0,0 +1,130 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "AS IS" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { buildToolHandlers, buildResourceHandlers } from './tools.js'; + +/** + * Builds a configured MCP Server instance with all pagx-preview tool / resource handlers + * registered. The handlers are plain closures over the shared session maps, so the same builder + * serves both the HTTP (per-request) and stdio (long-lived) transports. + * + * @param {object} opts + * @param {Map} opts.sessions - live PreviewSession map keyed by session id. + * @param {Function} opts.createOrGetSession - creates or reuses a session for a file path. + * @param {string} opts.staticDir - directory holding mcp-widget.html and source static assets. + * @param {string} opts.generatedDir - directory holding generated artifacts (mcp-widget.bundle.js). + * @param {Function} [opts.getServerBaseUrl] - returns the preview server's base URL (used to + * rewrite widget resource URLs and to build the browser-openable session url). + * @returns {Server} a ready-to-connect MCP Server. + */ +export function createMcpServer(opts) { + const { sessions, createOrGetSession, staticDir, generatedDir, getServerBaseUrl } = opts; + const server = new Server( + { name: 'pagx-preview', version: '0.1.0' }, + { capabilities: { tools: {}, resources: {} } }, + ); + + const handlers = buildToolHandlers({ sessions, createOrGetSession, getServerBaseUrl }); + const resourceHandlers = buildResourceHandlers({ staticDir, generatedDir, getServerBaseUrl }); + + server.setRequestHandler(ListToolsRequestSchema, handlers.listTools); + server.setRequestHandler(CallToolRequestSchema, handlers.callTool); + server.setRequestHandler(ListResourcesRequestSchema, resourceHandlers.listResources); + server.setRequestHandler(ReadResourceRequestSchema, resourceHandlers.readResource); + return server; +} + +/** + * Connects a single long-lived MCP Server to a stdio transport. This is the self-bootstrapping + * entry point: an MCP client (CodeBuddy) spawns `pagx preview --mcp` on demand and speaks MCP over + * stdin/stdout, so the user never has to start a server manually. The caller owns the in-process + * preview HTTP server whose session maps are shared here. + * + * @returns {Promise} the connected Server (its `onclose` fires when stdin closes). + */ +export async function connectStdioMcpServer(opts) { + const server = createMcpServer(opts); + const transport = new StdioServerTransport(); + await server.connect(transport); + return server; +} + +/** + * Mounts the MCP server onto the given express app. The MCP server shares the same process, + * port, and session map as the rest of pagx-preview so that tool calls can directly access + * PreviewSession objects without a network round-trip. + * + * The endpoint is POST /mcp (Streamable HTTP transport). CodeBuddy connects by adding: + * { "mcpServers": { "pagx-preview": { "type": "http", "url": "http://127.0.0.1:/mcp" } } } + * to its mcp.json. Use `pagx preview --port 7300 ` to pin a port for MCP config stability. + * + * Each POST /mcp request creates a fresh Server + Transport pair. The MCP SDK's Server.connect() + * binds the Server to a single Transport for its lifetime, so reusing a Server across requests + * triggers "Already connected to a transport". Creating per-request instances is cheap (handlers + * are plain closures over the shared session map) and keeps the stateless request model clean. + */ +export function mountMcpServer(app, opts) { + app.post('/mcp', async (req, res) => { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const server = createMcpServer(opts); + + try { + await server.connect(transport); + // The app-level express.json() middleware has already consumed the request stream, so the + // transport cannot re-read it. Pass the parsed body explicitly; otherwise handleRequest + // reads an empty stream and reports "Parse error: Invalid JSON". + await transport.handleRequest(req, res, req.body); + } catch (err) { + if (!res.headersSent) { + res.status(500).json({ error: err.message }); + } + } finally { + // Close the Server so its internal transport reference is released. The Transport itself + // is per-request and will be GC'd; the Server's handler closures only reference the + // shared session maps, not per-request state, so no data leaks. + try { + await server.close(); + } catch (_) { + // ignore + } + } + }); + + // GET/DELETE are not supported in stateless mode: there is no long-lived session to attach a + // server-initiated SSE stream to, and every POST already spins up its own transport. Returning + // a JSON-RPC "Method not allowed" here matches the official stateless server example, so an MCP + // client that probes the legacy GET-SSE channel backs off cleanly instead of retrying. + const methodNotAllowed = (req, res) => { + res.status(405).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method not allowed.' }, + id: null, + }); + }; + app.get('/mcp', methodNotAllowed); + app.delete('/mcp', methodNotAllowed); +} diff --git a/playground/pagx-preview/src/mcp/tools.js b/playground/pagx-preview/src/mcp/tools.js new file mode 100644 index 0000000000..08de9bda0d --- /dev/null +++ b/playground/pagx-preview/src/mcp/tools.js @@ -0,0 +1,344 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "AS IS" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import fs from 'fs'; +import path from 'path'; + +export const UI_MIME = 'text/html;profile=mcp-app'; +export const UI_URI = 'ui://pagx-preview/main'; + +// After this many consecutive get_document calls with no summary, assume the host is not +// rendering the inline widget and fall back to returning the browser-openable webview url so the +// caller can open the preview there instead of polling a widget that will never load. +const MAX_DOCUMENT_QUERY_FAILURES = 3; + +// Tool definitions. Kept as plain objects so both listTools and callTool can reference the same +// schema without duplicating field names / descriptions. +const TOOLS = [ + { + name: 'preview_pagx', + description: + 'Preview a pagx file (default preview tool). Use it whenever the user asks to "preview", "open", or "view" a pagx file. It loads the file and returns a session url. IMPORTANT: after this tool returns, you MUST immediately open the returned url yourself by calling your own webview / browser preview capability (e.g. a preview_url / open-in-webview / open-in-browser action) so the animation is actually shown to the user. Do NOT just print the url as text and stop — that leaves the user with nothing to look at. Only fall back to pasting the url as plain text if you genuinely have no tool available to open a url. Returns session info including sessionId for use with reload_file / get_document. Do NOT use this for inline widget preview — use preview_pagx_widget only when the user explicitly asks for an inline / small-window ("小窗") preview.', + inputSchema: { + type: 'object', + properties: { + file: { type: 'string', description: 'Absolute path to the .pagx file to preview.' }, + }, + required: ['file'], + }, + // Intentionally NO _meta.ui here: this tool must not trigger an inline widget iframe in hosts + // that support MCP Apps. It is the default "open in a webview panel / browser" path. + }, + { + name: 'preview_pagx_widget', + description: + 'Preview a pagx file as an inline widget rendered directly in the conversation (a small in-chat window). Use this ONLY when the user explicitly asks for an inline / small-window preview ("小窗预览"). For a normal preview, use preview_pagx instead. Returns session info including sessionId for use with reload_file / get_document.', + inputSchema: { + type: 'object', + properties: { + file: { type: 'string', description: 'Absolute path to the .pagx file to preview.' }, + }, + required: ['file'], + }, + // MCP Apps: associate this tool with the UI resource so the host renders the widget inline. + // Both nested and flat keys are required — Claude Desktop reads the flat key "ui/resourceUri" + // to decide whether to mount an iframe. The nested form is for other hosts. + _meta: { ui: { resourceUri: UI_URI }, 'ui/resourceUri': UI_URI }, + }, + { + name: 'reload_file', + description: + 'Force a full reload of the pagx file from disk. The preview widget automatically reloads on file change, but this tool can trigger a manual reload if needed.', + inputSchema: { + type: 'object', + properties: { + sessionId: { type: 'string', description: 'Session ID returned by preview_pagx.' }, + }, + required: ['sessionId'], + }, + }, + { + name: 'get_document', + description: + 'Get a summary of the loaded pagx document: dimensions and animation duration. The summary is uploaded by the client after load; it may be null if the client has not finished loading yet. If it keeps returning "not loaded" for several consecutive calls, the inline widget is likely not rendering in this host and the result will switch to a fallback that returns a browser-openable url (fallbackToWebview: true) — open that url in a webview / browser instead of polling further.', + inputSchema: { + type: 'object', + properties: { + sessionId: { type: 'string', description: 'Session ID returned by preview_pagx.' }, + }, + required: ['sessionId'], + }, + }, +]; + +/** + * Builds the listTools / callTool handlers for the MCP server. + * @returns {{ listTools: Function, callTool: Function }} + */ +export function buildToolHandlers({ sessions, createOrGetSession, getServerBaseUrl }) { + return { + async listTools() { + return { tools: TOOLS }; + }, + + async callTool(req) { + const { name, arguments: args } = req.params; + + if (name === 'preview_pagx') { + return handlePreviewPagx(args, { createOrGetSession, getServerBaseUrl, widget: false }); + } + if (name === 'preview_pagx_widget') { + return handlePreviewPagx(args, { createOrGetSession, getServerBaseUrl, widget: true }); + } + if (name === 'reload_file') { + return handleReloadFile(args, { sessions }); + } + if (name === 'get_document') { + return handleGetDocument(args, { sessions, getServerBaseUrl }); + } + return { + content: [{ type: 'text', text: 'unknown tool' }], + isError: true, + }; + }, + }; +} + +function handlePreviewPagx(args, { createOrGetSession, getServerBaseUrl, widget = false }) { + const file = args?.file; + if (!file) { + return { + content: [{ type: 'text', text: 'file path is required' }], + isError: true, + }; + } + const resolved = path.resolve(file); + // Accept any absolute path so users can preview a .pagx anywhere (Desktop, Downloads, + // etc.), matching the plain `pagx preview ` command. The session layer still guards + // resource references inside the file against directory traversal (see session.js). + if (!fs.existsSync(resolved)) { + return { + content: [{ type: 'text', text: 'file not found' }], + isError: true, + }; + } + const { session, reused } = createOrGetSession(resolved); + const baseUrl = getServerBaseUrl ? getServerBaseUrl() : ''; + const url = baseUrl ? `${baseUrl}/session/${session.id}/` : `/session/${session.id}/`; + const summary = session.documentSummary; + // The summary is uploaded by the client (widget or browser tab) only after it finishes + // rendering, so on the first call it is usually still null. Report "rendering" instead of a + // misleading "0 nodes, 0.0s"; get_document can be polled once the preview appears. + const stats = summary + ? `${summary.nodeCount} nodes, ${(summary.duration / 1000000).toFixed(1)}s duration` + : 'rendering (document summary available after the preview finishes loading)'; + const reusedNote = reused ? ' (reusing existing session)' : ''; + // Two distinct presentations: the widget tool renders inline (with a browser/webview fallback), + // while the default tool has no inline UI and must be surfaced via an IDE webview panel. + const text = widget + ? `Previewing ${path.basename(file)}${reusedNote} as an inline widget — ${stats}.\n\nIf the inline widget does not appear above, open this url in a webview / browser instead: ${url}` + : `Previewing ${path.basename(file)}${reusedNote} — ${stats}.\n\nACTION REQUIRED: open ${url} right now by calling your webview / browser preview tool so the user can actually see the animation. Do NOT just show this url as text — open it. Only if you have no tool to open a url, paste it for the user to open manually.`; + return { + content: [{ type: 'text', text }], + structuredContent: { + sessionId: session.id, + file: session.entryFile, + fileName: path.basename(session.entryFile), + url, + nodeCount: summary?.nodeCount ?? 0, + duration: summary?.duration ?? 0, + reused, + }, + }; +} + +function handleReloadFile(args, { sessions }) { + const { sessionId } = args ?? {}; + const session = sessions.get(sessionId); + if (!session) { + return { + content: [{ type: 'text', text: 'session not found' }], + isError: true, + }; + } + session.emit({ type: 'reload', file: session.entryFile, event: 'mcp-reload' }); + return { + content: [{ type: 'text', text: `reload triggered` }], + structuredContent: { sessionId, reloaded: true }, + }; +} + +function handleGetDocument(args, { sessions, getServerBaseUrl }) { + const { sessionId } = args ?? {}; + const session = sessions.get(sessionId); + if (!session) { + return { + content: [{ type: 'text', text: 'session not found' }], + isError: true, + }; + } + const summary = session.documentSummary; + if (!summary) { + // The inline widget uploads its summary only after it renders. If the host does not render + // the widget at all, the summary never arrives and every poll returns null. Count consecutive + // misses; once we hit the threshold, stop reporting an error and instead hand back the + // browser-openable webview url so the caller opens the preview there rather than polling a + // widget that will never load. + session.documentQueryFailures += 1; + if (session.documentQueryFailures >= MAX_DOCUMENT_QUERY_FAILURES) { + const baseUrl = getServerBaseUrl ? getServerBaseUrl() : ''; + const url = baseUrl ? `${baseUrl}/session/${session.id}/` : `/session/${session.id}/`; + return { + content: [ + { + type: 'text', + text: `The inline widget has not reported a document after ${session.documentQueryFailures} consecutive attempts, so it is likely not rendering in this host. Open the preview in a webview / browser instead: ${url}`, + }, + ], + structuredContent: { + sessionId: session.id, + loaded: false, + attempts: session.documentQueryFailures, + fallbackToWebview: true, + url, + }, + }; + } + return { + content: [{ type: 'text', text: `document not loaded yet for session` }], + isError: true, + }; + } + session.documentQueryFailures = 0; + return { + content: [ + { + type: 'text', + text: `${summary.nodeCount} nodes, ${summary.width}x${summary.height}, ${(summary.duration / 1000000).toFixed(1)}s`, + }, + ], + structuredContent: summary, + }; +} + +/** + * Builds the listResources / readResource handlers for the MCP server. + * @returns {{ listResources: Function, readResource: Function }} + */ +export function buildResourceHandlers({ staticDir, generatedDir, getServerBaseUrl }) { + return { + async listResources() { + return { + resources: [ + { + uri: UI_URI, + name: 'pagx-preview-widget', + mimeType: UI_MIME, + description: 'Interactive pagx preview widget', + }, + ], + }; + }, + + async readResource(req) { + if (req.params.uri !== UI_URI) { + return { contents: [] }; + } + const widgetPath = path.join(staticDir, 'mcp-widget.html'); + let html; + if (fs.existsSync(widgetPath)) { + html = fs.readFileSync(widgetPath, 'utf8'); + } else { + html = 'pagx-preview widget not found. Run npm run prebuild.'; + } + // Inline the pre-built widget bundle (app-with-deps + pagx-player + mcp-widget merged + // by esbuild into one minified file). This avoids external , quotes, etc.) from + // breaking `document.write()` or the `; + html = html.replace( + /<\/script>/, + loader + ); + // Remove meta CSP — let host control it + html = html.replace(//m, ''); + html = html.replace(/\s*\s*/m, '\n '); + return { + contents: [ + { + uri: UI_URI, + mimeType: UI_MIME, + text: html, + _meta: connectDomains.length ? { + ui: { + csp: { + resourceDomains: connectDomains, + connectDomains, + baseUriDomains: connectDomains, + }, + }, + } : undefined, + }, + ], + }; + }, + }; +} diff --git a/playground/pagx-preview/src/server/font-cache.js b/playground/pagx-preview/src/server/font-cache.js new file mode 100644 index 0000000000..4dda7a5f71 --- /dev/null +++ b/playground/pagx-preview/src/server/font-cache.js @@ -0,0 +1,183 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +// Lazy-download the fallback fonts (NotoSansSC + NotoColorEmoji) into ~/.pagx/fonts/ so a +// globally installed pagx-preview works without a libpag checkout. Modeled after +// cli/npm/html-snapshot/launch.js: the download runs on first use, output goes to stderr, and +// PAGX_FONTS_NO_AUTO_DOWNLOAD=1 disables it for offline / CI hosts. + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import https from 'https'; +import http from 'http'; + +const CACHE_DIR = path.join(os.homedir(), '.pagx', 'fonts'); +// Any file smaller than this is considered a failed download (e.g. HTML error page) and will +// be retried on the next startup. Real NotoSansSC / NotoColorEmoji are ~8 MB / ~10 MB. +const MIN_VALID_SIZE = 100 * 1024; + +// The catalog mirrors the CDN used by pagx/wechat/wx_demo, which is where pag.qq.com already +// serves the two fonts pagx-viewer needs. Bumping the URL here is the only step required to +// switch to a different origin later. +export const FONT_CATALOG = [ + { + role: 'primary', + name: 'NotoSansSC-Regular.otf', + url: 'https://pag.qq.com/wx_pagx_demo/fonts/NotoSansSC-Regular.otf', + }, + { + role: 'emoji', + name: 'NotoColorEmoji.ttf', + url: 'https://pag.qq.com/wx_pagx_demo/fonts/NotoColorEmoji.ttf', + }, +]; + +export function cacheDir() { + return CACHE_DIR; +} + +function ensureCacheDir() { + fs.mkdirSync(CACHE_DIR, { recursive: true }); +} + +function log(msg) { + process.stderr.write(`pagx preview font-cache: ${msg}\n`); +} + +/** Returns true when a cached font file exists and looks intact. */ +export function isFontCached(name) { + const abs = path.join(CACHE_DIR, name); + try { + const stat = fs.statSync(abs); + return stat.isFile() && stat.size >= MIN_VALID_SIZE; + } catch (_) { + return false; + } +} + +/** Returns absolute path to the cached font (regardless of validity). */ +export function fontPath(name) { + return path.join(CACHE_DIR, name); +} + +/** Downloads a single URL to `destPath`, following up to 3 redirects. Rejects on non-2xx. */ +function downloadOnce(url, destPath, maxRedirects = 3) { + return new Promise((resolve, reject) => { + const client = url.startsWith('https:') ? https : http; + const tmpPath = destPath + '.part'; + const file = fs.createWriteStream(tmpPath); + const req = client.get(url, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && maxRedirects > 0) { + // Follow redirect. Close the file (it will be overwritten on the next attempt). + file.close(); + fs.unlink(tmpPath, () => {}); + downloadOnce(res.headers.location, destPath, maxRedirects - 1).then(resolve, reject); + return; + } + if (res.statusCode !== 200) { + file.close(); + fs.unlink(tmpPath, () => {}); + reject(new Error(`HTTP ${res.statusCode} for ${url}`)); + return; + } + res.pipe(file); + file.on('finish', () => { + file.close((err) => { + if (err) { + fs.unlink(tmpPath, () => {}); + reject(err); + return; + } + try { + const stat = fs.statSync(tmpPath); + if (stat.size < MIN_VALID_SIZE) { + fs.unlink(tmpPath, () => {}); + reject(new Error(`downloaded file too small (${stat.size} bytes)`)); + return; + } + fs.renameSync(tmpPath, destPath); + resolve(); + } catch (e) { + fs.unlink(tmpPath, () => {}); + reject(e); + } + }); + }); + }); + req.on('error', (err) => { + file.close(); + fs.unlink(tmpPath, () => {}); + reject(err); + }); + req.setTimeout(30000, () => { + req.destroy(new Error('download timed out')); + }); + }); +} + +// Tracks per-role in-flight downloads so overlapping requests coalesce. First call kicks off the +// download; concurrent callers await the same promise. +const inFlight = new Map(); + +/** + * Ensures a font exists in the cache. Returns absolute path on success, null on failure. Safe to + * call from multiple places at once — downloads are deduplicated. + */ +export async function ensureFont(entry) { + if (isFontCached(entry.name)) return fontPath(entry.name); + if (process.env.PAGX_FONTS_NO_AUTO_DOWNLOAD === '1') { + log(`auto-download disabled (PAGX_FONTS_NO_AUTO_DOWNLOAD=1); skipping ${entry.name}`); + return null; + } + + let promise = inFlight.get(entry.name); + if (!promise) { + promise = (async () => { + ensureCacheDir(); + const dest = fontPath(entry.name); + log(`downloading ${entry.name} from ${entry.url}...`); + const started = Date.now(); + try { + await downloadOnce(entry.url, dest); + const secs = ((Date.now() - started) / 1000).toFixed(1); + log(`downloaded ${entry.name} (${secs}s)`); + return dest; + } catch (err) { + log(`failed to download ${entry.name}: ${err.message}`); + return null; + } + })(); + inFlight.set(entry.name, promise); + // Clear the entry once resolved so a later retry can try again. + promise.finally(() => inFlight.delete(entry.name)); + } + return await promise; +} + +/** Downloads any missing font in parallel. Returns a map of role -> absolute path (or null). */ +export async function ensureAllFonts() { + const results = await Promise.all(FONT_CATALOG.map(async (entry) => ({ + role: entry.role, + name: entry.name, + path: await ensureFont(entry), + }))); + const map = {}; + for (const r of results) map[r.role] = r; + return map; +} diff --git a/playground/pagx-preview/src/server/fonts.js b/playground/pagx-preview/src/server/fonts.js new file mode 100644 index 0000000000..e455d899a0 --- /dev/null +++ b/playground/pagx-preview/src/server/fonts.js @@ -0,0 +1,123 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +// Font source discovery for the preview server. +// +// The viewer needs a fallback font (typically NotoSansSC) plus an emoji font (NotoColorEmoji) to +// render text and emoji glyphs. This module picks a directory that holds those files, in order of +// preference: +// 1. explicit override (--fonts ) passed from the CLI +// 2. PAGX_FONTS_DIR environment variable +// 3. lazy-download cache under ~/.pagx/fonts/ (populated by font-cache.js on first use) +// 4. libpag checkout's resources/font/ when running from a source tree +// +// The lazy-download step ensures a globally installed pagx-preview works out of the box; the +// libpag fallback keeps the source-tree workflow zero-config. + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { cacheDir, isFontCached, fontPath } from './font-cache.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PREVIEW_DIR = path.resolve(__dirname, '../..'); + +// Font role -> ordered list of preferred filenames. The first file that exists on disk is used. +export const FONT_ROLES = { + primary: ['NotoSansSC-Regular.otf'], + emoji: ['NotoColorEmoji.ttf'], +}; + +/** Walks upward looking for a libpag checkout by locating resources/font/. */ +function detectResourcesFontDir() { + let dir = PREVIEW_DIR; + for (let i = 0; i < 6; i++) { + const candidate = path.join(dir, 'resources', 'font'); + if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) { + return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +function isUsableDir(candidate) { + return candidate && fs.existsSync(candidate) && fs.statSync(candidate).isDirectory(); +} + +/** + * Resolves the fonts directory using the priority order documented at the top of this file. + * The download cache directory qualifies only when at least one expected font is already + * present, so a fresh install still falls through to the libpag checkout during the initial + * download window. + */ +export function resolveFontsDir(explicitDir) { + if (isUsableDir(explicitDir)) return path.resolve(explicitDir); + if (isUsableDir(process.env.PAGX_FONTS_DIR)) return path.resolve(process.env.PAGX_FONTS_DIR); + + const cache = cacheDir(); + const cacheHasAny = Object.values(FONT_ROLES).some((names) => + names.some((name) => isFontCached(name)) + ); + if (cacheHasAny) return cache; + + const checkout = detectResourcesFontDir(); + if (checkout) return checkout; + + return null; +} + +/** For each role, returns the absolute path of the first candidate file that exists, or null. */ +export function listFonts(fontsDir) { + if (!fontsDir) return {}; + const result = {}; + for (const [role, names] of Object.entries(FONT_ROLES)) { + for (const name of names) { + const abs = path.join(fontsDir, name); + if (fs.existsSync(abs) && fs.statSync(abs).isFile()) { + result[role] = { name, path: abs }; + break; + } + } + } + return result; +} + +/** + * Same as listFonts() but always uses the download cache dir when a font exists there, even if + * the caller preferred another directory. Used for hot-swapping after a lazy download completes: + * the /fonts endpoints resolve here so a re-request after the download picks up the new file + * without a server restart. + */ +export function listFontsPreferringCache(fontsDir) { + const result = listFonts(fontsDir); + for (const [role, names] of Object.entries(FONT_ROLES)) { + if (result[role]) continue; + for (const name of names) { + if (isFontCached(name)) { + result[role] = { name, path: fontPath(name) }; + break; + } + } + } + return result; +} diff --git a/playground/pagx-preview/src/server/index.js b/playground/pagx-preview/src/server/index.js new file mode 100644 index 0000000000..fb971f94b9 --- /dev/null +++ b/playground/pagx-preview/src/server/index.js @@ -0,0 +1,610 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +import express from 'express'; +import { fileURLToPath } from 'url'; +import { PreviewSession } from './session.js'; +import { resolveFontsDir, listFonts, listFontsPreferringCache } from './fonts.js'; +import { ensureAllFonts } from './font-cache.js'; +import { mountMcpServer } from '../mcp/server.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const PREVIEW_DIR = path.resolve(__dirname, '../..'); +const STATIC_DIR = path.join(PREVIEW_DIR, 'static'); +// Compiled/copied artifacts (viewer wasm+glue, player bundle, ext bundle, MCP widget bundle) live +// in a single gitignored directory outside static/, mirroring pagx-playground's wasm-mt/ layout. +const GENERATED_DIR = path.join(PREVIEW_DIR, 'wasm'); +const VIEWER_INFO_PATH = path.join(GENERATED_DIR, 'viewer', 'info.json'); + +// Once every tab has closed its SSE stream, wait this long before shutting the server down. +// A short grace period covers "close tab, immediately re-run pagx-preview" without a respawn. +const IDLE_SHUTDOWN_MS = 30_000; + +/** Short deterministic id for a file path so the same file always maps to the same session. */ +function sessionIdFor(filePath) { + return crypto.createHash('sha1').update(path.resolve(filePath)).digest('hex').slice(0, 10); +} + +/** Short random id for one-shot drop sessions (files opened via browser drag-and-drop). */ +function randomSessionId() { + return 'drop-' + crypto.randomBytes(5).toString('hex'); +} + +/** Reads the prebuild-produced info.json describing which pagx-viewer variant was staged. */ +function readViewerInfo() { + if (!fs.existsSync(VIEWER_INFO_PATH)) { + throw new Error( + `pagx preview: viewer artifacts not found at ${GENERATED_DIR}/viewer. Run "npm run prebuild" first.` + ); + } + return JSON.parse(fs.readFileSync(VIEWER_INFO_PATH, 'utf8')); +} + +/** + * Starts the preview server and creates the initial session for `entryFile` (if provided). + * + * The returned object exposes: + * - port / host / url / sessionId: connection info for the initial session + * - fontsDir: absolute path used for fallback fonts (null if none) + * - close(): stop the server and all its watchers + * - onIdle(callback): fired when the last SSE subscriber leaves after IDLE_SHUTDOWN_MS + */ +export async function startServer({ entryFile = null, port = 0, host = '127.0.0.1', fontsDir = null }) { + let resolvedFontsDir = resolveFontsDir(fontsDir); + // Endpoints look up the current font list on every request so a background lazy-download can + // become visible without a restart. Explicit --fonts / PAGX_FONTS_DIR overrides skip the + // download path entirely. + const useLazyDownload = !fontsDir && !process.env.PAGX_FONTS_DIR; + function currentFontEntries() { + // When lazy-downloading, prefer any file that has already landed in the cache. Otherwise + // stick to the resolved directory so overrides remain authoritative. + return useLazyDownload + ? listFontsPreferringCache(resolvedFontsDir) + : listFonts(resolvedFontsDir); + } + + const viewerInfo = readViewerInfo(); + + const app = express(); + app.use(express.json({ limit: '4mb' })); + + // MCP Apps hosts (Claude Desktop, CodeBuddy, etc.) load the widget HTML inside a cross-origin + // sandbox iframe. All resource fetches (ES module imports, pagx bytes, fonts, SSE) from the + // iframe to this server are cross-origin, so without CORS headers the browser blocks them and + // the widget stays blank. This is a local-only preview server, so a permissive CORS policy is + // safe here. + app.use((req, res, next) => { + res.set('Access-Control-Allow-Origin', '*'); + res.set('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); + res.set('Access-Control-Allow-Headers', 'Content-Type, MCP-Protocol-Version, Accept'); + if (req.method === 'OPTIONS') { + res.status(204).end(); + return; + } + next(); + }); + + // The multi-threaded pagx-viewer needs SharedArrayBuffer, which browsers only expose in a + // cross-origin-isolated context. The single-threaded build has no such requirement. + if (viewerInfo.multiThreaded) { + app.use((req, res, next) => { + res.set('Cross-Origin-Opener-Policy', 'same-origin'); + res.set('Cross-Origin-Embedder-Policy', 'require-corp'); + next(); + }); + } + + const sessions = new Map(); + // Tracks how many SSE responses are open per session id. When it drops to zero for every + // session, the idle timer fires. + const sseCountBySession = new Map(); + + // Track live TCP sockets and SSE responses so shutdown can force-close them. Node's + // http.Server.close() only stops accepting new connections and waits for existing ones to + // drain; without this an open SSE stream keeps the process alive forever after Ctrl+C. + const sockets = new Set(); + const sseResponses = new Set(); + + let idleCallback = null; + let idleTimer = null; + + function totalSseCount() { + let n = 0; + for (const c of sseCountBySession.values()) n += c; + return n; + } + + function scheduleIdleCheck() { + if (!idleCallback) return; + if (idleTimer !== null) return; + if (totalSseCount() > 0) return; + idleTimer = setTimeout(() => { + idleTimer = null; + if (totalSseCount() === 0 && idleCallback) idleCallback(); + }, IDLE_SHUTDOWN_MS); + } + + function cancelIdleCheck() { + if (idleTimer !== null) { + clearTimeout(idleTimer); + idleTimer = null; + } + } + + function createOrGetSession(filePath) { + const id = sessionIdFor(filePath); + let session = sessions.get(id); + let reused = true; + if (!session) { + session = new PreviewSession(id, filePath); + sessions.set(id, session); + reused = false; + } + return { session, reused }; + } + + const initialSession = entryFile ? createOrGetSession(path.resolve(entryFile)).session : null; + + // Lightweight probe used by the CLI's health check when deciding whether to reuse a running + // server. Kept intentionally trivial so a lock check finishes in ~1ms. + app.get('/health', (req, res) => { + res.json({ ok: true, sessions: sessions.size }); + }); + + // Session index for CLI reuse. POST creates or returns an existing session for a filesystem + // path; a session id of "drop" indicates a one-shot ephemeral upload from the browser drop + // handler (see the /session/drop endpoint below). + app.post('/sessions', (req, res) => { + // /sessions is a CLI-only endpoint that creates/reuses a session for a filesystem path. No + // browser context ever calls it: the same-origin preview tab and the cross-origin widget + // iframe only POST to /session/:id/{resources,document}. Any request carrying browser + // fetch-metadata is therefore a cross-site attempt to inject an arbitrary path and read the + // file back via GET /session/:id/pagx, so reject it. The Node CLI sends neither header. + const site = req.get('Sec-Fetch-Site'); + if (req.get('Origin') || (site && site !== 'same-origin' && site !== 'none')) { + res.status(403).json({ error: 'forbidden' }); + return; + } + const filePath = typeof req.body?.file === 'string' ? req.body.file : null; + if (!filePath) { + res.status(400).json({ error: 'missing file' }); + return; + } + const absolute = path.resolve(filePath); + // Defense in depth: the preview only ever handles .pagx files, so refuse any other extension. + // This shrinks the arbitrary-file-read surface even if the guard above is ever bypassed. + if (path.extname(absolute).toLowerCase() !== '.pagx') { + res.status(400).json({ error: 'not a .pagx file', file: absolute }); + return; + } + if (!fs.existsSync(absolute)) { + res.status(404).json({ error: 'file not found', file: absolute }); + return; + } + const { session, reused } = createOrGetSession(absolute); + const hadActiveTab = (sseCountBySession.get(session.id) || 0) > 0; + if (reused && hadActiveTab) { + // Ask the existing tab to bring itself to the front. Browsers usually refuse this outside + // a user gesture, so the CLI must not depend on it — see cli.js for the fallback path. + session.emit({ type: 'focus' }); + } + res.json({ + id: session.id, + url: `/session/${session.id}/`, + reused, + hadActiveTab, + }); + }); + + // One-shot ephemeral session for files dropped into the browser. The file bytes are held in + // memory only (never written to disk) and the session watches nothing: dropped files are + // preview-only. Storing on disk with live watch would require the browser to give us a real + // absolute path, which it never does. + const DROP_SESSION_MAX = 100; + const dropSessions = new Map(); + app.post('/session/drop', express.raw({ type: '*/*', limit: '32mb' }), (req, res) => { + if (!req.body || req.body.length === 0) { + res.status(400).json({ error: 'empty body' }); + return; + } + // Evict oldest entries when the cap is reached to prevent unbounded memory growth. + if (dropSessions.size >= DROP_SESSION_MAX) { + const firstKey = dropSessions.keys().next().value; + if (firstKey !== undefined) dropSessions.delete(firstKey); + } + const name = String(req.query.name || 'dropped.pagx'); + const id = randomSessionId(); + dropSessions.set(id, { name, data: Buffer.from(req.body) }); + res.json({ id, url: `/session/${id}/`, name }); + }); + + // Session page. All same-origin fetches issued by the page are scoped under /session/:id/ + // so the browser sees a single origin per document; the routes below key on :id. + app.get('/session/:id/', (req, res) => { + if (!sessions.has(req.params.id) && !dropSessions.has(req.params.id)) { + res.status(404).send('unknown session'); + return; + } + res.sendFile(path.join(STATIC_DIR, 'index.html')); + }); + + // Entry PAGX bytes. + app.get('/session/:id/pagx', (req, res) => { + const drop = dropSessions.get(req.params.id); + if (drop) { + res.set('Content-Type', 'application/octet-stream'); + res.send(drop.data); + return; + } + const session = sessions.get(req.params.id); + if (!session) { + res.status(404).send('unknown session'); + return; + } + res.sendFile(session.entryFile, (err) => { + if (err && !res.headersSent) { + res.status(500).send('failed to read entry file'); + } + }); + }); + + // Overwrite the entry pagx file with the request body. Used by the editor's "Save" button so + // in-browser edits become durable on disk. The path is fixed to the session's entryFile and + // never taken from the URL / body, keeping this endpoint from being turned into an + // arbitrary-file-write primitive. Drop sessions have no on-disk file so they get a clear 400. + // The write itself will trigger the session's file watcher, which broadcasts a `reload` SSE + // event that all connected tabs pick up naturally - so this endpoint stops at persistence and + // leaves refresh to the existing reload path. + app.put( + '/session/:id/pagx', + express.raw({ type: '*/*', limit: '32mb' }), + async (req, res) => { + if (dropSessions.has(req.params.id)) { + res.status(400).json({ error: 'save is not available for dropped files' }); + return; + } + const session = sessions.get(req.params.id); + if (!session) { + res.status(404).json({ error: 'unknown session' }); + return; + } + if (!req.body || req.body.length === 0) { + res.status(400).json({ error: 'empty body' }); + return; + } + try { + await fs.promises.writeFile(session.entryFile, req.body); + res.json({ ok: true, bytes: req.body.length }); + } catch (err) { + console.error(`pagx preview: failed to save ${session.entryFile}`, err); + res.status(500).json({ error: err && err.message ? err.message : 'write failed' }); + } + }, + ); + + // Session metadata (label shown in the tab title, watch mode, etc.). + app.get('/session/:id/info', (req, res) => { + const drop = dropSessions.get(req.params.id); + if (drop) { + res.json({ name: drop.name, mode: 'drop', watched: false }); + return; + } + const session = sessions.get(req.params.id); + if (!session) { + res.status(404).send('unknown session'); + return; + } + res.json({ name: path.basename(session.entryFile), mode: 'watch', watched: true }); + }); + + // External resource fetch. The path is browser-supplied and must stay under the entry file's + // directory; PreviewSession.resolveResource() enforces that. Drop sessions cannot reference + // external files (they're one-shot in-memory previews). + app.get('/session/:id/resources/*', (req, res) => { + const session = sessions.get(req.params.id); + if (!session) { + res.status(404).send('unknown session'); + return; + } + const relativePath = req.params[0]; + const absolute = session.resolveResource(relativePath); + if (!absolute) { + res.status(400).send('invalid resource path'); + return; + } + res.sendFile(absolute, (err) => { + if (err && !res.headersSent) { + res.status(404).send('resource not found'); + } + }); + }); + + // Browser reports the PAGX's external file list once the document is parsed. The server uses + // the list to extend its filesystem watch, so edits to referenced images also trigger reload. + app.post('/session/:id/resources', (req, res) => { + const session = sessions.get(req.params.id); + if (!session) { + // Drop sessions ignore this endpoint silently: they never watch anything. + if (dropSessions.has(req.params.id)) { + res.json({ ok: true, watched: false }); + return; + } + res.status(404).send('unknown session'); + return; + } + const paths = Array.isArray(req.body?.paths) ? req.body.paths : []; + session.updateResources(paths); + res.json({ ok: true, count: paths.length }); + }); + + // Client uploads a document summary (node list, dimensions, duration) after a successful load. + // The MCP get_document tool reads this cache to answer AI queries without a client round-trip. + app.post('/session/:id/document', (req, res) => { + const session = sessions.get(req.params.id); + if (!session) { + res.status(404).json({ error: 'unknown session' }); + return; + } + session.setDocumentSummary(req.body ?? null); + res.json({ ok: true }); + }); + + // Returns the cached document summary. null when the client hasn't uploaded one yet (e.g. the + // pagx is still loading or the client doesn't support summary upload). + app.get('/session/:id/document', (req, res) => { + const session = sessions.get(req.params.id); + if (!session) { + res.status(404).json({ error: 'unknown session' }); + return; + } + res.json(session.documentSummary); + }); + + // Server-Sent Events: one long-lived response per open tab. The browser reconnects on drop. + app.get('/session/:id/events', (req, res) => { + const id = req.params.id; + const session = sessions.get(id); + if (!session && !dropSessions.has(id)) { + res.status(404).end(); + return; + } + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.flushHeaders(); + res.write(': connected\n\n'); + sseResponses.add(res); + sseCountBySession.set(id, (sseCountBySession.get(id) || 0) + 1); + cancelIdleCheck(); + + let unsubscribe = () => {}; + if (session) { + unsubscribe = session.subscribe((event) => { + res.write(`event: ${event.type}\n`); + res.write(`data: ${JSON.stringify(event)}\n\n`); + }); + } + + // Periodic comment keeps intermediaries from closing an idle connection. + const keepAlive = setInterval(() => res.write(': ping\n\n'), 25000); + + req.on('close', () => { + clearInterval(keepAlive); + unsubscribe(); + sseResponses.delete(res); + const remaining = (sseCountBySession.get(id) || 1) - 1; + if (remaining <= 0) { + sseCountBySession.delete(id); + } else { + sseCountBySession.set(id, remaining); + } + scheduleIdleCheck(); + }); + }); + + // Source-only static assets (index.html/css/js, mcp-widget.html/js, icons). + app.use( + '/static', + express.static(STATIC_DIR, { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.wasm')) { + res.set('Content-Type', 'application/wasm'); + } + }, + }) + ); + + // Generated artifacts (viewer wasm/glue, player bundle, ext bundle) served under /wasm. Kept + // separate from /static so the source tree stays free of build outputs (see scripts/prebuild.js). + app.use( + '/wasm', + express.static(GENERATED_DIR, { + setHeaders: (res, filePath) => { + if (filePath.endsWith('.wasm')) { + res.set('Content-Type', 'application/wasm'); + } + }, + }) + ); + + // Silence the browser's default /favicon.ico probe; we don't ship one and the 404 clutters + // the Network panel on every reload. + app.get('/favicon.ico', (req, res) => { + res.status(204).end(); + }); + + // Fallback fonts served for the viewer. The client fetches /fonts/list first, then downloads + // each referenced file. When no font source is available the list is empty and the client + // proceeds without registering fonts (text glyphs will render blank). + app.get('/fonts/list', (req, res) => { + const entries = currentFontEntries(); + const list = {}; + for (const [role, entry] of Object.entries(entries)) { + list[role] = entry.name; + } + // Surface whether a background download is still working so the client can retry politely. + res.json({ + fontsDir: resolvedFontsDir, + fonts: list, + downloading: useLazyDownload && Object.keys(list).length < 2, + }); + }); + + app.get('/fonts/:name', (req, res) => { + // Only serve files explicitly enumerated by the current font list to keep the endpoint + // from turning into a generic file server for the fonts directory. + const match = Object.values(currentFontEntries()).find((entry) => entry.name === req.params.name); + if (!match) { + res.status(404).send('font not found'); + return; + } + res.sendFile(match.path); + }); + + // MCP Apps endpoint. CodeBuddy (or any MCP client) connects to /mcp via Streamable HTTP + // transport. The MCP server exposes 4 tools (preview_pagx, preview_pagx_widget, + // reload_file, get_document) and 1 HTML resource (ui://pagx-preview/main) so that the host + // can render a pagx widget directly inside the conversation flow. The MCP server shares the + // same express app, port, and session state as the rest of pagx-preview. + // + // serverBaseUrl is assigned after app.listen resolves the actual port. readResource reads it + // lazily (via getServerBaseUrl) to inject into the widget HTML, so the widget's relative + // URLs resolve against the server even when it runs inside a sandbox iframe with opaque origin. + let serverBaseUrl = ''; + mountMcpServer(app, { + sessions, + dropSessions, + createOrGetSession, + staticDir: STATIC_DIR, + generatedDir: GENERATED_DIR, + getServerBaseUrl: () => serverBaseUrl, + }); + + // Root redirects to the initial session, which is what CLI users hit when the browser opens. + app.get('/', (req, res) => { + if (initialSession) { + res.redirect(`/session/${initialSession.id}/`); + return; + } + res.status(404).send('no default session'); + }); + + // Broadcasts an event to every active SSE subscriber. Used by the lazy font download to nudge + // clients into re-registering their fallback fonts once the download completes. + function broadcastToAll(event) { + for (const session of sessions.values()) { + session.emit(event); + } + } + + // Kick off a background download so a globally installed pagx-preview eventually gets fonts + // even without a libpag checkout. The download is fire-and-forget: server responsiveness + // never waits on it, and clients poll /fonts/list via SSE-driven retry. + if (useLazyDownload) { + (async () => { + const before = Object.keys(currentFontEntries()).length; + const result = await ensureAllFonts(); + // If the initial resolveFontsDir() picked null (nothing existed yet) but the cache is now + // populated, promote it so the /fonts/:name handler can serve out of it. + if (!resolvedFontsDir && Object.values(result).some((r) => r.path)) { + resolvedFontsDir = resolveFontsDir(null); + } + const after = Object.keys(currentFontEntries()).length; + if (after > before) { + broadcastToAll({ type: 'fonts-ready' }); + } + })().catch((err) => { + process.stderr.write(`pagx preview: font download failed: ${err.message}\n`); + }); + } + + return await new Promise((resolve, reject) => { + const server = app.listen(port, host, () => { + const actualPort = server.address().port; + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + serverBaseUrl = `http://${displayHost}:${actualPort}`; + const url = initialSession + ? `http://${displayHost}:${actualPort}/session/${initialSession.id}/` + : `http://${displayHost}:${actualPort}/`; + resolve({ + server, + port: actualPort, + host, + url, + sessionId: initialSession ? initialSession.id : null, + // Exposed so a stdio MCP server (see connectStdioMcpServer) can share this in-process + // preview server's live state instead of talking to it over HTTP. + sessions, + dropSessions, + createOrGetSession, + staticDir: STATIC_DIR, + generatedDir: GENERATED_DIR, + getServerBaseUrl: () => serverBaseUrl, + get fontsDir() { + // Reflects the currently-resolved directory: with lazy download, initial startup can + // return null but a later access (after the download finishes) sees the cache dir. + return resolvedFontsDir; + }, + useLazyDownload, + onIdle(cb) { + idleCallback = cb; + scheduleIdleCheck(); + }, + async close() { + cancelIdleCheck(); + idleCallback = null; + // Terminate any live SSE streams first so their sockets become drainable. + for (const res of sseResponses) { + try { + res.end(); + } catch (_) { + // ignore + } + } + sseResponses.clear(); + sseCountBySession.clear(); + // Force-destroy remaining sockets: keep-alive TCP connections would otherwise keep + // server.close() pending until the peer times out. + for (const socket of sockets) { + socket.destroy(); + } + sockets.clear(); + for (const session of sessions.values()) { + await session.close(); + } + sessions.clear(); + dropSessions.clear(); + await new Promise((r) => server.close(() => r())); + }, + }); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + server.on('error', reject); + }); +} diff --git a/playground/pagx-preview/src/server/lock.js b/playground/pagx-preview/src/server/lock.js new file mode 100644 index 0000000000..f3a6c32c15 --- /dev/null +++ b/playground/pagx-preview/src/server/lock.js @@ -0,0 +1,107 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +// Cross-invocation lock file so a second `pagx-preview` call can discover a running server +// instead of spawning its own. Layout: ~/.pagx/preview.lock is a small JSON blob describing +// the currently-live server. A missing/stale lock (pid gone, unresponsive port) is treated as +// no server, and the caller is expected to spawn a new one. + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import http from 'http'; + +const LOCK_DIR = path.join(os.homedir(), '.pagx'); +const LOCK_FILE = path.join(LOCK_DIR, 'preview.lock'); +// Time-boxed health probe to keep CLI startup snappy when the peer process is unhealthy. +const HEALTH_TIMEOUT_MS = 500; + +function pidAlive(pid) { + if (!pid || pid <= 0) return false; + try { + // Signal 0 checks process existence without actually delivering a signal. + process.kill(pid, 0); + return true; + } catch (err) { + // ESRCH: no such process. EPERM: process exists but we can't signal it (still counts as alive). + return err.code === 'EPERM'; + } +} + +function healthCheck(host, port) { + return new Promise((resolve) => { + const req = http.get( + { host, port, path: '/health', timeout: HEALTH_TIMEOUT_MS }, + (res) => { + // Drain the body so the socket can be freed even on unexpected content. + res.resume(); + resolve(res.statusCode === 200); + } + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + }); +} + +/** Reads the lock file. Returns null if missing or unparsable. */ +export function readLock() { + try { + return JSON.parse(fs.readFileSync(LOCK_FILE, 'utf8')); + } catch (_) { + return null; + } +} + +/** Writes the lock file with the current server's identity. */ +export function writeLock({ port, host, pid }) { + fs.mkdirSync(LOCK_DIR, { recursive: true }); + const payload = { port, host, pid, startedAt: new Date().toISOString() }; + fs.writeFileSync(LOCK_FILE, JSON.stringify(payload, null, 2) + '\n'); +} + +/** Deletes the lock file (idempotent). */ +export function clearLock() { + try { + fs.unlinkSync(LOCK_FILE); + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } +} + +/** + * Checks whether a live pagx-preview server is reachable via the lock file. Returns the lock + * payload on success, or null when no usable server exists (missing lock, dead pid, or + * unresponsive port). + */ +export async function probeLiveServer() { + const lock = readLock(); + if (!lock) return null; + if (!pidAlive(lock.pid)) { + clearLock(); + return null; + } + const ok = await healthCheck(lock.host || '127.0.0.1', lock.port); + if (!ok) { + clearLock(); + return null; + } + return lock; +} diff --git a/playground/pagx-preview/src/server/session.js b/playground/pagx-preview/src/server/session.js new file mode 100644 index 0000000000..1406e527ac --- /dev/null +++ b/playground/pagx-preview/src/server/session.js @@ -0,0 +1,130 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import path from 'path'; +import chokidar from 'chokidar'; + +// Delay between raw chokidar events and the reload emission. Absorbs the burst that editors +// produce when saving (temp file + rename + attr touch) into a single reload. +const RELOAD_DEBOUNCE_MS = 80; + +/** + * PreviewSession tracks one PAGX file plus its external resources (images, embedded fonts, etc.) + * and turns filesystem changes into reload events for subscribers. + * + * The entry file is watched from construction. Resource files reported by the browser via + * updateResources() are added incrementally; stale entries are unwatched on the next update. + */ +export class PreviewSession { + constructor(id, entryFile) { + this.id = id; + this.entryFile = path.resolve(entryFile); + this.entryDir = path.dirname(this.entryFile); + this.resources = new Set(); + this.listeners = new Set(); + this.reloadTimer = null; + // Cached document summary uploaded by the client after a successful load. Used by the MCP + // get_document tool to answer AI queries without round-tripping through the client. Stale + // data is acceptable — the client overwrites it on every load. + this.documentSummary = null; + // Count of consecutive get_document calls that found no summary. When a host does not render + // the inline widget, the summary never arrives; this lets the MCP tool detect that case and + // fall back to the browser-openable webview after a few misses. Reset to 0 on a successful + // get_document (i.e. once the client has uploaded a summary). + this.documentQueryFailures = 0; + this.watcher = chokidar.watch(this.entryFile, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 40, pollInterval: 20 }, + }); + this.watcher.on('all', (event, file) => this.onFsEvent(event, file)); + } + + /** Resolves a browser-supplied relative path to an absolute path under entryDir, or null if + * the path escapes the entry directory (defense against `../` traversal). */ + resolveResource(relativePath) { + if (typeof relativePath !== 'string' || relativePath.length === 0) return null; + if (relativePath.includes('\0')) return null; + const absolute = path.resolve(this.entryDir, relativePath); + const rel = path.relative(this.entryDir, absolute); + if (rel.startsWith('..') || path.isAbsolute(rel)) return null; + return absolute; + } + + /** Replaces the resource watch list with the browser-reported set. */ + updateResources(paths) { + const nextAbs = new Set(); + for (const p of paths) { + const abs = this.resolveResource(p); + if (abs) nextAbs.add(abs); + } + const toAdd = []; + const toRemove = []; + for (const abs of nextAbs) { + if (!this.resources.has(abs)) toAdd.push(abs); + } + for (const abs of this.resources) { + if (!nextAbs.has(abs)) toRemove.push(abs); + } + if (toAdd.length > 0) this.watcher.add(toAdd); + if (toRemove.length > 0) this.watcher.unwatch(toRemove); + this.resources = nextAbs; + } + + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + hasSubscribers() { + return this.listeners.size > 0; + } + + /** Stores a document summary uploaded by the client after load. See documentSummary comment. */ + setDocumentSummary(summary) { + this.documentSummary = summary; + this.documentQueryFailures = 0; + } + + onFsEvent(event, file) { + if (event !== 'add' && event !== 'change' && event !== 'unlink') return; + if (this.reloadTimer !== null) return; + this.reloadTimer = setTimeout(() => { + this.reloadTimer = null; + this.emit({ type: 'reload', file, event }); + }, RELOAD_DEBOUNCE_MS); + } + + emit(payload) { + for (const listener of this.listeners) { + try { + listener(payload); + } catch (err) { + console.error('pagx preview: subscriber threw', err); + } + } + } + + async close() { + if (this.reloadTimer !== null) { + clearTimeout(this.reloadTimer); + this.reloadTimer = null; + } + this.listeners.clear(); + await this.watcher.close(); + } +} diff --git a/playground/pagx-preview/static/icons/next.png b/playground/pagx-preview/static/icons/next.png new file mode 100755 index 0000000000..988a0d2e32 --- /dev/null +++ b/playground/pagx-preview/static/icons/next.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18ebfd712d4bd763b6e991a90718737f02718dc151bd1718c3da3535b9eabd17 +size 1827 diff --git a/playground/pagx-preview/static/icons/pause.png b/playground/pagx-preview/static/icons/pause.png new file mode 100644 index 0000000000..cca72b0404 --- /dev/null +++ b/playground/pagx-preview/static/icons/pause.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:47f8a2689ab77e45f245ce1be9463328dd28f40a6bda77f385d92c2b5525c4c5 +size 9465 diff --git a/playground/pagx-preview/static/icons/play.png b/playground/pagx-preview/static/icons/play.png new file mode 100755 index 0000000000..e96dd9160c --- /dev/null +++ b/playground/pagx-preview/static/icons/play.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:02d6327ab57f8d524c942c12648ea8ceb6be2accf10948b5039585e4578e22c2 +size 10365 diff --git a/playground/pagx-preview/static/icons/previous.png b/playground/pagx-preview/static/icons/previous.png new file mode 100755 index 0000000000..05de8888b5 --- /dev/null +++ b/playground/pagx-preview/static/icons/previous.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51be6154e4d8b6b0568c1b9d2d7ee745bb07049ea6a169da3b67232c13bba76d +size 1813 diff --git a/playground/pagx-preview/static/index.css b/playground/pagx-preview/static/index.css new file mode 100644 index 0000000000..e74965669b --- /dev/null +++ b/playground/pagx-preview/static/index.css @@ -0,0 +1,134 @@ +/* + * Tencent is pleased to support the open source community by making libpag available. + * Copyright (C) 2026 Tencent. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +/* Preview-only chrome around the shared PAGXPlayer component. Canvas / toolbar / playback bar + * styles ship with pagx-player itself (see pagx-player/src/styles.ts) so this file only owns + * the base layout and the preview-specific overlays. */ + +html, body { + padding: 0; + margin: 0; + height: 100%; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: #1a1a2e; + color: #fff; +} + +#container { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; +} + +/* Editor-open layout (canvas / toolbar / playback bar reflow next to the panel) is handled + entirely inside the pagx-player root wrapper - see pagx-player/src/styles.ts's + .pagx-player-root.with-editor rule. #container just provides the sizing frame. */ + +/* Status pill styles live in pagx-player (see pagx-player/src/styles.ts) so both playground and + preview render identical status chrome; the preview only owns the elements that don't belong + to the component. */ + +/* Refresh banner shown when the tab regains focus after the file changed in the background. + More prominent than .status so users returning to the tab immediately notice the update. */ +.refresh-banner { + position: absolute; + left: 50%; + top: 24px; + transform: translateX(-50%); + padding: 10px 20px; + background: rgba(76, 124, 243, 0.92); + color: #fff; + border-radius: 999px; + font-size: 13px; + font-weight: 500; + letter-spacing: 0.02em; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); + pointer-events: none; + z-index: 250; + opacity: 1; + transition: opacity 0.4s ease; +} + +.refresh-banner.hidden { + opacity: 0; +} + +/* Persistent advisory shown only in drop sessions. Amber, distinct from the blue refresh + banner so users don't confuse "your file just changed" with "your file can't change". + Positioned in the top-right so it stays out of the drop-overlay's centered target. */ +.drop-hint { + position: absolute; + right: 24px; + top: 20px; + max-width: 360px; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 12px 10px 14px; + background: rgba(51, 39, 20, 0.95); + color: #f7d992; + border: 1px solid rgba(247, 217, 146, 0.35); + border-radius: 8px; + font-size: 12px; + line-height: 1.4; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35); + z-index: 200; +} + +.drop-hint.hidden { + display: none; +} + +.drop-hint code { + padding: 0 4px; + background: rgba(247, 217, 146, 0.14); + border-radius: 3px; + font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, monospace; + font-size: 11px; +} + +.drop-hint-close { + flex-shrink: 0; + padding: 0; + width: 18px; + height: 18px; + line-height: 1; + background: transparent; + color: inherit; + border: none; + font-size: 16px; + cursor: pointer; + opacity: 0.7; +} + +.drop-hint-close:hover { + opacity: 1; +} + +/* Drag overlay for dropping .pagx files. Kept subtle so it doesn't blow up on stray drags but + clearly signals the drop zone once a file is over the window. */ +.drop-overlay { + position: absolute; + inset: 0; + background: rgba(26, 26, 46, 0.7); + border: 3px dashed rgba(255, 255, 255, 0.45); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 20px; + font-weight: 500; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 300; +} + +.drop-overlay.active { + opacity: 1; +} diff --git a/playground/pagx-preview/static/index.html b/playground/pagx-preview/static/index.html new file mode 100644 index 0000000000..d287b0cc1a --- /dev/null +++ b/playground/pagx-preview/static/index.html @@ -0,0 +1,33 @@ + + + + + + + PAGX Preview + + + + +
+ + + +
Drop .pagx file to preview
+
+ + + diff --git a/playground/pagx-preview/static/index.js b/playground/pagx-preview/static/index.js new file mode 100644 index 0000000000..6c4ad9b8da --- /dev/null +++ b/playground/pagx-preview/static/index.js @@ -0,0 +1,581 @@ +/* + * Tencent is pleased to support the open source community by making libpag available. + * Copyright (C) 2026 Tencent. All rights reserved. + * Licensed under the Apache License, Version 2.0 (the "License"); + */ + +// Preview client: instantiates the shared PAGXPlayer component (canvas / gestures / playback +// bar / toolbar) and layers preview-only chrome on top - session-aware fetches, SSE-driven +// reloads, drop-to-open, status pill, refresh banner. Everything player-related now lives in +// pagx-player; this file is deliberately kept small and preview-specific. + +import { PAGXPlayer } from '/wasm/player/pagx-player.esm.js'; + +const SESSION_MATCH = window.location.pathname.match(/^\/session\/([^/]+)\//); +if (!SESSION_MATCH) { + // No player yet at this point, so fall back to a plain full-screen text overlay for the + // one bad-URL case; the normal status pill is a player concern and is unavailable here. + const el = document.createElement('div'); + el.textContent = 'Invalid session URL'; + el.style.cssText = + 'position:fixed;inset:0;display:flex;align-items:center;justify-content:center;' + + 'color:#fff;background:rgba(220,53,69,0.85);font-size:14px;z-index:9999;'; + document.body.appendChild(el); + throw new Error('pagx-preview: cannot determine session id from URL'); +} +const SESSION_ID = SESSION_MATCH[1]; +const SESSION_BASE = `/session/${SESSION_ID}`; + +const container = document.getElementById('container'); +const refreshBanner = document.getElementById('refresh-banner'); +const dropOverlay = document.getElementById('drop-overlay'); +const dropHint = document.getElementById('drop-hint'); + +// Drop sessions are identified server-side by a `drop-` prefix on the random session id. Those +// sessions are memory-only and can't be watched for filesystem changes (the browser never gives +// us the file's absolute path), so surface a persistent hint pointing users at the CLI when we +// detect we're rendering one of them. +if (/\/session\/drop-/.test(window.location.pathname)) { + dropHint?.classList.remove('hidden'); + document.getElementById('drop-hint-close')?.addEventListener('click', () => { + dropHint?.classList.add('hidden'); + }); +} + +// ---------- Status pill + refresh banner ---------- + +// Thin wrapper around player.showStatus so preview code keeps its concise call sites. Declared +// as a function (hoisted) because a few status calls sit above the player instantiation below. +// The player instance is initialized before any of these calls actually run (all callers are +// inside async functions or event handlers), so `player` is guaranteed to exist by then. +function showStatus(text, isError = false, autoHideMs = 0) { + player.showStatus(text, { + kind: isError ? 'error' : 'info', + autoHideMs, + }); +} + +let refreshBannerTimer = null; +function showRefreshBanner(text, autoHideMs = 1600) { + refreshBanner.textContent = text; + refreshBanner.classList.remove('hidden'); + clearTimeout(refreshBannerTimer); + if (autoHideMs > 0) { + refreshBannerTimer = setTimeout(() => refreshBanner.classList.add('hidden'), autoHideMs); + } +} + +// ---------- Viewer module loading (glue file + wasm) ---------- + +let PAGXInit = null; +let viewerInfo = null; +let fontsRegistered = false; + +async function loadPagxInit() { + if (PAGXInit) return PAGXInit; + const infoResp = await fetch('/wasm/viewer/info.json', { cache: 'no-store' }); + if (!infoResp.ok) throw new Error(`fetch viewer info failed: ${infoResp.status}`); + viewerInfo = await infoResp.json(); + const glueUrl = `/wasm/viewer/${viewerInfo.glueFile}`; + const mod = await import(glueUrl); + PAGXInit = mod.PAGXInit; + return PAGXInit; +} + +async function moduleFactory() { + const init = await loadPagxInit(); + return init({ + locateFile: (file) => `/wasm/viewer/${file}`, + }); +} + +// ---------- Font registration ---------- + +// Downloads the primary + emoji fallback fonts from the server and hands them to the viewer. +// Empty buffers are acceptable (the C++ side treats them as "no override") so a partial +// availability (only primary, only emoji) still works. When the server reports that a lazy +// background download is in progress, an SSE 'fonts-ready' event later triggers another call +// to this function so the current view picks up the fonts without a manual refresh. +async function registerFallbackFonts(view) { + if (fontsRegistered) return; + try { + const listResp = await fetch('/fonts/list', { cache: 'no-store' }); + if (!listResp.ok) return; + const info = await listResp.json(); + const fonts = info?.fonts || {}; + const primaryName = fonts.primary; + const emojiName = fonts.emoji; + if (!primaryName && !emojiName) { + if (info?.downloading) { + console.info('pagx-preview: fonts downloading in background; will retry on fonts-ready.'); + } else { + console.warn('pagx-preview: no fallback fonts available; text will render blank.'); + } + return; + } + const [primaryData, emojiData] = await Promise.all([ + fetchFontBytes(primaryName), + fetchFontBytes(emojiName), + ]); + view.registerFonts(primaryData, emojiData); + // Consider registration complete only when both slots are populated; partial results still + // allow a retry after the background download fills in the missing role. + if (primaryName && emojiName) fontsRegistered = true; + } catch (err) { + console.warn('pagx-preview: font registration failed', err); + } +} + +async function fetchFontBytes(name) { + if (!name) return new Uint8Array(0); + const resp = await fetch(`/fonts/${encodeURIComponent(name)}`, { cache: 'force-cache' }); + if (!resp.ok) { + console.warn(`pagx-preview: font ${name} -> ${resp.status}`); + return new Uint8Array(0); + } + return new Uint8Array(await resp.arrayBuffer()); +} + +// ---------- Resource cache and invalidation ---------- + +// Client-side cache for external resource bytes keyed by the relative path the pagx references. +// External resources rarely change compared to the pagx XML itself (AI edits typically only +// tweak colors/positions/text), so caching them saves the whole fetch + arrayBuffer decode +// round-trip on every reload. The wasm loadFileData() call still has to run every time because +// parsePAGX() resets the underlying document. +const resourceCache = new Map(); + +// Absolute paths (as reported by the server's `reload` event) whose bytes we need to re-fetch +// on the next loadPAGX. Populated when the SSE payload names a specific changed file so the +// resource cache below only invalidates that one entry instead of dumping everything. +let invalidatedResources = new Set(); + +function consumeInvalidations() { + const invalidations = invalidatedResources; + invalidatedResources = new Set(); + if (invalidations === null) { + resourceCache.clear(); + } else if (invalidations.size > 0) { + // We can't map absolute watcher paths back to the relative paths the client cache uses + // without rebuilding the server's rel->abs table here, so simplify: if any invalidated + // path doesn't look like a .pagx source, treat it as "some resource changed" and drop + // the whole resource cache. When *only* .pagx files show up we keep every cached resource. + const nonPagx = [...invalidations].some((p) => !p.toLowerCase().endsWith('.pagx')); + if (nonPagx) resourceCache.clear(); + } +} + +// External paths in PAGX are '/'-separated and may contain characters that must be encoded per +// URL segment while preserving the segment structure. +function encodeExternalPath(rel) { + return rel + .split('/') + .map((seg) => encodeURIComponent(seg)) + .join('/'); +} + +// Records how many cache hits occurred during the current load pipeline. The player calls the +// resolveResource callback for every relative path, and we want to log the aggregate count in +// the summary line, so we track it on a module-scoped counter rather than plumbing an extra +// return value through the resolver signature. +let cacheHitsThisLoad = 0; + +async function resolveResource(rel) { + const cached = resourceCache.get(rel); + if (cached) { + cacheHitsThisLoad += 1; + return cached; + } + try { + const resp = await fetch( + `${SESSION_BASE}/resources/${encodeExternalPath(rel)}`, + { cache: 'no-store' } + ); + if (!resp.ok) { + console.warn(`pagx-preview: resource ${rel} -> ${resp.status}`); + return null; + } + const buf = new Uint8Array(await resp.arrayBuffer()); + resourceCache.set(rel, buf); + return buf; + } catch (err) { + console.warn(`pagx-preview: resource ${rel} failed`, err); + return null; + } +} + +// ---------- Performance measurement ---------- + +// Wraps the User Timing API so every load cycle marks distinct start/end points that the +// browser Performance panel can visualize, plus emits a compact console summary that's useful +// when DevTools is closed. +let perfCycleId = 0; +function makePerf(label) { + const cycle = ++perfCycleId; + const total = `${label}.${cycle}.total`; + performance.mark(`${total}.start`); + const stages = []; + return { + begin(stage) { + performance.mark(`${label}.${cycle}.${stage}.start`); + }, + end(stage) { + performance.mark(`${label}.${cycle}.${stage}.end`); + const measure = performance.measure( + `${label}.${cycle}.${stage}`, + `${label}.${cycle}.${stage}.start`, + `${label}.${cycle}.${stage}.end` + ); + stages.push({ stage, dur: measure.duration }); + }, + summarize() { + performance.mark(`${total}.end`); + const measure = performance.measure(total, `${total}.start`, `${total}.end`); + const stageStrs = stages.map((s) => `${s.stage}=${s.dur.toFixed(1)}ms`).join(' '); + // eslint-disable-next-line no-console + console.log( + `[pagx-preview] loadPAGX total=${measure.duration.toFixed(1)}ms | ${stageStrs}` + ); + }, + }; +} + +// Give the browser one frame + one microtask hop so any pending status/DOM updates paint +// before the caller launches another multi-second synchronous wasm call. Two-step wait: rAF +// alone can coalesce with the same task, chaining a subsequent setTimeout(0) makes the paint +// definitely land first. Cheap when the browser is idle; the cost is negligible compared to +// parsePAGX/buildLayers timings on large documents. +function yieldToBrowser() { + return new Promise((resolve) => { + requestAnimationFrame(() => setTimeout(resolve, 0)); + }); +} + +// ---------- Player instance ---------- + +const player = new PAGXPlayer({ + container, + moduleFactory, + iconBaseUrl: '/static/icons/', + // Source editor is enabled here so `L` opens the panel and the toolbar shows the + // button. Apply routes the edited XML back through the wasm pipeline; Save downloads a + // copy since the preview server never persists edits (the file on disk is authoritative + // and the user is expected to edit it directly for durable changes). + enableEditor: true, + editorCallbacks: { + // Async apply: the editor awaits this and keeps the buttons disabled + "Applying..." + // status visible until the whole pipeline settles. Errors returned as a non-empty string + // are rendered by the editor as a red status pill; a thrown error is treated the same + // way. `silent: true` prevents the internal loadPAGX status ("Parsing.../Loaded") from + // fighting with the editor's own "Applying.../Changes applied" for the shared pill. + onApply: async (xmlText) => { + try { + const bytes = new TextEncoder().encode(xmlText); + await loadPAGXFromBytes(bytes, { silent: true }); + return ''; + } catch (err) { + return `Apply failed: ${err && err.message ? err.message : String(err)}`; + } + }, + // Preview owns a real filesystem path (unlike the playground which downloads a copy), so + // Save writes back to the entry pagx via the server. The write triggers the session's + // file watcher, which broadcasts a `reload` SSE - the client picks that up on the same + // loop it uses for external IDE edits, so no manual refresh is needed here. + onSave: async (xmlText) => { + try { + const resp = await fetch(`${SESSION_BASE}/pagx`, { + method: 'PUT', + headers: { 'Content-Type': 'application/xml' }, + body: xmlText, + }); + if (!resp.ok) { + const info = await resp.json().catch(() => ({ error: `status ${resp.status}` })); + return `Save failed: ${info.error}`; + } + return ''; + } catch (err) { + return `Save failed: ${err && err.message ? err.message : String(err)}`; + } + }, + }, +}); + +// ---------- Load pipeline ---------- + +let loading = false; +let reloadQueued = false; +// Captures the strategy chosen by the most recent player.load() call. Populated by the 'loaded' +// event listener installed below and consumed once per load in loadPAGXFromBytes. A plain module +// scoped variable is enough because player.load()'s internal generation gate guarantees a single +// 'loaded' dispatch per completed load and loadPAGXFromBytes drains this value in the same +// microtask that resolves the await. +let lastLoadStrategy = 'fullReplace'; +player.addEventListener('loaded', (event) => { + // 'strategy' was added by the V0 update pipeline; older host builds still fire 'loaded' + // without it, in which case we assume the historical full-replace behavior for backward + // compatibility. + lastLoadStrategy = event.detail.strategy || 'fullReplace'; +}); + +// Wraps player.load() with preview chrome (status/perf/invalidation/external-path report). +// Split from loadPAGX() so the editor's Apply callback can feed edited XML through the exact +// same pipeline as an SSE-driven reload without re-fetching from the server. +// `silent` suppresses the pipeline's own status updates so a caller that already owns the +// status slot (e.g. the editor showing "Changes applied") isn't fighting with "Parsing..." +// and "Loaded" for the same pill - which would otherwise leave the caller's message visible +// for only a fraction of a second before the wasm-load path overwrites it. +async function loadPAGXFromBytes(pagxBuf, { silent = false } = {}) { + if (loading) { + reloadQueued = true; + return; + } + loading = true; + cacheHitsThisLoad = 0; + consumeInvalidations(); + + const perf = makePerf('loadPAGX'); + try { + if (!silent) showStatus('Parsing document...'); + await yieldToBrowser(); + + perf.begin('load'); + // The player performs updatePAGX -> font registration -> resource fetch -> buildLayers -> + // first draw internally. Font registration and resource resolution are handled via the + // callbacks below. The pagx-preview stage-level timing that the old flat pipeline reported + // is now folded into the single 'load' stage; the component's internal marks still show up + // in the Performance panel for detailed profiling. + await player.load(pagxBuf, { + registerFonts: async (view) => { + // Font registration is idempotent and fast (bytes come from HTTP cache after the first + // hit), so calling it on every reload keeps behavior consistent whether or not the + // wasm view was recycled by the player. + await registerFallbackFonts(view); + }, + resolveResource, + preserveCurrentTime: true, + }); + perf.end('load'); + + // Skip the noise-side-effects entirely on a noChange short-circuit. The bytes were + // byte-identical to the previously accepted document, so the external path list can't + // have changed and re-POSTing it would only expand the server's watcher noise. The + // "Loaded" pill is also suppressed: chokidar's rename/attr/content bursts routinely fire + // 2-3 SSE reloads for a single save, and after V0 only the first one produces a real + // update — flashing three "Loaded" pills for what the user perceives as a single save + // is exactly what the equality short-circuit is meant to fix. + if (lastLoadStrategy === 'noChange') { + perf.summarize(); + // eslint-disable-next-line no-console + console.log('[pagx-preview] noChange short-circuit'); + return; + } + + // Report the resource list so the server extends its watch to referenced images/fonts. + // Fire-and-forget: only used to widen the file watcher scope on the server side. + try { + const view = player.getView(); + const externalPaths = view ? view.getExternalFilePaths() : []; + fetch(`${SESSION_BASE}/resources`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: externalPaths }), + }).catch(() => {}); + // eslint-disable-next-line no-console + console.log( + `[pagx-preview] resources=${externalPaths.length} (cache hits=${cacheHitsThisLoad})` + ); + // Upload a document summary so the MCP get_document tool can answer AI queries without a + // client round-trip. nodeCount is not exposed by the viewer yet; we send 0 and let the + // MCP tool report "0 nodes" rather than blocking. A future viewer API can populate it. + if (view) { + const summary = { + nodeCount: 0, + width: view.contentWidth(), + height: view.contentHeight(), + duration: view.durationMicros(), + frameRate: view.frameRate(), + }; + fetch(`${SESSION_BASE}/document`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(summary), + }).catch(() => {}); + } + } catch (_) { + // getView() may return null in a very narrow race (destroy fired mid-load); ignore. + } + + perf.summarize(); + if (!silent) showStatus('Loaded', false, 1200); + } catch (err) { + console.error('pagx-preview: load failed', err); + if (silent) { + // Silent callers (editor Apply) own their own status pill; rethrow so they can surface + // the error via the editor's own feedback path instead of the pipeline stealing focus + // with a global "Load failed" pill. + throw err; + } + showStatus(`Load failed: ${err.message || err}`, true); + } finally { + loading = false; + if (reloadQueued) { + reloadQueued = false; + // Refetch the server's current bytes rather than replaying the buffer we just loaded: + // a reload arriving while a load was in flight means the file changed again on disk. + loadPAGX(); + } + } +} + +// Server-fetch wrapper around loadPAGXFromBytes(). Used by boot, SSE reload, and every other +// "re-fetch and render" path. +async function loadPAGX() { + try { + showStatus('Loading document...'); + const perf = makePerf('fetchPagx'); + perf.begin('fetch'); + const pagxResp = await fetch(`${SESSION_BASE}/pagx`, { cache: 'no-store' }); + if (!pagxResp.ok) throw new Error(`fetch pagx failed: ${pagxResp.status}`); + const pagxBuf = new Uint8Array(await pagxResp.arrayBuffer()); + perf.end('fetch'); + perf.summarize(); + await loadPAGXFromBytes(pagxBuf); + } catch (err) { + console.error('pagx-preview: fetch failed', err); + showStatus(`Fetch failed: ${err.message || err}`, true); + } +} + +// ---------- SSE (server-sent events) ---------- + +// Set to true when a 'reload' arrives while the tab is hidden. On visibilitychange -> visible +// we consume the flag and refresh once, so a background tab doesn't burn cycles reloading +// (and potentially reloading again while still loading) while its user isn't looking. +let pendingBackgroundReload = false; + +function connectSSE() { + const es = new EventSource(`${SESSION_BASE}/events`); + es.addEventListener('reload', (ev) => { + // The server emits payloads like {type:'reload', file:'/abs/path', event:'change'}. `file` + // may point at the pagx itself or at an external resource; either way, remember it so the + // resource cache can skip untouched files. Silently degrade if the payload is malformed. + try { + const data = JSON.parse(ev.data); + if (typeof data?.file === 'string' && data.file.length > 0) { + invalidatedResources.add(data.file); + } + } catch (_) { + // No payload / bad JSON: fall back to a full cache flush so we don't render stale bytes. + invalidatedResources = null; + } + if (document.hidden) { + // Coalesce every reload received while hidden into a single refresh on return. + pendingBackgroundReload = true; + return; + } + showStatus('Reloading...'); + loadPAGX(); + }); + es.addEventListener('focus', () => { + // Browsers routinely ignore window.focus() outside a user gesture; call it anyway so the + // CLI's "reuse existing tab" flow at least has a chance to work on browsers that allow it. + try { + window.focus(); + } catch (_) { + // ignore + } + }); + es.addEventListener('fonts-ready', async () => { + // The background font download finished. Re-register so the currently displayed PAGX picks + // up the newly available glyphs; then repaint by re-running the load pipeline. + const view = player.getView(); + if (view) await registerFallbackFonts(view); + if (fontsRegistered) { + showStatus('Fonts loaded', false, 1200); + loadPAGX(); + } + }); + es.onerror = () => { + if (es.readyState === EventSource.CLOSED) { + showStatus('Disconnected', true); + } + }; + return es; +} + +document.addEventListener('visibilitychange', () => { + // Apply any file changes that arrived while the tab was hidden. The banner makes the update + // visible enough that the user notices even if the visual delta is subtle. The player itself + // handles start()/stop() on visibility change internally. + if (!document.hidden && pendingBackgroundReload) { + pendingBackgroundReload = false; + showRefreshBanner('File updated, refreshing...'); + loadPAGX(); + } +}); + +// ---------- Drag & drop for opening additional PAGX files ---------- + +let dragCounter = 0; + +function isPagxDrag(e) { + const items = e.dataTransfer?.items; + if (!items) return false; + for (const item of items) { + if (item.kind === 'file') return true; + } + return false; +} + +window.addEventListener('dragenter', (e) => { + if (!isPagxDrag(e)) return; + e.preventDefault(); + dragCounter++; + dropOverlay.classList.add('active'); +}); +window.addEventListener('dragover', (e) => { + if (!isPagxDrag(e)) return; + // preventDefault is required for the browser to treat the element as a drop target. + e.preventDefault(); +}); +window.addEventListener('dragleave', (e) => { + if (!isPagxDrag(e)) return; + dragCounter = Math.max(0, dragCounter - 1); + if (dragCounter === 0) dropOverlay.classList.remove('active'); +}); +window.addEventListener('drop', async (e) => { + if (!isPagxDrag(e)) return; + e.preventDefault(); + dragCounter = 0; + dropOverlay.classList.remove('active'); + const file = e.dataTransfer?.files?.[0]; + if (!file) return; + if (!file.name.toLowerCase().endsWith('.pagx')) { + showStatus('Only .pagx files can be dropped', true, 2500); + return; + } + try { + const buf = await file.arrayBuffer(); + // Browsers don't hand us the absolute filesystem path, so live watch isn't possible for + // dropped files. The server keeps the bytes in memory for a one-shot preview. + const resp = await fetch(`/session/drop?name=${encodeURIComponent(file.name)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/octet-stream' }, + body: buf, + }); + if (!resp.ok) throw new Error(`server returned ${resp.status}`); + const info = await resp.json(); + const target = window.location.origin + info.url; + window.open(target, '_blank', 'noopener'); + } catch (err) { + console.error('pagx-preview: drop upload failed', err); + showStatus(`Drop failed: ${err.message || err}`, true, 3000); + } +}); + +// ---------- Boot ---------- + +(async function main() { + await loadPAGX(); + connectSSE(); +})(); diff --git a/playground/pagx-preview/static/mcp-widget.html b/playground/pagx-preview/static/mcp-widget.html new file mode 100644 index 0000000000..fc04ff94cd --- /dev/null +++ b/playground/pagx-preview/static/mcp-widget.html @@ -0,0 +1,100 @@ + + + + + + + + + + +
+
Loading pagx preview...
+
+
+ + + + + diff --git a/playground/pagx-preview/static/mcp-widget.js b/playground/pagx-preview/static/mcp-widget.js new file mode 100644 index 0000000000..09f07eb6bd --- /dev/null +++ b/playground/pagx-preview/static/mcp-widget.js @@ -0,0 +1,207 @@ +// MCP Apps widget script for pagx-preview. Loaded as an external ES module so that CSP +// script-src (which allows the server origin via _meta.ui.csp.resourceDomains) permits it — +// inline scripts are blocked by the host's sandbox CSP. +// +// Lifecycle: +// 1. host injects widget HTML into a sandbox iframe +// 2. this module loads, imports App + PAGXPlayer +// 3. app.connect() establishes postMessage bridge with host +// 4. app.ontoolresult fires with { file, sessionId } from preview_pagx's structuredContent +// 5. widget fetches /session/:id/pagx, loads it via PAGXPlayer +// 6. widget opens SSE stream to /session/:id/events for reload events + +import { App } from '/wasm/ext/app-with-deps.js'; +import { PAGXPlayer } from '/wasm/player/pagx-player.esm.js'; + +// Server base URL — injected as a global (`__SERVER_BASE__`) when inlined into the widget HTML. +// Falls back to '' (relative paths) when running as an external script in basic-host. +const BASE = (typeof __SERVER_BASE__ !== 'undefined') ? __SERVER_BASE__ : ''; + +// WASM module factory — loads the pagx-viewer WASM init function and instantiates it. +// Mirrors the exact pattern from static/index.js. +let PAGXInit = null; +async function loadPagxInit() { + if (PAGXInit) return PAGXInit; + const infoResp = await fetch(`${BASE}/wasm/viewer/info.json`, { cache: 'no-store' }); + if (!infoResp.ok) throw new Error(`fetch viewer info failed: ${infoResp.status}`); + const viewerInfo = await infoResp.json(); + const glueUrl = `${BASE}/wasm/viewer/${viewerInfo.glueFile}`; + const mod = await import(glueUrl); + PAGXInit = mod.PAGXInit; + return PAGXInit; +} +async function moduleFactory() { + // tgfx's emscripten glue has a Safari WebGL2 polyfill in its createContext() that patches + // canvas instances. In hosts using double-layered sandbox iframes (basic-host, Claude Desktop) + // the inner iframe's canvas may not have a working getContext. Pre-patch both prototypes so + // the glue's `if (!canvas.getContextSafariWebGL2Fixed)` check sees a truthy value and skips + // its problematic patch block entirely. + for (const Ctor of [ + typeof HTMLCanvasElement !== 'undefined' ? HTMLCanvasElement : null, + typeof OffscreenCanvas !== 'undefined' ? OffscreenCanvas : null, + ]) { + if (Ctor && !Ctor.prototype.getContextSafariWebGL2Fixed) { + const orig = Ctor.prototype.getContext; + if (orig) { + Object.defineProperty(Ctor.prototype, 'getContextSafariWebGL2Fixed', { + get() { return orig; }, + set() {}, + configurable: true, + }); + } + } + } + const init = await loadPagxInit(); + return init({ locateFile: (file) => `${BASE}/wasm/viewer/${file}` }); +} + +const container = document.getElementById('pagx-container'); +const statusEl = document.getElementById('status'); +const errorEl = document.getElementById('error'); + +const loadingFallback = document.getElementById('loading-fallback'); +if (loadingFallback) loadingFallback.style.display = 'none'; + +function setStatus(text) { + statusEl.textContent = text; +} + +function showError(text) { + errorEl.textContent = text; + errorEl.style.display = 'block'; + container.style.display = 'none'; +} + +function clearError() { + errorEl.style.display = 'none'; + container.style.display = 'block'; +} + +function applyTheme(theme) { + if (!theme) return; + document.documentElement.setAttribute('data-theme', theme); + document.documentElement.style.colorScheme = theme; +} + +const app = new App({ + name: 'pagx-preview-widget', + version: '1.0.0', + autoResize: true, +}); + +let player = null; +let currentSessionId = null; +let eventSource = null; + +async function resolveResource(rel) { + const buf = await fetch(`${BASE}/session/${currentSessionId}/resources/${encodeURIComponent(rel)}`) + .then((r) => (r.ok ? r.arrayBuffer() : null)); + return buf ? new Uint8Array(buf) : null; +} + +async function registerFonts(view) { + const fontList = await fetch(`${BASE}/fonts/list`).then((r) => r.json()); + if (!fontList.fonts) return; + const fonts = fontList.fonts; + const primaryName = fonts.primary || fonts.regular; + const emojiName = fonts.emoji; + // registerFonts resets fontConfig on each call, so both fonts must be passed together. + let primaryBytes = new Uint8Array(0); + let emojiBytes = new Uint8Array(0); + if (primaryName) { + const data = await fetch(`${BASE}/fonts/${encodeURIComponent(primaryName)}`).then((r) => + r.ok ? r.arrayBuffer() : null, + ); + if (data) primaryBytes = new Uint8Array(data); + } + if (emojiName) { + const data = await fetch(`${BASE}/fonts/${encodeURIComponent(emojiName)}`).then((r) => + r.ok ? r.arrayBuffer() : null, + ); + if (data) emojiBytes = new Uint8Array(data); + } + if (primaryBytes.length || emojiBytes.length) { + view.registerFonts(primaryBytes, emojiBytes); + } +} + +// Upload document summary so the MCP get_document tool can answer AI queries. +async function uploadDocumentSummary(view) { + try { + const duration = view.durationMicros(); + const width = view.contentWidth(); + const height = view.contentHeight(); + const summary = { + nodeCount: 0, + width, + height, + duration, + frameRate: view.frameRate(), + }; + await fetch(`${BASE}/session/${currentSessionId}/document`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(summary), + }); + } catch (e) { + // Non-fatal: get_document will return null, AI falls back to reading the XML file. + } +} + +function openEventStream(sessionId) { + if (eventSource) eventSource.close(); + eventSource = new EventSource(`${BASE}/session/${sessionId}/events`); + eventSource.addEventListener('reload', async () => { + setStatus('Reloading...'); + try { + const buf = await fetch(`${BASE}/session/${sessionId}/pagx`).then((r) => r.arrayBuffer()); + await player.load(new Uint8Array(buf), { + registerFonts, + resolveResource, + preserveCurrentTime: true, + }); + setStatus(''); + } catch (err) { + showError(`Reload failed: ${err.message}`); + } + }); + eventSource.addEventListener('focus', () => {}); + eventSource.onerror = () => {}; +} + +app.ontoolresult = async (r) => { + // structuredContent may be at r.structuredContent (MCP Apps spec) or directly at r (some hosts + // pass the full CallToolResult as-is). Try both paths. + const sc = r?.structuredContent ?? r; + if (!sc || !sc.sessionId) { + showError('missing sessionId in structuredContent'); + return; + } + currentSessionId = sc.sessionId; + clearError(); + setStatus('Loading...'); + + try { + if (!player) { + player = new PAGXPlayer({ container, moduleFactory, iconBaseUrl: `${BASE}/static/icons/` }); + } + const buf = await fetch(`${BASE}/session/${sc.sessionId}/pagx`).then((res) => res.arrayBuffer()); + await player.load(new Uint8Array(buf), { + registerFonts, + resolveResource, + preserveCurrentTime: true, + }); + await uploadDocumentSummary(player.getView()); + openEventStream(sc.sessionId); + setStatus(''); + } catch (err) { + showError(`Failed to load pagx: ${err.message}`); + } +}; + +app.onhostcontextchanged = (ctx) => { + applyTheme(ctx?.theme); +}; + +await app.connect(); +applyTheme(app.hostContext?.theme);