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
+176
View File
@@ -0,0 +1,176 @@
package esm_test
import (
"context"
"os"
"strings"
"testing"
"git.fsdpf.net/go/jscriptx"
"git.fsdpf.net/go/jscriptx/esm"
"git.fsdpf.net/go/jscriptx/internal/testext"
)
// 两个独立的 .ts 文件,各自一个 class、各自一个 VM,通过同一个 scope 共享 store。
func TestScope_两个文件共享(t *testing.T) {
e := newEngine(t, "testdata/app/src")
cart, err := e.Script("Shared/CartController")
if err != nil {
t.Fatal(err)
}
order, err := e.Script("Shared/OrderController")
if err != nil {
t.Fatal(err)
}
st := testext.New()
ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st))
cartObj, err := cart.New(ctx)
if err != nil {
t.Fatal(err)
}
defer cartObj.Close()
orderObj, err := order.New(ctx)
if err != nil {
t.Fatal(err)
}
defer orderObj.Close()
// CartController 写
if _, err := cartObj.Call(ctx, "Add", "A-1", 2); err != nil {
t.Fatal(err)
}
if _, err := cartObj.Call(ctx, "Add", "B-2", 3); err != nil {
t.Fatal(err)
}
if _, err := cartObj.Call(ctx, "Add", "A-1", 1); err != nil {
t.Fatal(err)
}
// OrderController 读得到——两个文件、两个 VM,同一份 store
got, err := orderObj.Call(ctx, "Checkout")
if err != nil {
t.Fatalf("跨文件读取失败: %v", err)
}
if got != "A-1,B-2|6" {
t.Errorf("Checkout = %#v, want %q", got, "A-1,B-2|6")
}
// Go 侧看到的是同一份
if n, _ := st.Get("total").(float64); n != 6 {
t.Errorf("Go 侧 total = %v, want 6", n)
}
// 但各自的 this.ops 是独立的:VM 不共用
if got, _ := cartObj.Call(ctx, "Ops"); got.(int64) != 3 {
t.Errorf("cart.ops = %#v, want 3", got)
}
if got, _ := orderObj.Call(ctx, "Ops"); got.(int64) != 1 {
t.Errorf("order.ops = %#v, want 1(不该被 cart 的调用带上)", got)
}
}
// 换一个 scope,两个文件看到的是另一份 store。
func TestScope_不同scope互不干扰(t *testing.T) {
e := newEngine(t, "testdata/app/src")
cart, _ := e.Script("Shared/CartController")
order, _ := e.Script("Shared/OrderController")
newCtx := func() context.Context {
return jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(testext.New()))
}
ctx1, ctx2 := newCtx(), newCtx()
c1, err := cart.New(ctx1)
if err != nil {
t.Fatal(err)
}
defer c1.Close()
c1.Call(ctx1, "Add", "A-1", 5)
// 第二个 scope 里的 OrderController 看不到第一个 scope 写的东西
o2, err := order.New(ctx2)
if err != nil {
t.Fatal(err)
}
defer o2.Close()
got, err := o2.Call(ctx2, "Checkout")
if err != nil {
t.Fatal(err)
}
if got != "|null" {
t.Errorf("第二个 scope 应该是空的, got %#v", got)
}
}
// 脚本用 import 拿扩展,而不是直接用全局变量。
func TestScope_import扩展模块(t *testing.T) {
loader, err := esm.NewLoader("testdata/extapp", esm.WithExtensions(testext.New()))
if err != nil {
t.Fatalf("NewLoader 失败: %v", err)
}
e := newEngineWith(t, loader)
s, err := e.Script("Mod/TypedController")
if err != nil {
t.Fatal(err)
}
st := testext.New()
ctx := jscriptx.WithScope(context.Background(), jscriptx.ScopeExtensions(st))
obj, err := s.New(ctx)
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if _, err := obj.Call(ctx, "Put", "k", "import 写进来的"); err != nil {
t.Fatalf("import 的 store 不能用: %v", err)
}
if st.Get("k") != "import 写进来的" {
t.Errorf("Go 侧读不到: %#v", st.Get("k"))
}
if got, _ := obj.Call(ctx, "Take", "k"); got != "import 写进来的" {
t.Errorf("Take = %#v", got)
}
if got, _ := obj.Call(ctx, "Describe"); got.(int64) != 1 {
t.Errorf("Describe = %#v", got)
}
}
// 没把扩展交给 loader 时,import 它会在打包阶段就失败——这比运行时才发现好。
func TestScope_import未注册的扩展(t *testing.T) {
_, err := esm.NewLoader("testdata/extapp") // 没有 WithExtensions
if err == nil {
t.Fatal("TypedController import 了 @jscriptx/store,没注册扩展时应该打包失败")
}
for _, want := range []string{"@jscriptx/store", "TypedController"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("报错里没有 %q: %v", want, err)
}
}
t.Logf("%v", err)
}
// 扩展模块是虚拟的:磁盘上没有这个文件,内容由扩展自己给。
func TestScope_扩展模块是虚拟的(t *testing.T) {
if _, err := os.Stat("testdata/extapp/node_modules/@jscriptx"); err == nil {
t.Fatal("测试前提不成立:磁盘上真有 @jscriptx 目录,那就不算虚拟模块了")
}
loader, err := esm.NewLoader("testdata/extapp", esm.WithExtensions(testext.New()))
if err != nil {
t.Fatal(err)
}
src, _, err := loader.Load("Mod/TypedController")
if err != nil {
t.Fatal(err)
}
// store 的方法应该被内联进产物,而不是留下一个 import
if strings.Contains(src, "@jscriptx/store") && strings.Contains(src, "import") {
t.Error("产物里还留着 import,说明模块没被内联")
}
}