284 lines
11 KiB
Go
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)
|
|
}
|