Files
jscriptx/esm/loader.go
T
what efb3141734 feat(esm): Vendor 把装好的 npm 包连同依赖摊平成单文件
解决的是「脚本要用第三方库,但目标机器上没有 node」。

npm 真正干的活是解析依赖树——读 semver 范围、查注册表定版本、递归、处理冲突。
这步绕不开,得在有 node 的机器上做一次。但做完之后依赖树就是死数据了,用
esbuild 摊平成一个文件,发布物里只带那一个就够:

    qs         v6.16.0   打进  47 个文件 -> 73.6 KB   (原 19 个包 1.7 MB)
    es-toolkit v1.52.0   打进 219 个文件 -> 51.9 KB

产物是最小的 node_modules 布局,脚本照常 import,写法完全不变。

两个实现细节:

  - 入口不能直接写包名,esbuild 的 EntryPoints 是文件路径。所以造一段转发
    源码当 stdin 入口,包名放进 import,才走正常的 node_modules 解析。
  - 转发源码里写 export { default } 时,只有具名导出的包会报错(ESM 原生的
    很多是这样),退回去用只带具名导出的版本重打一次。

打包目标从 Loader 里提成了共用常量:摊平出来的库必须跟脚本同一档,
否则库能打出脚本引擎跑不了的语法。

零依赖的包不用这个——直接下 tarball 解开就行,README 里记了命令。
2026-09-07 09:54:18 +08:00

232 lines
7.9 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 (
"fmt"
"sync"
"git.fsdpf.net/go/jscriptx"
"github.com/evanw/esbuild/pkg/api"
)
// outDir 只是给 esbuild 算相对路径用的虚拟目录,不落盘。
const outDir = "__jsx_out"
// defaultTarget 是打包输出的默认 ECMAScript 版本。goja 对更新的语法覆盖不全,
// 所以不跟着 esnext 走。Loader 和 Vendor 共用它——摊平出来的库要跟脚本同一档,
// 否则库能打出脚本引擎跑不了的语法。
const defaultTarget = api.ES2017
// DefaultGlobs 是默认的入口规则:根目录第一层子目录下的 js/ts 文件。
// 对应 app/PkgVersion/PkgImportController.ts 这样的结构。
var DefaultGlobs = []string{"*/*.js", "*/*.ts", "*/*.mjs"}
// Loader 从一个目录加载 ESM/TypeScript 源码,打包成 goja 能执行的代码。
// 它实现 jscriptx.Loader,交给 jscriptx.WithLoader 使用。
type Loader struct {
dir string
globs []string
target api.Target
define map[string]string
nodePaths []string
alias map[string]string
exts []jscriptx.Extension
label string // WithVersion 给这一层贴的版本标签,调用时用 name@label 点名
mu sync.Mutex
bundles map[string]*bundle
built bool
}
// Option 是 NewLoader 的配置项。
type Option func(*Loader)
// WithVersion 给这一层贴一个版本标签。多层叠放时,调用点可以用 name@版本 点名
// 要哪一层的实现,不点名还是照旧"上层盖下层":
//
// v1, _ := esm.NewLoader("app/v1/src", esm.WithVersion("v1"))
// v2, _ := esm.NewLoader("app/v2/src", esm.WithVersion("v2"))
// e, _ := jscriptx.New(jscriptx.WithLoader(v1, v2))
//
// e.New(ctx, "Resource/ResCreateController") // 不点名:拿到 v2(最上层)
// e.New(ctx, "Resource/ResCreateController@v1") // 点名:要 v1 那一层的
//
// 标签只在叠层里有意义,单个 Loader 用不上;各层的标签不能重名。详见 jscriptx.Overlay。
func WithVersion(v string) Option {
return func(l *Loader) { l.label = v }
}
// Version 返回 WithVersion 贴的版本标签,没贴过是空串。
func (l *Loader) Version() string { return l.label }
// WithGlobs 自定义入口规则(相对根目录的 glob,用 / 分隔)。
// 不设时用 DefaultGlobs:第一层子目录下的所有 js/ts 文件。
//
// 只作为入口的文件才能按名字取到;被 import 的模块不需要是入口。
func WithGlobs(globs ...string) Option {
return func(l *Loader) { l.globs = globs }
}
// WithExtensions 让脚本能 import 扩展的 TS 模块拿到类型:
//
// import store from "@jscriptx/store"
//
// 传进来的扩展只用于**打包阶段**——提供模块源码和类型。运行时真正注入哪些扩展,
// 由每次调用的 ctx 决定(jscriptx.WithScope + ScopeExtensions)。所以这里传的
// 应该是"脚本可能用到的全部扩展",通常跟运行时那份是同一批。
//
// 不传也能用扩展,只是脚本得直接写全局变量(没有类型提示)。
func WithExtensions(exts ...jscriptx.Extension) Option {
return func(l *Loader) { l.exts = append(l.exts, exts...) }
}
// WithTarget 设置输出的 ECMAScript 版本,默认 ES2017。
// goja 对 ES6+ 的覆盖不是 100%,遇到脚本里用了 goja 不认识的语法时,
// 调低这个值让 esbuild 把它降级掉。
func WithTarget(t api.Target) Option {
return func(l *Loader) { l.target = t }
}
// WithDefine 设置编译期常量替换,比如 WithDefine(map[string]string{"__DEV__": "false"})。
func WithDefine(define map[string]string) Option {
return func(l *Loader) { l.define = define }
}
// WithNodePaths 指定额外的 node_modules 搜索目录,相当于 Node 的 NODE_PATH。
//
// 不设时按 Node 的默认规则来:从 import 所在文件的目录逐级往上找 node_modules
// 所以公共库放在加载目录里(app/node_modules)或它的任意上层目录都能被找到。
// 只有公共库放在完全不相干的路径下时才需要这个选项:
//
// esm.NewLoader("app", esm.WithNodePaths("/opt/fsdpf/js-libs"))
//
// 传进来的目录本身相当于一个 node_modules:包直接放在它下面(<dir>/tinylib/),
// 不要再套一层 node_modules。多个目录按先后顺序查找。
func WithNodePaths(paths ...string) Option {
return func(l *Loader) { l.nodePaths = append(l.nodePaths, paths...) }
}
// WithAlias 把模块名映射到具体的文件或目录,绕过 node_modules 查找:
//
// esm.WithAlias(map[string]string{"@fsdpf/util": "/opt/fsdpf/util/index.ts"})
//
// 适合把某个名字钉死到一份实现上,或者给旧名字做转发。
func WithAlias(alias map[string]string) Option {
return func(l *Loader) {
if l.alias == nil {
l.alias = map[string]string{}
}
for k, v := range alias {
l.alias[k] = v
}
}
}
// NewLoader 创建一个目录加载器,立刻打包一次,源码有语法错误会在这里就报出来。
func NewLoader(dir string, opts ...Option) (*Loader, error) {
l := &Loader{
dir: dir,
globs: DefaultGlobs,
target: defaultTarget,
}
for _, opt := range opts {
opt(l)
}
if err := l.Rebuild(); err != nil {
return nil, err
}
return l, nil
}
// Load 实现 jscriptx.Loader。返回打包好的自包含代码,以及一个随任何参与打包的
// 源文件变化而变化的版本号——配合 jscriptx.WithAutoReload,改了 .ts 就会自动重编译。
func (l *Loader) Load(name string) (string, string, error) {
l.mu.Lock()
b, ok := l.bundles[name]
built := l.built
l.mu.Unlock()
if ok && b.version == l.versionOf(b.inputs) {
return b.code, b.version, nil // 依赖没变,直接给缓存
}
// 名字没见过:先确认目录里到底有没有这个入口,有才值得重新打包。
// 不做这一步的话,每次问一个不存在的名字都要把整个目录重打一遍——
// 叠层时下层被问到"上层专属的脚本"是常事,代价会很显眼。
if !ok && built && !l.mightHave(name) {
return "", "", l.notFound(name)
}
// 新增了文件,或者依赖变了:重新打包整个目录
if err := l.Rebuild(); err != nil {
return "", "", err
}
l.mu.Lock()
b, ok = l.bundles[name]
l.mu.Unlock()
if !ok {
return "", "", l.notFound(name)
}
return b.code, b.version, nil
}
// mightHave 扫一遍目录,看有没有这个名字的入口。只是 WalkDir,比重新打包便宜得多。
func (l *Loader) mightHave(name string) bool {
entries, err := l.findEntries()
if err != nil {
return true // 扫不动就当它可能在,走重打包让错误浮出来
}
for _, rel := range entries {
if toName(rel) == name {
return true
}
}
return false
}
func (l *Loader) notFound(name string) error {
return fmt.Errorf("%w: %s(目录 %s 下没有这个入口)",
jscriptx.ErrScriptNotFound, name, l.dir)
}
// Rebuild 重新扫描目录并打包。
func (l *Loader) Rebuild() error {
bundles, err := l.buildAll()
if err != nil {
return err
}
l.mu.Lock()
l.bundles = bundles
l.built = true
l.mu.Unlock()
return nil
}
// Names 返回当前所有入口的名字(如 PkgVersion/PkgImportController),顺序不定。
func (l *Loader) Names() []string {
l.mu.Lock()
defer l.mu.Unlock()
out := make([]string, 0, len(l.bundles))
for name := range l.bundles {
out = append(out, name)
}
return out
}
// Dir 返回加载的根目录。
func (l *Loader) Dir() string { return l.dir }
// Prepared 实现 jscriptx.Prepared:这里给出的源码已经过 esbuild 打包成 IIFE
// Engine 必须跳过打包——再打一遍会被当死代码摇空。
func (l *Loader) Prepared() bool { return true }
// 编译期钉住:Loader 必须满足这两个接口。
//
// Prepared 尤其要紧:它是靠类型断言识别的,方法签名改了不会有编译错误,
// 而这里给出的产物是 IIFE,被 Engine 再打包一遍就会当死代码摇空——
// 脚本什么都不剩。这行断言就是防这个。
var (
_ jscriptx.Loader = (*Loader)(nil)
_ jscriptx.Prepared = (*Loader)(nil)
)