Files
jscriptx/bundle.go
T
what 7e1893b246 refactor: 文件按主类型划分,一个 struct 一个功能
约定:文件名 = 主类型名;同一个类型要拆多个文件时用 类型_子项.go。

  engine.go          355 → 231 行。原来混了三件不相干的事
  engine_globals.go  ← bind / lazyGlobal / freeze / defineReadOnly(96 行)
                       它是「Go 值 → 只读 JS 全局」的转换层,跟脚本缓存毫无关系
  engine_console.go  ← console.go,跟上面是同一主题
  script.go          325 → 100 行,只留公开方法
  script_vm.go       ← VM 的取、还、装载。上个 commit 合一的三条路径现在住一起
  script_static.go   ← static.go
  errors.go          364 → 150 行
  errors_goja.go     ← goja 错误的翻译层
  errors_hints.go    ← missingGlobalHint,一份 JS 运行时知识库,跟错误分类是
                       两回事;拆出来之后 missing_global_test.go 才有对应源文件
  bundle_finalize.go ← esmwrap.go
  bundle.go          收下 validIdent / hashVersion(原来住在 engine.go)

Engine.New 原来排在所有私有函数之后,挪到导出方法那一段。

classify 83 → 54 行:四个 errors.As 分支各手搓一个 8 字段的 &Error{},脚本上下文
那三行重复了 4 遍,抽出 gojaError 构造器。

顺带修四处注释漂移:

  Session/会话   代码里叫 Instance,注释里大面积残留。engine_globals.go 那条
                 「注入会话全局对象失败」还是用户可见文案,而公开 API 里根本
                 没有「会话」这个概念
  Extension      文档示例写 Module() string,接口是 Module() (path, source string)。
                 这是唯一一段教人写扩展的文档,照抄编译不过
  doc.go 的 freeze  说白名单「逐层拷贝成只读对象,不会跨 VM 共享可变的 Go map」,
                 但那只对 map[string]any 成立。结构体指针和 slice 是**共享同一个
                 对象**的——扩展走的正是这条路,不该被当成隔离保证
2026-09-10 15:28:59 +08:00

189 lines
6.5 KiB
Go
Raw 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 jscriptx
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"github.com/evanw/esbuild/pkg/api"
)
// BundleOption 配置源码的打包方式。
type BundleOption func(*bundleOptions)
type bundleOptions struct {
resolveDir string
target api.Target
define map[string]string
nodePaths []string
alias map[string]string
}
// WithResolveDir 给源码一个解析 import 的基准目录。不设时源码里不能有 import
// (单段源码没有文件系统上下文,esbuild 解析不了相对路径)。
func WithResolveDir(dir string) BundleOption {
return func(o *bundleOptions) { o.resolveDir = dir }
}
// WithBundleTarget 设置输出的 ECMAScript 版本,默认 ES2017。
func WithBundleTarget(t api.Target) BundleOption {
return func(o *bundleOptions) { o.target = t }
}
// WithBundleDefine 设置编译期常量替换。
func WithBundleDefine(define map[string]string) BundleOption {
return func(o *bundleOptions) { o.define = define }
}
// WithNodePaths 指定额外的 node_modules 搜索目录,相当于 Node 的 NODE_PATH。
// 传进来的目录本身相当于一个 node_modules:包直接放在它下面,不要再套一层。
//
// 没有同时配 WithResolveDir 时,会拿当前工作目录当解析起点——esbuild 需要一个起点
// 才会启动模块解析,真正的查找仍然走这里给的目录。
func WithNodePaths(paths ...string) BundleOption {
return func(o *bundleOptions) { o.nodePaths = append(o.nodePaths, paths...) }
}
// WithAlias 把模块名映射到具体的文件或目录,绕过 node_modules 查找。
func WithAlias(alias map[string]string) BundleOption {
return func(o *bundleOptions) {
if o.alias == nil {
o.alias = map[string]string{}
}
for k, v := range alias {
o.alias[k] = v
}
}
}
// Bundle 把一段 ESM/TypeScript 源码打包成本库能直接执行的形式。
//
// goja 没有 ES module 支持,也不认识 TypeScript,所有脚本都要先过这一步。
// Engine.Compile 内部就是调它;自定义 Loader 如果返回的是原始源码,也用它处理。
//
// filename 是这段源码的文件名,有两个实际作用:**扩展名决定按什么语法解析**
// .ts/.mts 按 TypeScript.json 按 JSON,其余按 TypeScript——它是 JS 的超集,
// 普通 JS 照样能过),以及作为 sourcemap 和报错里显示的位置。
//
// 打包后的代码是自包含的 IIFE,模块导出挂在 ModuleGlobal 上,末尾补一句入口表达式。
// 因为产物是 IIFE**脚本必须有 export**——没有导出的顶层代码会被当成死代码摇掉。
func Bundle(filename, source string, opts ...BundleOption) (string, error) {
o := &bundleOptions{target: api.ES2017}
for _, opt := range opts {
opt(o)
}
if o.resolveDir == "" && len(o.nodePaths) > 0 {
// NodePaths 是"额外去哪儿找",esbuild 仍然需要一个起点才会启动解析流程,
// 没有起点时连 NodePaths 都不查。用当前工作目录当锚点,真正的查找还是走
// NodePaths。(Alias 是直接映射,不受这个限制。)
o.resolveDir = "."
}
res := api.Build(api.BuildOptions{
Stdin: &api.StdinOptions{
Contents: source,
Sourcefile: filename,
Loader: loaderFor(filename),
ResolveDir: o.resolveDir,
},
Bundle: true,
// 用 ESM 格式而不是 IIFEIIFE 会附带一整套 CommonJS interop helper
// 每建一个 VM 都要重跑一遍。拿到 ESM 产物后由 FinalizeBundle 自己包成
// 立即执行函数,见 esmwrap.go。
Format: api.FormatESModule,
Target: o.target,
Define: o.define,
NodePaths: o.nodePaths,
Alias: o.alias,
Write: false,
Sourcemap: api.SourceMapInline,
SourcesContent: api.SourcesContentExclude,
LogLevel: api.LogLevelSilent,
})
if len(res.Errors) > 0 {
return "", bundleError(filename, res.Errors, o.resolveDir)
}
if len(res.OutputFiles) == 0 {
return "", fmt.Errorf("打包 %s 没有产出", filename)
}
return FinalizeBundle(string(res.OutputFiles[0].Contents)), nil
}
// FinalizeBundle 把 esbuild 的 ESM 产物改写成本库能直接执行的形式:一个立即执行函数,
// 完成值就是脚本的导出(default 优先,只有命名导出时是整个模块对象)。
//
// 自定义 Loader 自己调 esbuild 打包时,产物也要过这一步,格式才对得上。
// 怎么改写、为什么不直接用 esbuild 的 IIFE 格式,见 esmwrap.go。
func FinalizeBundle(code string) string {
const marker = "//# sourceMappingURL="
i := strings.LastIndex(code, marker)
if i < 0 {
return wrapESM(code)
}
// sourcemap 注释留在最后,包装只作用于代码部分
return wrapESM(strings.TrimRight(code[:i], "\n")) + code[i:]
}
// loaderFor 按文件名的扩展名挑解析方式。
func loaderFor(filename string) api.Loader {
switch {
case strings.HasSuffix(filename, ".ts"), strings.HasSuffix(filename, ".mts"):
return api.LoaderTS
case strings.HasSuffix(filename, ".json"):
return api.LoaderJSON
default:
// 没有扩展名或是 .js 时按 TS 解析:TS 是 JS 的超集,普通 JS 照样能过,
// 顺便让不带扩展名的脚本名也能写类型注解。
return api.LoaderTS
}
}
// bundleError 把 esbuild 的报错整理成一条带位置和出路的错误。
func bundleError(filename string, errs []api.Message, resolveDir string) error {
var b strings.Builder
fmt.Fprintf(&b, "打包 %s 失败", filename)
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)
// 最常见的坑:单段源码里写了 import 却没有解析基准目录
if resolveDir == "" && strings.Contains(e.Text, "Could not resolve") {
b.WriteString("(这段源码没有解析 import 的基准目录:" +
"用 jscriptx/esm 子包按目录加载,或给 Compile 配 WithResolveDir")
}
}
return fmt.Errorf("%s", b.String())
}
// validIdent 校验全局名是不是合法的 JS 标识符(只允许 ASCII 字母、数字、_ 和 $)。
func validIdent(s string) bool {
if s == "" {
return false
}
for i, r := range s {
switch {
case r == '_' || r == '$':
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
if i == 0 {
return false
}
default:
return false
}
}
return true
}
func hashVersion(source string) string {
sum := sha256.Sum256([]byte(source))
return hex.EncodeToString(sum[:8])
}