Files
mesh-catalog/modules/mesh-build-node/cmd/mesh-build-node/build.go
T

284 lines
11 KiB
Go

package main
// The build step (novox/hq ADR 0405 §3 and §7, ADR 0469 §4; issue 1490): a TypeScript bundle of a fetched workspace
// compiled with the toolchain's tsc, bundled with its esbuild, given its launchers, packed with mesh-sdk's Pack and
// ended with the archive's digest. No network. The commands and flags are the build agent's, copied from
// mesh-controller origin/main internal/builder: toolchain.go:143-147 (tsc), builder.go:1637-1705 (compile),
// :1926-1956 (writeLaunchers), :1971-2039 (bundled), :1735-1747 (sourcesFor). Where they ran in the toolchain image
// they run here from the module's directory with the toolchain's real paths, and tsc and esbuild find the SDK in the
// workspace's node_modules (workspace.go).
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// launcherSuffix is what an entrypoint's launcher is called beside it: index.js is started as index.serve.mjs
// (novox/hq ADR 0193).
const launcherSuffix = ".serve.mjs"
// Out is where a bundle's compiled files go in the module's directory (builder toolchain.go:118).
func Out(artifact string) string { return ".mesh-build/" + artifact }
// banner is esbuild's createRequire shim: CommonJS inlined into an ES module still finds `require`.
const banner = "--banner:js=import { createRequire as __meshRequire } from 'node:module'; const require = __meshRequire(import.meta.url);"
// artifact is the part of a module.json artifact the build reads.
type artifact struct {
Name string `json:"name"`
Language string `json:"language"`
Entrypoints []string `json:"entrypoints"`
External []string `json:"external"`
}
// BuildResult is what a passed build step says.
type BuildResult struct {
Workspace string `json:"workspace"`
Module string `json:"module"`
Artifact string `json:"artifact"`
// Digest is "sha256:" and the hex of the archive's bytes (buildstep.Pack).
Digest string `json:"digest"`
Bytes int `json:"bytes"`
// Archive is the packed file, in the workspace beside its tree.
Archive string `json:"archive"`
Files []string `json:"files"`
}
func (m *Module) buildTool() StepTool {
return StepTool{
Name: "build_node_build",
Description: "Build a TypeScript bundle of a fetched workspace: compile it with the toolchain's tsc, bundle each " +
"entrypoint with its esbuild, write the launchers and package.json, pack the bundle and answer its digest. " +
"Answers a step id at once; the step ends on its key in the steps state with the digest. No network.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"workspace": map[string]any{"type": "string", "description": "the workspace id a fetch step answered"},
"module": map[string]any{"type": "string", "description": "the module's directory in the workspace, '.' when it is the root"},
"artifact": map[string]any{"type": "string", "description": "the name of the bundle in the module's module.json"},
},
"required": []string{"workspace", "artifact"},
},
Long: true,
Run: m.build,
}
}
func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
workspace, _ := args["workspace"].(string)
module, _ := args["module"].(string)
name, _ := args["artifact"].(string)
root, dir, err := m.moduleDirOf(workspace, module)
if err != nil {
return nil, err
}
a, err := readArtifact(dir, name)
if err != nil {
return nil, err
}
if why, err := lockProblem(dir, module); err != nil || why != "" {
if err == nil {
err = fmt.Errorf("%s was refused by the fetch: %s", name, why)
}
return nil, err
}
// Built at the build agent's depth (builder.go: the module at /app/modules/module, the packages at /app/node_modules),
// so the path comments esbuild writes, `../../node_modules/...`, and with them the bundle's bytes equal its.
stage := filepath.Join(root, "stage-"+a.Name)
if err := os.RemoveAll(stage); err != nil {
return nil, err
}
src := dir
dir = filepath.Join(stage, "modules", "module")
if err := copyTree(src, dir); err != nil {
return nil, fmt.Errorf("staging the module: %w", err)
}
if err := copyPackages(filepath.Join(m.toolchainDir(), "node_modules"), filepath.Join(stage, "node_modules")); err != nil {
return nil, fmt.Errorf("providing the toolchain's packages to the build: %w", err)
}
out, final := Out(a.Name), Out(a.Name)+".bundled"
if err := os.MkdirAll(filepath.Join(dir, out), 0o755); err != nil {
return nil, err
}
// tsc: builder.go:1637-1705 with toolchain.go:143-147's flags, rooted at the module so an entrypoint lands where
// it is named.
tsc := []string{m.tsc(), "--module", "NodeNext", "--moduleResolution", "NodeNext", "--target", "ES2022",
"--rootDir", ".", "--outDir", out}
tsc = append(tsc, sourcesFor(a.Entrypoints, out, ".ts")...)
if why := ranFailed(m.runIn(ctx, dir, nil, "node", tsc...)); why != "" {
return nil, fmt.Errorf("tsc for %s: %s", a.Name, why)
}
launchers, err := writeLaunchers(filepath.Join(dir, out), a)
if err != nil {
return nil, err
}
if err := m.bundle(ctx, dir, a, launchers); err != nil {
return nil, err
}
bundleDir := filepath.Join(dir, final)
if len(a.External) > 0 {
if err := copyPackages(filepath.Join(m.toolchainDir(), "node_modules"), filepath.Join(bundleDir, "node_modules")); err != nil {
return nil, fmt.Errorf("copying the packages %s keeps external: %w", a.Name, err)
}
}
archive, digest, err := buildstep.Pack(bundleDir)
if err != nil {
return nil, fmt.Errorf("packing %s: %w", a.Name, err)
}
file := filepath.Join(root, a.Name+".tar.gz")
if err := os.WriteFile(file, archive, 0o600); err != nil {
return nil, err
}
files := []string{}
_ = filepath.WalkDir(bundleDir, func(p string, d os.DirEntry, err error) error {
if err == nil && !d.IsDir() {
rel, _ := filepath.Rel(bundleDir, p)
files = append(files, filepath.ToSlash(rel))
}
return nil
})
sort.Strings(files)
return BuildResult{Workspace: workspace, Module: module, Artifact: a.Name, Digest: digest, Bytes: len(archive), Archive: file, Files: files}, nil
}
// bundle makes every entrypoint and every launcher ONE file, with esbuild (builder.go:1971-2039): an entrypoint is
// bundled in place under its own name, a launcher keeps its name with the extension .mjs.
func (m *Module) bundle(ctx context.Context, dir string, a artifact, launchers map[string]string) error {
out, final := Out(a.Name), Out(a.Name)+".bundled"
if err := os.RemoveAll(filepath.Join(dir, final)); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(dir, final), 0o755); err != nil {
return err
}
common := []string{"--bundle", "--platform=node", "--format=esm", "--target=node22",
"--outbase=" + out, "--outdir=" + final, "--log-level=warning", banner}
for _, x := range a.External {
common = append(common, "--external:"+x)
}
var plain, launch []string
for _, e := range a.Entrypoints {
if strings.HasSuffix(e, ".js") {
plain = append(plain, out+"/"+e)
}
}
for _, l := range sortedValues(launchers) {
launch = append(launch, out+"/"+l)
}
step := func(entries []string, extra ...string) error {
if len(entries) == 0 {
return nil
}
args := append(append(append([]string{}, entries...), common...), extra...)
if why := ranFailed(m.runIn(ctx, dir, nil, m.esbuild(), args...)); why != "" {
return fmt.Errorf("esbuild for %s: %s", a.Name, why)
}
return nil
}
if err := step(plain); err != nil {
return err
}
if err := step(launch, "--out-extension:.js=.mjs"); err != nil {
return err
}
// Plain `.js` output is an ES module; said once.
if err := os.WriteFile(filepath.Join(dir, final, "package.json"), []byte(`{"type":"module","private":true}`+"\n"), 0o644); err != nil {
return err
}
for _, l := range launchers {
path := filepath.Join(dir, final, filepath.FromSlash(l))
if _, err := os.Stat(path); err == nil {
if err := os.Chmod(path, 0o755); err != nil {
return err
}
}
}
return nil
}
// writeLaunchers writes, beside every entrypoint, an executable that imports the entrypoint and serves what it
// registered over MCP on stdio (builder.go:1926-1956; novox/hq ADR 0193). Its answer is each entrypoint's launcher,
// relative to the bundle's root.
func writeLaunchers(root string, a artifact) (map[string]string, error) {
out := map[string]string{}
for _, entry := range a.Entrypoints {
if !strings.HasSuffix(entry, ".js") {
continue
}
launcher := strings.TrimSuffix(entry, ".js") + launcherSuffix
body := "#!/usr/bin/env node\n" +
"// Written by the mesh's builder (novox/hq ADR 0193): serve what " + entry + " registers,\n" +
"// over MCP on stdio, as the module the node's runtime names in MESH_SERVED_MODULE.\n" +
"import { serveRegisteredOverStdio } from \"@novox/mesh-sdk/stdio\";\n" +
"await import(\"./" + filepath.Base(entry) + "\");\n" +
"await serveRegisteredOverStdio();\n"
path := filepath.Join(root, filepath.FromSlash(launcher))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
return nil, err
}
if err := os.Chmod(path, 0o755); err != nil {
return nil, err
}
out[entry] = launcher
}
return out, nil
}
// sourcesFor turns compiled entrypoints back into what to compile (builder.go:1735-1747).
func sourcesFor(entrypoints []string, out, ext string) []string {
sources := make([]string, 0, len(entrypoints))
for _, e := range entrypoints {
at := strings.TrimPrefix(strings.TrimPrefix(e, out), "/")
sources = append(sources, strings.TrimSuffix(at, filepath.Ext(at))+ext)
}
return sources
}
func sortedValues(m map[string]string) []string {
out := make([]string, 0, len(m))
for _, v := range m {
out = append(out, v)
}
sort.Strings(out)
return out
}
// readArtifact reads the named artifact of the module.json in dir, which must be a TypeScript bundle with entrypoints.
func readArtifact(dir, name string) (artifact, error) {
raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
if err != nil {
return artifact{}, fmt.Errorf("the module has no module.json: %w", err)
}
var m struct {
Build struct {
Artifacts []artifact `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return artifact{}, fmt.Errorf("module.json is not JSON: %w", err)
}
for _, a := range m.Build.Artifacts {
if a.Name != name {
continue
}
if a.Language != "typescript" {
return artifact{}, fmt.Errorf("artifact %q is %q, and this module builds typescript", name, a.Language)
}
if len(a.Entrypoints) == 0 {
return artifact{}, fmt.Errorf("artifact %q names no entrypoints", name)
}
return a, nil
}
return artifact{}, fmt.Errorf("module.json declares no artifact %q", name)
}