From 6ce9b483fdf328eadc1afa5464e1035f6b0d83e6 Mon Sep 17 00:00:00 2001 From: what Date: Sat, 5 Sep 2026 22:13:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(esm):=20=E6=8C=89=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E8=84=9A=E6=9C=AC=E3=80=81=E6=89=93=E5=8C=85?= =?UTF-8?q?=E4=B8=8E=E7=83=AD=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把一个目录当脚本仓库:按路径寻址、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 一遍太贵。 --- esm/async_test.go | 94 +++++ esm/bundler.go | 283 +++++++++++++ esm/esm_test.go | 345 ++++++++++++++++ esm/layout_test.go | 211 ++++++++++ esm/loader.go | 226 ++++++++++ esm/node_modules_test.go | 188 +++++++++ esm/overlay_test.go | 388 ++++++++++++++++++ esm/plugin.go | 50 +++ esm/scope_test.go | 176 ++++++++ .../app/node_modules/@fsdpf/util/index.ts | 1 + .../app/node_modules/@fsdpf/util/package.json | 1 + .../app/node_modules/tinylib/index.js | 2 + .../app/node_modules/tinylib/package.json | 1 + esm/testdata/app/package.json | 9 + esm/testdata/app/src/Async/AsyncController.ts | 15 + .../app/src/PkgVersion/PkgImportController.js | 5 + .../src/PkgVersion/PkgOptionsController.ts | 3 + .../app/src/Resource/ResCreateController.ts | 16 + .../app/src/Resource/ResDeleteController.ts | 4 + esm/testdata/app/src/Resource/ResExecuter.ts | 5 + .../app/src/Resource/ResSchemaController.ts | 17 + esm/testdata/app/src/Resource/shared.ts | 4 + esm/testdata/app/src/Shared/CartController.ts | 15 + .../app/src/Shared/OrderController.ts | 13 + esm/testdata/app/src/lib/format.ts | 4 + esm/testdata/app/tsconfig.json | 14 + esm/testdata/app2/node_modules/only2/index.js | 1 + .../app2/node_modules/only2/package.json | 1 + .../app2/src/Extra/ExtraController.ts | 5 + .../app2/src/Resource/ResCreateController.ts | 4 + esm/testdata/app2/tsconfig.json | 1 + esm/testdata/extapp/Mod/TypedController.ts | 17 + esm/typings.go | 130 ++++++ esm/typings_test.go | 154 +++++++ 34 files changed, 2403 insertions(+) create mode 100644 esm/async_test.go create mode 100644 esm/bundler.go create mode 100644 esm/esm_test.go create mode 100644 esm/layout_test.go create mode 100644 esm/loader.go create mode 100644 esm/node_modules_test.go create mode 100644 esm/overlay_test.go create mode 100644 esm/plugin.go create mode 100644 esm/scope_test.go create mode 100644 esm/testdata/app/node_modules/@fsdpf/util/index.ts create mode 100644 esm/testdata/app/node_modules/@fsdpf/util/package.json create mode 100644 esm/testdata/app/node_modules/tinylib/index.js create mode 100644 esm/testdata/app/node_modules/tinylib/package.json create mode 100644 esm/testdata/app/package.json create mode 100644 esm/testdata/app/src/Async/AsyncController.ts create mode 100644 esm/testdata/app/src/PkgVersion/PkgImportController.js create mode 100644 esm/testdata/app/src/PkgVersion/PkgOptionsController.ts create mode 100644 esm/testdata/app/src/Resource/ResCreateController.ts create mode 100644 esm/testdata/app/src/Resource/ResDeleteController.ts create mode 100644 esm/testdata/app/src/Resource/ResExecuter.ts create mode 100644 esm/testdata/app/src/Resource/ResSchemaController.ts create mode 100644 esm/testdata/app/src/Resource/shared.ts create mode 100644 esm/testdata/app/src/Shared/CartController.ts create mode 100644 esm/testdata/app/src/Shared/OrderController.ts create mode 100644 esm/testdata/app/src/lib/format.ts create mode 100644 esm/testdata/app/tsconfig.json create mode 100644 esm/testdata/app2/node_modules/only2/index.js create mode 100644 esm/testdata/app2/node_modules/only2/package.json create mode 100644 esm/testdata/app2/src/Extra/ExtraController.ts create mode 100644 esm/testdata/app2/src/Resource/ResCreateController.ts create mode 100644 esm/testdata/app2/tsconfig.json create mode 100644 esm/testdata/extapp/Mod/TypedController.ts create mode 100644 esm/typings.go create mode 100644 esm/typings_test.go diff --git a/esm/async_test.go b/esm/async_test.go new file mode 100644 index 0000000..d22b73e --- /dev/null +++ b/esm/async_test.go @@ -0,0 +1,94 @@ +package esm_test + +import ( + "context" + "errors" + "strings" + "testing" + + "git.fsdpf.net/go/jscriptx" +) + +// async 方法要能像同步方法一样用:Promise 由本库解包,不泄漏 goja 类型。 +func TestAsync_自动解包(t *testing.T) { + e := newEngine(t, "testdata/app/src") + ctx := context.Background() + + obj, err := e.New(ctx, "Async/AsyncController") + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + for _, c := range []struct{ fn, want string }{ + {"Sync", "同步结果"}, + {"Async", "异步结果"}, + {"AwaitSomething", "await 的结果"}, + } { + got, err := obj.Call(ctx, c.fn) + if err != nil { + t.Errorf("%s 失败: %v", c.fn, err) + continue + } + if got != c.want { + t.Errorf("%s = %#v, want %q", c.fn, got, c.want) + } + } + + // CallInto 也要解包 + var s string + if err := obj.CallInto(ctx, "Async", &s); err != nil { + t.Fatalf("CallInto 失败: %v", err) + } + if s != "异步结果" { + t.Errorf("CallInto = %q", s) + } +} + +// async 方法抛异常(Promise reject)要变成正常的 Go error。 +func TestAsync_reject转成错误(t *testing.T) { + e := newEngine(t, "testdata/app/src") + ctx := context.Background() + + obj, err := e.New(ctx, "Async/AsyncController") + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + _, err = obj.Call(ctx, "Fails") + if err == nil { + t.Fatal("应该报错") + } + if !errors.Is(err, jscriptx.ErrPromiseRejected) { + t.Errorf("want ErrPromiseRejected, got %v", err) + } + var jsErr *jscriptx.Error + if errors.As(err, &jsErr) && !strings.Contains(jsErr.Msg, "异步失败") { + t.Errorf("没带上 reject 的原因: %q", jsErr.Msg) + } + t.Logf("%v", err) +} + +// goja 没有事件循环,等待真正的异步会让 Promise 永远 pending,要报清楚。 +func TestAsync_永不完成的Promise(t *testing.T) { + e := newEngine(t, "testdata/app/src") + ctx := context.Background() + + obj, err := e.New(ctx, "Async/AsyncController") + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + _, err = obj.Call(ctx, "Never") + if !errors.Is(err, jscriptx.ErrPromisePending) { + t.Fatalf("want ErrPromisePending, got %v", err) + } + var jsErr *jscriptx.Error + errors.As(err, &jsErr) + if !strings.Contains(jsErr.Msg, "事件循环") { + t.Errorf("报错没解释原因: %q", jsErr.Msg) + } + t.Logf("%v", jsErr.Msg) +} diff --git a/esm/bundler.go b/esm/bundler.go new file mode 100644 index 0000000..6c0f0f3 --- /dev/null +++ b/esm/bundler.go @@ -0,0 +1,283 @@ +// Package esm 让业务脚本能用 ESM 语法和 TypeScript 编写:加载一个目录, +// 用 esbuild 把每个入口连同它 import 的模块打包成 goja 能直接执行的代码。 +// +// goja 本身没有 ES module 支持(export/import 在它的 token 表里还是 futureKeyword), +// 也不认识 TypeScript。打包这一步就是用来抹平这个差距的——源码怎么舒服怎么写, +// 交给 goja 的永远是打包好的、自包含的一份代码。 +// +// 典型用法: +// +// loader, err := esm.NewLoader("app") +// e, err := jscriptx.New( +// jscriptx.WithLoader(loader), +// jscriptx.WithAutoReload(true), // 改了 .ts 不用重启 +// jscriptx.WithGlobals(bindings.Safe()), +// ) +// ctrl, err := e.New(ctx, "PkgVersion/PkgImportController") +// got, err := ctrl.Call(ctx, "Init") +// +// 对应的目录结构: +// +// app/ +// ├── PkgVersion/ +// │ ├── PkgImportController.ts +// │ └── PkgManifestController.ts +// └── Resource/ +// ├── ResCreateController.ts +// └── ResExecuter.ts ← 可以被同目录的 controller import +// +// # 共享只到代码层面,不到状态层面 +// +// 多个 controller import 同一个模块时,各自拿到的是独立的副本:模块被内联进各自的 +// 产物是一层原因,更根本的是每个 controller 实例独占一个 VM,VM 之间不共享任何 JS 状态。 +// +// 公共的纯函数、工具类、常量、类型 → 随便 import +// 公共的状态(缓存、计数器、连接) → 各自一份,必须由 Go 侧注入 +// +// 这是那种低负载下看不出问题、一上量才暴露的坑,务必注意。 +package esm + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "git.fsdpf.net/go/jscriptx" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/evanw/esbuild/pkg/api" +) + +// bundle 是一个入口的打包结果。 +type bundle struct { + name string // 按路径的名字,如 PkgVersion/PkgImportController + code string // 打包产物(自包含的立即执行函数) + inputs []string // 参与打包的源文件(相对 dir),用来算版本号 + version string +} + +// buildAll 扫描目录并打包所有入口。一次性打包所有入口比逐个打包更快, +// esbuild 内部会复用已解析的模块。 +func (l *Loader) buildAll() (map[string]*bundle, error) { + entries, err := l.findEntries() + if err != nil { + return nil, err + } + if len(entries) == 0 { + return map[string]*bundle{}, nil + } + + abs, err := filepath.Abs(l.dir) + if err != nil { + return nil, err + } + // esbuild 内部会把符号链接解析掉(macOS 上 /var/... → /private/var/...), + // AbsWorkingDir/Outbase 必须用同一种形式,否则 Outbase 匹配不上, + // 产物路径会被剥掉目录前缀。 + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } + + result := api.Build(api.BuildOptions{ + EntryPoints: entries, + AbsWorkingDir: abs, + // Outbase 必须显式钉在根目录:不设时 esbuild 拿所有入口的公共父目录当基准, + // 入口恰好都在同一个子目录下时产物路径会被剥掉前缀,名字就对不上了。 + Outbase: abs, + Bundle: true, + // ESM 格式而不是 IIFE:IIFE 会附带一整套 CommonJS interop helper, + // 每建一个 VM 都要重跑一遍。FinalizeBundle 会把产物改写成立即执行函数。 + Format: api.FormatESModule, + Outdir: outDir, + Write: false, + Metafile: true, + Target: l.target, + Sourcemap: api.SourceMapInline, + SourcesContent: api.SourcesContentExclude, + LogLevel: api.LogLevelSilent, + Define: l.define, + NodePaths: l.nodePaths, + Alias: l.alias, + Plugins: []api.Plugin{extensionPlugin(l.exts)}, + Loader: map[string]api.Loader{ + ".js": api.LoaderJS, + ".ts": api.LoaderTS, + ".mjs": api.LoaderJS, + ".json": api.LoaderJSON, + }, + }) + if len(result.Errors) > 0 { + return nil, buildError(result.Errors) + } + + inputsOf, err := parseMetafile(result.Metafile) + if err != nil { + return nil, err + } + + out := make(map[string]*bundle, len(result.OutputFiles)) + for _, f := range result.OutputFiles { + // 不能用 filepath.Rel(abs, f.Path):esbuild 会把符号链接解析掉 + // (macOS 上 /var/... 变成 /private/var/...),算出来的相对路径是错的。 + // 直接从产物路径里截 outDir 之后的部分。 + rel, ok := afterOutDir(f.Path) + if !ok { + continue + } + name := toName(rel) + b := &bundle{ + name: name, + code: jscriptx.FinalizeBundle(string(f.Contents)), + inputs: inputsOf[path.Join(outDir, rel)], + } + b.version = l.versionOf(b.inputs) + out[name] = b + } + return out, nil +} + +// findEntries 按 glob 找出入口文件,返回相对 dir 的路径。 +func (l *Loader) findEntries() ([]string, error) { + var out []string + err := filepath.WalkDir(l.dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if d.Name() == "node_modules" || strings.HasPrefix(d.Name(), ".") { + if p != l.dir { + return fs.SkipDir + } + } + return nil + } + rel, err := filepath.Rel(l.dir, p) + if err != nil { + return nil + } + rel = filepath.ToSlash(rel) + if !isSource(rel) || !l.matches(rel) { + return nil + } + out = append(out, rel) + return nil + }) + if err != nil { + return nil, fmt.Errorf("扫描目录 %s 失败: %w", l.dir, err) + } + sort.Strings(out) + return out, nil +} + +// matches 判断相对路径是否命中入口 glob。 +func (l *Loader) matches(rel string) bool { + for _, g := range l.globs { + if ok, _ := path.Match(g, rel); ok { + return true + } + } + return false +} + +func isSource(rel string) bool { + switch strings.ToLower(filepath.Ext(rel)) { + case ".js", ".ts", ".mjs": + return true + } + return false +} + +// toName 把产物的相对路径变成脚本名:去掉扩展名,统一用 /。 +func toName(rel string) string { + rel = filepath.ToSlash(rel) + return strings.TrimSuffix(rel, path.Ext(rel)) +} + +// versionOf 根据参与打包的源文件的修改时间和大小算一个版本号, +// 任何一个依赖改了版本号就会变,热更新据此触发重新编译。 +// +// node_modules 里的文件不算在内:依赖包是装出来的,改动总伴随显式的安装/升级动作, +// 而真实的 npm 包动辄成百上千个文件,每次取脚本都 stat 一遍太贵。 +// 依赖装完或换版本后调 Rebuild 让它重新打包。 +func (l *Loader) versionOf(inputs []string) string { + h := sha256.New() + for _, in := range inputs { + if isVendored(in) { + continue + } + st, err := os.Stat(filepath.Join(l.dir, filepath.FromSlash(in))) + if err != nil { + fmt.Fprintf(h, "%s|missing\n", in) + continue + } + fmt.Fprintf(h, "%s|%d|%d\n", in, st.ModTime().UnixNano(), st.Size()) + } + return hex.EncodeToString(h.Sum(nil)[:8]) +} + +// parseMetafile 从 esbuild 的 metafile 里取出每个产物用到了哪些源文件。 +func parseMetafile(meta string) (map[string][]string, error) { + if meta == "" { + return map[string][]string{}, nil + } + var m struct { + Outputs map[string]struct { + Inputs map[string]struct { + BytesInOutput int `json:"bytesInOutput"` + } `json:"inputs"` + } `json:"outputs"` + } + if err := json.Unmarshal([]byte(meta), &m); err != nil { + return nil, fmt.Errorf("解析 esbuild metafile 失败: %w", err) + } + out := make(map[string][]string, len(m.Outputs)) + for outPath, o := range m.Outputs { + ins := make([]string, 0, len(o.Inputs)) + for in := range o.Inputs { + ins = append(ins, in) + } + sort.Strings(ins) + out[filepath.ToSlash(outPath)] = ins + } + return out, nil +} + +// buildError 把 esbuild 的报错整理成一条带位置的错误。 +func buildError(errs []api.Message) error { + var b strings.Builder + b.WriteString("打包失败") + for i, e := range errs { + if i >= 5 { + fmt.Fprintf(&b, "\n …还有 %d 条错误", len(errs)-i) + break + } + b.WriteString("\n ") + if loc := e.Location; loc != nil { + fmt.Fprintf(&b, "%s:%d:%d: ", loc.File, loc.Line, loc.Column) + } + b.WriteString(e.Text) + } + return fmt.Errorf("%s", b.String()) +} + +// afterOutDir 从产物的绝对路径里截出 outDir 之后的部分。 +func afterOutDir(p string) (string, bool) { + slash := filepath.ToSlash(p) + marker := "/" + outDir + "/" + i := strings.LastIndex(slash, marker) + if i < 0 { + return "", false + } + return slash[i+len(marker):], true +} + +// isVendored 判断一个源文件是不是来自 node_modules。 +func isVendored(p string) bool { + p = filepath.ToSlash(p) + return strings.HasPrefix(p, "node_modules/") || strings.Contains(p, "/node_modules/") +} diff --git a/esm/esm_test.go b/esm/esm_test.go new file mode 100644 index 0000000..04b04ca --- /dev/null +++ b/esm/esm_test.go @@ -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) +} diff --git a/esm/layout_test.go b/esm/layout_test.go new file mode 100644 index 0000000..7e137d7 --- /dev/null +++ b/esm/layout_test.go @@ -0,0 +1,211 @@ +package esm_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "git.fsdpf.net/go/jscriptx/esm" + "github.com/evanw/esbuild/pkg/api" +) + +// 推荐的项目布局:配置和依赖放在项目根,源码放在 src/,加载器指向 src/。 +// +// app/ +// ├── package.json ← npm 配置 +// ├── tsconfig.json ← esbuild 会自动往上找到并生效 +// ├── node_modules/ ← 依赖 +// └── src/ ← NewLoader 指这里,寻址不带前缀 +// ├── PkgVersion/ +// └── Resource/ +func TestLayout_src布局(t *testing.T) { + root := t.TempDir() + app := filepath.Join(root, "app") + w := func(p, s string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(s), 0o644); err != nil { + t.Fatal(err) + } + } + + w(filepath.Join(app, "package.json"), `{"name":"fsdpf-app","type":"module"}`) + w(filepath.Join(app, "tsconfig.json"), `{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@lib/*": ["./src/lib/*"] }, + "experimentalDecorators": true + } +}`) + w(filepath.Join(app, "node_modules", "tinylib", "package.json"), `{"name":"tinylib","main":"index.js"}`) + w(filepath.Join(app, "node_modules", "tinylib", "index.js"), `export function hi() { return "nm" }`) + w(filepath.Join(app, "src", "lib", "helper.ts"), `export function help(): string { return "helper" }`) + + // 依赖 + tsconfig 的 paths 别名 + w(filepath.Join(app, "src", "Resource", "ResCreateController.ts"), `import { hi } from "tinylib" +import { help } from "@lib/helper" +export default class ResCreateController { + Get(): string { return hi() + "/" + help() } +}`) + + // tsconfig 里开的 experimentalDecorators + w(filepath.Join(app, "src", "Decor", "DecorController.ts"), `function Route(path: string) { + return function (target: any, key: string, desc: PropertyDescriptor) { + const orig = desc.value + desc.value = function (...args: any[]) { return path + ":" + orig.apply(this, args) } + return desc + } +} +export default class DecorController { + @Route("/api") + Get(): string { return "decorated" } +}`) + + src := filepath.Join(app, "src") + + // 加载器指向 app 时,源码深了一层,默认 glob 匹配不到 + if l, err := esm.NewLoader(app); err == nil && len(l.Names()) != 0 { + t.Errorf("默认 glob 只认第一层子目录,不该匹配到 src 下的文件: %v", l.Names()) + } + + loader, err := esm.NewLoader(src) + if err != nil { + t.Fatalf("NewLoader 失败: %v", err) + } + e := newEngineWith(t, loader) + ctx := context.Background() + + for _, c := range []struct{ name, want string }{ + // node_modules 在 src 的上层(app 下),逐级往上能找到; + // tsconfig.json 同样在上层,paths 别名照样生效 + {"Resource/ResCreateController", "nm/helper"}, + // tsconfig 里的 experimentalDecorators 也生效 + {"Decor/DecorController", "/api:decorated"}, + } { + obj, err := e.New(ctx, c.name) + if err != nil { + t.Errorf("%s: %v", c.name, err) + continue + } + got, err := obj.Call(ctx, "Get") + if err != nil { + t.Errorf("%s: %v", c.name, err) + } else if got != c.want { + t.Errorf("%s = %#v, want %q", c.name, got, c.want) + } + obj.Close() + } +} + +// 想把辅助模块挡在入口之外,用 glob 收窄。 +func TestLayout_用glob只收Controller(t *testing.T) { + root := t.TempDir() + w := func(p, s string) { + t.Helper() + os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, []byte(s), 0o644); err != nil { + t.Fatal(err) + } + } + w(filepath.Join(root, "Resource", "ResCreateController.ts"), + `import { help } from "../lib/helper" +export default class C { Get(): string { return help() } }`) + w(filepath.Join(root, "lib", "helper.ts"), `export function help(): string { return "helper" }`) + + loader, err := esm.NewLoader(root, esm.WithGlobs("*/*Controller.ts")) + if err != nil { + t.Fatal(err) + } + names := loader.Names() + if len(names) != 1 || names[0] != "Resource/ResCreateController" { + t.Errorf("入口应该只有 Controller: %v", names) + } + + // 挡在入口外不影响被 import + e := newEngineWith(t, loader) + obj, err := e.New(context.Background(), "Resource/ResCreateController") + if err != nil { + t.Fatal(err) + } + defer obj.Close() + if got, _ := obj.Call(context.Background(), "Get"); got != "helper" { + t.Errorf("got = %#v", got) + } +} + +// testdata 本身就是推荐布局,这里验证它的配置文件确实在起作用: +// node_modules 和 tsconfig.json 都在 src 的上层,靠逐级往上查找生效。 +func TestLayout_testdata走的就是推荐布局(t *testing.T) { + e := newEngine(t, "testdata/app/src") + ctx := context.Background() + + obj, err := e.New(ctx, "Resource/ResSchemaController") + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + // tinylib / @fsdpf/util 来自 app/node_modules(src 的上层) + got, err := obj.Call(ctx, "Describe", "widget", 12.5) + if err != nil { + t.Fatalf("node_modules 没生效: %v", err) + } + if got != "WIDGET ¥12.50 stored:s" { + t.Errorf("Describe = %#v", got) + } + + // @lib/format 是 app/tsconfig.json 里配的 paths 别名 + got, err = obj.Call(ctx, "Tag", "x") + if err != nil { + t.Fatalf("tsconfig 的 paths 别名没生效: %v", err) + } + if got != "[x]" { + t.Errorf("Tag = %#v", got) + } +} + +// WithTarget / WithDefine:打包时的语法降级和编译期常量替换。 +func TestLayout_打包选项(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "Mod") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "C.ts"), []byte(`declare const __MODE__: string +export default class C { + Mode(): string { return __MODE__ } + Pick(o: any): string { return o?.a?.b ?? "兜底" } +}`), 0o644); err != nil { + t.Fatal(err) + } + + loader, err := esm.NewLoader(root, + esm.WithDefine(map[string]string{"__MODE__": `"生产"`}), + // 可选链和空值合并降级到 ES2015,goja 对新语法覆盖不全时的出路 + esm.WithTarget(api.ES2015), + ) + if err != nil { + t.Fatal(err) + } + e := newEngineWith(t, loader) + ctx := context.Background() + + obj, err := e.New(ctx, "Mod/C") + if err != nil { + t.Fatal(err) + } + defer obj.Close() + + if got, _ := obj.Call(ctx, "Mode"); got != "生产" { + t.Errorf("WithDefine 没生效, got %#v", got) + } + if got, err := obj.Call(ctx, "Pick", nil); err != nil || got != "兜底" { + t.Errorf("降级后语法失效: got=%#v err=%v", got, err) + } + if got, _ := obj.Call(ctx, "Pick", map[string]any{"a": map[string]any{"b": "命中"}}); got != "命中" { + t.Errorf("got = %#v", got) + } +} diff --git a/esm/loader.go b/esm/loader.go new file mode 100644 index 0000000..9ae049d --- /dev/null +++ b/esm/loader.go @@ -0,0 +1,226 @@ +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) +) diff --git a/esm/node_modules_test.go b/esm/node_modules_test.go new file mode 100644 index 0000000..64d0a13 --- /dev/null +++ b/esm/node_modules_test.go @@ -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/util(scoped + .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) + } +} diff --git a/esm/overlay_test.go b/esm/overlay_test.go new file mode 100644 index 0000000..e643b68 --- /dev/null +++ b/esm/overlay_test.go @@ -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) + } +} diff --git a/esm/plugin.go b/esm/plugin.go new file mode 100644 index 0000000..60eca35 --- /dev/null +++ b/esm/plugin.go @@ -0,0 +1,50 @@ +package esm + +import ( + "git.fsdpf.net/go/jscriptx" + "github.com/evanw/esbuild/pkg/api" +) + +// extNamespace 是扩展模块在 esbuild 里的命名空间。这些模块不在磁盘上, +// 靠插件在解析阶段临时提供,所以要跟真实文件区分开。 +const extNamespace = "jscriptx-ext" + +// extensionPlugin 把扩展的 TS 模块变成可以 import 的虚拟模块: +// +// import store from "@jscriptx/store" +// +// 磁盘上没有这个文件,路径由扩展自己的 Module() 给出,源码也是它提供的。 +// 模块本身只是个门面——把全局对象转发出来并附上类型声明,真正的实现在 Go 侧, +// 由 jscriptx 在建 VM 时注入。 +// +// 拿不到扩展实例时(比如脚本 import 了但 ctx 里没带这个扩展),打包阶段不会报错, +// 因为模块只声明全局;真正缺东西要到运行时才暴露——这跟直接用全局变量的行为一致。 +func extensionPlugin(exts []jscriptx.Extension) api.Plugin { + modules := make(map[string]string, len(exts)) + for _, ext := range exts { + path, source := ext.Module() + if path == "" || source == "" { + continue + } + modules[path] = source + } + + return api.Plugin{ + Name: "jscriptx-extensions", + Setup: func(build api.PluginBuild) { + // 认领扩展的模块路径,交给下面的 OnLoad 提供内容 + build.OnResolve(api.OnResolveOptions{Filter: `.*`}, func(args api.OnResolveArgs) (api.OnResolveResult, error) { + if _, ok := modules[args.Path]; !ok { + return api.OnResolveResult{}, nil // 不是扩展模块,让 esbuild 照常解析 + } + return api.OnResolveResult{Path: args.Path, Namespace: extNamespace}, nil + }) + + build.OnLoad(api.OnLoadOptions{Filter: `.*`, Namespace: extNamespace}, + func(args api.OnLoadArgs) (api.OnLoadResult, error) { + source := modules[args.Path] + return api.OnLoadResult{Contents: &source, Loader: api.LoaderTS}, nil + }) + }, + } +} diff --git a/esm/scope_test.go b/esm/scope_test.go new file mode 100644 index 0000000..f4ac411 --- /dev/null +++ b/esm/scope_test.go @@ -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,说明模块没被内联") + } +} diff --git a/esm/testdata/app/node_modules/@fsdpf/util/index.ts b/esm/testdata/app/node_modules/@fsdpf/util/index.ts new file mode 100644 index 0000000..1450b8d --- /dev/null +++ b/esm/testdata/app/node_modules/@fsdpf/util/index.ts @@ -0,0 +1 @@ +export function money(n: number): string { return "¥" + n.toFixed(2) } diff --git a/esm/testdata/app/node_modules/@fsdpf/util/package.json b/esm/testdata/app/node_modules/@fsdpf/util/package.json new file mode 100644 index 0000000..492ce2e --- /dev/null +++ b/esm/testdata/app/node_modules/@fsdpf/util/package.json @@ -0,0 +1 @@ +{ "name": "@fsdpf/util", "version": "1.0.0", "main": "index.ts", "types": "index.ts" } diff --git a/esm/testdata/app/node_modules/tinylib/index.js b/esm/testdata/app/node_modules/tinylib/index.js new file mode 100644 index 0000000..bfe84b6 --- /dev/null +++ b/esm/testdata/app/node_modules/tinylib/index.js @@ -0,0 +1,2 @@ +export function upper(s) { return String(s).toUpperCase() } +export default { upper } diff --git a/esm/testdata/app/node_modules/tinylib/package.json b/esm/testdata/app/node_modules/tinylib/package.json new file mode 100644 index 0000000..55a2b50 --- /dev/null +++ b/esm/testdata/app/node_modules/tinylib/package.json @@ -0,0 +1 @@ +{ "name": "tinylib", "version": "1.0.0", "main": "index.js" } diff --git a/esm/testdata/app/package.json b/esm/testdata/app/package.json new file mode 100644 index 0000000..4cc684d --- /dev/null +++ b/esm/testdata/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "fsdpf-app", + "private": true, + "type": "module", + "dependencies": { + "@fsdpf/util": "^1.0.0", + "tinylib": "^1.0.0" + } +} diff --git a/esm/testdata/app/src/Async/AsyncController.ts b/esm/testdata/app/src/Async/AsyncController.ts new file mode 100644 index 0000000..fd70f2e --- /dev/null +++ b/esm/testdata/app/src/Async/AsyncController.ts @@ -0,0 +1,15 @@ +export default class AsyncController { + Sync(): string { return "同步结果" } + + async Async(): Promise { return "异步结果" } + + async AwaitSomething(): Promise { + const v = await Promise.resolve("await 的结果") + return v + } + + async Fails(): Promise { throw new Error("异步失败") } + + // 永远不会 resolve —— goja 没有事件循环,没人能让它完成 + Never(): Promise { return new Promise(function () {}) } +} diff --git a/esm/testdata/app/src/PkgVersion/PkgImportController.js b/esm/testdata/app/src/PkgVersion/PkgImportController.js new file mode 100644 index 0000000..989e035 --- /dev/null +++ b/esm/testdata/app/src/PkgVersion/PkgImportController.js @@ -0,0 +1,5 @@ +export default class PkgImportController { + constructor(pkgName) { this.pkgName = pkgName; this.count = 0 } + Init() { return "PkgImport:" + this.pkgName } + Handle() { return ++this.count } +} diff --git a/esm/testdata/app/src/PkgVersion/PkgOptionsController.ts b/esm/testdata/app/src/PkgVersion/PkgOptionsController.ts new file mode 100644 index 0000000..743aa74 --- /dev/null +++ b/esm/testdata/app/src/PkgVersion/PkgOptionsController.ts @@ -0,0 +1,3 @@ +// 只有命名导出,没有 default——应该整个模块当导出对象用 +export function Options(): string { return "opts" } +export function Version(): string { return "v1" } diff --git a/esm/testdata/app/src/Resource/ResCreateController.ts b/esm/testdata/app/src/Resource/ResCreateController.ts new file mode 100644 index 0000000..2d72c18 --- /dev/null +++ b/esm/testdata/app/src/Resource/ResCreateController.ts @@ -0,0 +1,16 @@ +import ResExecuter from "./ResExecuter" +import { nextId, tag } from "./shared" + +export default class ResCreateController { + private ex: ResExecuter + private count = 0 + + constructor(private resName: string, private mode: string) { + this.ex = new ResExecuter() + } + + Init(): string { return "ResCreate:" + this.resName + "/" + this.mode } + Store(cfg: string): string { this.count++; return this.ex.Store(cfg) + "#" + this.count } + Seq(): string { return tag("create:" + nextId()) } + Boom(): void { throw new Error("创建失败") } +} diff --git a/esm/testdata/app/src/Resource/ResDeleteController.ts b/esm/testdata/app/src/Resource/ResDeleteController.ts new file mode 100644 index 0000000..b8d9d2b --- /dev/null +++ b/esm/testdata/app/src/Resource/ResDeleteController.ts @@ -0,0 +1,4 @@ +import { nextId, tag } from "./shared" +export default class ResDeleteController { + Seq(): string { return tag("delete:" + nextId()) } +} diff --git a/esm/testdata/app/src/Resource/ResExecuter.ts b/esm/testdata/app/src/Resource/ResExecuter.ts new file mode 100644 index 0000000..3678197 --- /dev/null +++ b/esm/testdata/app/src/Resource/ResExecuter.ts @@ -0,0 +1,5 @@ +// 被同目录 controller import 的公共类 +export default class ResExecuter { + Store(cfg: string): string { return "stored:" + cfg } + Destroy(id: string): string { return "destroyed:" + id } +} diff --git a/esm/testdata/app/src/Resource/ResSchemaController.ts b/esm/testdata/app/src/Resource/ResSchemaController.ts new file mode 100644 index 0000000..33eca3f --- /dev/null +++ b/esm/testdata/app/src/Resource/ResSchemaController.ts @@ -0,0 +1,17 @@ +import { upper } from "tinylib" +import { money } from "@fsdpf/util" +import { bracket } from "@lib/format" +import ResExecuter from "./ResExecuter" + +export default class ResSchemaController { + private ex = new ResExecuter() + + Describe(name: string, price: number): string { + return upper(name) + " " + money(price) + " " + this.ex.Store("s") + } + + // 走 tsconfig 里配的 @lib/* 路径别名 + Tag(s: string): string { + return bracket(s) + } +} diff --git a/esm/testdata/app/src/Resource/shared.ts b/esm/testdata/app/src/Resource/shared.ts new file mode 100644 index 0000000..70d900a --- /dev/null +++ b/esm/testdata/app/src/Resource/shared.ts @@ -0,0 +1,4 @@ +// 公共纯函数 + 一个模块级状态(用来验证状态不跨 controller 共享) +let seq = 0 +export function nextId(): number { return ++seq } +export function tag(s: string): string { return "[" + s + "]" } diff --git a/esm/testdata/app/src/Shared/CartController.ts b/esm/testdata/app/src/Shared/CartController.ts new file mode 100644 index 0000000..680f30e --- /dev/null +++ b/esm/testdata/app/src/Shared/CartController.ts @@ -0,0 +1,15 @@ +// 往共享的 store 里写购物车 +export default class CartController { + private ops = 0 + + Add(sku: string, qty: number): number { + this.ops++ + const items = store.GetOr("items", {}) as Record + items[sku] = (items[sku] || 0) + qty + store.Set("items", items) + return store.Incr("total", qty) + } + + // 自己的调用计数,跟另一个 controller 无关 + Ops(): number { return this.ops } +} diff --git a/esm/testdata/app/src/Shared/OrderController.ts b/esm/testdata/app/src/Shared/OrderController.ts new file mode 100644 index 0000000..f61f7a8 --- /dev/null +++ b/esm/testdata/app/src/Shared/OrderController.ts @@ -0,0 +1,13 @@ +// 从共享的 store 里读购物车下单 +export default class OrderController { + private ops = 0 + + Checkout(): string { + this.ops++ + const items = store.GetOr("items", {}) as Record + const skus = Object.keys(items).sort() + return skus.join(",") + "|" + store.Get("total") + } + + Ops(): number { return this.ops } +} diff --git a/esm/testdata/app/src/lib/format.ts b/esm/testdata/app/src/lib/format.ts new file mode 100644 index 0000000..fdb5973 --- /dev/null +++ b/esm/testdata/app/src/lib/format.ts @@ -0,0 +1,4 @@ +// 放在 src/lib 下的公共模块,controller 通过 tsconfig 的 @lib/* 别名引用 +export function bracket(s: string): string { + return "[" + s + "]" +} diff --git a/esm/testdata/app/tsconfig.json b/esm/testdata/app/tsconfig.json new file mode 100644 index 0000000..53b23b0 --- /dev/null +++ b/esm/testdata/app/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2017", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "baseUrl": ".", + "paths": { + "@lib/*": ["./src/lib/*"] + }, + "experimentalDecorators": true + }, + "include": ["src/**/*"] +} diff --git a/esm/testdata/app2/node_modules/only2/index.js b/esm/testdata/app2/node_modules/only2/index.js new file mode 100644 index 0000000..15d4e49 --- /dev/null +++ b/esm/testdata/app2/node_modules/only2/index.js @@ -0,0 +1 @@ +export function only() { return "app2 专属依赖" } diff --git a/esm/testdata/app2/node_modules/only2/package.json b/esm/testdata/app2/node_modules/only2/package.json new file mode 100644 index 0000000..c54297d --- /dev/null +++ b/esm/testdata/app2/node_modules/only2/package.json @@ -0,0 +1 @@ +{ "name": "only2", "main": "index.js" } diff --git a/esm/testdata/app2/src/Extra/ExtraController.ts b/esm/testdata/app2/src/Extra/ExtraController.ts new file mode 100644 index 0000000..207f637 --- /dev/null +++ b/esm/testdata/app2/src/Extra/ExtraController.ts @@ -0,0 +1,5 @@ +import { only } from "only2" + +export default class ExtraController { + Who(): string { return "app2 独有:" + only() } +} diff --git a/esm/testdata/app2/src/Resource/ResCreateController.ts b/esm/testdata/app2/src/Resource/ResCreateController.ts new file mode 100644 index 0000000..9e5b879 --- /dev/null +++ b/esm/testdata/app2/src/Resource/ResCreateController.ts @@ -0,0 +1,4 @@ +// 跟 app/src 下同名,用来验证「后面的目录覆盖前面的」 +export default class ResCreateController { + Init(): string { return "来自 app2 的覆盖实现" } +} diff --git a/esm/testdata/app2/tsconfig.json b/esm/testdata/app2/tsconfig.json new file mode 100644 index 0000000..1cc3e6d --- /dev/null +++ b/esm/testdata/app2/tsconfig.json @@ -0,0 +1 @@ +{ "compilerOptions": { "target": "ES2017", "strict": true } } diff --git a/esm/testdata/extapp/Mod/TypedController.ts b/esm/testdata/extapp/Mod/TypedController.ts new file mode 100644 index 0000000..4eacc7e --- /dev/null +++ b/esm/testdata/extapp/Mod/TypedController.ts @@ -0,0 +1,17 @@ +// 用 import 拿到 store 的类型,而不是直接用全局 +import store, { Store } from "@jscriptx/store" + +export default class TypedController { + Put(k: string, v: string): void { + store.Set(k, v) + } + + Take(k: string): any { + return store.Get(k) + } + + // 类型也导出得到 + Describe(s: Store = store): number { + return s.Len() + } +} diff --git a/esm/typings.go b/esm/typings.go new file mode 100644 index 0000000..ed10bd2 --- /dev/null +++ b/esm/typings.go @@ -0,0 +1,130 @@ +package esm + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "git.fsdpf.net/go/jscriptx" +) + +// Typings 返回扩展提供的 TS 模块:import 路径 -> 源码。 +// +// 打包时这些模块由插件凭空提供(见 plugin.go),磁盘上没有对应文件—— +// 编辑器因此解析不了 `import store from "@jscriptx/store"`,写脚本时一路飘红。 +// 拿到这份内容自己落盘或者走接口发出去,就能把类型补给编辑器。 +// +// 落盘用 WriteTypings 更省事。 +func (l *Loader) Typings() map[string]string { + out := make(map[string]string, len(l.exts)) + for _, ext := range l.exts { + path, source := ext.Module() + if path == "" || source == "" { + continue + } + out[path] = source + } + return out +} + +// WriteTypings 把扩展的 TS 模块按 node_modules 的布局写到 dir 下: +// +// dir/@jscriptx/store/package.json +// dir/@jscriptx/store/index.ts +// +// 写的是 index.ts 而**不是** index.d.ts:扩展给的是真正的模块源码,里面可能带 +// 实现(比如 @jscriptx/sse 的 EventStream 基类,方法体要转发到注入的全局)。 +// 声明文件里不允许有实现,落成 .d.ts 会让 tsc 报 TS1183。 +// +// 编辑器和 tsc 按 Node 的规则往上找 node_modules,所以 dir 传脚本根目录旁边的 +// node_modules,`import store from "@jscriptx/store"` 就不飘红了,也不用配 +// tsconfig 的 paths: +// +// loader.WriteTypings("app/node_modules") +// +// 写出来的只是类型,运行期不会用到——真正的实现在 Go 侧,打包时由插件提供源码。 +// 所以这些目录随时可以删掉重写,也不该提交进版本库。 +// +// 已经存在的同名文件会被覆盖。返回写了几个模块。 +func (l *Loader) WriteTypings(dir string) (int, error) { + return writeTypings(dir, l.Typings()) +} + +// WriteTypings 把一组扩展的类型落盘,不必先有 Loader。 +// +// 用在只导类型、不跑脚本的地方——比如一条 dump-typings 命令:那时没有脚本目录, +// 也不该为了拿类型去建引擎。语义跟上面那个方法完全一样。 +func WriteTypings(dir string, exts ...jscriptx.Extension) (int, error) { + return writeTypings(dir, Typings(exts...)) +} + +func writeTypings(dir string, typings map[string]string) (int, error) { + // 排一下序,出错信息和日志才稳定 + paths := make([]string, 0, len(typings)) + for p := range typings { + paths = append(paths, p) + } + sort.Strings(paths) + + for _, modPath := range paths { + sub, err := modDir(modPath) + if err != nil { + return 0, err + } + target := filepath.Join(dir, sub) + if err := os.MkdirAll(target, 0o755); err != nil { + return 0, fmt.Errorf("jscriptx/esm: 建目录 %s 失败: %w", target, err) + } + files := map[string]string{ + typingsFile: typings[modPath], + "package.json": packageJSON(modPath), + } + for name, content := range files { + p := filepath.Join(target, name) + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + return 0, fmt.Errorf("jscriptx/esm: 写 %s 失败: %w", p, err) + } + } + } + return len(paths), nil +} + +// modDir 把模块路径变成相对目录,顺便挡住能跳出 dir 的路径。 +func modDir(modPath string) (string, error) { + if modPath == "" || strings.HasPrefix(modPath, "/") || filepath.IsAbs(modPath) { + return "", fmt.Errorf("jscriptx/esm: 模块路径 %q 不能是绝对路径", modPath) + } + for _, seg := range strings.Split(modPath, "/") { + if seg == "" || seg == "." || seg == ".." { + return "", fmt.Errorf("jscriptx/esm: 模块路径 %q 里有 %q 这样的片段", modPath, seg) + } + } + return filepath.FromSlash(modPath), nil +} + +// typingsFile 是每个模块落盘时的文件名。见 WriteTypings 里为什么不是 .d.ts。 +const typingsFile = "index.ts" + +// packageJSON 给类型包一个最小的 package.json。只指 types,不指 main—— +// 运行期没有这个模块,指了 main 反而会让人以为能 require。 +// +// 不能有 exports 字段:一旦有,Node 就只认里面列出的子路径,像 +// @jscriptx/app/response 这种嵌在别的模块目录里的子路径会解析不到。 +func packageJSON(modPath string) string { + return fmt.Sprintf(`{ + "name": %q, + "version": "0.0.0", + "types": %q, + "private": true +} +`, modPath, typingsFile) +} + +// Typings 返回一组扩展提供的 TS 模块,不必先有 Loader。 +// +// 想在没建 Loader 的地方拿类型(比如一个只负责生成类型的命令)时用它。 +func Typings(exts ...jscriptx.Extension) map[string]string { + return (&Loader{exts: exts}).Typings() +} diff --git a/esm/typings_test.go b/esm/typings_test.go new file mode 100644 index 0000000..378e615 --- /dev/null +++ b/esm/typings_test.go @@ -0,0 +1,154 @@ +package esm_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "git.fsdpf.net/go/jscriptx/esm" +) + +// fakeExt 是个只提供模块的扩展。 +type fakeExt struct { + name, path, source string +} + +func (e fakeExt) Name() string { return e.name } +func (e fakeExt) Bindings() map[string]any { return nil } +func (e fakeExt) Module() (string, string) { return e.path, e.source } + +var ( + extA = fakeExt{"a", "@jscriptx/aaa", "export declare const a: number\n"} + extB = fakeExt{"b", "@scope/deep/bbb", "export declare const b: string\n"} + extC = fakeExt{"c", "", "没有模块路径,不该出现"} +) + +func TestTypings_取扩展的类型(t *testing.T) { + loader, err := esm.NewLoader("testdata/app/src", esm.WithExtensions(extA, extB, extC)) + if err != nil { + t.Fatal(err) + } + + got := loader.Typings() + if len(got) != 2 { + t.Fatalf("该有两个模块(没有路径的不算),拿到 %d 个: %v", len(got), got) + } + if got["@jscriptx/aaa"] != extA.source { + t.Errorf("源码不对: %q", got["@jscriptx/aaa"]) + } + if _, ok := got[""]; ok { + t.Error("没有模块路径的扩展不该进来") + } +} + +// 不建 Loader 也能取。 +func TestTypings_包级函数(t *testing.T) { + got := esm.Typings(extA, extB) + if len(got) != 2 { + t.Fatalf("拿到 %d 个", len(got)) + } +} + +// 写成 node_modules 的布局,编辑器就能解析 import 了。 +func TestWriteTypings_落盘布局(t *testing.T) { + dir := t.TempDir() + loader, err := esm.NewLoader("testdata/app/src", esm.WithExtensions(extA, extB)) + if err != nil { + t.Fatal(err) + } + + n, err := loader.WriteTypings(dir) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Errorf("该写两个,写了 %d 个", n) + } + + // 带 scope 的路径要变成对应的多级目录 + for _, tc := range []struct{ modPath, source string }{ + {"@jscriptx/aaa", extA.source}, + {"@scope/deep/bbb", extB.source}, + } { + base := filepath.Join(dir, filepath.FromSlash(tc.modPath)) + + dts, err := os.ReadFile(filepath.Join(base, "index.ts")) + if err != nil { + t.Fatalf("%s: %v", tc.modPath, err) + } + if string(dts) != tc.source { + t.Errorf("%s 的类型内容不对: %q", tc.modPath, dts) + } + + raw, err := os.ReadFile(filepath.Join(base, "package.json")) + if err != nil { + t.Fatalf("%s: %v", tc.modPath, err) + } + var pkg struct { + Name string `json:"name"` + Types string `json:"types"` + Main string `json:"main"` + } + if err := json.Unmarshal(raw, &pkg); err != nil { + t.Fatalf("%s 的 package.json 不是合法 JSON: %v", tc.modPath, err) + } + if pkg.Name != tc.modPath { + t.Errorf("name = %q,该是模块路径", pkg.Name) + } + if pkg.Types != "index.ts" { + t.Errorf("types = %q", pkg.Types) + } + if pkg.Main != "" { + t.Error("不该有 main:运行期没有这个模块,指了会让人以为能 require") + } + } +} + +// 重写一遍要能覆盖,不能因为文件已存在就失败。 +func TestWriteTypings_可重复写(t *testing.T) { + dir := t.TempDir() + loader, _ := esm.NewLoader("testdata/app/src", esm.WithExtensions(extA)) + + if _, err := loader.WriteTypings(dir); err != nil { + t.Fatal(err) + } + if _, err := loader.WriteTypings(dir); err != nil { + t.Fatalf("重写该成功: %v", err) + } +} + +// 模块路径不能把文件写到 dir 外面去。 +func TestWriteTypings_挡住越界路径(t *testing.T) { + for _, bad := range []string{"../evil", "/etc/evil", "@x/../../evil", "a//b"} { + dir := t.TempDir() + loader, _ := esm.NewLoader("testdata/app/src", + esm.WithExtensions(fakeExt{"x", bad, "export {}\n"})) + + if _, err := loader.WriteTypings(dir); err == nil { + t.Errorf("模块路径 %q 该被挡下来", bad) + } + } +} + +// 写出来的类型要跟打包时用的是同一份,不然编辑器不飘红但运行时挂。 +func TestWriteTypings_跟打包用的是同一份(t *testing.T) { + dir := t.TempDir() + loader, err := esm.NewLoader("testdata/app/src", esm.WithExtensions(extA)) + if err != nil { + t.Fatal(err) + } + if _, err := loader.WriteTypings(dir); err != nil { + t.Fatal(err) + } + + dts, err := os.ReadFile(filepath.Join(dir, "@jscriptx", "aaa", "index.ts")) + if err != nil { + t.Fatal(err) + } + // Typings 是打包插件读的同一个来源(都走 Extension.Module) + if strings.TrimSpace(string(dts)) != strings.TrimSpace(loader.Typings()["@jscriptx/aaa"]) { + t.Error("落盘的类型跟打包用的对不上") + } +}