diff --git a/src/config/sidebar.ts b/src/config/sidebar.ts index 7000d697f3f..99086774f4b 100644 --- a/src/config/sidebar.ts +++ b/src/config/sidebar.ts @@ -495,6 +495,14 @@ export const SIDEBAR: Partial> = { url: "cre/guides/workflow/using-randomness", highlightAsCurrent: ["cre/guides/workflow/using-randomness-ts", "cre/guides/workflow/using-randomness-go"], }, + { + title: "Building a Reusable Library", + url: "cre/guides/workflow/building-a-library", + highlightAsCurrent: [ + "cre/guides/workflow/building-a-library-ts", + "cre/guides/workflow/building-a-library-go", + ], + }, ], }, { diff --git a/src/content/cre/guides/workflow/building-a-library-go.mdx b/src/content/cre/guides/workflow/building-a-library-go.mdx new file mode 100644 index 00000000000..a1221a76552 --- /dev/null +++ b/src/content/cre/guides/workflow/building-a-library-go.mdx @@ -0,0 +1,283 @@ +--- +section: cre +date: Last Modified +title: "Building a Reusable Library" +sdkLang: "go" +pageId: "building-a-library" +metadata: + description: "How to build and publish a Go module that CRE workflows can import, including WASI/WASM restrictions and CRE SDK HTTP requirements." + datePublished: "2026-08-14" + lastModified: "2026-08-14" +--- + +import { Aside } from "@components" + +This guide shows you how to package reusable integration logic (for example, a Slack notifier, a PagerDuty client, or a price-feed parser) as a standard Go module that other developers can `go get` and import directly into their own CRE workflows. + +This is not a guide to writing a workflow itself, it assumes you're already familiar with that. If you're new to CRE, start with the [getting-started guide](/cre/getting-started/overview) first. + +## Who this is for + +Any developer who wants to publish a reusable Go module that other people's CRE workflows will depend on. + +## Your library runs where the workflow runs + +A CRE Go workflow isn't run as a normal Go binary. It's cross-compiled with `GOOS=wasip1 GOARCH=wasm` into a WebAssembly module that runs inside the CRE host's WASI sandbox. When a workflow author adds your module as a dependency, your code is compiled into that same binary, so it runs under the exact same restrictions as the workflow's own code. + +Unlike the TypeScript SDK, which runs inside a stripped-down QuickJS engine, Go workflows compile with the **full Go standard library** available at compile time. This means far more of the ecosystem "just works" without compatibility checking, but the WASI sandbox still enforces real limits at runtime: + +- No filesystem access. There's no real disk backing the sandbox, so file I/O fails or is meaningless even though the code compiles. +- No arbitrary outbound network access. There's no raw socket layer to dial into, so everything non-deterministic (HTTP calls, secrets, blockchain reads/writes) must go through CRE SDK capability APIs, which the DON executes and brings to consensus on your library's behalf. +- Single-threaded, deterministic execution. All DON nodes must execute your code identically and produce the same output. + +Design your library around this from the start. Don't write it as a generic Go module that happens to shell out to the filesystem or network and hope it works under WASI, write it against the CRE SDK's runtime primitives. + +## Pitfall 1: No filesystem or arbitrary network access + +Your library (or one of its transitive dependencies) must not depend on: + +- **Filesystem access**: `os.Open`, `os.ReadFile`, `os.WriteFile`, config-file loaders, embedded file caches written to disk, and so on. There is no writable (or meaningfully readable) filesystem in the WASI sandbox the workflow runs in. +- **Direct network access**: `net.Dial`, `net/http`'s `http.Client`/`http.Get`, gRPC dialing, database drivers, or any other package that opens its own socket. These either fail at runtime or, worse, silently do nothing useful, because there's no outbound network stack available to your code directly, only to the CRE host, through capability calls. + +Anything your library needs from disk (default config, lookup tables, certificates) should be compiled in as Go constants, embedded with `//go:embed` (a compile-time read, not a runtime filesystem access, so this is safe), or passed in as arguments by the caller. Anything it needs from the network must go through [`cre-sdk-go`](#pitfall-2-no-standard-http-client)'s `http.Client`/`http.SendRequest`, never a raw dependency that dials sockets itself. + +{/* prettier-ignore */} + + +## Pitfall 2: No standard HTTP client + +Do not depend on `net/http`, a REST client wrapper built on it, or any package that dials its own connections. Instead, all outbound requests must go through [`http.Client`](/cre/reference/sdk/http-client-go) from `github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http`. This isn't just a technical requirement, it's what gives the request DON-wide consensus. An HTTP call in CRE runs independently on every node in the DON (node mode), and the SDK aggregates the individual results into one trusted value before the workflow continues. A library that dials out with `net/http` would bypass this consensus mechanism entirely, even if it somehow ran. + +Consequence for library design: your exported functions should take a `cre.Runtime` as a parameter, and use it (via `http.SendRequest` or `cre.RunInNodeMode`) to construct requests, exactly as a workflow author would inline. Your library is a thin, well-tested wrapper around this pattern, not a replacement for it. + +```go +// pagerduty.go, inside your library module +package pagerduty + +import ( + "encoding/json" + "fmt" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http" + "github.com/smartcontractkit/cre-sdk-go/cre" +) + +type Options struct { + RoutingKeySecret string +} + +type Alert struct { + Summary string `json:"summary"` + Severity string `json:"severity"` + Source string `json:"source"` +} + +type Client struct { + options Options +} + +func New(options Options) *Client { + return &Client{options: options} +} + +func (c *Client) Trigger(runtime cre.Runtime, alert Alert) (int, error) { + sendAlert := func(config Options, nodeRuntime cre.NodeRuntime) (int, error) { + secret, err := nodeRuntime.GetSecret(&cre.SecretRequest{Id: config.RoutingKeySecret}).Await() + if err != nil { + return 0, fmt.Errorf("failed to get routing key: %w", err) + } + + payload := map[string]any{ + "routing_key": secret.Value, + "event_action": "trigger", + "payload": alert, + } + + body, err := json.Marshal(payload) + if err != nil { + return 0, fmt.Errorf("failed to marshal payload: %w", err) + } + + client := &http.Client{} + resp, err := client.SendRequest(nodeRuntime, &http.Request{ + Url: "https://events.pagerduty.com/v2/enqueue", + Method: "POST", + MultiHeaders: map[string]*http.HeaderValues{ + "Content-Type": {Values: []string{"application/json"}}, + }, + Body: body, + // Prevents every node from firing a duplicate alert + CacheSettings: &http.CacheSettings{Store: true}, + }).Await() + if err != nil { + return 0, fmt.Errorf("PagerDuty request failed: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, fmt.Errorf("PagerDuty request failed with status %d", resp.StatusCode) + } + + return int(resp.StatusCode), nil + } + + promise := cre.RunInNodeMode(c.options, runtime, sendAlert, cre.ConsensusIdenticalAggregation[int]()) + return promise.Await() +} +``` + +Notes on this pattern: + +- Secrets are only ever resolved with `runtime.GetSecret()` / `nodeRuntime.GetSecret()` (backed by the Vault DON), never read from environment variables or hardcoded. Your library should accept a secret ID/name, not a raw credential, and let the consuming workflow own the actual value in [CRE secrets](/cre/guides/workflow/secrets). +- `GetSecret()` itself doesn't require node mode, but you'll typically call it from inside a `RunInNodeMode` block anyway, since that's where you're already building the request that needs the secret value. +- Non-idempotent calls (POST/PUT/PATCH/DELETE) should set `CacheSettings` so a single DON-wide action isn't repeated once per node. +- Wrap HTTP calls in `cre.RunInNodeMode(...)` with an explicit [consensus aggregation](/cre/reference/sdk/consensus-go) (`cre.ConsensusIdenticalAggregation`, `cre.ConsensusMedianAggregation`, or `cre.ConsensusAggregationFromTags` for a tagged result struct) so the DON agrees on one result before your function returns control to the workflow. +- `Body` on `http.Request` is a plain `[]byte`; unlike the TypeScript SDK, there's no base64-encoding step to worry about. +- All SDK capability calls return a [`Promise[T]`](/cre/reference/sdk/core-go#promise) that you resolve with `.Await()`, see the next section. + +## Pitfall 3: `Promise`/`Await` concurrency, not goroutines + +The Go SDK doesn't use `async`/`await` keywords, it uses a `Promise[T]` type with `.Await()`, `cre.Then()`, and `cre.ThenPromise()` for chaining (see [Core SDK Reference](/cre/reference/sdk/core-go#promise)). This exists because the underlying operation only actually runs when you call `.Await()`, building a promise chain without awaiting it does nothing. + +The pitfall specific to Go: don't reach for goroutines, channels, or `sync` primitives (`WaitGroup`, `Mutex`, and so on) to run multiple SDK calls "concurrently" inside your library. CRE workflows execute in a single-threaded WASM environment, so using Go's concurrency primitives to fan out capability calls doesn't get you real parallelism, and a `select` across multiple channels picks a ready channel non-deterministically, which will cause consensus failures if the result depends on which one "won." + +- Never use `select` with multiple ready channels to decide which result to use or which branch to take, see [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-go#3-concurrency-and-channel-selection). +- If you need to make several capability calls, initiate them in a fixed order and resolve them (`.Await()`) in that same fixed order. You can build up several `Promise` values before awaiting any of them, but the order you await them in must never vary. +- `cre.Then()` / `cre.ThenPromise()` are the idiomatic way to chain dependent async steps without nested `.Await()` calls, prefer them over manually orchestrating goroutines. + +## Pitfall 4: determinism + +Every trigger callback in a CRE workflow runs independently on every DON node and must produce identical results (DON mode), except inside `cre.RunInNodeMode()` blocks, which are explicitly for non-deterministic, per-node work that then gets aggregated. If nodes diverge, they generate different request IDs for capability calls and consensus fails outright. + +- **Never call `time.Now()` or any other `time` package clock function.** Always use `runtime.Now()` instead, so you don't need to branch on whether you're inside a `RunInNodeMode` block. +- **Never use `math/rand` (or `crypto/rand`) directly.** Use `runtime.Rand()` from the CRE SDK, which provides a consensus-safe generator so every node produces the same sequence. +- **Never iterate a Go map directly** when the order affects your output. Go maps intentionally randomize iteration order. Use [`cre.OrderedEntries`](/cre/reference/sdk/core-go#creorderedentries-and-creorderedentriesfunc) (or `cre.OrderedEntriesFunc` for non-`cmp.Ordered` keys) instead of `for k, v := range someMap`. +- **Use `encoding/json` v1, not v2.** The v2 library uses randomized hashing for field ordering, which serializes the same struct differently across nodes. If you serialize Protocol Buffers, use `proto.MarshalOptions{Deterministic: true}.Marshal()` too, since the default `proto.Marshal` doesn't guarantee field order. +- **Never use `select` with multiple ready channels** to make a decision, see Pitfall 3 above. + +See [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-go) for the complete guide, including LLM output handling. + +## Pitfall 5: numeric precision for onchain values + +If your library produces or accepts values that will end up in a smart contract call, such as a price, an amount, or a `uint256`, use `*big.Int`, never a plain `int`/`int64`/`float64`. For monetary or price values that aren't already onchain integers, use a fixed-point decimal type such as [`github.com/shopspring/decimal`](https://github.com/shopspring/decimal) rather than `float64`, which loses precision under repeated arithmetic. This matches the convention used throughout the CRE Go SDK's own examples. + +## Packaging your library + +Structure it as an ordinary Go module, there's no special CRE project layout for a library, only for a workflow (which is itself a package inside the consuming project's single Go module). + +``` +my-cre-library/ +├── go.mod +├── go.sum +├── pagerduty.go +└── README.md +``` + +**`go.mod`:** + +``` +module github.com/you/my-cre-library + +go 1.25.3 + +require github.com/smartcontractkit/cre-sdk-go v1.6.0 +``` + +Key points: + +- Use a normal `require` for `github.com/smartcontractkit/cre-sdk-go`, there's no `peerDependency` concept in Go modules, and you don't need one. Go's module resolution (minimal version selection) automatically unifies the SDK version across your library and the consuming workflow's `go.mod` to a single compatible version, so there's no risk of bundling two copies the way there is with npm. +- **Don't add a `//go:build wasip1` build tag to your library's own files** unless you have a specific reason to (for example, wrapping a WASI-only import). `cre.Runtime`, `cre.NodeRuntime`, and the capability client types are plain, portable Go, they compile under any `GOOS`. Only the workflow's own entry point (the file calling `wasm.NewRunner`) needs the `wasip1` tag. Keeping your library tag-free means your library's own `go test` suite, and any consumer's tests that exercise your library, run under the host's native `GOOS` instead of requiring a full WASM build. +- Take advantage of the SDK's mock packages where they exist (for example, `github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm/mock` for EVM calls) to unit test logic that depends on `cre.Runtime` without running `cre workflow simulate`. There is currently no equivalent mock package for the HTTP capability, so HTTP-calling code still needs an end-to-end `cre workflow simulate` pass to verify. +- Keep your own dependency tree small. Everything you import gets compiled into the consuming workflow's single WASM binary, which is subject to production size and memory quotas (inspect them with `cre workflow limits export`, see [Testing Production Limits](/cre/guides/operations/understanding-limits)). A heavy library, or one with a dependency that unexpectedly requires cgo, can push an otherwise-fine workflow over its quota or break its WASM build entirely (cgo is not supported under `wasip1`). + +## Publishing + +Go modules don't have a central publish step or registry like npm, you publish by pushing your code to a Git repository (typically GitHub) and tagging a semver release: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +Your module path (in `go.mod`) should match the repository's import path, for example `github.com/you/my-cre-library`. Nothing about tagging a release is CRE-specific, the workflow author's own `go build`/`cre workflow simulate` pipeline is what turns your published Go code into something that runs inside the WASM sandbox, on their side. + +## Using your library from a CRE workflow + +Recall that a Go CRE project is a single Go module, so the workflow author adds your library with `go get` from the project root (not from inside the workflow subdirectory): + +```bash +go get github.com/you/my-cre-library@v1.0.0 +``` + +Then imports and calls it from their workflow handler, passing in the `runtime` they already have: + +```go +//go:build wasip1 + +package main + +import ( + "log/slog" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" + "github.com/you/my-cre-library" +) + +type Config struct { + Schedule string `json:"schedule"` +} + +var pagerDutyClient = pagerduty.New(pagerduty.Options{RoutingKeySecret: "pager-duty-routing-key"}) + +func onCronTrigger(config *Config, runtime cre.Runtime, trigger *cron.Payload) (string, error) { + _, err := pagerDutyClient.Trigger(runtime, pagerduty.Alert{ + Summary: "ETH/USD price breached threshold", + Severity: "critical", + Source: "cre-workflow", + }) + if err != nil { + return "", err + } + return "alert sent", nil +} + +func InitWorkflow(config *Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[*Config], error) { + return cre.Workflow[*Config]{ + cre.Handler(cron.Trigger(&cron.Config{Schedule: config.Schedule}), onCronTrigger), + }, nil +} + +func main() { + wasm.NewRunner(cre.ParseJSON[*Config]).Run(InitWorkflow) +} +``` + +The workflow author still owns: + +- Storing the actual secret value (`pager-duty-routing-key`) in their [CRE secrets](/cre/guides/workflow/secrets); your library only ever refers to it by name. +- Running `cre workflow simulate` to verify your library behaves correctly compiled into their WASM binary before deploying. + +## Pre-publish checklist + +- No filesystem access (`os.Open`, `os.ReadFile`, `os.WriteFile`, and similar) anywhere in your library or its dependencies. +- No direct network access (`net.Dial`, `net/http`, database drivers, gRPC dialing); all outbound calls go through `http.Client`/`http.SendRequest` (or `evm.Client` for chain reads/writes). +- HTTP calls, time, and randomness are wrapped in `cre.RunInNodeMode()` with an explicit consensus aggregation, or delegated back to the caller's `runtime`. Secrets are resolved with `GetSecret()`, typically from inside that same block. +- No goroutines, channels, or `select` across multiple channels used to make a decision that affects your function's output. +- No direct map iteration where order affects the output, use `cre.OrderedEntries`/`cre.OrderedEntriesFunc`. +- `encoding/json` v1 (not v2) and, if applicable, `proto.MarshalOptions{Deterministic: true}` for serialization. +- No `time.Now()`, `math/rand`, or `crypto/rand`, use `runtime.Now()` and `runtime.Rand()`. +- `*big.Int` for onchain integer values; a decimal type (not `float64`) for prices and other precision-sensitive values. +- Secrets are accepted as an ID/name, never as a literal value baked into your library. +- No `//go:build wasip1` tag on library files unless truly required, keep your library testable with plain `go test`. +- Verified end-to-end with `cre workflow simulate` in a real (throwaway) CRE workflow that depends on your published or local module. + +## Learn more + +- [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-go): The complete determinism guide +- [SDK Reference: HTTP Client](/cre/reference/sdk/http-client-go): Full `http.Client` API +- [SDK Reference: Core](/cre/reference/sdk/core-go): `Runtime`, `NodeRuntime`, `Promise`, and `cre.OrderedEntries` +- [SDK Reference: Consensus & Aggregation](/cre/reference/sdk/consensus-go): Aggregation functions for `cre.RunInNodeMode` +- [Secrets](/cre/guides/workflow/secrets): Storing and retrieving secrets for a deployed workflow diff --git a/src/content/cre/guides/workflow/building-a-library-ts.mdx b/src/content/cre/guides/workflow/building-a-library-ts.mdx new file mode 100644 index 00000000000..632a1a4fe38 --- /dev/null +++ b/src/content/cre/guides/workflow/building-a-library-ts.mdx @@ -0,0 +1,303 @@ +--- +section: cre +date: Last Modified +title: "Building a Reusable Library" +sdkLang: "ts" +pageId: "building-a-library" +metadata: + description: "How to build and publish an npm package that CRE TypeScript workflows can import, including QuickJS/WASM restrictions and CRE SDK HTTP requirements." + datePublished: "2026-08-14" + lastModified: "2026-08-14" +--- + +import { Aside } from "@components" + +This guide shows you how to package reusable integration logic (for example, a Slack notifier, a PagerDuty client, or a price-feed parser) as a standard npm package that other developers can install and import directly into their own CRE TypeScript workflows. + +This is not a guide to writing a workflow itself, it assumes you're already familiar with that. If you're new to CRE, start with the [getting-started guide](/cre/getting-started/overview) first. + +## Who this is for + +Any developer who wants to publish a reusable package to npm that other people's CRE workflows will depend on. + +## Your library runs where the workflow runs + +A CRE TypeScript workflow isn't run by Node.js. It's transpiled and compiled into a single WebAssembly binary that executes inside [QuickJS](https://bellard.org/quickjs), a minimal JavaScript engine embedded via [Javy](https://github.com/bytecodealliance/javy). When a workflow author adds your package as a dependency, your code gets bundled into that same WASM binary, so it runs under the exact same restrictions as the workflow's own code. See [TypeScript Runtime Environment](/cre/concepts/typescript-wasm-runtime) for the full compilation pipeline. + +That means: + +- There is no Node.js runtime underneath your library at execution time: no `fs`, no `http`, no native modules. +- There is no network stack for your code to call into directly. All non-deterministic work (HTTP calls, secrets, blockchain reads/writes) must go through CRE SDK capability APIs, which the DON executes and brings to consensus on your library's behalf. + +Design your library around this from the start. Don't write it as a generic Node or browser package and hope it happens to work, write it against the CRE SDK's runtime primitives. + +## Pitfall 1: No Node.js built-ins + +QuickJS provides standard ECMAScript (`Map`, `Set`, `Promise`, most ES2020+ syntax) but not the Node.js API surface. The following are unavailable and will fail at compile or runtime if your library, or one of its transitive dependencies, imports them: + +``` +fs, path, crypto, process, http, https, net, stream, child_process, +os, worker_threads, cluster, dgram, dns, tls, vm, zlib, readline, +events (Node's EventEmitter), util (Node's promisify, etc.) +``` + +Practical implications: + +- **`Buffer` is available and is the standard way to base64-encode request bodies.** Despite `buffer` being a Node built-in in principle, every CRE HTTP example in this documentation uses `Buffer.from(bytes).toString("base64")` to encode request bodies, and it works the same in simulation and production. Use it the way the reference examples do instead of working around it with manual `btoa`/`String.fromCharCode` calls. `TextEncoder`/`TextDecoder` are also available and are the standard way to convert between strings and `Uint8Array`. +- **No `process.env`.** Never read configuration or credentials this way, there is no process. Accept configuration through your library's function arguments, and read secrets with `runtime.getSecret()` (see below). +- **No Node `crypto`.** If you need hashing or signing, use a pure-JS library with no native bindings. +- **No `setTimeout`/`setInterval`.** The WASM execution model is synchronous; there is no event loop to schedule against. + +{/* prettier-ignore */} + + +Before depending on any third-party package inside your library, check its `package.json` and source for the built-ins above. When in doubt, check the [QuickJS Node.js compatibility reference](https://sebastianwessel.github.io/quickjs/docs/module-resolution/node-compatibility.html), and confirm with `cre workflow simulate` in a throwaway test workflow, since simulation runs your code in the same WASM environment as production, so incompatibilities surface immediately. + +### Alternatives to common Node built-ins + +| Instead of... | Use... | Notes | +| ---------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node:crypto` | [`@noble/hashes`, `@noble/curves`](https://paulmillr.com/noble/) | Pure JS, no native bindings. | +| `ethers` (uses `node:crypto` internally) | [`viem`](https://viem.sh/) | Verified compatible. Use for ABI encoding/decoding, address/unit utilities, and general Ethereum type handling. | +| `axios`, `node-fetch`, `got`, `undici` | `HTTPClient` from `@chainlink/cre-sdk` | Not optional, see [Pitfall 2](#pitfall-2-no-standard-http-client). There is no working substitute; every HTTP call must go through the SDK's node-mode and consensus path. | +| `ws` (WebSockets) | Poll via `HTTPClient` on a cron or HTTP trigger instead | Persistent socket connections aren't supported in the WASM sandbox. | +| `dotenv` / `process.env` | `runtime.getSecret()` or function parameters | There is no `process`, see above. | +| `import { Buffer } from "node:buffer"` | The global `Buffer` (already available, no import needed) | See the note above. | +| `uuid` / `crypto.randomUUID()` | `Math.random()`-based generation inside the CRE runtime, or a uuid package's pure-JS build | Some `uuid` package builds pull in `node:crypto` for `randomUUID`. Verify with `cre workflow simulate`. | +| `lodash`, `date-fns`, `dayjs`, `zod` | Generally fine as-is | Pure-JS utility/validation libraries with no Node built-ins typically work unmodified. `zod` is a documented SDK dependency. Still confirm with simulation. | +| Node's `events` (`EventEmitter`) | Plain callbacks/arrays, or a pure-JS emitter with zero dependencies | Node's own `EventEmitter` isn't available; most third-party emitters that don't import `node:events` work fine. | + +{/* prettier-ignore */} + + +## Pitfall 2: No standard HTTP client + +Do not depend on `axios`, `node-fetch`, `ws`, or any HTTP or socket library, since they all sit on top of Node's `http`/`net`/`stream` modules and will not work. There is also no bare global `fetch` you can rely on directly from a library the way you would in a browser or in Node 18+. + +Instead, all outbound requests must go through [`HTTPClient`](/cre/reference/sdk/http-client-ts) from `@chainlink/cre-sdk`. This isn't just a technical requirement, it's what gives the request DON-wide consensus. An HTTP call in CRE runs independently on every node in the DON (node mode), and the SDK aggregates the individual results into one trusted value before the workflow continues. A library that shells out to `axios` would bypass this consensus mechanism entirely, even if it somehow ran. + +Consequence for library design: your exported functions should take a CRE `Runtime` (or, in some cases, a `NodeRuntime`) as a parameter, and use it to construct requests via `HTTPClient`, exactly as a workflow author would inline. Your library is a thin, well-tested wrapper around this pattern, not a replacement for it. + +```typescript +// src/index.ts, inside your library package +import { HTTPClient, consensusIdenticalAggregation, ok, type Runtime, type NodeRuntime } from "@chainlink/cre-sdk" + +export interface PagerDutyOptions { + routingKeySecret: string +} + +export interface PagerDutyAlert { + summary: string + severity: "critical" | "error" | "warning" | "info" + source: string +} + +export class PagerDuty { + constructor(private options: PagerDutyOptions) {} + + trigger(runtime: Runtime, alert: PagerDutyAlert): { statusCode: number } { + const sendAlert = (nodeRuntime: NodeRuntime): { statusCode: number } => { + const routingKey = nodeRuntime.getSecret({ id: this.options.routingKeySecret }).result().value + + const payload = { + routing_key: routingKey, + event_action: "trigger", + payload: alert, + } + + const bodyBytes = new TextEncoder().encode(JSON.stringify(payload)) + const body = Buffer.from(bodyBytes).toString("base64") + + const httpClient = new HTTPClient() + const response = httpClient + .sendRequest(nodeRuntime, { + url: "https://events.pagerduty.com/v2/enqueue", + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + // Prevents every node from firing a duplicate alert + cacheSettings: { readFromCache: true, maxAgeMs: 60_000 }, + }) + .result() + + if (!ok(response)) { + throw new Error(`PagerDuty request failed with status ${response.statusCode}`) + } + + return { statusCode: response.statusCode } + } + + return runtime.runInNodeMode(sendAlert, consensusIdenticalAggregation<{ statusCode: number }>())().result() + } +} +``` + +Notes on this pattern: + +- Secrets are only ever resolved with `runtime.getSecret()` / `nodeRuntime.getSecret()` (backed by the Vault DON), never read from environment variables or hardcoded. Your library should accept a secret ID/name, not a raw credential, and let the consuming workflow own the actual value in [CRE secrets](/cre/guides/workflow/secrets). +- `getSecret()` itself doesn't require node mode, but you'll typically call it from inside a `runInNodeMode` block anyway, since that's where you're already building the request that needs the secret value. +- Non-idempotent calls (POST/PUT/PATCH/DELETE) should set `cacheSettings` so a single DON-wide action isn't repeated once per node. +- Wrap HTTP calls in `runtime.runInNodeMode(...)` with an explicit [consensus aggregation](/cre/reference/sdk/consensus-ts) (`consensusIdenticalAggregation`, `consensusMedianAggregation`, or `ConsensusAggregationByFields` for multi-field objects) so the DON agrees on one result before your function returns control to the workflow. +- All SDK capability calls use the [`.result()` pattern](/cre/reference/sdk/core-ts#understanding-the-result-pattern) instead of `await`, see the next section. + +## Pitfall 3: No top-level `async`/`await` around SDK calls + +`Promise` and `async`/`await` exist in QuickJS, but CRE SDK capabilities (`HTTPClient`, `EVMClient`, secrets, and so on) don't use them, they use a synchronous `.result()` handshake between the WASM guest and the CRE host instead, because the guest/host boundary can't await across WASM calls. This means: + +- Never use `Promise.race()` / `Promise.any()` around capability calls: result order between nodes is not deterministic and will break consensus. +- Write your library's functions as plain synchronous functions that call `.result()` inline, matching how workflow code itself is written. You can still use `async`/`await` for your own internal pure-JS logic that doesn't touch the SDK, but don't expose an `async` public API that wraps SDK calls, since it will mislead consumers into thinking they should `await` your function when they shouldn't (and can't, at the top level of a handler). + +## Pitfall 4: determinism + +Every trigger callback in a CRE workflow runs independently on every DON node and must produce identical results (DON mode), except inside `runtime.runInNodeMode()` blocks, which are explicitly for non-deterministic, per-node work that then gets aggregated. If nodes diverge, they generate different request IDs for capability calls and consensus fails outright. + +- **Never call `Date.now()` or `new Date()`.** Always use `runtime.now()` instead, it's available on both `Runtime` and `NodeRuntime`, so you don't need to branch on whether you're inside a `runInNodeMode` block. +- **`Math.random()` is safe, but only inside the CRE WASM runtime.** The CRE runtime overrides `Math.random()` with a seeded generator so every node produces the same sequence, and it isn't cryptographically secure, so don't use it for anything security-sensitive such as key generation or signing nonces. Randomness computed outside that runtime, such as a pre-computed value passed in or a dependency with its own PRNG, won't go through CRE's override and will diverge across nodes. +- **Never use `Promise.race()`, `Promise.any()`, or unpredictably-ordered `Promise.all()`** around `.result()` calls. Call `.result()` on each operation in a fixed order instead, for example always try API 1 then fall back to API 2. You can still initiate multiple requests before resolving any of them, as long as the resolution order never varies. +- **Prefer `Map`/`Set` over plain objects when output order matters, and avoid `for...in`.** Since ES2015, plain-object key enumeration order is defined by spec (integer-like keys ascending, then string keys in insertion order) and is consistent across DON nodes running the same engine, so it isn't a source of non-determinism on its own. The real risk is `for...in` also walking inherited enumerable properties. Use `Object.keys(obj)`, or `Map`/`Set`, which guarantee insertion order without that gotcha. + +See [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-ts) for the complete guide, including LLM output handling. + +## Pitfall 5: numeric precision for onchain values + +If your library produces or accepts values that will end up in a smart contract call, such as a price, an amount, or a `uint256`, use `bigint` (the `123n` suffix), never `number`. JavaScript `number` silently loses precision above `2^53`, which corrupts large integers without throwing an error. For scaling between human-readable decimals and fixed-point onchain representations, use viem's `parseUnits()`/`formatUnits()` (string-based, no floating-point math) rather than hand-rolled `* 10**18` arithmetic. + +## Packaging your library + +Structure it as an ordinary TypeScript npm package, there's no special CRE project layout for a library, only for a workflow. + +``` +my-cre-library/ +├── src/ +│ └── index.ts +├── package.json +├── tsconfig.json +└── README.md +``` + +**`package.json`:** + +```json +{ + "name": "my-cre-library", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "tsc" + }, + "peerDependencies": { + "@chainlink/cre-sdk": ">=1.0.0" + }, + "devDependencies": { + "@chainlink/cre-sdk": "latest", + "typescript": "^5.9.0" + } +} +``` + +Key points: + +- Declare `@chainlink/cre-sdk` as a `peerDependency`, not a regular dependency. The consuming workflow already depends on `@chainlink/cre-sdk` directly, so a peer dependency avoids bundling two separate copies of the SDK into the same WASM binary and avoids version-mismatch surprises. Add it to `devDependencies` too so your own build and typecheck work. +- Ship plain compiled JS and `.d.ts` declarations. `tsc` output is enough, no bundler is required, since the workflow's own build step is what ultimately compiles everything down to WASM via Bun and Javy. Keep your compiled output free of Node-specific syntax; target `ES2020`/`ESNext` module output, not CommonJS. +- Don't ship a `postinstall` script, that's a workflow-project concern (`bunx cre-setup`), not a library concern. +- Keep your own dependency tree small. Everything you import gets bundled into the consuming workflow's single WASM binary, which is subject to production size and memory quotas (inspect them with `cre workflow limits export`, see [Testing Production Limits](/cre/guides/operations/understanding-limits)). A heavy library can push an otherwise-fine workflow over its quota. + +**`tsconfig.json`** (mirrors the workflow's own compiler settings so output stays compatible): + +```json +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} +``` + +## Publishing + +Publish it like any other npm package: + +```bash +npm run build +npm publish --access public +``` + +Nothing about `npm publish` itself is CRE-specific, the workflow author's `bun install` / `cre-setup` pipeline is what turns your published JS into something that runs inside the WASM sandbox, on their side. + +## Using your library from a CRE workflow + +The workflow author adds it like any other dependency: + +```bash +cd my-workflow +bun add my-cre-library +``` + +Then imports and calls it from their workflow handler, passing in the `runtime` they already have: + +```typescript +import { CronCapability, handler, Runner, type Runtime } from "@chainlink/cre-sdk" +import { PagerDuty } from "my-cre-library" + +type Config = { schedule: string } + +const pagerDuty = new PagerDuty({ routingKeySecret: "pager-duty-routing-key" }) + +const onCronTrigger = (runtime: Runtime): string => { + pagerDuty.trigger(runtime, { + summary: "ETH/USD price breached threshold", + severity: "critical", + source: "cre-workflow", + }) + return "alert sent" +} + +const initWorkflow = (config: Config) => { + const cron = new CronCapability() + return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)] +} + +export async function main() { + const runner = await Runner.newRunner() + await runner.run(initWorkflow) +} +``` + +The workflow author still owns: + +- Storing the actual secret value (`pager-duty-routing-key`) in their [CRE secrets](/cre/guides/workflow/secrets); your library only ever refers to it by name. +- Running `cre workflow simulate` to verify your library behaves correctly compiled into their WASM binary before deploying. + +## Pre-publish checklist + +- No import of `fs`, `path`, `crypto`, `process`, `http`, `https`, `net`, `stream`, or any other Node built-in (aside from `Buffer`, which is available), directly or transitively. +- All outbound network calls go through `HTTPClient` (or `EVMClient` for chain reads/writes), never a third-party HTTP or socket library. +- HTTP calls, time, and randomness are wrapped in `runtime.runInNodeMode()` with an explicit consensus aggregation, or delegated back to the caller's `runtime`. Secrets are resolved with `getSecret()`, typically from inside that same block. +- No `Promise.race()`, `Promise.any()`, or order-unpredictable `Promise.all()` around `.result()` calls; resolution order is always fixed. +- No plain-object `for...in` iteration relying on inherited properties; use `Object.keys()`, or `Map`/`Set`, instead. +- No `async` public API wrapping SDK capability calls; expose the synchronous `.result()`-based pattern. +- Secrets are accepted as an ID/name, never as a literal value baked into your library. +- `@chainlink/cre-sdk` is a `peerDependency`, not a bundled dependency. +- Verified end-to-end with `cre workflow simulate` in a real (throwaway) CRE workflow that depends on your published or local package. + +## Learn more + +- [TypeScript Runtime Environment](/cre/concepts/typescript-wasm-runtime): QuickJS compatibility and the WASM compilation pipeline +- [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-ts): The complete determinism guide +- [SDK Reference: HTTP Client](/cre/reference/sdk/http-client-ts): Full `HTTPClient` API +- [SDK Reference: Core](/cre/reference/sdk/core-ts): `Runtime`, `NodeRuntime`, and the `.result()` pattern +- [Secrets](/cre/guides/workflow/secrets): Storing and retrieving secrets for a deployed workflow diff --git a/src/content/cre/llms-full-go.txt b/src/content/cre/llms-full-go.txt index a30214ec02d..ab17ec16b39 100644 --- a/src/content/cre/llms-full-go.txt +++ b/src/content/cre/llms-full-go.txt @@ -13083,6 +13083,282 @@ Go workflows are verifiable by default and do not require a template or Docker. --- +# Building a Reusable Library +Source: https://docs.chain.link/cre/guides/workflow/building-a-library-go +Last Updated: 2026-08-14 + +This guide shows you how to package reusable integration logic (for example, a Slack notifier, a PagerDuty client, or a price-feed parser) as a standard Go module that other developers can `go get` and import directly into their own CRE workflows. + +This is not a guide to writing a workflow itself, it assumes you're already familiar with that. If you're new to CRE, start with the [getting-started guide](/cre/getting-started/overview) first. + +## Who this is for + +Any developer who wants to publish a reusable Go module that other people's CRE workflows will depend on. + +## Your library runs where the workflow runs + +A CRE Go workflow isn't run as a normal Go binary. It's cross-compiled with `GOOS=wasip1 GOARCH=wasm` into a WebAssembly module that runs inside the CRE host's WASI sandbox. When a workflow author adds your module as a dependency, your code is compiled into that same binary, so it runs under the exact same restrictions as the workflow's own code. + +Unlike the TypeScript SDK, which runs inside a stripped-down QuickJS engine, Go workflows compile with the **full Go standard library** available at compile time. This means far more of the ecosystem "just works" without compatibility checking, but the WASI sandbox still enforces real limits at runtime: + +- No filesystem access. There's no real disk backing the sandbox, so file I/O fails or is meaningless even though the code compiles. +- No arbitrary outbound network access. There's no raw socket layer to dial into, so everything non-deterministic (HTTP calls, secrets, blockchain reads/writes) must go through CRE SDK capability APIs, which the DON executes and brings to consensus on your library's behalf. +- Single-threaded, deterministic execution. All DON nodes must execute your code identically and produce the same output. + +Design your library around this from the start. Don't write it as a generic Go module that happens to shell out to the filesystem or network and hope it works under WASI, write it against the CRE SDK's runtime primitives. + +## Pitfall 1: No filesystem or arbitrary network access + +Your library (or one of its transitive dependencies) must not depend on: + +- **Filesystem access**: `os.Open`, `os.ReadFile`, `os.WriteFile`, config-file loaders, embedded file caches written to disk, and so on. There is no writable (or meaningfully readable) filesystem in the WASI sandbox the workflow runs in. +- **Direct network access**: `net.Dial`, `net/http`'s `http.Client`/`http.Get`, gRPC dialing, database drivers, or any other package that opens its own socket. These either fail at runtime or, worse, silently do nothing useful, because there's no outbound network stack available to your code directly, only to the CRE host, through capability calls. + +Anything your library needs from disk (default config, lookup tables, certificates) should be compiled in as Go constants, embedded with `//go:embed` (a compile-time read, not a runtime filesystem access, so this is safe), or passed in as arguments by the caller. Anything it needs from the network must go through [`cre-sdk-go`](#pitfall-2-no-standard-http-client)'s `http.Client`/`http.SendRequest`, never a raw dependency that dials sockets itself. + + + + +## Pitfall 2: No standard HTTP client + +Do not depend on `net/http`, a REST client wrapper built on it, or any package that dials its own connections. Instead, all outbound requests must go through [`http.Client`](/cre/reference/sdk/http-client-go) from `github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http`. This isn't just a technical requirement, it's what gives the request DON-wide consensus. An HTTP call in CRE runs independently on every node in the DON (node mode), and the SDK aggregates the individual results into one trusted value before the workflow continues. A library that dials out with `net/http` would bypass this consensus mechanism entirely, even if it somehow ran. + +Consequence for library design: your exported functions should take a `cre.Runtime` as a parameter, and use it (via `http.SendRequest` or `cre.RunInNodeMode`) to construct requests, exactly as a workflow author would inline. Your library is a thin, well-tested wrapper around this pattern, not a replacement for it. + +```go +// pagerduty.go, inside your library module +package pagerduty + +import ( + "encoding/json" + "fmt" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http" + "github.com/smartcontractkit/cre-sdk-go/cre" +) + +type Options struct { + RoutingKeySecret string +} + +type Alert struct { + Summary string `json:"summary"` + Severity string `json:"severity"` + Source string `json:"source"` +} + +type Client struct { + options Options +} + +func New(options Options) *Client { + return &Client{options: options} +} + +func (c *Client) Trigger(runtime cre.Runtime, alert Alert) (int, error) { + sendAlert := func(config Options, nodeRuntime cre.NodeRuntime) (int, error) { + secret, err := nodeRuntime.GetSecret(&cre.SecretRequest{Id: config.RoutingKeySecret}).Await() + if err != nil { + return 0, fmt.Errorf("failed to get routing key: %w", err) + } + + payload := map[string]any{ + "routing_key": secret.Value, + "event_action": "trigger", + "payload": alert, + } + + body, err := json.Marshal(payload) + if err != nil { + return 0, fmt.Errorf("failed to marshal payload: %w", err) + } + + client := &http.Client{} + resp, err := client.SendRequest(nodeRuntime, &http.Request{ + Url: "https://events.pagerduty.com/v2/enqueue", + Method: "POST", + MultiHeaders: map[string]*http.HeaderValues{ + "Content-Type": {Values: []string{"application/json"}}, + }, + Body: body, + // Prevents every node from firing a duplicate alert + CacheSettings: &http.CacheSettings{Store: true}, + }).Await() + if err != nil { + return 0, fmt.Errorf("PagerDuty request failed: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return 0, fmt.Errorf("PagerDuty request failed with status %d", resp.StatusCode) + } + + return int(resp.StatusCode), nil + } + + promise := cre.RunInNodeMode(c.options, runtime, sendAlert, cre.ConsensusIdenticalAggregation[int]()) + return promise.Await() +} +``` + +Notes on this pattern: + +- Secrets are only ever resolved with `runtime.GetSecret()` / `nodeRuntime.GetSecret()` (backed by the Vault DON), never read from environment variables or hardcoded. Your library should accept a secret ID/name, not a raw credential, and let the consuming workflow own the actual value in [CRE secrets](/cre/guides/workflow/secrets). +- `GetSecret()` itself doesn't require node mode, but you'll typically call it from inside a `RunInNodeMode` block anyway, since that's where you're already building the request that needs the secret value. +- Non-idempotent calls (POST/PUT/PATCH/DELETE) should set `CacheSettings` so a single DON-wide action isn't repeated once per node. +- Wrap HTTP calls in `cre.RunInNodeMode(...)` with an explicit [consensus aggregation](/cre/reference/sdk/consensus-go) (`cre.ConsensusIdenticalAggregation`, `cre.ConsensusMedianAggregation`, or `cre.ConsensusAggregationFromTags` for a tagged result struct) so the DON agrees on one result before your function returns control to the workflow. +- `Body` on `http.Request` is a plain `[]byte`; unlike the TypeScript SDK, there's no base64-encoding step to worry about. +- All SDK capability calls return a [`Promise[T]`](/cre/reference/sdk/core-go#promise) that you resolve with `.Await()`, see the next section. + +## Pitfall 3: `Promise`/`Await` concurrency, not goroutines + +The Go SDK doesn't use `async`/`await` keywords, it uses a `Promise[T]` type with `.Await()`, `cre.Then()`, and `cre.ThenPromise()` for chaining (see [Core SDK Reference](/cre/reference/sdk/core-go#promise)). This exists because the underlying operation only actually runs when you call `.Await()`, building a promise chain without awaiting it does nothing. + +The pitfall specific to Go: don't reach for goroutines, channels, or `sync` primitives (`WaitGroup`, `Mutex`, and so on) to run multiple SDK calls "concurrently" inside your library. CRE workflows execute in a single-threaded WASM environment, so using Go's concurrency primitives to fan out capability calls doesn't get you real parallelism, and a `select` across multiple channels picks a ready channel non-deterministically, which will cause consensus failures if the result depends on which one "won." + +- Never use `select` with multiple ready channels to decide which result to use or which branch to take, see [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-go#3-concurrency-and-channel-selection). +- If you need to make several capability calls, initiate them in a fixed order and resolve them (`.Await()`) in that same fixed order. You can build up several `Promise` values before awaiting any of them, but the order you await them in must never vary. +- `cre.Then()` / `cre.ThenPromise()` are the idiomatic way to chain dependent async steps without nested `.Await()` calls, prefer them over manually orchestrating goroutines. + +## Pitfall 4: determinism + +Every trigger callback in a CRE workflow runs independently on every DON node and must produce identical results (DON mode), except inside `cre.RunInNodeMode()` blocks, which are explicitly for non-deterministic, per-node work that then gets aggregated. If nodes diverge, they generate different request IDs for capability calls and consensus fails outright. + +- **Never call `time.Now()` or any other `time` package clock function.** Always use `runtime.Now()` instead, so you don't need to branch on whether you're inside a `RunInNodeMode` block. +- **Never use `math/rand` (or `crypto/rand`) directly.** Use `runtime.Rand()` from the CRE SDK, which provides a consensus-safe generator so every node produces the same sequence. +- **Never iterate a Go map directly** when the order affects your output. Go maps intentionally randomize iteration order. Use [`cre.OrderedEntries`](/cre/reference/sdk/core-go#creorderedentries-and-creorderedentriesfunc) (or `cre.OrderedEntriesFunc` for non-`cmp.Ordered` keys) instead of `for k, v := range someMap`. +- **Use `encoding/json` v1, not v2.** The v2 library uses randomized hashing for field ordering, which serializes the same struct differently across nodes. If you serialize Protocol Buffers, use `proto.MarshalOptions{Deterministic: true}.Marshal()` too, since the default `proto.Marshal` doesn't guarantee field order. +- **Never use `select` with multiple ready channels** to make a decision, see Pitfall 3 above. + +See [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-go) for the complete guide, including LLM output handling. + +## Pitfall 5: numeric precision for onchain values + +If your library produces or accepts values that will end up in a smart contract call, such as a price, an amount, or a `uint256`, use `*big.Int`, never a plain `int`/`int64`/`float64`. For monetary or price values that aren't already onchain integers, use a fixed-point decimal type such as [`github.com/shopspring/decimal`](https://github.com/shopspring/decimal) rather than `float64`, which loses precision under repeated arithmetic. This matches the convention used throughout the CRE Go SDK's own examples. + +## Packaging your library + +Structure it as an ordinary Go module, there's no special CRE project layout for a library, only for a workflow (which is itself a package inside the consuming project's single Go module). + +``` +my-cre-library/ +├── go.mod +├── go.sum +├── pagerduty.go +└── README.md +``` + +**`go.mod`:** + +``` +module github.com/you/my-cre-library + +go 1.25.3 + +require github.com/smartcontractkit/cre-sdk-go v1.6.0 +``` + +Key points: + +- Use a normal `require` for `github.com/smartcontractkit/cre-sdk-go`, there's no `peerDependency` concept in Go modules, and you don't need one. Go's module resolution (minimal version selection) automatically unifies the SDK version across your library and the consuming workflow's `go.mod` to a single compatible version, so there's no risk of bundling two copies the way there is with npm. +- **Don't add a `//go:build wasip1` build tag to your library's own files** unless you have a specific reason to (for example, wrapping a WASI-only import). `cre.Runtime`, `cre.NodeRuntime`, and the capability client types are plain, portable Go, they compile under any `GOOS`. Only the workflow's own entry point (the file calling `wasm.NewRunner`) needs the `wasip1` tag. Keeping your library tag-free means your library's own `go test` suite, and any consumer's tests that exercise your library, run under the host's native `GOOS` instead of requiring a full WASM build. +- Take advantage of the SDK's mock packages where they exist (for example, `github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm/mock` for EVM calls) to unit test logic that depends on `cre.Runtime` without running `cre workflow simulate`. There is currently no equivalent mock package for the HTTP capability, so HTTP-calling code still needs an end-to-end `cre workflow simulate` pass to verify. +- Keep your own dependency tree small. Everything you import gets compiled into the consuming workflow's single WASM binary, which is subject to production size and memory quotas (inspect them with `cre workflow limits export`, see [Testing Production Limits](/cre/guides/operations/understanding-limits)). A heavy library, or one with a dependency that unexpectedly requires cgo, can push an otherwise-fine workflow over its quota or break its WASM build entirely (cgo is not supported under `wasip1`). + +## Publishing + +Go modules don't have a central publish step or registry like npm, you publish by pushing your code to a Git repository (typically GitHub) and tagging a semver release: + +```bash +git tag v1.0.0 +git push origin v1.0.0 +``` + +Your module path (in `go.mod`) should match the repository's import path, for example `github.com/you/my-cre-library`. Nothing about tagging a release is CRE-specific, the workflow author's own `go build`/`cre workflow simulate` pipeline is what turns your published Go code into something that runs inside the WASM sandbox, on their side. + +## Using your library from a CRE workflow + +Recall that a Go CRE project is a single Go module, so the workflow author adds your library with `go get` from the project root (not from inside the workflow subdirectory): + +```bash +go get github.com/you/my-cre-library@v1.0.0 +``` + +Then imports and calls it from their workflow handler, passing in the `runtime` they already have: + +```go +//go:build wasip1 + +package main + +import ( + "log/slog" + + "github.com/smartcontractkit/cre-sdk-go/capabilities/scheduler/cron" + "github.com/smartcontractkit/cre-sdk-go/cre" + "github.com/smartcontractkit/cre-sdk-go/cre/wasm" + "github.com/you/my-cre-library" +) + +type Config struct { + Schedule string `json:"schedule"` +} + +var pagerDutyClient = pagerduty.New(pagerduty.Options{RoutingKeySecret: "pager-duty-routing-key"}) + +func onCronTrigger(config *Config, runtime cre.Runtime, trigger *cron.Payload) (string, error) { + _, err := pagerDutyClient.Trigger(runtime, pagerduty.Alert{ + Summary: "ETH/USD price breached threshold", + Severity: "critical", + Source: "cre-workflow", + }) + if err != nil { + return "", err + } + return "alert sent", nil +} + +func InitWorkflow(config *Config, logger *slog.Logger, secretsProvider cre.SecretsProvider) (cre.Workflow[*Config], error) { + return cre.Workflow[*Config]{ + cre.Handler(cron.Trigger(&cron.Config{Schedule: config.Schedule}), onCronTrigger), + }, nil +} + +func main() { + wasm.NewRunner(cre.ParseJSON[*Config]).Run(InitWorkflow) +} +``` + +The workflow author still owns: + +- Storing the actual secret value (`pager-duty-routing-key`) in their [CRE secrets](/cre/guides/workflow/secrets); your library only ever refers to it by name. +- Running `cre workflow simulate` to verify your library behaves correctly compiled into their WASM binary before deploying. + +## Pre-publish checklist + +- No filesystem access (`os.Open`, `os.ReadFile`, `os.WriteFile`, and similar) anywhere in your library or its dependencies. +- No direct network access (`net.Dial`, `net/http`, database drivers, gRPC dialing); all outbound calls go through `http.Client`/`http.SendRequest` (or `evm.Client` for chain reads/writes). +- HTTP calls, time, and randomness are wrapped in `cre.RunInNodeMode()` with an explicit consensus aggregation, or delegated back to the caller's `runtime`. Secrets are resolved with `GetSecret()`, typically from inside that same block. +- No goroutines, channels, or `select` across multiple channels used to make a decision that affects your function's output. +- No direct map iteration where order affects the output, use `cre.OrderedEntries`/`cre.OrderedEntriesFunc`. +- `encoding/json` v1 (not v2) and, if applicable, `proto.MarshalOptions{Deterministic: true}` for serialization. +- No `time.Now()`, `math/rand`, or `crypto/rand`, use `runtime.Now()` and `runtime.Rand()`. +- `*big.Int` for onchain integer values; a decimal type (not `float64`) for prices and other precision-sensitive values. +- Secrets are accepted as an ID/name, never as a literal value baked into your library. +- No `//go:build wasip1` tag on library files unless truly required, keep your library testable with plain `go test`. +- Verified end-to-end with `cre workflow simulate` in a real (throwaway) CRE workflow that depends on your published or local module. + +## Learn more + +- [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-go): The complete determinism guide +- [SDK Reference: HTTP Client](/cre/reference/sdk/http-client-go): Full `http.Client` API +- [SDK Reference: Core](/cre/reference/sdk/core-go): `Runtime`, `NodeRuntime`, `Promise`, and `cre.OrderedEntries` +- [SDK Reference: Consensus & Aggregation](/cre/reference/sdk/consensus-go): Aggregation functions for `cre.RunInNodeMode` +- [Secrets](/cre/guides/workflow/secrets): Storing and retrieving secrets for a deployed workflow + +--- + # Using Secrets in Simulation Source: https://docs.chain.link/cre/guides/workflow/secrets/using-secrets-simulation-go Last Updated: 2025-11-04 diff --git a/src/content/cre/llms-full-ts.txt b/src/content/cre/llms-full-ts.txt index 283bd8dc994..cc9c0b4b90f 100644 --- a/src/content/cre/llms-full-ts.txt +++ b/src/content/cre/llms-full-ts.txt @@ -13285,6 +13285,302 @@ Because the build runs inside a pinned Docker image with a locked dependency tre --- +# Building a Reusable Library +Source: https://docs.chain.link/cre/guides/workflow/building-a-library-ts +Last Updated: 2026-08-14 + +This guide shows you how to package reusable integration logic (for example, a Slack notifier, a PagerDuty client, or a price-feed parser) as a standard npm package that other developers can install and import directly into their own CRE TypeScript workflows. + +This is not a guide to writing a workflow itself, it assumes you're already familiar with that. If you're new to CRE, start with the [getting-started guide](/cre/getting-started/overview) first. + +## Who this is for + +Any developer who wants to publish a reusable package to npm that other people's CRE workflows will depend on. + +## Your library runs where the workflow runs + +A CRE TypeScript workflow isn't run by Node.js. It's transpiled and compiled into a single WebAssembly binary that executes inside [QuickJS](https://bellard.org/quickjs), a minimal JavaScript engine embedded via [Javy](https://github.com/bytecodealliance/javy). When a workflow author adds your package as a dependency, your code gets bundled into that same WASM binary, so it runs under the exact same restrictions as the workflow's own code. See [TypeScript Runtime Environment](/cre/concepts/typescript-wasm-runtime) for the full compilation pipeline. + +That means: + +- There is no Node.js runtime underneath your library at execution time: no `fs`, no `http`, no native modules. +- There is no network stack for your code to call into directly. All non-deterministic work (HTTP calls, secrets, blockchain reads/writes) must go through CRE SDK capability APIs, which the DON executes and brings to consensus on your library's behalf. + +Design your library around this from the start. Don't write it as a generic Node or browser package and hope it happens to work, write it against the CRE SDK's runtime primitives. + +## Pitfall 1: No Node.js built-ins + +QuickJS provides standard ECMAScript (`Map`, `Set`, `Promise`, most ES2020+ syntax) but not the Node.js API surface. The following are unavailable and will fail at compile or runtime if your library, or one of its transitive dependencies, imports them: + +``` +fs, path, crypto, process, http, https, net, stream, child_process, +os, worker_threads, cluster, dgram, dns, tls, vm, zlib, readline, +events (Node's EventEmitter), util (Node's promisify, etc.) +``` + +Practical implications: + +- **`Buffer` is available and is the standard way to base64-encode request bodies.** Despite `buffer` being a Node built-in in principle, every CRE HTTP example in this documentation uses `Buffer.from(bytes).toString("base64")` to encode request bodies, and it works the same in simulation and production. Use it the way the reference examples do instead of working around it with manual `btoa`/`String.fromCharCode` calls. `TextEncoder`/`TextDecoder` are also available and are the standard way to convert between strings and `Uint8Array`. +- **No `process.env`.** Never read configuration or credentials this way, there is no process. Accept configuration through your library's function arguments, and read secrets with `runtime.getSecret()` (see below). +- **No Node `crypto`.** If you need hashing or signing, use a pure-JS library with no native bindings. +- **No `setTimeout`/`setInterval`.** The WASM execution model is synchronous; there is no event loop to schedule against. + + + + +Before depending on any third-party package inside your library, check its `package.json` and source for the built-ins above. When in doubt, check the [QuickJS Node.js compatibility reference](https://sebastianwessel.github.io/quickjs/docs/module-resolution/node-compatibility.html), and confirm with `cre workflow simulate` in a throwaway test workflow, since simulation runs your code in the same WASM environment as production, so incompatibilities surface immediately. + +### Alternatives to common Node built-ins + +| Instead of... | Use... | Notes | +| ---------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node:crypto` | [`@noble/hashes`, `@noble/curves`](https://paulmillr.com/noble/) | Pure JS, no native bindings. | +| `ethers` (uses `node:crypto` internally) | [`viem`](https://viem.sh/) | Verified compatible. Use for ABI encoding/decoding, address/unit utilities, and general Ethereum type handling. | +| `axios`, `node-fetch`, `got`, `undici` | `HTTPClient` from `@chainlink/cre-sdk` | Not optional, see [Pitfall 2](#pitfall-2-no-standard-http-client). There is no working substitute; every HTTP call must go through the SDK's node-mode and consensus path. | +| `ws` (WebSockets) | Poll via `HTTPClient` on a cron or HTTP trigger instead | Persistent socket connections aren't supported in the WASM sandbox. | +| `dotenv` / `process.env` | `runtime.getSecret()` or function parameters | There is no `process`, see above. | +| `import { Buffer } from "node:buffer"` | The global `Buffer` (already available, no import needed) | See the note above. | +| `uuid` / `crypto.randomUUID()` | `Math.random()`-based generation inside the CRE runtime, or a uuid package's pure-JS build | Some `uuid` package builds pull in `node:crypto` for `randomUUID`. Verify with `cre workflow simulate`. | +| `lodash`, `date-fns`, `dayjs`, `zod` | Generally fine as-is | Pure-JS utility/validation libraries with no Node built-ins typically work unmodified. `zod` is a documented SDK dependency. Still confirm with simulation. | +| Node's `events` (`EventEmitter`) | Plain callbacks/arrays, or a pure-JS emitter with zero dependencies | Node's own `EventEmitter` isn't available; most third-party emitters that don't import `node:events` work fine. | + + + + +## Pitfall 2: No standard HTTP client + +Do not depend on `axios`, `node-fetch`, `ws`, or any HTTP or socket library, since they all sit on top of Node's `http`/`net`/`stream` modules and will not work. There is also no bare global `fetch` you can rely on directly from a library the way you would in a browser or in Node 18+. + +Instead, all outbound requests must go through [`HTTPClient`](/cre/reference/sdk/http-client-ts) from `@chainlink/cre-sdk`. This isn't just a technical requirement, it's what gives the request DON-wide consensus. An HTTP call in CRE runs independently on every node in the DON (node mode), and the SDK aggregates the individual results into one trusted value before the workflow continues. A library that shells out to `axios` would bypass this consensus mechanism entirely, even if it somehow ran. + +Consequence for library design: your exported functions should take a CRE `Runtime` (or, in some cases, a `NodeRuntime`) as a parameter, and use it to construct requests via `HTTPClient`, exactly as a workflow author would inline. Your library is a thin, well-tested wrapper around this pattern, not a replacement for it. + +```typescript +// src/index.ts, inside your library package +import { HTTPClient, consensusIdenticalAggregation, ok, type Runtime, type NodeRuntime } from "@chainlink/cre-sdk" + +export interface PagerDutyOptions { + routingKeySecret: string +} + +export interface PagerDutyAlert { + summary: string + severity: "critical" | "error" | "warning" | "info" + source: string +} + +export class PagerDuty { + constructor(private options: PagerDutyOptions) {} + + trigger(runtime: Runtime, alert: PagerDutyAlert): { statusCode: number } { + const sendAlert = (nodeRuntime: NodeRuntime): { statusCode: number } => { + const routingKey = nodeRuntime.getSecret({ id: this.options.routingKeySecret }).result().value + + const payload = { + routing_key: routingKey, + event_action: "trigger", + payload: alert, + } + + const bodyBytes = new TextEncoder().encode(JSON.stringify(payload)) + const body = Buffer.from(bodyBytes).toString("base64") + + const httpClient = new HTTPClient() + const response = httpClient + .sendRequest(nodeRuntime, { + url: "https://events.pagerduty.com/v2/enqueue", + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + // Prevents every node from firing a duplicate alert + cacheSettings: { readFromCache: true, maxAgeMs: 60_000 }, + }) + .result() + + if (!ok(response)) { + throw new Error(`PagerDuty request failed with status ${response.statusCode}`) + } + + return { statusCode: response.statusCode } + } + + return runtime.runInNodeMode(sendAlert, consensusIdenticalAggregation<{ statusCode: number }>())().result() + } +} +``` + +Notes on this pattern: + +- Secrets are only ever resolved with `runtime.getSecret()` / `nodeRuntime.getSecret()` (backed by the Vault DON), never read from environment variables or hardcoded. Your library should accept a secret ID/name, not a raw credential, and let the consuming workflow own the actual value in [CRE secrets](/cre/guides/workflow/secrets). +- `getSecret()` itself doesn't require node mode, but you'll typically call it from inside a `runInNodeMode` block anyway, since that's where you're already building the request that needs the secret value. +- Non-idempotent calls (POST/PUT/PATCH/DELETE) should set `cacheSettings` so a single DON-wide action isn't repeated once per node. +- Wrap HTTP calls in `runtime.runInNodeMode(...)` with an explicit [consensus aggregation](/cre/reference/sdk/consensus-ts) (`consensusIdenticalAggregation`, `consensusMedianAggregation`, or `ConsensusAggregationByFields` for multi-field objects) so the DON agrees on one result before your function returns control to the workflow. +- All SDK capability calls use the [`.result()` pattern](/cre/reference/sdk/core-ts#understanding-the-result-pattern) instead of `await`, see the next section. + +## Pitfall 3: No top-level `async`/`await` around SDK calls + +`Promise` and `async`/`await` exist in QuickJS, but CRE SDK capabilities (`HTTPClient`, `EVMClient`, secrets, and so on) don't use them, they use a synchronous `.result()` handshake between the WASM guest and the CRE host instead, because the guest/host boundary can't await across WASM calls. This means: + +- Never use `Promise.race()` / `Promise.any()` around capability calls: result order between nodes is not deterministic and will break consensus. +- Write your library's functions as plain synchronous functions that call `.result()` inline, matching how workflow code itself is written. You can still use `async`/`await` for your own internal pure-JS logic that doesn't touch the SDK, but don't expose an `async` public API that wraps SDK calls, since it will mislead consumers into thinking they should `await` your function when they shouldn't (and can't, at the top level of a handler). + +## Pitfall 4: determinism + +Every trigger callback in a CRE workflow runs independently on every DON node and must produce identical results (DON mode), except inside `runtime.runInNodeMode()` blocks, which are explicitly for non-deterministic, per-node work that then gets aggregated. If nodes diverge, they generate different request IDs for capability calls and consensus fails outright. + +- **Never call `Date.now()` or `new Date()`.** Always use `runtime.now()` instead, it's available on both `Runtime` and `NodeRuntime`, so you don't need to branch on whether you're inside a `runInNodeMode` block. +- **`Math.random()` is safe, but only inside the CRE WASM runtime.** The CRE runtime overrides `Math.random()` with a seeded generator so every node produces the same sequence, and it isn't cryptographically secure, so don't use it for anything security-sensitive such as key generation or signing nonces. Randomness computed outside that runtime, such as a pre-computed value passed in or a dependency with its own PRNG, won't go through CRE's override and will diverge across nodes. +- **Never use `Promise.race()`, `Promise.any()`, or unpredictably-ordered `Promise.all()`** around `.result()` calls. Call `.result()` on each operation in a fixed order instead, for example always try API 1 then fall back to API 2. You can still initiate multiple requests before resolving any of them, as long as the resolution order never varies. +- **Prefer `Map`/`Set` over plain objects when output order matters, and avoid `for...in`.** Since ES2015, plain-object key enumeration order is defined by spec (integer-like keys ascending, then string keys in insertion order) and is consistent across DON nodes running the same engine, so it isn't a source of non-determinism on its own. The real risk is `for...in` also walking inherited enumerable properties. Use `Object.keys(obj)`, or `Map`/`Set`, which guarantee insertion order without that gotcha. + +See [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-ts) for the complete guide, including LLM output handling. + +## Pitfall 5: numeric precision for onchain values + +If your library produces or accepts values that will end up in a smart contract call, such as a price, an amount, or a `uint256`, use `bigint` (the `123n` suffix), never `number`. JavaScript `number` silently loses precision above `2^53`, which corrupts large integers without throwing an error. For scaling between human-readable decimals and fixed-point onchain representations, use viem's `parseUnits()`/`formatUnits()` (string-based, no floating-point math) rather than hand-rolled `* 10**18` arithmetic. + +## Packaging your library + +Structure it as an ordinary TypeScript npm package, there's no special CRE project layout for a library, only for a workflow. + +``` +my-cre-library/ +├── src/ +│ └── index.ts +├── package.json +├── tsconfig.json +└── README.md +``` + +**`package.json`:** + +```json +{ + "name": "my-cre-library", + "version": "1.0.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": ["dist"], + "scripts": { + "build": "tsc" + }, + "peerDependencies": { + "@chainlink/cre-sdk": ">=1.0.0" + }, + "devDependencies": { + "@chainlink/cre-sdk": "latest", + "typescript": "^5.9.0" + } +} +``` + +Key points: + +- Declare `@chainlink/cre-sdk` as a `peerDependency`, not a regular dependency. The consuming workflow already depends on `@chainlink/cre-sdk` directly, so a peer dependency avoids bundling two separate copies of the SDK into the same WASM binary and avoids version-mismatch surprises. Add it to `devDependencies` too so your own build and typecheck work. +- Ship plain compiled JS and `.d.ts` declarations. `tsc` output is enough, no bundler is required, since the workflow's own build step is what ultimately compiles everything down to WASM via Bun and Javy. Keep your compiled output free of Node-specific syntax; target `ES2020`/`ESNext` module output, not CommonJS. +- Don't ship a `postinstall` script, that's a workflow-project concern (`bunx cre-setup`), not a library concern. +- Keep your own dependency tree small. Everything you import gets bundled into the consuming workflow's single WASM binary, which is subject to production size and memory quotas (inspect them with `cre workflow limits export`, see [Testing Production Limits](/cre/guides/operations/understanding-limits)). A heavy library can push an otherwise-fine workflow over its quota. + +**`tsconfig.json`** (mirrors the workflow's own compiler settings so output stays compatible): + +```json +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*.ts"] +} +``` + +## Publishing + +Publish it like any other npm package: + +```bash +npm run build +npm publish --access public +``` + +Nothing about `npm publish` itself is CRE-specific, the workflow author's `bun install` / `cre-setup` pipeline is what turns your published JS into something that runs inside the WASM sandbox, on their side. + +## Using your library from a CRE workflow + +The workflow author adds it like any other dependency: + +```bash +cd my-workflow +bun add my-cre-library +``` + +Then imports and calls it from their workflow handler, passing in the `runtime` they already have: + +```typescript +import { CronCapability, handler, Runner, type Runtime } from "@chainlink/cre-sdk" +import { PagerDuty } from "my-cre-library" + +type Config = { schedule: string } + +const pagerDuty = new PagerDuty({ routingKeySecret: "pager-duty-routing-key" }) + +const onCronTrigger = (runtime: Runtime): string => { + pagerDuty.trigger(runtime, { + summary: "ETH/USD price breached threshold", + severity: "critical", + source: "cre-workflow", + }) + return "alert sent" +} + +const initWorkflow = (config: Config) => { + const cron = new CronCapability() + return [handler(cron.trigger({ schedule: config.schedule }), onCronTrigger)] +} + +export async function main() { + const runner = await Runner.newRunner() + await runner.run(initWorkflow) +} +``` + +The workflow author still owns: + +- Storing the actual secret value (`pager-duty-routing-key`) in their [CRE secrets](/cre/guides/workflow/secrets); your library only ever refers to it by name. +- Running `cre workflow simulate` to verify your library behaves correctly compiled into their WASM binary before deploying. + +## Pre-publish checklist + +- No import of `fs`, `path`, `crypto`, `process`, `http`, `https`, `net`, `stream`, or any other Node built-in (aside from `Buffer`, which is available), directly or transitively. +- All outbound network calls go through `HTTPClient` (or `EVMClient` for chain reads/writes), never a third-party HTTP or socket library. +- HTTP calls, time, and randomness are wrapped in `runtime.runInNodeMode()` with an explicit consensus aggregation, or delegated back to the caller's `runtime`. Secrets are resolved with `getSecret()`, typically from inside that same block. +- No `Promise.race()`, `Promise.any()`, or order-unpredictable `Promise.all()` around `.result()` calls; resolution order is always fixed. +- No plain-object `for...in` iteration relying on inherited properties; use `Object.keys()`, or `Map`/`Set`, instead. +- No `async` public API wrapping SDK capability calls; expose the synchronous `.result()`-based pattern. +- Secrets are accepted as an ID/name, never as a literal value baked into your library. +- `@chainlink/cre-sdk` is a `peerDependency`, not a bundled dependency. +- Verified end-to-end with `cre workflow simulate` in a real (throwaway) CRE workflow that depends on your published or local package. + +## Learn more + +- [TypeScript Runtime Environment](/cre/concepts/typescript-wasm-runtime): QuickJS compatibility and the WASM compilation pipeline +- [Avoiding Non-Determinism in Workflows](/cre/concepts/non-determinism-ts): The complete determinism guide +- [SDK Reference: HTTP Client](/cre/reference/sdk/http-client-ts): Full `HTTPClient` API +- [SDK Reference: Core](/cre/reference/sdk/core-ts): `Runtime`, `NodeRuntime`, and the `.result()` pattern +- [Secrets](/cre/guides/workflow/secrets): Storing and retrieving secrets for a deployed workflow + +--- + # Using Secrets in Simulation Source: https://docs.chain.link/cre/guides/workflow/secrets/using-secrets-simulation-ts Last Updated: 2025-11-04