Files
what 6ce9b483fd 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 一遍太贵。
2026-09-05 22:13:57 +08:00

131 lines
4.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}