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
+388
View File
@@ -0,0 +1,388 @@
package esm_test
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.fsdpf.net/go/jscriptx"
"git.fsdpf.net/go/jscriptx/esm"
)
// 两个 esm.Loader 叠起来:入口合并,同名的后面盖前面。
func TestOverlay_两个目录合并与覆盖(t *testing.T) {
base, err := esm.NewLoader("testdata/app/src")
if err != nil {
t.Fatal(err)
}
over, err := esm.NewLoader("testdata/app2/src")
if err != nil {
t.Fatal(err)
}
e := newEngineWith(t, base, over)
ctx := context.Background()
// 同名的取后面那层
obj, err := e.New(ctx, "Resource/ResCreateController")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if got, _ := obj.Call(ctx, "Init"); got != "来自 app2 的覆盖实现" {
t.Errorf("覆盖没生效: %#v", got)
}
// 前面那层独有的照常能用
obj2, err := e.New(ctx, "PkgVersion/PkgImportController", "framework")
if err != nil {
t.Fatal(err)
}
defer obj2.Close()
if got, _ := obj2.Call(ctx, "Init"); got != "PkgImport:framework" {
t.Errorf("前面那层的入口坏了: %#v", got)
}
}
// 每层各自往上找自己的 node_modules,依赖互不干扰。
func TestOverlay_各层独立解析依赖(t *testing.T) {
base, _ := esm.NewLoader("testdata/app/src")
over, _ := esm.NewLoader("testdata/app2/src")
e := newEngineWith(t, base, over)
ctx := context.Background()
// app2 的入口用 app2/node_modules 里的包
obj, err := e.New(ctx, "Extra/ExtraController")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if got, _ := obj.Call(ctx, "Who"); got != "app2 独有:app2 专属依赖" {
t.Errorf("app2 的依赖没解析对: %#v", got)
}
// app 的入口用 app/node_modules 里的包,两边互不影响
obj2, err := e.New(ctx, "Resource/ResSchemaController")
if err != nil {
t.Fatal(err)
}
defer obj2.Close()
if got, _ := obj2.Call(ctx, "Describe", "widget", 12.5); got != "WIDGET ¥12.50 stored:s" {
t.Errorf("app 的依赖被 app2 影响了: %#v", got)
}
}
// 每层可以有完全不同的配置——这里给两层不同的入口规则。
func TestOverlay_各层配置独立(t *testing.T) {
d1, d2 := t.TempDir(), t.TempDir()
write(t, d1, "Mod/A.ts", `export default class A { V(): string { return "a" } }`)
write(t, d2, "Mod/B.ts", `export default class B { V(): string { return "b" } }`)
write(t, d2, "Mod/Skip.ts", `export default class S { V(): string { return "s" } }`)
base, _ := esm.NewLoader(d1)
// 只认 B 开头的入口,Skip.ts 不算
over, err := esm.NewLoader(d2, esm.WithGlobs("*/B*.ts"))
if err != nil {
t.Fatal(err)
}
e := newEngineWith(t, base, over)
if _, err := e.New(context.Background(), "Mod/B"); err != nil {
t.Errorf("Mod/B 该能取到: %v", err)
}
if _, err := e.New(context.Background(), "Mod/Skip"); err == nil {
t.Error("Mod/Skip 不符合那层的入口规则,不该被收进来")
}
}
// 覆盖层的文件删掉后,被盖住的实现要能恢复。
func TestOverlay_覆盖撤销后恢复(t *testing.T) {
d1, d2 := t.TempDir(), t.TempDir()
write(t, d1, "Mod/C.ts", `export default class C { Who(): string { return "base" } }`)
write(t, d2, "Mod/C.ts", `export default class C { Who(): string { return "override" } }`)
base, _ := esm.NewLoader(d1)
over, _ := esm.NewLoader(d2)
e := newEngineWith(t, base, over)
call := func() string {
t.Helper()
obj, err := e.New(context.Background(), "Mod/C")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
got, err := obj.Call(context.Background(), "Who")
if err != nil {
t.Fatal(err)
}
return got.(string)
}
if got := call(); got != "override" {
t.Fatalf("覆盖没生效: %q", got)
}
time.Sleep(10 * time.Millisecond)
os.Remove(filepath.Join(d2, "Mod/C.ts"))
if err := over.Rebuild(); err != nil {
t.Fatal(err)
}
if got := call(); got != "base" {
t.Errorf("撤销覆盖后没落回 base: %q", got)
}
}
// 热更新对每一层都生效。
func TestOverlay_热更新(t *testing.T) {
d1, d2 := t.TempDir(), t.TempDir()
write(t, d1, "A/X.ts", `export default class X { V(): string { return "a-v1" } }`)
write(t, d2, "B/Y.ts", `export default class Y { V(): string { return "b-v1" } }`)
base, _ := esm.NewLoader(d1)
over, _ := esm.NewLoader(d2)
e := newEngineWith(t, base, over)
call := func(name string) string {
t.Helper()
obj, err := e.New(context.Background(), name)
if err != nil {
t.Fatal(err)
}
defer obj.Close()
got, _ := obj.Call(context.Background(), "V")
return got.(string)
}
if call("A/X") != "a-v1" || call("B/Y") != "b-v1" {
t.Fatal("初始状态不对")
}
time.Sleep(10 * time.Millisecond)
write(t, d2, "B/Y.ts", `export default class Y { V(): string { return "b-v2" } }`)
if got := call("B/Y"); got != "b-v2" {
t.Errorf("上层的热更新没生效: %q", got)
}
time.Sleep(10 * time.Millisecond)
write(t, d1, "A/X.ts", `export default class X { V(): string { return "a-v2" } }`)
if got := call("A/X"); got != "a-v2" {
t.Errorf("下层的热更新没生效: %q", got)
}
}
// 覆盖层新增一个同名入口,要能盖住已经在跑的下层实现。
func TestOverlay_运行中新增覆盖(t *testing.T) {
d1, d2 := t.TempDir(), t.TempDir()
write(t, d1, "Mod/D.ts", `export default class D { V(): string { return "base" } }`)
base, _ := esm.NewLoader(d1)
over, _ := esm.NewLoader(d2) // 一开始是空的
e := newEngineWith(t, base, over)
call := func() string {
t.Helper()
obj, err := e.New(context.Background(), "Mod/D")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
got, _ := obj.Call(context.Background(), "V")
return got.(string)
}
if got := call(); got != "base" {
t.Fatalf("初始该是 base: %q", got)
}
time.Sleep(10 * time.Millisecond)
write(t, d2, "Mod/D.ts", `export default class D { V(): string { return "patched" } }`)
if err := over.Rebuild(); err != nil {
t.Fatal(err)
}
if got := call(); got != "patched" {
t.Errorf("新增的覆盖没生效: %q", got)
}
}
// Overlay 汇总各层的入口名。
func TestOverlay_Names汇总(t *testing.T) {
base, _ := esm.NewLoader("testdata/app/src")
over, _ := esm.NewLoader("testdata/app2/src")
o, err := jscriptx.Overlay(base, over)
if err != nil {
t.Fatal(err)
}
names := strings.Join(o.Names(), " ")
for _, want := range []string{"PkgVersion/PkgImportController", "Extra/ExtraController"} {
if !strings.Contains(names, want) {
t.Errorf("Names() 里少了 %s: %s", want, names)
}
}
// 同名只算一次
if n := strings.Count(names, "Resource/ResCreateController"); n != 1 {
t.Errorf("同名入口出现了 %d 次: %s", n, names)
}
}
func write(t *testing.T, dir, rel, content string) {
t.Helper()
p := filepath.Join(dir, rel)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// README 里推荐的混合来源写法:磁盘目录一层 + 自定义来源一层,
// 后者用 PreparedFunc + Bundle 自己打包,好跟 esm.Loader 的 Prepared 对齐。
func TestOverlay_磁盘层加自定义来源层(t *testing.T) {
disk, err := esm.NewLoader("testdata/app/src")
if err != nil {
t.Fatal(err)
}
// 假装是数据库:只存了一份覆盖脚本
rows := map[string]string{
"Resource/ResCreateController": `export default class C { Init(): string { return "来自数据库" } }`,
}
db := jscriptx.PreparedFunc(func(name string) (string, string, error) {
src, ok := rows[name]
if !ok {
return "", "", fmt.Errorf("%w: %s", jscriptx.ErrScriptNotFound, name)
}
bundled, err := jscriptx.Bundle(name+".ts", src)
if err != nil {
return "", "", err
}
return bundled, "v1", nil
})
e, err := jscriptx.New(jscriptx.WithLogger(nil), jscriptx.WithLoader(disk, db))
if err != nil {
t.Fatal(err)
}
defer e.Close()
ctx := context.Background()
// 数据库那层盖住了磁盘上的同名脚本
obj, err := e.New(ctx, "Resource/ResCreateController")
if err != nil {
t.Fatal(err)
}
defer obj.Close()
if got, _ := obj.Call(ctx, "Init"); got != "来自数据库" {
t.Errorf("数据库那层没盖住磁盘的: %#v", got)
}
// 数据库里没有的照样落回磁盘那层
obj2, err := e.New(ctx, "PkgVersion/PkgImportController", "framework")
if err != nil {
t.Fatal(err)
}
defer obj2.Close()
if got, _ := obj2.Call(ctx, "Init"); got != "PkgImport:framework" {
t.Errorf("没落回磁盘那层: %#v", got)
}
}
// 两个版本的脚本目录,调用时点名要哪个版本。
func TestVersion_两个版本的目录(t *testing.T) {
d1, d2 := t.TempDir(), t.TempDir()
write(t, d1, "Resource/ResCreateController.ts",
`export default class C { Init(): string { return "v1 的实现" } }`)
write(t, d1, "Legacy/OldController.ts",
`export default class O { Init(): string { return "只有 v1 有" } }`)
write(t, d2, "Resource/ResCreateController.ts",
`export default class C { Init(): string { return "v2 的实现" } }`)
v1, err := esm.NewLoader(d1, esm.WithVersion("v1"))
if err != nil {
t.Fatal(err)
}
v2, err := esm.NewLoader(d2, esm.WithVersion("v2"))
if err != nil {
t.Fatal(err)
}
if v1.Version() != "v1" {
t.Errorf("Version() = %q", v1.Version())
}
e := newEngineWith(t, v1, v2)
ctx := context.Background()
call := func(name string) string {
t.Helper()
obj, err := e.New(ctx, name)
if err != nil {
t.Fatal(err)
}
defer obj.Close()
got, err := obj.Call(ctx, "Init")
if err != nil {
t.Fatal(err)
}
return got.(string)
}
if got := call("Resource/ResCreateController"); got != "v2 的实现" {
t.Errorf("不点名该拿到最上层: %q", got)
}
if got := call("Resource/ResCreateController@v1"); got != "v1 的实现" {
t.Errorf("点名 v1: %q", got)
}
if got := call("Resource/ResCreateController@v2"); got != "v2 的实现" {
t.Errorf("点名 v2: %q", got)
}
if got := call("Legacy/OldController@v1"); got != "只有 v1 有" {
t.Errorf("点名 v1 独有的: %q", got)
}
// v2 层没有 Legacy/OldController,点名它就该报不存在,不能回落到 v1
if _, err := e.New(ctx, "Legacy/OldController@v2"); !errors.Is(err, jscriptx.ErrScriptNotFound) {
t.Errorf("点名 v2 该报不存在: %v", err)
}
}
// 点名各版本后,各自的热更新互不干扰。
func TestVersion_各版本独立热更新(t *testing.T) {
d1, d2 := t.TempDir(), t.TempDir()
write(t, d1, "Mod/A.ts", `export default class A { V(): string { return "v1-a" } }`)
write(t, d2, "Mod/A.ts", `export default class A { V(): string { return "v2-a" } }`)
v1, _ := esm.NewLoader(d1, esm.WithVersion("v1"))
v2, _ := esm.NewLoader(d2, esm.WithVersion("v2"))
e := newEngineWith(t, v1, v2)
ctx := context.Background()
call := func(name string) string {
t.Helper()
obj, err := e.New(ctx, name)
if err != nil {
t.Fatal(err)
}
defer obj.Close()
got, _ := obj.Call(ctx, "V")
return got.(string)
}
if call("Mod/A@v1") != "v1-a" || call("Mod/A@v2") != "v2-a" {
t.Fatal("初始状态不对")
}
// 只改 v1 那份
time.Sleep(10 * time.Millisecond)
write(t, d1, "Mod/A.ts", `export default class A { V(): string { return "v1-b" } }`)
if got := call("Mod/A@v1"); got != "v1-b" {
t.Errorf("v1 的热更新没生效: %q", got)
}
if got := call("Mod/A@v2"); got != "v2-a" {
t.Errorf("改 v1 不该影响 v2: %q", got)
}
}