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:
@@ -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)
|
||||
}
|
||||
+283
@@ -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/")
|
||||
}
|
||||
+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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+226
@@ -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:包直接放在它下面(<dir>/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)
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,说明模块没被内联")
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export function money(n: number): string { return "¥" + n.toFixed(2) }
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "@fsdpf/util", "version": "1.0.0", "main": "index.ts", "types": "index.ts" }
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
export function upper(s) { return String(s).toUpperCase() }
|
||||
export default { upper }
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "tinylib", "version": "1.0.0", "main": "index.js" }
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "fsdpf-app",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fsdpf/util": "^1.0.0",
|
||||
"tinylib": "^1.0.0"
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
export default class AsyncController {
|
||||
Sync(): string { return "同步结果" }
|
||||
|
||||
async Async(): Promise<string> { return "异步结果" }
|
||||
|
||||
async AwaitSomething(): Promise<string> {
|
||||
const v = await Promise.resolve("await 的结果")
|
||||
return v
|
||||
}
|
||||
|
||||
async Fails(): Promise<string> { throw new Error("异步失败") }
|
||||
|
||||
// 永远不会 resolve —— goja 没有事件循环,没人能让它完成
|
||||
Never(): Promise<string> { return new Promise(function () {}) }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// 只有命名导出,没有 default——应该整个模块当导出对象用
|
||||
export function Options(): string { return "opts" }
|
||||
export function Version(): string { return "v1" }
|
||||
@@ -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("创建失败") }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { nextId, tag } from "./shared"
|
||||
export default class ResDeleteController {
|
||||
Seq(): string { return tag("delete:" + nextId()) }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// 被同目录 controller import 的公共类
|
||||
export default class ResExecuter {
|
||||
Store(cfg: string): string { return "stored:" + cfg }
|
||||
Destroy(id: string): string { return "destroyed:" + id }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
// 公共纯函数 + 一个模块级状态(用来验证状态不跨 controller 共享)
|
||||
let seq = 0
|
||||
export function nextId(): number { return ++seq }
|
||||
export function tag(s: string): string { return "[" + s + "]" }
|
||||
+15
@@ -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<string, number>
|
||||
items[sku] = (items[sku] || 0) + qty
|
||||
store.Set("items", items)
|
||||
return store.Incr("total", qty)
|
||||
}
|
||||
|
||||
// 自己的调用计数,跟另一个 controller 无关
|
||||
Ops(): number { return this.ops }
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
// 从共享的 store 里读购物车下单
|
||||
export default class OrderController {
|
||||
private ops = 0
|
||||
|
||||
Checkout(): string {
|
||||
this.ops++
|
||||
const items = store.GetOr("items", {}) as Record<string, number>
|
||||
const skus = Object.keys(items).sort()
|
||||
return skus.join(",") + "|" + store.Get("total")
|
||||
}
|
||||
|
||||
Ops(): number { return this.ops }
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
// 放在 src/lib 下的公共模块,controller 通过 tsconfig 的 @lib/* 别名引用
|
||||
export function bracket(s: string): string {
|
||||
return "[" + s + "]"
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@lib/*": ["./src/lib/*"]
|
||||
},
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export function only() { return "app2 专属依赖" }
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "only2", "main": "index.js" }
|
||||
@@ -0,0 +1,5 @@
|
||||
import { only } from "only2"
|
||||
|
||||
export default class ExtraController {
|
||||
Who(): string { return "app2 独有:" + only() }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 跟 app/src 下同名,用来验证「后面的目录覆盖前面的」
|
||||
export default class ResCreateController {
|
||||
Init(): string { return "来自 app2 的覆盖实现" }
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{ "compilerOptions": { "target": "ES2017", "strict": true } }
|
||||
+17
@@ -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()
|
||||
}
|
||||
}
|
||||
+130
@@ -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()
|
||||
}
|
||||
@@ -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("落盘的类型跟打包用的对不上")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user