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,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