If you want to have custom scripts as part of the process,
initialize your project with genctl init --eval-node.
The setup
If we look at the project structure, you will see, that there are some extra fields compared to regular one.
├── .genroc # registers the `import` resolver for .ts files
├── compose.yaml # adds an `eval-node` worker service
├── package.json # @genroc/eval-node npm package
├── tsconfig.json
└── definitions
├── hello.genroc.yaml # the example process below
├── script-node.genroc.yaml # the child process that runs a script
└── greet.ts # the script hello runs
The script-node.genroc.yaml is a child process which is an abstraction
above the execution engine. Inside it uses external task to expose the script
code and the input.
compose.yaml then contains setup for genroc/eval-node docker
container. This is our node worker that is able process the script
tasks. You can take this as an example implementation of
genroc worker.
Example process
The example process will look something like this:
name: hello
input_schema:
type: object
properties:
who: { type: string, default: "world" }
tasks:
- id: greet
action:
type: child
# `name`, `input_schema` and `raises` come from the child
<<: "$process: ./script-node.genroc.yaml"
input:
# script code
code: "$import: ./greet.ts"
# script input
input:
who: "$: input.who"
# defining the script output
result_schema:
type: object
properties:
greeting: { type: string }
at: { type: string }
required: [greeting]
output: "$: self.result"
switch:
- goto: end
output: "$: outputs.greet"
We are using two kinds of resolvers. $process: is build in documented in child process section.
Then we have $import: ./greet.ts, this is a special resolver intended for importing typescript files.
The script format
Genroc expects default export to be function and it will pass input as a first argument. It will take it's output as a result. The function may also be async.
// greet.ts
import type { Input, Output } from "./greet.genroc";
export default function (input: Input): Output {
return { greeting: `hello, ${input.who}`, at: new Date().toISOString() };
}
The bundler will also include any imported files and imported libraries.
Node built-ins stay as imports and need "types": ["node"] in tsconfig.json.
How does it work?
The content of the .ts file is bundled with rollup into one string and included directly into the process definition,
so it can't drift.
The script-node child process exposes external task eval_node, which is then picked up by eval-node worker.
eval-node is running in a separate container and it uses genroc worker API to poll for work.
What if the script fails
script-node child process raises different errors in different situations.
| Code | Kind | When | error.data |
|---|---|---|---|
script_threw | raise | the script threw or its promise rejected | {name, stack?} |
script_timeout | raise | the script ran past timeout_ms (default 5000) | {budget_ms} |
script_unknown | raise | no worker answered within timeout_ms: none is running, all are busy, or one died holding the task | — |
script_broken | panic | the script does not compile, returns a value that is not JSON, or calls process.exit() | {name, stack?} |
The raises are copied into your task by <<: "$process: ./script-node.genroc.yaml", so on_error
can match them directly. script_broken is a panic and cannot be caught: it fails the caller and
every process above it, because the only fix is editing the script.
error.data.name is the thrown error's class name (class LimitExceeded extends Error {} gives
LimitExceeded), unless the script sets e.name = "MyName" to override it. Throwing something
that is not an Error gives Thrown.
Generated types
Run genctl generate and this setup will also write Input and Output types for the greet.ts script.
This way the script input and output is tied to the current input and result_schema defined in process.
genctl apply will fail if those types don't match.
Timeouts
You can set timeout for the script:
- id: greet
action:
type: child
# `name`, `input_schema` and `raises` come from the child
<<: "$process: ./script-node.genroc.yaml"
input:
# script code
code: "$import: ./greet.ts"
# script input
input:
who: "$: input.who"
timeout_ms: 10_000
timeout_ms (default 5000) is the deadline of the whole script task, and the eval-node worker
runs the script against that same deadline. It stops the script just before it and reports
script_timeout; if no worker picks the task up in time, the result is script_unknown. Time
spent waiting in the queue counts toward the budget.
Custom resolvers
If you open .genroc file, you can see it contains entry for import resolver.
It executes genroc-import script which is a part of @genroc/eval-node package (installed in node_modules).
definitions: ["./definitions/**/*.genroc.yaml"]
resolvers:
- name: import
phase: typed
ext: [.ts]
command: [npx, genroc-import]
# Slot addresses the generated `Input` and `Output` are typed from, relative to the task.
types:
Input: task.action.input.input
Output: task.action.result
The resolver can define what types it needs and genroc will provide json schema types for the specified
fields. Here we need task.action.input.input which is the input slot next to the code. And then
we also take result as a output.
You can create your custom resolvers in the similar fashion (check project file reference).
eval-node
Typescript support is enabled by eval-node docker image
which runs as a separate service. It is a node worker which expects the genroc server on port 8448
and resolves external task from script-node process.
npm package
Resolver in .genroc file calls eval-node npm package binary, which uses rollup to bundle the requested file into a single string (also strips types). It also inlines dependencies so you can use javascript/typescript packages as well.