feat(esm): 按目录加载脚本、打包与热更新

把一个目录当脚本仓库:按路径寻址、esbuild 打包、内容变了自动重编。

  - Loader    扫目录建索引,Load(name) 给出打好包的源码和版本号
  - bundler   esbuild 的封装。ESM 格式而不是 IIFE——IIFE 会附带一整套
              CommonJS interop helper,每建一个 VM 都要重跑一遍
  - plugin    把扩展的 TS 模块变成可以 import 的虚拟模块,磁盘上没有文件
  - typings   把这些虚拟模块的类型按 node_modules 布局落盘,编辑器才认识

esbuild 原生实现了 Node 的模块解析,所以脚本能直接 import node_modules
里的第三方库。写出来的类型文件是 index.ts 而不是 index.d.ts:扩展给的是
真正的模块源码,里面可能带实现,声明文件里不允许有实现。

node_modules 不参与热更新的版本计算——依赖包是装出来的,改动总伴随显式的
安装动作,而真实的 npm 包动辄上千个文件,每次取脚本 stat 一遍太贵。
This commit is contained in:
2026-09-05 22:13:57 +08:00
parent 7fb8d0f966
commit 6ce9b483fd
34 changed files with 2403 additions and 0 deletions
+154
View File
@@ -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("落盘的类型跟打包用的对不上")
}
}