Files
jscriptx/esm/vendor.go
T
what 00d95c496f feat(esm/npm): 用 Go 拉依赖树,加库不再需要 node
零依赖的包下个 tarball 就能用,有依赖的得先解析依赖树——读 semver 范围、
查注册表定版本、递归。这个包把那件事用 Go 做了,于是整条链没有 node:

    esm.Install(ctx, "qs", "app/node_modules")
    // qs v6.16.0  打进 50 个文件 -> 73.3 KB(依赖树 19 个包 1.7 MB)

= npm.Fetch(拉依赖树到临时目录)+ Vendor(摊平成一个文件)。

刻意不做的(它不是 npm):

  - 不跑安装脚本。那是供应链攻击的主要入口,而纯 JS 库没有编译步骤
  - 不管 devDependencies / peerDependencies / 平台二进制
  - semver 只实现 ^ / ~ / 精确 / x / >= 这个子集
  - 只平铺不嵌套

碰上支持不了的(复合范围、主版本冲突)明确报错并指向 npm + Vendor,
不猜版本——猜错了装出来能跑但行为不对,比装不上难查。

子集划得这么小是有依据的:抽 8 个常见包的 44 个传递依赖统计,^ 占 95%,
~ 和精确各一两处,主版本冲突 0 个。

安全上做了两件事:校验注册表给的 sha512(中间的缓存代理、私有源镜像
是真实存在的),以及挡住 tarball 里带 ../ 的路径。

测试全部走内存假注册表,不碰网络:依赖树平铺、共同依赖只装一次、
版本冲突报错、校验和不符、目录穿越。semver 那组表驱动——写这组时抓到
一个真 bug:1.2.x 被映射成了 ^1.2.0,只锁主版本,实际该锁到 1.2。
2026-09-07 10:11:51 +08:00

214 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package esm
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"git.fsdpf.net/go/jscriptx/esm/npm"
"github.com/evanw/esbuild/pkg/api"
)
// Vendor 把一个装好的 npm 包连同它的依赖树,摊平成一个文件放进 outRoot。
//
// 解决的是这个问题:脚本要用第三方库,但**跑脚本的机器上没有 node**。
//
// npm 真正干的活是解析依赖树——读 semver 范围、查注册表定版本、递归、处理冲突。
// 这一步绕不开,得在有 node 的机器上做一次。但做完之后依赖树就是死数据了,
// 用 esbuild 摊平成单文件,发布物里只带那一个文件就够:
//
// 开发机(有 node) 发布物 / 目标机器(不需要 node)
// npm i qs app/node_modules/qs/
// → 19 个包 1.7 MB ├── index.mjs 73 KB,依赖全内联
// esm.Vendor(".", "qs", …) └── package.json 最小的,只为让解析器找到入口
//
// 摊平之后脚本照常 `import qs from "qs"`,写法完全不变。
//
// fromDir 是**装好依赖的目录**(npm i 跑过的那个,它下面有 node_modules);
// outRoot 一般传脚本目录旁边的 node_modules。
//
// target 要跟加载脚本的 Loader 一致,否则可能打出脚本引擎跑不了的语法——
// 用 WithTarget 改过 Loader 的话这里也要改。
func Vendor(fromDir, name, outRoot string, opts ...VendorOption) (VendorResult, error) {
v := &vendorOpts{target: defaultTarget}
for _, o := range opts {
o(v)
}
var res VendorResult
version, err := installedVersion(fromDir, name)
if err != nil {
return res, err
}
out, modules, err := vendorBundle(fromDir, name, v.target)
if err != nil {
return res, err
}
dir := filepath.Join(outRoot, filepath.FromSlash(name))
if err := os.MkdirAll(dir, 0o755); err != nil {
return res, fmt.Errorf("jscriptx/esm: 建目录 %s 失败: %w", dir, err)
}
if err := os.WriteFile(filepath.Join(dir, vendorEntry), out, 0o644); err != nil {
return res, fmt.Errorf("jscriptx/esm: 写 %s 失败: %w", vendorEntry, err)
}
if err := os.WriteFile(filepath.Join(dir, "package.json"), minimalPkg(name, version), 0o644); err != nil {
return res, fmt.Errorf("jscriptx/esm: 写 package.json 失败: %w", err)
}
return VendorResult{
Name: name, Version: version,
Modules: modules, Bytes: len(out), Dir: dir,
}, nil
}
// Install 从注册表拉一个包(连同它的依赖),摊平后放进 outRoot。
//
// 等于 npm.Fetch + Vendor 两步,只是中间那棵依赖树落在临时目录里,用完就删——
// **整条链不需要 node**。
//
// res, err := esm.Install(ctx, "qs", "app/node_modules")
// // qs v6.16.0 打进 47 个文件 -> 73.6 KB
//
// spec 的形式是 `名字` 或 `名字@范围`,不写范围取 latest。
//
// 要装的包已经在本地装好了(或者碰上 npm.Fetch 支持不了的复合版本范围),
// 就直接用 Vendor 指着那个目录。
func Install(ctx context.Context, spec, outRoot string, opts ...VendorOption) (VendorResult, error) {
tmp, err := os.MkdirTemp("", "jscriptx-install-")
if err != nil {
return VendorResult{}, fmt.Errorf("jscriptx/esm: 建临时目录失败: %w", err)
}
defer os.RemoveAll(tmp)
if _, err := npm.Fetch(ctx, tmp, spec); err != nil {
return VendorResult{}, err
}
name, _ := npm.SplitSpec(spec)
return Vendor(tmp, name, outRoot, opts...)
}
// VendorResult 是一次摊平的结果,用来打日志。
type VendorResult struct {
Name string
Version string
Modules int // 一共把多少个文件打了进去(含所有依赖)
Bytes int // 摊平后多大
Dir string // 落到哪
}
// VendorOption 调整摊平的行为。
type VendorOption func(*vendorOpts)
type vendorOpts struct{ target api.Target }
// WithVendorTarget 设置输出的 ECMAScript 版本,默认跟 Loader 一样是 ES2017。
//
// 单独一个选项而不是复用 WithTarget:那个改的是 Loader,这个改的是摊平产物,
// 两者恰好该保持一致,但不是同一件事。
func WithVendorTarget(t api.Target) VendorOption {
return func(o *vendorOpts) { o.target = t }
}
const vendorEntry = "index.mjs"
// vendorBundle 用 esbuild 把包和它的依赖打成一个 ESM 文件。
//
// 入口不能直接写包名——esbuild 的 EntryPoints 是**文件路径**。所以造一段转发源码
// 当 stdin 入口,包名放在 import 里,才会走正常的 node_modules 解析。
func vendorBundle(fromDir, name string, target api.Target) ([]byte, int, error) {
// 先按"有默认导出"打。ESM 原生的包往往只有具名导出,那样 esbuild 会报
// "No matching export ... for import default",退回去用只带具名导出的版本重打。
for _, withDefault := range []bool{true, false} {
src := fmt.Sprintf("export * from %q\n", name)
if withDefault {
src += fmt.Sprintf("export { default } from %q\n", name)
}
r := api.Build(api.BuildOptions{
Stdin: &api.StdinOptions{
Contents: src,
ResolveDir: fromDir,
Loader: api.LoaderJS,
},
Bundle: true,
Format: api.FormatESModule,
Target: target,
Write: false,
Metafile: true,
MinifyWhitespace: true,
LogLevel: api.LogLevelSilent,
})
if len(r.Errors) == 0 {
return r.OutputFiles[0].Contents, countInputs(r.Metafile), nil
}
if withDefault && onlyDefaultMissing(r.Errors) {
continue
}
return nil, 0, fmt.Errorf("jscriptx/esm: 摊平 %s 失败: %s\n(依赖没装全的话,"+
"先在 %s 跑一次 npm i", name, r.Errors[0].Text, fromDir)
}
return nil, 0, fmt.Errorf("jscriptx/esm: 摊平 %s 失败", name)
}
// onlyDefaultMissing 判断这批错误是不是只因为包没有默认导出。
func onlyDefaultMissing(errs []api.Message) bool {
for _, e := range errs {
if !strings.Contains(e.Text, "default") {
return false
}
}
return true
}
func countInputs(metafile string) int {
var m struct {
Inputs map[string]json.RawMessage `json:"inputs"`
}
if err := json.Unmarshal([]byte(metafile), &m); err != nil {
return 0
}
return len(m.Inputs)
}
// installedVersion 读已装好的那份 package.json 拿版本号,顺便确认包真的在。
func installedVersion(fromDir, name string) (string, error) {
p := filepath.Join(fromDir, "node_modules", filepath.FromSlash(name), "package.json")
b, err := os.ReadFile(p)
if err != nil {
return "", fmt.Errorf("jscriptx/esm: %s 里没装 %s——先在那个目录跑 `npm i %s`: %w",
fromDir, name, name, err)
}
var pkg struct {
Version string `json:"version"`
}
if err := json.Unmarshal(b, &pkg); err != nil {
return "", fmt.Errorf("jscriptx/esm: 解析 %s 失败: %w", p, err)
}
return pkg.Version, nil
}
// minimalPkg 是摊平后那个包的 package.json。
//
// **不能省**——esbuild 靠 main/module 定位入口,没有这个文件解析会直接失败。
//
// 不写 exports 字段:一旦有,解析器就只认里面列出的子路径,而摊平之后所有东西都在
// 一个文件里,子路径 import`import x from "qs/lib/foo"`)本来就不成立了。
func minimalPkg(name, version string) []byte {
b, _ := json.MarshalIndent(map[string]string{
"name": name,
"version": version,
"type": "module",
"module": vendorEntry,
"main": vendorEntry,
}, "", " ")
return append(b, '\n')
}