310 lines
12 KiB
Python
310 lines
12 KiB
Python
"""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()
|