Navigation

Node service builder module

This module defines the NodeServiceBuilder ornament, the nodeService constructor, the concrete node-service-demo spec, and the exported views used by the tour page.

Source path: examples/node-service/builder.nix.

nix
{ mb, fx, lib, pkgs, ... }:

let
  H = fx.types.hoas;
  G = fx.types.generic;
  ops = mb.operations;
  eff = mb.program.eff;

  NodeServiceBuilder = H.ornament mb.descriptions.BuilderSpec {
    name = "MetaBuilderNodeService";
    constructors.MetaBuilderSpec.fields = [
      { insert = "runtimeVersion"; type = H.string; }
      { insert = "entrypoint"; type = H.string; }
      { insert = "packageManager"; type = H.string; }
      { insert = "scripts"; type = H.attrs; }
      { insert = "testCommand"; type = H.string; }
      { insert = "service"; type = mb.descriptions.ServiceSpec.T; }
      { keep = "name"; }
      { keep = "parameters"; }
      { keep = "inputs"; }
      { keep = "dependencies"; }
      { keep = "tools"; }
      { keep = "operations"; }
      { keep = "outputs"; }
      { keep = "evidence"; }
    ];
  };

  nodeService =
    { name
    , source
    , version ? "0.1.0"
    , runtimePackage ? pkgs.nodejs
    , runtimeVersion ? runtimePackage.version or "node"
    , entrypoint ? baseNameOf (toString source)
    , packageManager ? "npm"
    , port ? 3000
    , healthPath ? "/health"
    }:
    let
      sourceFile = ops.localSource {
        name = entrypoint;
        path = source;
      };

      nodeTool = ops.tool {
        name = "node";
        package = runtimePackage;
      };
      bashTool = ops.tool {
        name = "bash";
        package = pkgs.bash;
      };

      lifecycleSet = ops.capabilitySet {
        categories = [ mb.ornaments.capabilities.builtins.lifecycle ];
      };
      protocol = ops.protocol {
        name = "http";
        description = "HTTP service protocol";
        transport = ops.transports.TCP;
        serialization = ops.serializations.JSON;
        defaultPort = port;
        portEnvVar = "PORT";
        capabilities = lifecycleSet;
        options = {
          inherit healthPath;
        };
      };
      service = ops.service {
        name = "${name}-service";
        description = "Runnable Node service artifact";
        package = runtimePackage;
        capabilities = lifecycleSet;
        protocols = [ protocol ];
        config = [
          (ops.param {
            name = "port";
            description = "TCP port for the HTTP server";
            type = ops.runtimeTypes.RTInt;
            required = false;
          })
          (ops.param {
            name = "healthPath";
            description = "HTTP path used for health checks";
            type = ops.runtimeTypes.RTString;
            required = false;
          })
        ];
      };

      scripts = {
        start = "node ${entrypoint}";
        test = "node --check ${entrypoint}";
      };
      testCommand = scripts.test;

      appOutput = ops.output {
        name = "service-tree";
        path = "$out";
        format = "tree";
      };
      packageOutput = ops.output {
        name = "package-json";
        path = "share/${name}/package.json";
        format = "json";
      };

      packageJson = builtins.toJSON {
        inherit name version;
        type = "module";
        main = entrypoint;
        private = true;
        inherit scripts;
      };

      installScript = ''
        set -eu
        src="$1"
        mkdir -p "$out/bin" "$out/lib/${name}" "$out/share/${name}"
        cp "$src" "$out/lib/${name}/${entrypoint}"
        cat > "$out/bin/${name}" <<EOF
        #!${pkgs.runtimeShell}
        exec ${runtimePackage}/bin/node "$out/lib/${name}/${entrypoint}" "$@"
        EOF
        chmod +x "$out/bin/${name}"
      '';

      # Builder and runtime operations share one ordered carrier over the
      # full signature: builder ops first, then the runtime declarations.
      operations =
        [
          (eff.builder.readSource {
            name = entrypoint;
            source = sourceFile;
          })
          (eff.builder.declareTool { tool = nodeTool; })
          (eff.builder.declareTool { tool = bashTool; })
          (eff.builder.writeFile {
            output = packageOutput;
            text = packageJson;
          })
          (eff.builder.runTool {
            name = "syntax-check";
            tool = nodeTool;
            args = [ "--check" (toString source) ];
          })
          (eff.builder.runTool {
            name = "install-service";
            tool = bashTool;
            args = [ "-c" installScript "install-node-service" (toString source) ];
          })
          (eff.builder.emitDescriptor {
            descriptor = ops.descriptor {
              name = "${name}-metadata";
              payload = {
                kind = "node-service";
                inherit entrypoint packageManager runtimeVersion port healthPath;
              };
            };
          })
          (eff.builder.transformOutput { output = appOutput; })
          (eff.builder.transformOutput { output = packageOutput; })
          (eff.builder.materializeDerivation {
            name = "${name}-artifact";
            builder = "runCommand";
          })
        ]
        ++ map (category: eff.runtime.declareCapability { inherit category; })
          lifecycleSet.categories
        ++ [
          (eff.runtime.declareProtocol { inherit protocol; })
          (eff.runtime.declareService { inherit service; })
          (eff.runtime.materializeUnit { name = service.name; })
        ];
    in
    {
      _con = "MetaBuilderSpec";
      inherit name runtimeVersion entrypoint packageManager scripts testCommand service operations;
      parameters = [
        (ops.parameter { name = "port"; value = port; })
        (ops.parameter { name = "healthPath"; value = healthPath; })
      ];
      inputs = [ sourceFile ];
      dependencies = [ ];
      tools = [ nodeTool bashTool ];
      outputs = [ appOutput packageOutput ];
      evidence = [
        (ops.evidence {
          name = "self-test";
          payload = {
            command = testCommand;
          };
        })
      ];
    };

  spec = nodeService {
    name = "node-service-demo";
    source = ./server.js;
  };

  program = mb.program.fromOrnamentedSpec NodeServiceBuilder.T spec;

  shellScript = mb.program.backends.shell.run program;

  value = {
    builder = {
      inherit NodeServiceBuilder nodeService;
      descriptor = G.derive.deriveDescriptor NodeServiceBuilder;
      schema = G.derive.deriveSchema NodeServiceBuilder;
    };
    inherit spec program;
    specValidation = fx.types.validateValue [ ] NodeServiceBuilder.T spec;
    validation = mb.program.validate.run program;
    deps = mb.program.deps.run program;
    dryRun = mb.program."dry-run".run program;
    planView = mb.program."plan-view".run program;
    docs = mb.program.describe.run program;
    selfView = mb.program.introspect.run program;
    materialize = mb.program.materialize.run program;
    materializeShell = shellScript;
    materializeShellCheck = mb.program.backends.shell.shellcheckFor shellScript;
    materializeDockerfile = mb.program.backends.dockerfile.run program;
    planExport = mb.program."plan-export".json program;
  };

in
{
  scope = {
    inherit NodeServiceBuilder nodeService spec program value;
  };
}