The Constructive Functions toolkit lets any external repo pnpm add a small set of npm packages, drop in its own functions/ directory, and get a full code-gen + Docker + k8s + manifest-registry pipeline — without git submodules or copy-paste.
fn-cli ──► fn-client ──► fn-generator ──► fn-types
└────────────────────► fn-types
└────────► fn-types
fn-runtime ──► fn-types (handlers import this + fn-types)
knative-job-fn (fn-app) (low-level Express middleware)
| Package | Single responsibility |
|---|---|
@constructive-io/fn-types |
Source-of-truth TS types: FunctionHandler, FunctionContext, HandlerManifest, FnRegistry, FnConfig + defineConfig(). No logic. |
@constructive-io/fn-runtime |
Express server factory + GraphQL clients + log + job-callback wiring. The contract handlers import. |
@constructive-io/knative-job-fn |
Low-level Express middleware for Knative job request/response shape. fn-runtime depends on it. |
@constructive-io/fn-generator |
Programmatic builders that emit Dockerfiles, k8s YAML, configmaps, skaffold profiles, manifest registry. Pure functions; idempotent file I/O at the boundary. |
@constructive-io/fn-client |
Importable FnClient API — config loading, manifest reading, pnpm build, child-process orchestration for dev. |
@constructive-io/fn-cli |
The fn executable. Subcommands: init, generate, build, dev, manifest, verify. |
# In a fresh project
pnpm add -D @constructive-io/fn-cli
pnpm add @constructive-io/fn-runtime
# Scaffold a function
pnpm fn init send-welcome --no-tty --description "Welcome email sender"
# → functions/send-welcome/{handler.json, handler.ts}
# Stamp out the workspace package, build, run
pnpm fn generate
pnpm install # link the just-created generated/* workspaces
pnpm fn build
pnpm fn dev # functions run as local Node processesfn init uses genomic under the hood — the same template engine pgpm init uses — so the prompt conventions and --no-tty flag-mapping match the rest of the Constructive ecosystem. Two handler types ship today: --type=node-graphql (default) and --type=python.
my-app/
├── functions/
│ └── send-welcome/
│ ├── handler.json # {"name":"send-welcome","version":"0.1.0","type":"node-graphql"}
│ └── handler.ts # default-exported FunctionHandler
├── fn.config.json # FnConfig (typed via fn-types) — optional
└── package.json
fn init <name> [--type=node-graphql|python] [--description=<d>] [--force] [--no-tty]
fn generate [--only=<name>] [--packages-only]
fn build [--only=<name>]
fn dev [--only=<name>]
fn manifest # print on-disk functions-manifest.json
fn verify # check manifest matches functions/
fn --version # print fn-cli versionCommon flags: --root=<dir>, --config=<file>.
The job-service no longer hardcodes function names. It loads its registry at startup from one of three sources, in priority order:
-
FUNCTIONS_REGISTRYenv var
Format:name:moduleName:port,...—moduleNameandportare optional (missingmoduleNamefalls back to@constructive-io/<name>-fn). -
FUNCTIONS_MANIFEST_PATHenv var pointing to a JSON file with the existingfunctions-manifest.jsonshape. Manifest entries can carry an optionalmoduleNamefield; otherwise convention applies. -
Default file:
<cwd>/generated/functions-manifest.json— whatfn generateproduces.
Empty registry is allowed; lookups still throw Unknown function "<name>" to preserve the legacy behaviour.
The CI workflow at .github/workflows/publish.yaml publishes all six packages with npm provenance when a fn-v* tag is pushed. Steps:
- Update versions in each
packages/fn-*/package.json(andpackages/fn-app/package.json). Bump in lock-step for now; we'll move to changesets later. - Verify locally:
pnpm --filter '@constructive-io/fn-*' build pnpm --filter @constructive-io/fn-generator test pnpm --filter @constructive-io/fn-client test for pkg in fn-types fn-app fn-runtime fn-generator fn-client fn-cli; do (cd "packages/$pkg" && pnpm publish --dry-run --no-git-checks --access public) done
- Tag and push:
git tag fn-v0.1.0 git push origin fn-v0.1.0
- CI publishes in dependency order:
fn-types→fn-app→fn-runtime→fn-generator→fn-client→fn-cli.
You can also run the workflow with workflow_dispatch (default dry_run: true) to verify packing before tagging.
- Snapshot regression:
pnpm --filter @constructive-io/fn-generator testpasses (asserts byte-identical output vsscripts/generate.ts). - Job-registry tests:
pnpm exec jest tests/integration/job-registry.test.ts— six cases pass. - Brasilia E2E: with the live k8s stack running (
make skaffold-dev),pnpm test:e2estill picks up jobs end-to-end. - Scratch repo: in a fresh
/tmp/test-fn-apprepo,pnpm add -D @constructive-io/fn-cli && pnpm add @constructive-io/fn-runtime, addfunctions/hello/handler.{json,ts}, runfn generate && fn build && fn manifest. Confirm output is sensible anddocker build -f generated/hello/Dockerfile .succeeds. - Hub integration: in
constructive-hub/istanbul,pnpm bootstrap && pnpm startstill launchessend-verification-linkand processes a job (the hub does not yet consume the new toolkit; this confirms Wave 1-3 didn't regress the existing submodule path).
- Wave 4c — replace hand-written
k8s/base/functions/*.yamlwith generator output. The hand-written manifests carry mailgun secrets, dry-run env vars, and a different image strategy (single bundled image, args-driven entry vs per-function image with Dockerfile CMD). Migrating safely requires either teachingKnativeServiceBuilderto emit those fields or providing a Kustomize patch overlay. Tracked separately. - fn.config.ts/.js loading — JSON only for now. Adding
.tsrequires anesbuild/jitiloader. fn initandfn dockerfile/fn k8sstandalone subcommands — the underlying builders exist (buildPackages,buildSkaffold); these are thin CLI wrappers to add later.- Templates packaging — currently
templatesDiris a constructor option pointing at the host repo'stemplates/. A future change can ship templates insidefn-generator(or a separatefn-templatespackage) so customer repos don't need their own copy.