feat(esm): 按目录加载脚本、打包与热更新

把一个目录当脚本仓库:按路径寻址、esbuild 打包、内容变了自动重编。

  - Loader    扫目录建索引,Load(name) 给出打好包的源码和版本号
  - bundler   esbuild 的封装。ESM 格式而不是 IIFE——IIFE 会附带一整套
              CommonJS interop helper,每建一个 VM 都要重跑一遍
  - plugin    把扩展的 TS 模块变成可以 import 的虚拟模块,磁盘上没有文件
  - typings   把这些虚拟模块的类型按 node_modules 布局落盘,编辑器才认识

esbuild 原生实现了 Node 的模块解析,所以脚本能直接 import node_modules
里的第三方库。写出来的类型文件是 index.ts 而不是 index.d.ts:扩展给的是
真正的模块源码,里面可能带实现,声明文件里不允许有实现。

node_modules 不参与热更新的版本计算——依赖包是装出来的,改动总伴随显式的
安装动作,而真实的 npm 包动辄上千个文件,每次取脚本 stat 一遍太贵。
This commit is contained in:
2026-09-05 22:13:57 +08:00
parent 7fb8d0f966
commit 6ce9b483fd
34 changed files with 2403 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
package esm_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.fsdpf.net/go/jscriptx/esm"
)
// 脚本可以从 node_modules 引用公共库:裸模块名、scoped 包、.ts 入口都支持。
func TestNodeModules_公共库(t *testing.T) {
e := newEngine(t, "testdata/app/src")
ctx := context.Background()
obj, err := e.New(ctx, "Resource/ResSchemaController")
if err != nil {
t.Fatalf("加载失败: %v", err)
}
defer obj.Close()
got, err := obj.Call(ctx, "Describe", "widget", 12.5)
if err != nil {
t.Fatalf("调用失败: %v", err)
}
// upper 来自 tinylib、money 来自 @fsdpf/utilscoped + .ts)、ex 来自同目录相对 import
if got != "WIDGET ¥12.50 stored:s" {
t.Errorf("got = %#v", got)
}
}
// node_modules 里的文件不能被当成入口。
func TestNodeModules_不算入口(t *testing.T) {
l, err := esm.NewLoader("testdata/app/src")
if err != nil {
t.Fatal(err)
}
for _, n := range l.Names() {
if strings.Contains(n, "node_modules") {
t.Errorf("node_modules 里的文件被当成入口了: %s", n)
}
}
}
// 依赖包不参与版本计算:改了 node_modules 不会自动重编译,Rebuild 之后才生效。
// 这是有意的取舍——真实 npm 包成百上千个文件,每次取脚本都 stat 一遍太贵。
func TestNodeModules_不参与热更新(t *testing.T) {
dir := t.TempDir()
mod := filepath.Join(dir, "Mod")
lib := filepath.Join(dir, "node_modules", "mylib")
os.MkdirAll(mod, 0o755)
os.MkdirAll(lib, 0o755)
write := func(p, s string) {
t.Helper()
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(lib, "package.json"), `{"name":"mylib","main":"index.js"}`)
write(filepath.Join(lib, "index.js"), `export function hi() { return "lib-v1" }`)
write(filepath.Join(mod, "C.ts"), `import { hi } from "mylib"
export default class C { Get(): string { return hi() } }`)
loader, err := esm.NewLoader(dir)
if err != nil {
t.Fatal(err)
}
e := newEngineWith(t, loader)
ctx := context.Background()
obj, _ := e.New(ctx, "Mod/C")
if got, _ := obj.Call(ctx, "Get"); got != "lib-v1" {
t.Fatalf("got = %#v", got)
}
obj.Close()
time.Sleep(10 * time.Millisecond)
write(filepath.Join(lib, "index.js"), `export function hi() { return "lib-v2" }`)
// 改依赖包不触发重编译
obj2, _ := e.New(ctx, "Mod/C")
if got, _ := obj2.Call(ctx, "Get"); got != "lib-v1" {
t.Errorf("依赖包不该参与热更新, got %#v", got)
}
obj2.Close()
// 显式 Rebuild 之后生效
if err := loader.Rebuild(); err != nil {
t.Fatal(err)
}
e.Invalidate("Mod/C")
obj3, err := e.New(ctx, "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj3.Close()
if got, _ := obj3.Call(ctx, "Get"); got != "lib-v2" {
t.Errorf("Rebuild 后应该生效, got %#v", got)
}
// 业务代码照常热更新
time.Sleep(10 * time.Millisecond)
write(filepath.Join(mod, "C.ts"), `import { hi } from "mylib"
export default class C { Get(): string { return hi() + "!" } }`)
obj4, err := e.New(ctx, "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj4.Close()
if got, _ := obj4.Call(ctx, "Get"); got != "lib-v2!" {
t.Errorf("业务代码该正常热更新, got %#v", got)
}
}
// 公共库可以放在加载目录之外,用 WithNodePaths 指过去。
func TestNodeModules_指定目录(t *testing.T) {
root := t.TempDir()
app := filepath.Join(root, "app", "Mod")
libs := filepath.Join(root, "shared-libs", "tinylib")
os.MkdirAll(app, 0o755)
os.MkdirAll(libs, 0o755)
write := func(p, s string) {
t.Helper()
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(libs, "package.json"), `{"name":"tinylib","main":"index.js"}`)
write(filepath.Join(libs, "index.js"), `export function hi() { return "来自共享目录" }`)
write(filepath.Join(app, "C.ts"), `import { hi } from "tinylib"
export default class C { Get(): string { return hi() } }`)
appDir := filepath.Join(root, "app")
// 不指定时找不到:app 及其上层都没有 node_modules
if _, err := esm.NewLoader(appDir); err == nil {
t.Error("app 下没有 node_modules,不指定应该解析失败")
}
// 指过去就能用
loader, err := esm.NewLoader(appDir, esm.WithNodePaths(filepath.Join(root, "shared-libs")))
if err != nil {
t.Fatalf("WithNodePaths 没生效: %v", err)
}
e := newEngineWith(t, loader)
obj, err := e.New(context.Background(), "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if got, _ := obj.Call(context.Background(), "Get"); got != "来自共享目录" {
t.Errorf("got = %#v", got)
}
}
// Alias 把模块名钉到具体文件上,绕过 node_modules 查找。
func TestNodeModules_Alias(t *testing.T) {
root := t.TempDir()
app := filepath.Join(root, "app", "Mod")
os.MkdirAll(app, 0o755)
os.MkdirAll(filepath.Join(root, "impl"), 0o755)
os.WriteFile(filepath.Join(root, "impl", "mylib.ts"),
[]byte(`export function hi(): string { return "来自 alias" }`), 0o644)
os.WriteFile(filepath.Join(app, "C.ts"), []byte(`import { hi } from "tinylib"
export default class C { Get(): string { return hi() } }`), 0o644)
loader, err := esm.NewLoader(filepath.Join(root, "app"), esm.WithAlias(map[string]string{
"tinylib": filepath.Join(root, "impl", "mylib.ts"),
}))
if err != nil {
t.Fatalf("WithAlias 没生效: %v", err)
}
e := newEngineWith(t, loader)
obj, err := e.New(context.Background(), "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if got, _ := obj.Call(context.Background(), "Get"); got != "来自 alias" {
t.Errorf("got = %#v", got)
}
}