零依赖的包下个 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。
214 lines
7.3 KiB
Go
214 lines
7.3 KiB
Go
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')
|
||
}
|