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
+130
View File
@@ -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()
}