Files
what ad722b12f9 fix(esm): Vendor 支持子路径,不然 es-toolkit/compat 这种装不了
有些包把东西放在子路径下(es-toolkit 的 toString 只在 compat 里,主入口没有),
而摊平之后原包的 exports 映射就没了,import "es-toolkit/compat" 解析不到。

现在直接写子路径就行:

    esm.Install(ctx, "es-toolkit/compat", "app/node_modules")

拉的是根包,摊平的是子路径,落到 node_modules/es-toolkit/compat/。根包和子路径
可以共存——子路径目录嵌在根包目录里,而最小 package.json 不写 exports,
所以解析器认得出来。

npm.SplitPath 负责拆名字,scoped 包名自带一个斜杠所以前两段才是包名。
2026-09-07 10:58:34 +08:00

229 lines
8.0 KiB
Go
Raw Permalink 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。
//
// name 可以带子路径,比如 es-toolkit/compat——有些包把东西放在子路径下,
// 而摊平之后原包的 exports 映射就没了,子路径解析不到。这时把子路径本身
// 当成一个包摊平,落到 node_modules/es-toolkit/compat/,脚本照常
// `import { toString } from "es-toolkit/compat"`。
//
// 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
// name 可以带子路径(es-toolkit/compat)。版本从**根包**读,
// 但打包入口和输出目录都用完整的名字。
pkg, _ := npm.SplitPath(name)
version, err := installedVersion(fromDir, pkg)
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)
// spec 可能带子路径(es-toolkit/compat@^1):拉的是根包,摊的是子路径
name, rng := npm.SplitSpec(spec)
pkg, _ := npm.SplitPath(name)
fetchSpec := pkg
if rng != "" {
fetchSpec = pkg + "@" + rng
}
if _, err := npm.Fetch(ctx, tmp, fetchSpec); err != nil {
return VendorResult{}, err
}
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')
}