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:
+345
@@ -0,0 +1,345 @@
|
||||
package esm_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.fsdpf.net/go/jscriptx"
|
||||
"git.fsdpf.net/go/jscriptx/esm"
|
||||
)
|
||||
|
||||
func newEngine(t *testing.T, dir string, opts ...esm.Option) *jscriptx.Engine {
|
||||
t.Helper()
|
||||
loader, err := esm.NewLoader(dir, opts...)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoader 失败: %v", err)
|
||||
}
|
||||
return newEngineWith(t, loader)
|
||||
}
|
||||
|
||||
func newEngineWith(t *testing.T, loaders ...*esm.Loader) *jscriptx.Engine {
|
||||
t.Helper()
|
||||
layers := make([]jscriptx.Loader, len(loaders))
|
||||
for i, l := range loaders {
|
||||
layers[i] = l
|
||||
}
|
||||
e, err := jscriptx.New(
|
||||
jscriptx.WithLogger(nil),
|
||||
jscriptx.WithLoader(layers...),
|
||||
jscriptx.WithAutoReload(true),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(e.Close)
|
||||
return e
|
||||
}
|
||||
|
||||
// 核心场景:按 目录/文件 取到 class,实例化,调方法。
|
||||
func TestLoadDir_按路径取Controller(t *testing.T) {
|
||||
e := newEngine(t, "testdata/app/src")
|
||||
ctx := context.Background()
|
||||
|
||||
ctrl, err := e.New(ctx, "Resource/ResCreateController", "产品", "创建")
|
||||
if err != nil {
|
||||
t.Fatalf("取 controller 失败: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
got, err := ctrl.Call(ctx, "Init")
|
||||
if err != nil {
|
||||
t.Fatalf("调用 Init 失败: %v", err)
|
||||
}
|
||||
if got != "ResCreate:产品/创建" {
|
||||
t.Errorf("Init = %#v", got)
|
||||
}
|
||||
|
||||
// import 进来的公共类能用,实例状态跨调用保持
|
||||
for i := 1; i <= 3; i++ {
|
||||
got, err := ctrl.Call(ctx, "Store", "配置")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "stored:配置#" + string(rune('0'+i))
|
||||
if got != want {
|
||||
t.Errorf("第 %d 次 Store = %#v, want %q", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDir_JS和TS混用(t *testing.T) {
|
||||
e := newEngine(t, "testdata/app/src")
|
||||
ctx := context.Background()
|
||||
|
||||
// .js 写的 controller
|
||||
ctrl, err := e.New(ctx, "PkgVersion/PkgImportController", "framework")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
if got, _ := ctrl.Call(ctx, "Init"); got != "PkgImport:framework" {
|
||||
t.Errorf("got = %#v", got)
|
||||
}
|
||||
if got, _ := ctrl.Call(ctx, "Handle"); got.(int64) != 1 {
|
||||
t.Errorf("got = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 只有命名导出、没有 default 的模块,整个模块当导出对象用。
|
||||
func TestLoadDir_只有命名导出(t *testing.T) {
|
||||
e := newEngine(t, "testdata/app/src")
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := e.Script("PkgVersion/PkgOptionsController")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.Call(ctx, "Options")
|
||||
if err != nil {
|
||||
t.Fatalf("调用命名导出失败: %v", err)
|
||||
}
|
||||
if got != "opts" {
|
||||
t.Errorf("got = %#v", got)
|
||||
}
|
||||
if got, _ := s.Call(ctx, "Version"); got != "v1" {
|
||||
t.Errorf("got = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 入口列表:默认只有第一层子目录下的文件算入口。
|
||||
func TestLoadDir_入口规则(t *testing.T) {
|
||||
loader, err := esm.NewLoader("testdata/app/src")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := loader.Names()
|
||||
want := map[string]bool{
|
||||
"Async/AsyncController": true,
|
||||
"PkgVersion/PkgImportController": true,
|
||||
"PkgVersion/PkgOptionsController": true,
|
||||
"Resource/ResCreateController": true,
|
||||
"Resource/ResDeleteController": true,
|
||||
"Resource/ResSchemaController": true,
|
||||
"Resource/ResExecuter": true,
|
||||
"Resource/shared": true,
|
||||
"lib/format": true,
|
||||
"Shared/CartController": true,
|
||||
"Shared/OrderController": true,
|
||||
}
|
||||
if len(names) != len(want) {
|
||||
t.Errorf("入口数 = %d (%v), want %d", len(names), names, len(want))
|
||||
}
|
||||
for _, n := range names {
|
||||
if !want[n] {
|
||||
t.Errorf("多了个入口: %s", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义 glob:只要 *Controller
|
||||
loader2, err := esm.NewLoader("testdata/app/src", esm.WithGlobs("*/*Controller.js", "*/*Controller.ts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range loader2.Names() {
|
||||
if !strings.HasSuffix(n, "Controller") {
|
||||
t.Errorf("glob 没生效,混进了 %s", n)
|
||||
}
|
||||
}
|
||||
if len(loader2.Names()) != 8 {
|
||||
t.Errorf("Controller 数 = %d, want 8: %v", len(loader2.Names()), loader2.Names())
|
||||
}
|
||||
}
|
||||
|
||||
// 公共模块的状态不跨 controller 共享——这是必须让脚本作者知道的事。
|
||||
func TestLoadDir_公共模块状态不共享(t *testing.T) {
|
||||
e := newEngine(t, "testdata/app/src")
|
||||
ctx := context.Background()
|
||||
|
||||
a, err := e.New(ctx, "Resource/ResCreateController", "x", "y")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := e.New(ctx, "Resource/ResDeleteController")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
got1, _ := a.Call(ctx, "Seq")
|
||||
got2, _ := a.Call(ctx, "Seq")
|
||||
got3, _ := b.Call(ctx, "Seq")
|
||||
|
||||
if got1 != "[create:1]" || got2 != "[create:2]" {
|
||||
t.Errorf("同一实例内应该累加: %v %v", got1, got2)
|
||||
}
|
||||
if got3 != "[delete:1]" {
|
||||
t.Errorf("另一个 controller 的公共模块状态应该独立,got %v", got3)
|
||||
}
|
||||
|
||||
// 同一个 controller 的两个实例之间也不共享
|
||||
a2, _ := e.New(ctx, "Resource/ResCreateController", "x", "y")
|
||||
defer a2.Close()
|
||||
if got, _ := a2.Call(ctx, "Seq"); got != "[create:1]" {
|
||||
t.Errorf("不同实例之间不该共享公共模块状态,got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 错误要能定位回 .ts 源文件的行号。
|
||||
func TestLoadDir_错误定位回源码(t *testing.T) {
|
||||
e := newEngine(t, "testdata/app/src")
|
||||
ctx := context.Background()
|
||||
|
||||
ctrl, err := e.New(ctx, "Resource/ResCreateController", "x", "y")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
_, err = ctrl.Call(ctx, "Boom")
|
||||
if err == nil {
|
||||
t.Fatal("应该报错")
|
||||
}
|
||||
var jsErr *jscriptx.Error
|
||||
if !errors.As(err, &jsErr) {
|
||||
t.Fatalf("错误类型不对: %T", err)
|
||||
}
|
||||
if !strings.Contains(jsErr.Msg, "创建失败") {
|
||||
t.Errorf("没带上脚本的错误信息: %q", jsErr.Msg)
|
||||
}
|
||||
if len(jsErr.Stack) == 0 {
|
||||
t.Fatal("没有栈信息")
|
||||
}
|
||||
t.Logf("定位: %s:%d:%d", jsErr.Stack[0].Source, jsErr.Stack[0].Line, jsErr.Stack[0].Column)
|
||||
// Boom 在 ResCreateController.ts 的第 15 行(打包产物里不是这个位置,
|
||||
// 能对上说明 sourcemap 生效了)
|
||||
if jsErr.Stack[0].Line != 15 {
|
||||
t.Errorf("行号没映射回源码, got %d, want 15", jsErr.Stack[0].Line)
|
||||
}
|
||||
}
|
||||
|
||||
// 改了 .ts 源文件,自动重新打包生效。
|
||||
func TestLoadDir_热更新(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "Mod")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
helper := filepath.Join(sub, "helper.ts")
|
||||
ctrl := filepath.Join(sub, "MyController.ts")
|
||||
|
||||
write := func(p, s string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write(helper, `export function greet(): string { return "v1" }`)
|
||||
write(ctrl, `import { greet } from "./helper"
|
||||
export default class MyController {
|
||||
Get(): string { return greet() }
|
||||
}`)
|
||||
|
||||
e := newEngine(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
obj, err := e.New(ctx, "Mod/MyController")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := obj.Call(ctx, "Get"); got != "v1" {
|
||||
t.Fatalf("got = %#v", got)
|
||||
}
|
||||
obj.Close()
|
||||
|
||||
// 改的是被 import 的文件,不是 controller 本身——依赖变化也要能感知
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
write(helper, `export function greet(): string { return "v2" }`)
|
||||
|
||||
obj2, err := e.New(ctx, "Mod/MyController")
|
||||
if err != nil {
|
||||
t.Fatalf("热更新后取实例失败: %v", err)
|
||||
}
|
||||
defer obj2.Close()
|
||||
got, err := obj2.Call(ctx, "Get")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "v2" {
|
||||
t.Errorf("改了被 import 的文件没生效, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 新增文件后能被发现。
|
||||
func TestLoadDir_新增文件(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "Mod")
|
||||
os.MkdirAll(sub, 0o755)
|
||||
os.WriteFile(filepath.Join(sub, "A.ts"), []byte(`export default class A { Hi() { return "a" } }`), 0o644)
|
||||
|
||||
e := newEngine(t, dir)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := e.New(ctx, "Mod/B"); !errors.Is(err, jscriptx.ErrScriptNotFound) {
|
||||
t.Fatalf("还没建 B,应该报找不到, got %v", err)
|
||||
}
|
||||
|
||||
os.WriteFile(filepath.Join(sub, "B.ts"), []byte(`export default class B { Hi() { return "b" } }`), 0o644)
|
||||
|
||||
obj, err := e.New(ctx, "Mod/B")
|
||||
if err != nil {
|
||||
t.Fatalf("新增的文件没被发现: %v", err)
|
||||
}
|
||||
defer obj.Close()
|
||||
if got, _ := obj.Call(ctx, "Hi"); got != "b" {
|
||||
t.Errorf("got = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TypeScript 语法错误要在 NewLoader 就报出来,且带文件位置。
|
||||
func TestLoadDir_语法错误(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "Mod")
|
||||
os.MkdirAll(sub, 0o755)
|
||||
os.WriteFile(filepath.Join(sub, "Bad.ts"), []byte("export default class { 语法错误 ((("), 0o644)
|
||||
|
||||
_, err := esm.NewLoader(dir)
|
||||
if err == nil {
|
||||
t.Fatal("语法错误应该在 NewLoader 就报出来")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Bad.ts") {
|
||||
t.Errorf("报错没带文件名: %v", err)
|
||||
}
|
||||
t.Logf("%v", err)
|
||||
}
|
||||
|
||||
// import 一个不存在的模块要报清楚。
|
||||
func TestLoadDir_import不存在的模块(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "Mod")
|
||||
os.MkdirAll(sub, 0o755)
|
||||
// 必须真正用到 import 进来的东西,否则 esbuild 会 tree-shaking 掉,
|
||||
// 根本不需要解析那个模块
|
||||
os.WriteFile(filepath.Join(sub, "A.ts"), []byte(`import { nope } from "./nope"
|
||||
export default class A { Hi() { return nope() } }`), 0o644)
|
||||
|
||||
_, err := esm.NewLoader(dir)
|
||||
if err == nil {
|
||||
t.Fatal("应该报错")
|
||||
}
|
||||
t.Logf("%v", err)
|
||||
}
|
||||
|
||||
func TestLoadDir_取不存在的入口(t *testing.T) {
|
||||
e := newEngine(t, "testdata/app/src")
|
||||
_, err := e.New(context.Background(), "Nope/NoController")
|
||||
if !errors.Is(err, jscriptx.ErrScriptNotFound) {
|
||||
t.Fatalf("want ErrScriptNotFound, got %v", err)
|
||||
}
|
||||
t.Logf("%v", err)
|
||||
}
|
||||
Reference in New Issue
Block a user