@flow-as-code/tf
npm i @flow-as-code/tf
Apache-2.0, Node 22.12 or newer.
Site and docs: https://flow-as-code.dev/. This package on npm: https://www.npmjs.com/package/@flow-as-code/tf.
Implements docs/03-tf-emitter.md. Deterministic HCL emission, terraform validate-clean when the address map is complete, loud TODO placeholders when it is not. Never emits literal ARNs, provider blocks, or backend config.
One-way: FlowDoc in, HCL out, and this output is not read back. @flow-as-code/hcl writes and reads the provider-native shape (a resource per flow with its actions as blocks), for the flow-as-code/flowascode provider; docs/06-terraform-provider.md says when to pick which.
Use
import { emitTf, writeTf } from "@flow-as-code/tf";
const { files } = emitTf([doc], {
addressMap: { "queue:front-desk": "aws_connect_queue.front_desk.arn" },
});
writeTf([doc], "infra/flows", { addressMap });
emitTf is pure: documents in, a relative path to content map out, sorted by path and byte-identical across runs. writeTf is the filesystem wrapper; it creates directories, overwrites the files it emits, and touches nothing else in the target directory.
@flow-as-code/tf/emit is the same emitter without the filesystem half. The package index re-exports writeTf, which imports node:fs, so a browser bundle (@flow-as-code/studio's Terraform export) imports the subpath instead.
Output
| File | What it holds |
|---|---|
flows/<name>.flow.tftpl |
Deployable Flow language JSON, each reference replaced by a templatefile interpolation |
flows.tf |
aws_connect_contact_flow / aws_connect_contact_flow_module, plus awscc module version and alias resources |
flow_refs.tf |
locals holding the terraform address behind every reference |
variables.tf |
The Connect instance id variable, unless instanceIdExpression supplies one |
versions.tf.example |
Required providers and versions, as an example; not loaded by either tool |
Provider configuration, credentials, and backend configuration are yours. The emitter writes none of them, by rule.
References
Every ${cdref:type:name} token becomes a template variable named <type>_<name>_arn, hyphens as underscores, with a module alias folded in: queue_front_desk_arn, module_survey_prod_arn. Addresses come from three places, in this order:
- The document set itself. A
${cdref:module:x@prod}whose modulexis emitted here resolves toawscc_connect_contact_flow_module_alias.x_prod.contact_flow_module_alias_arn; a${cdref:flow:y}whose flowyis emitted here resolves to that flow. An address map entry for one of these is ignored, and the emitted file says so on the line above. - The address map. Keys are a token (
${cdref:queue:front-desk}), the token body (queue:front-desk,module:survey@prod), or the variable name (queue_front_desk_arn). Values are HCL address expressions. A literal ARN is refused, as is anything that would comment out or overrun its line. Entries matching no reference in the set are left out, so one shared map can serve several flow sets, and their keys come back in the result'sunusedMapKeys(the CLI warns on each, or refuses under--strict); an entry a reference reaches but the set resolves itself counts as used. - Nothing. The entry becomes
TODO_MISSING_ADDRESS_<type>_<name>with a# TODOnaming the reference. That is an undeclared reference, soterraform validateandtofu validatefail loudly rather than an apply writing a broken flow. The result'sunboundlists each such key with the documents that make it; the CLI exits 0 on this target all the same, since the placeholder is the loud form.
Addresses of resources the set emits itself live in a per-document local (merge(local.flow_refs, { ... })) rather than in the shared map. Putting them in the shared map makes the map depend on the resources rendered from it, and both tools report that as a dependency cycle. Two flows that reference each other are still a cycle, because it is one.
Escaping
The .tftpl body is JSON containing caller text, and caller text legitimately contains ${ and %{, the two sequences HCL reads as template introducers. Every one of them is escaped ($${, %%{) and only the emitter's own reference interpolations are left live. The substitution runs through unique sentinels chosen so they cannot collide with the document, so text that looks like a sentinel is caller text and stays caller text. conformance/emit-tf/hostile-text holds the fixture, and the gated tests render it with a real tofu and compare against a reference implementation of the template grammar.
Versions and aliases
Flow versioning is not available: CreateContactFlowVersion "only supports creating versions for flows of type Campaign" (API reference). Flows update their content in place. Modules get awscc_connect_contact_flow_module_version and awscc_connect_contact_flow_module_alias, which is how a caller pins the module content it invokes. A version snapshots content at create time, so a module content change needs the version resource replaced for aliases to serve it.
The aliases a module gets are the ones flows in the same set invoke it through (${cdref:module:survey@prod} gives survey a prod alias). A module nothing in the set references gets its resource and a version, and no alias: the emitter cannot know what a root elsewhere will bind. To release such a module on its own, declare the aliases it publishes, moduleAliases: { survey: ["live"] } (flow-cli emit --module-alias module:survey@live, repeatable); the alias resource is then written exactly as for an invoked module, and another root binds module:survey@live through its address map. A module not in the set, or an alias that is not a slug, is refused. conformance/emit-tf/module-release is the case.
Tests
npm test runs the goldens in conformance/emit-tf and the unit tests. The tests that run a real tofu init, tofu validate, tofu fmt, and tofu console need the network and a few minutes on a cold provider cache, so they are gated behind RUN_TOFU_VALIDATE=1:
RUN_TOFU_VALIDATE=1 npx vitest run --project @flow-as-code/tf
CI runs them in the emit-tf job, and an ungated test asserts that the job still sets the variable. Regenerate goldens deliberately with UPDATE_GOLDENS=1 and read the diff.