Files

310 lines
12 KiB
Python
Raw Permalink Normal View History

2026-08-22 22:28:41 +08:00
"""JS → WASM 预编译服务(基于 Javy / QuickJS)。
职责:
- 调用 Javy 把项目演示目录里的 .js 文件预编译为 .wasm 模块
- 把项目“下载压缩包”里的全部 JS 合并打包为单个 .wasm(与 zip 同目录,可下载)
- 产物与 report.json 编译报告一起输出,只做“编译”,绝不执行任何上传代码
使用场景说明:
- 浏览器里的 JS 无法真正“预编译”成 WASM 后代替原脚本运行:
浏览器自带 V8 JIT 运行时编译,本身就很快;而 Javy 产物是
“QuickJS 解释器 + 字节码”,更慢且无法操作 DOM,因此演示页
仍然运行原始 JS 文件。
- Javy 产物真正有用的场景是“服务端沙箱执行”:把不可信的 JS
关进 WASM 沙箱里运行,隔离文件/网络/系统访问,而不是直接在
服务器上执行原 JS。
"""
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from ..database import PROJECT_ROOT, SessionLocal
from ..models import Project
# 上传根目录(与 routers/project.py 解析方式一致,可通过 .env 的 UPLOADS_DIR 覆盖)
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
DEMOS_DIR = UPLOADS_ROOT / "demos"
# Javy 编译工具路径(可通过 .env 的 JAVY_PATH 覆盖)
JAVY_PATH = os.getenv("JAVY_PATH") or "/usr/local/bin/javy"
# 功能总开关:false 时接口直接返回“未启用”,便于临时关闭
WASM_COMPILE_ENABLED = os.getenv("WASM_COMPILE_ENABLED", "true").strip().lower() in (
"1", "true", "yes", "on",
)
# 单个 JS 文件大小上限(MB):防止超大文件把内存/磁盘撑爆
WASM_MAX_JS_SIZE_MB = float(os.getenv("WASM_MAX_JS_SIZE_MB") or "3")
# 压缩包打包成单个 wasm 时,全部 JS 的总量上限(MB)
WASM_BUNDLE_MAX_MB = float(os.getenv("WASM_BUNDLE_MAX_SIZE_MB") or "10")
# 单文件编译超时(秒):防止 javy 卡死拖慢接口
WASM_TIMEOUT_SECONDS = int(os.getenv("WASM_TIMEOUT_SECONDS") or "60")
# 编译时跳过的目录(避免把编译产物/第三方依赖再编译一遍)
_SKIP_DIRS = {"wasm", "node_modules", ".git", "__pycache__"}
def javy_version() -> str:
"""返回 Javy 版本号;工具未安装时返回空字符串。"""
try:
result = subprocess.run(
[JAVY_PATH, "--version"], capture_output=True, text=True, timeout=10,
)
return (result.stdout or result.stderr).strip()
except (OSError, subprocess.SubprocessError):
return ""
def _resolve_zip_path(download_url: str) -> Path:
"""把数据库里的下载地址(/uploads/project/xxx.zip)解析为服务器文件路径。"""
if not download_url:
return Path()
relative = download_url.lstrip("/")
if relative.startswith("uploads/"):
relative = relative[len("uploads/"):]
return UPLOADS_ROOT / relative
def find_js_files(project_dir: Path):
"""收集项目目录中的 .js 文件(跳过 wasm/ 等产物目录),按路径排序。"""
files = []
for path in sorted(project_dir.rglob("*.js")):
rel = path.relative_to(project_dir)
if any(part in _SKIP_DIRS for part in rel.parts):
continue
files.append(path)
return files
def _wasm_output_path(project_dir: Path, src: Path) -> Path:
"""生成 wasm 输出路径:wasm/<去斜杠相对路径>.wasm,避免子目录重名冲突。"""
rel = src.relative_to(project_dir)
if len(rel.parts) > 1:
slug = "_".join(rel.parts[:-1]) + "_" + rel.stem
else:
slug = rel.stem
return project_dir / "wasm" / f"{slug}.wasm"
def compile_one(src: Path, out: Path) -> dict:
"""调用 javy 编译单个 JS 文件,返回该文件的编译结果记录。"""
item = {
"file": src.name,
"status": "failed",
"message": "",
"js_size": src.stat().st_size,
"wasm_size": 0,
"compile_ms": 0,
"output": None,
}
out.parent.mkdir(parents=True, exist_ok=True)
start = time.monotonic()
try:
result = subprocess.run(
[JAVY_PATH, "build", str(src), "-o", str(out)],
capture_output=True, text=True, timeout=WASM_TIMEOUT_SECONDS,
)
item["compile_ms"] = int((time.monotonic() - start) * 1000)
if result.returncode != 0:
item["message"] = (result.stderr or result.stdout or "编译失败").strip()[:300]
return item
if out.is_file():
item["status"] = "ok"
item["wasm_size"] = out.stat().st_size
item["output"] = str(out.relative_to(UPLOADS_ROOT)).replace("\\", "/")
except subprocess.TimeoutExpired:
item["message"] = f"编译超时(超过 {WASM_TIMEOUT_SECONDS} 秒)"
except OSError as exc:
item["message"] = f"无法运行 Javy{exc}"
return item
def compile_zip_bundle(zip_path: Path) -> dict:
"""把压缩包里的全部 JS 合并打包为单个 wasm(与 zip 同目录、同名 .wasm)。
说明:zip 不是 JS,不能直接编译;这里把 zip 内的所有 .js 按文件名排序
拼接成一个临时 bundle.js,再交给 Javy 编译,产物即“压缩包的 wasm 版本”。
"""
item = {
"file": zip_path.name,
"status": "skipped",
"message": "",
"js_size": 0,
"wasm_size": 0,
"compile_ms": 0,
"output": None,
"source_zip": f"project/{zip_path.name}",
}
if not zip_path.is_file():
item["message"] = "下载压缩包不存在,跳过"
return item
max_file_bytes = int(WASM_MAX_JS_SIZE_MB * 1024 * 1024)
max_total_bytes = int(WASM_BUNDLE_MAX_MB * 1024 * 1024)
total_bytes = 0
parts = []
try:
with zipfile.ZipFile(zip_path) as zf:
for info in zf.infolist():
if info.is_dir():
continue
name = info.filename.replace("\\", "/")
if not name.lower().endswith(".js"):
continue
if name.startswith("__MACOSX/") or any(seg in _SKIP_DIRS for seg in name.split("/")):
continue
if info.file_size > max_file_bytes:
item["message"] = f"zip 内含超限文件 {name}(超过 {WASM_MAX_JS_SIZE_MB:g}MB),跳过打包"
return item
total_bytes += info.file_size
if total_bytes > max_total_bytes:
item["message"] = f"zip 内 JS 总量超过 {WASM_BUNDLE_MAX_MB:g}MB,跳过打包"
return item
parts.append((name, zf.read(info)))
except zipfile.BadZipFile:
item["message"] = "压缩包损坏,跳过"
return item
if not parts:
item["message"] = "压缩包内没有 JS 文件,跳过"
return item
# 按文件名排序后合并(保持确定性;浏览器脚本间依赖全局变量,字母序通常可用)
parts.sort(key=lambda p: p[0])
bundle_lines = []
for name, data in parts:
bundle_lines.append(f"// ===== {name} =====")
bundle_lines.append(data.decode("utf-8", errors="replace"))
bundle_js = "\n".join(bundle_lines)
# 写临时 bundle.js 编译,产物输出到 zip 同目录:{zip文件名}.wasm
out = zip_path.with_suffix(".wasm")
with tempfile.NamedTemporaryFile(
"w", suffix=".js", encoding="utf-8", delete=False,
) as tmp:
tmp.write(bundle_js)
tmp_path = Path(tmp.name)
try:
result = compile_one(tmp_path, out)
item["status"] = result["status"]
item["message"] = result["message"]
item["js_size"] = total_bytes
item["wasm_size"] = result["wasm_size"]
item["compile_ms"] = result["compile_ms"]
item["output"] = result["output"]
if result["status"] == "ok":
item["file"] = f"{zip_path.name}(全部 JS 打包为单个 wasm"
finally:
tmp_path.unlink(missing_ok=True)
return item
def compile_project(project_id: int) -> dict:
"""编译指定项目(演示目录 JS + 下载压缩包打包),返回完整报告。"""
from fastapi import HTTPException, status # 局部导入,避免循环依赖
db = SessionLocal()
try:
project = db.get(Project, project_id)
finally:
db.close()
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
if not WASM_COMPILE_ENABLED:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="WASM 预编译功能未启用(WASM_COMPILE_ENABLED=false",
)
if not (shutil.which(JAVY_PATH) or Path(JAVY_PATH).is_file()):
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="服务器未安装 Javy 编译工具,请先执行 deploy/install_javy.sh",
)
project_dir = DEMOS_DIR / str(project_id)
items = []
if project_dir.is_dir():
files = find_js_files(project_dir)
max_bytes = int(WASM_MAX_JS_SIZE_MB * 1024 * 1024)
for src in files:
if src.stat().st_size > max_bytes:
items.append({
"file": src.name,
"status": "skipped",
"message": f"文件超过 {WASM_MAX_JS_SIZE_MB:g}MB 上限",
"js_size": src.stat().st_size,
"wasm_size": 0,
"compile_ms": 0,
"output": None,
})
continue
out = _wasm_output_path(project_dir, src)
items.append(compile_one(src, out))
else:
items.append({
"file": "(无在线演示目录)",
"status": "skipped",
"message": "该项目没有在线演示目录,仅尝试打包下载压缩包",
"js_size": 0,
"wasm_size": 0,
"compile_ms": 0,
"output": None,
})
zip_bundle = compile_zip_bundle(_resolve_zip_path(project.download_url))
ok_items = [i for i in items if i["status"] == "ok"]
report = {
"project_id": project_id,
"created_time": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"javy_version": javy_version(),
"summary": {
"js_files": len(items),
"success": len(ok_items),
"failed": sum(1 for i in items if i["status"] == "failed"),
"skipped": sum(1 for i in items if i["status"] == "skipped"),
"total_js_bytes": sum(i["js_size"] for i in items),
"total_wasm_bytes": sum(i["wasm_size"] for i in ok_items),
"zip_bundle": zip_bundle["status"],
"zip_bundle_wasm_bytes": zip_bundle["wasm_size"],
},
"items": items,
"zip_bundle": zip_bundle,
}
if project_dir.is_dir():
report_dir = project_dir / "wasm"
report_dir.mkdir(parents=True, exist_ok=True)
(report_dir / "report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8",
)
return report
def main() -> None:
"""命令行入口:python -m backend.services.wasm_builder <项目ID>。"""
import argparse
parser = argparse.ArgumentParser(description="把项目 JS 预编译为 WASM")
parser.add_argument("project_id", type=int, help="项目 ID")
args = parser.parse_args()
try:
report = compile_project(args.project_id)
except Exception as exc: # noqa: BLE001 - 命令行场景统一打印错误原因
print(f"编译失败:{getattr(exc, 'detail', exc)}")
sys.exit(1)
print(json.dumps(report, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()