package builder import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "sort" "strings" ) // A module's own packages, installed before its bundle is compiled, so the bundler inlines them. // // **A bundle could only import what the toolchain happened to carry.** The compiler and the bundler // resolve an import by walking up from the module's source: the module's own directory first, then // the toolchain image's node_modules. Nothing ever put anything in the first, so a module needing a // database driver (`pg`, `mongodb`, `mssql`) could not be a bundle at all, and kept a container whose // recipe installed it by hand (novox/hq ADR 0198 §4: "the backend's own driver inside the bundle"). // Now the module's `package.json` says what it depends on, as any Node package does, and the build // installs exactly that into the module's own directory before compiling. // // **The SDK the toolchain carries is the one a bundle is built with, whatever the module says** // (novox/hq issue 212: the toolchain is rebuilt on every SDK release and every bundle after it). A // module's `package.json` names `@novox/mesh-sdk` with a range — it has to, to type-check on a // workstation — and installing that range would shadow the toolchain's copy for this module alone: // one module compiled against an older SDK than its neighbours, chosen by a caret nobody re-reads. // So the SDK is taken out of what is installed (and never fetched), and any copy something else // pulls in is removed afterwards; every import of it resolves past the module's node_modules to the // toolchain's. A module therefore cannot pin a different SDK, by design: the toolchain is the pin. // // **Correctness before speed.** Every build installs afresh into a fresh clone, from the lockfile // when the module has one (`npm ci`, exact) and from its ranges otherwise; nothing installed is kept // between builds. What is shared is npm's own download cache, a named volume, which is // content-addressed and verified by integrity on every read — it saves the network, never the // install. Install scripts do not run: the build node runs nobody's postinstall, and what a script // would build natively could not be inlined into one file anyway. // sdkPackage is the package a TypeScript bundle's launcher serves through, and the one package a // module's own dependencies never supply (above). const sdkPackage = "@novox/mesh-sdk" // npmCache is the named volume npm's download cache lives in across builds on one build node. const npmCache = "mesh-builder-npm-cache" // ownDependencies is what a module's package.json depends on beyond the SDK, sorted; nothing when // the module has no package.json or depends on nothing else — which builds exactly as before. func ownDependencies(tree string) ([]string, error) { raw, err := os.ReadFile(filepath.Join(tree, "package.json")) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, err } var p struct { Dependencies map[string]string `json:"dependencies"` } if err := json.Unmarshal(raw, &p); err != nil { return nil, fmt.Errorf("the module's package.json is not JSON: %w", err) } var names []string for name := range p.Dependencies { if name != sdkPackage { names = append(names, name) } } sort.Strings(names) return names, nil } // installSteps is the script run inside the toolchain image, from the module's own directory ($0). // It works in a scratch copy so the module's package.json and lockfile are never rewritten, takes // the SDK out of what is installed, installs production dependencies only, removes any copy of the // SDK something pulled in, and puts the result at the module's node_modules. const installSteps = `set -e work="$(mktemp -d)" cp "$0/package.json" "$work/" if [ -f "$0/package-lock.json" ]; then cp "$0/package-lock.json" "$work/"; fi cd "$work" node -e ' const fs = require("fs"), sdk = process.argv[1]; const p = JSON.parse(fs.readFileSync("package.json", "utf8")); for (const k of ["dependencies", "peerDependencies", "optionalDependencies"]) if (p[k]) delete p[k][sdk]; delete p.devDependencies; delete p.scripts; fs.writeFileSync("package.json", JSON.stringify(p)); ' "$1" shift if [ -f package-lock.json ]; then npm ci --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund "$@" else npm install --omit=dev --omit=peer --ignore-scripts --no-audit --no-fund --no-package-lock "$@" fi find node_modules -depth -type d -path "*/node_modules/@novox/mesh-sdk" -exec rm -rf {} + rm -rf "$0/node_modules" cp -a node_modules "$0/node_modules" ` // installOwn installs a TypeScript module's own production dependencies into its directory, in the // toolchain image, before the compile — or does nothing at all for a module that has none. func installOwn(ctx context.Context, run Runner, tree string, chain Toolchain, base string, registry Npmrc, say func(step, format string, args ...any)) error { if chain.Language != "typescript" { return nil } deps, err := ownDependencies(tree) if err != nil || len(deps) == 0 { return err } scoped := strings.TrimSpace(registry.Scope) if !registry.Enabled() { // **No registry, no scoped package.** Without the mesh's registry a scoped name resolves on // the public one, where anybody may have published it: a dependency that installs is not // the dependency the module meant. for _, d := range deps { if strings.HasPrefix(d, "@novox/") { return fmt.Errorf("the module depends on %s, and this build knows no package registry "+ "for its scope; it would resolve from the public registry, which is not where the "+ "mesh publishes it", d) } } } const within = "/app/modules/module" invocation := []string{"run", "--rm", "--volume", tree + ":" + within, "--volume", npmCache + ":/root/.npm", "--workdir", within} var flags []string if registry.Enabled() { // The registry is reached where the binding says it is, which may be this machine's own // loopback — the reason an image build that resolves packages runs on the host network too. invocation = append(invocation, "--network", "host") reg := strings.TrimSpace(registry.Registry) if !strings.HasSuffix(reg, "/") { reg += "/" } flags = append(flags, "--"+scoped+":registry="+reg) } invocation = append(invocation, base, "sh", "-c", installSteps, within, sdkPackage) invocation = append(invocation, flags...) say("bundle", "installing the module's own packages: %s", strings.Join(deps, ", ")) if _, err := run(ctx, tree, "docker", invocation...); err != nil { return fmt.Errorf("installing the module's own packages (%s): %w", strings.Join(deps, ", "), err) } return nil }