package esm import ( "fmt" "sync" "git.fsdpf.net/go/jscriptx" "github.com/evanw/esbuild/pkg/api" ) // outDir 只是给 esbuild 算相对路径用的虚拟目录,不落盘。 const outDir = "__jsx_out" // 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:包直接放在它下面(/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: api.ES2017, } 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) )