Skip to content

Commit 09cc142

Browse files
authored
Merge pull request #41 from constructive-io/feat/portable-functions
feat(packages): extract fn-types; make fn-runtime/fn-app publishable (Wave 1 of portable-functions toolkit)
2 parents 1dc38f5 + 9af70f6 commit 09cc142

67 files changed

Lines changed: 3757 additions & 31 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/docker.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,14 @@ jobs:
5959
- name: Setup pnpm
6060
uses: pnpm/action-setup@v6
6161

62+
# Pinned to v4: setup-node@v5 auto-detects pnpm via the
63+
# packageManager field in package.json and tries to cache the
64+
# store, which fails here because this workflow doesn't run
65+
# `pnpm install` on the runner — the store path doesn't exist
66+
# and v5 promotes the "missing path" warning to an error.
67+
# Sticking to v4 until v5's auto-cache can be opted out cleanly.
6268
- name: Setup Node.js
63-
uses: actions/setup-node@v5
69+
uses: actions/setup-node@v4
6470
with:
6571
node-version: '22'
6672

.github/workflows/publish.yaml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: Publish toolkit packages
2+
3+
on:
4+
push:
5+
tags:
6+
- 'fn-v*' # toolkit release tag, e.g. fn-v0.1.0
7+
workflow_dispatch:
8+
inputs:
9+
dry_run:
10+
description: 'Run pnpm publish --dry-run only'
11+
type: boolean
12+
default: true
13+
14+
concurrency:
15+
group: publish-${{ github.ref }}
16+
cancel-in-progress: false
17+
18+
jobs:
19+
publish:
20+
name: Publish @constructive-io/fn-* to npm
21+
runs-on: ubuntu-latest
22+
permissions:
23+
contents: read
24+
id-token: write # required for npm provenance
25+
steps:
26+
- name: Checkout
27+
uses: actions/checkout@v5
28+
29+
- name: Setup pnpm
30+
uses: pnpm/action-setup@v6
31+
32+
- name: Setup Node.js
33+
uses: actions/setup-node@v5
34+
with:
35+
node-version: '22'
36+
registry-url: 'https://registry.npmjs.org'
37+
cache: 'pnpm'
38+
39+
- name: Generate function packages
40+
run: node --experimental-strip-types scripts/generate.ts
41+
42+
- name: Install dependencies
43+
run: pnpm install --frozen-lockfile
44+
45+
- name: Build toolkit packages
46+
run: |
47+
pnpm --filter @constructive-io/fn-types build
48+
pnpm --filter @constructive-io/knative-job-fn build
49+
pnpm --filter @constructive-io/fn-runtime build
50+
pnpm --filter @constructive-io/fn-generator build
51+
pnpm --filter @constructive-io/fn-client build
52+
pnpm --filter @constructive-io/fn-cli build
53+
54+
- name: Verify generator snapshot
55+
run: pnpm --filter @constructive-io/fn-generator test
56+
57+
- name: Publish (dry run for workflow_dispatch when requested)
58+
if: github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'true'
59+
run: |
60+
for pkg in fn-types fn-app fn-runtime fn-generator fn-client fn-cli; do
61+
(cd "packages/$pkg" && pnpm publish --dry-run --no-git-checks --access public)
62+
done
63+
64+
- name: Publish to npm with provenance
65+
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run != 'true')
66+
env:
67+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
68+
NPM_CONFIG_PROVENANCE: 'true'
69+
run: |
70+
# Order matters: deps first, dependents last.
71+
for pkg in fn-types fn-app fn-runtime fn-generator fn-client fn-cli; do
72+
(cd "packages/$pkg" && pnpm publish --no-git-checks --access public)
73+
done

.github/workflows/test.yaml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,52 @@ jobs:
4040
- run: pnpm install
4141
- run: pnpm build
4242
- run: pnpm test:integration
43+
44+
toolkit:
45+
name: Toolkit package tests
46+
runs-on: ubuntu-latest
47+
steps:
48+
- uses: actions/checkout@v5
49+
- uses: pnpm/action-setup@v6
50+
- uses: actions/setup-node@v5
51+
with:
52+
node-version: '22'
53+
cache: 'pnpm'
54+
- run: node --experimental-strip-types scripts/generate.ts
55+
- run: pnpm install
56+
- name: Build toolkit packages
57+
run: |
58+
pnpm --filter @constructive-io/fn-types build
59+
pnpm --filter @constructive-io/knative-job-fn build
60+
pnpm --filter @constructive-io/fn-runtime build
61+
pnpm --filter @constructive-io/fn-generator build
62+
pnpm --filter @constructive-io/fn-client build
63+
pnpm --filter @constructive-io/fn-cli build
64+
- name: Run toolkit unit tests
65+
run: |
66+
pnpm --filter @constructive-io/fn-generator test
67+
pnpm --filter @constructive-io/fn-client test
68+
pnpm --filter @constructive-io/fn-cli test
69+
70+
fn-init-e2e:
71+
name: fn init end-to-end
72+
runs-on: ubuntu-latest
73+
steps:
74+
- uses: actions/checkout@v5
75+
- uses: pnpm/action-setup@v6
76+
- uses: actions/setup-node@v5
77+
with:
78+
node-version: '22'
79+
cache: 'pnpm'
80+
- run: node --experimental-strip-types scripts/generate.ts
81+
- run: pnpm install
82+
- name: Build fn-cli (transitive)
83+
run: |
84+
pnpm --filter @constructive-io/fn-types build
85+
pnpm --filter @constructive-io/knative-job-fn build
86+
pnpm --filter @constructive-io/fn-runtime build
87+
pnpm --filter @constructive-io/fn-generator build
88+
pnpm --filter @constructive-io/fn-client build
89+
pnpm --filter @constructive-io/fn-cli build
90+
- name: Binary integration test
91+
run: pnpm exec jest tests/integration/fn-init.test.ts

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,22 @@
22

33
Functions playground for Constructive — isolated workspace for building, testing, and deploying Knative-style HTTP functions.
44

5-
## Quick Start
5+
This repo is also the source of the **Portable Functions Toolkit**: a set of `@constructive-io/fn-*` npm packages that any external repo can `pnpm add` to get the same code-gen + Docker + k8s pipeline against its own `functions/` directory. See [docs/portable-functions-toolkit.md](docs/portable-functions-toolkit.md) for the full toolkit guide.
6+
7+
## Quick start (in another repo)
8+
9+
```bash
10+
pnpm add -D @constructive-io/fn-cli
11+
pnpm add @constructive-io/fn-runtime
12+
13+
pnpm fn init send-welcome --no-tty # scaffold functions/send-welcome/
14+
pnpm fn generate # stamp out generated/<name>/ packages
15+
pnpm install # link the new workspaces
16+
pnpm fn build # compile
17+
pnpm fn dev # run functions as local Node processes
18+
```
19+
20+
## Quick start (this repo, dogfood)
621

722
```bash
823
# Install dependencies

docs/portable-functions-toolkit.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Portable Functions Toolkit
2+
3+
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.
4+
5+
## Package layout (Starship V2 style)
6+
7+
```
8+
fn-cli ──► fn-client ──► fn-generator ──► fn-types
9+
└────────────────────► fn-types
10+
└────────► fn-types
11+
fn-runtime ──► fn-types (handlers import this + fn-types)
12+
knative-job-fn (fn-app) (low-level Express middleware)
13+
```
14+
15+
| Package | Single responsibility |
16+
|---|---|
17+
| `@constructive-io/fn-types` | Source-of-truth TS types: `FunctionHandler`, `FunctionContext`, `HandlerManifest`, `FnRegistry`, `FnConfig` + `defineConfig()`. No logic. |
18+
| `@constructive-io/fn-runtime` | Express server factory + GraphQL clients + log + job-callback wiring. The contract handlers import. |
19+
| `@constructive-io/knative-job-fn` | Low-level Express middleware for Knative job request/response shape. fn-runtime depends on it. |
20+
| `@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. |
21+
| `@constructive-io/fn-client` | Importable `FnClient` API — config loading, manifest reading, `pnpm build`, child-process orchestration for `dev`. |
22+
| `@constructive-io/fn-cli` | The `fn` executable. Subcommands: `init`, `generate`, `build`, `dev`, `manifest`, `verify`. |
23+
24+
## Quick start
25+
26+
```bash
27+
# In a fresh project
28+
pnpm add -D @constructive-io/fn-cli
29+
pnpm add @constructive-io/fn-runtime
30+
31+
# Scaffold a function
32+
pnpm fn init send-welcome --no-tty --description "Welcome email sender"
33+
# → functions/send-welcome/{handler.json, handler.ts}
34+
35+
# Stamp out the workspace package, build, run
36+
pnpm fn generate
37+
pnpm install # link the just-created generated/* workspaces
38+
pnpm fn build
39+
pnpm fn dev # functions run as local Node processes
40+
```
41+
42+
`fn init` uses [`genomic`](https://www.npmjs.com/package/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`.
43+
44+
## Repo layout the toolkit expects
45+
46+
```
47+
my-app/
48+
├── functions/
49+
│ └── send-welcome/
50+
│ ├── handler.json # {"name":"send-welcome","version":"0.1.0","type":"node-graphql"}
51+
│ └── handler.ts # default-exported FunctionHandler
52+
├── fn.config.json # FnConfig (typed via fn-types) — optional
53+
└── package.json
54+
```
55+
56+
## CLI surface
57+
58+
```bash
59+
fn init <name> [--type=node-graphql|python] [--description=<d>] [--force] [--no-tty]
60+
fn generate [--only=<name>] [--packages-only]
61+
fn build [--only=<name>]
62+
fn dev [--only=<name>]
63+
fn manifest # print on-disk functions-manifest.json
64+
fn verify # check manifest matches functions/
65+
fn --version # print fn-cli version
66+
```
67+
68+
Common flags: `--root=<dir>`, `--config=<file>`.
69+
70+
## Job-service registry (when running the `jobs-bundle` preset)
71+
72+
The job-service no longer hardcodes function names. It loads its registry at startup from one of three sources, in priority order:
73+
74+
1. `FUNCTIONS_REGISTRY` env var
75+
Format: `name:moduleName:port,...``moduleName` and `port` are optional (missing `moduleName` falls back to `@constructive-io/<name>-fn`).
76+
77+
2. `FUNCTIONS_MANIFEST_PATH` env var pointing to a JSON file with the existing `functions-manifest.json` shape. Manifest entries can carry an optional `moduleName` field; otherwise convention applies.
78+
79+
3. Default file: `<cwd>/generated/functions-manifest.json` — what `fn generate` produces.
80+
81+
Empty registry is allowed; lookups still throw `Unknown function "<name>"` to preserve the legacy behaviour.
82+
83+
## Releasing the toolkit (Wave 5)
84+
85+
The CI workflow at `.github/workflows/publish.yaml` publishes all six packages with [npm provenance](https://docs.npmjs.com/generating-provenance-statements) when a `fn-v*` tag is pushed. Steps:
86+
87+
1. Update versions in each `packages/fn-*/package.json` (and `packages/fn-app/package.json`). Bump in lock-step for now; we'll move to changesets later.
88+
2. Verify locally:
89+
```bash
90+
pnpm --filter '@constructive-io/fn-*' build
91+
pnpm --filter @constructive-io/fn-generator test
92+
pnpm --filter @constructive-io/fn-client test
93+
for pkg in fn-types fn-app fn-runtime fn-generator fn-client fn-cli; do
94+
(cd "packages/$pkg" && pnpm publish --dry-run --no-git-checks --access public)
95+
done
96+
```
97+
3. Tag and push:
98+
```bash
99+
git tag fn-v0.1.0
100+
git push origin fn-v0.1.0
101+
```
102+
4. CI publishes in dependency order: `fn-types``fn-app``fn-runtime``fn-generator``fn-client``fn-cli`.
103+
104+
You can also run the workflow with `workflow_dispatch` (default `dry_run: true`) to verify packing before tagging.
105+
106+
## Verification checklist (manual, before first release)
107+
108+
- [ ] **Snapshot regression**: `pnpm --filter @constructive-io/fn-generator test` passes (asserts byte-identical output vs `scripts/generate.ts`).
109+
- [ ] **Job-registry tests**: `pnpm exec jest tests/integration/job-registry.test.ts` — six cases pass.
110+
- [ ] **Brasilia E2E**: with the live k8s stack running (`make skaffold-dev`), `pnpm test:e2e` still picks up jobs end-to-end.
111+
- [ ] **Scratch repo**: in a fresh `/tmp/test-fn-app` repo, `pnpm add -D @constructive-io/fn-cli && pnpm add @constructive-io/fn-runtime`, add `functions/hello/handler.{json,ts}`, run `fn generate && fn build && fn manifest`. Confirm output is sensible and `docker build -f generated/hello/Dockerfile .` succeeds.
112+
- [ ] **Hub integration**: in `constructive-hub/istanbul`, `pnpm bootstrap && pnpm start` still launches `send-email-link` and processes a job (the hub does not yet consume the new toolkit; this confirms Wave 1-3 didn't regress the existing submodule path).
113+
114+
## Deferred follow-ups (not in this branch)
115+
116+
- **Wave 4c — replace hand-written `k8s/base/functions/*.yaml` with 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 teaching `KnativeServiceBuilder` to emit those fields or providing a Kustomize patch overlay. Tracked separately.
117+
- **fn.config.ts/.js loading** — JSON only for now. Adding `.ts` requires an `esbuild`/`jiti` loader.
118+
- **`fn init` and `fn dockerfile` / `fn k8s` standalone subcommands** — the underlying builders exist (`buildPackages`, `buildSkaffold`); these are thin CLI wrappers to add later.
119+
- **Templates packaging** — currently `templatesDir` is a constructor option pointing at the host repo's `templates/`. A future change can ship templates inside `fn-generator` (or a separate `fn-templates` package) so customer repos don't need their own copy.

job/service/src/index.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,12 @@ import {
2525
KnativeJobsSvcResult,
2626
StartedFunction
2727
} from './types';
28+
import {
29+
FunctionRegistryEntry,
30+
loadFunctionRegistry
31+
} from './registry';
2832

29-
type FunctionRegistryEntry = {
30-
moduleName: string;
31-
defaultPort: number;
32-
};
33-
34-
const functionRegistry: Record<FunctionName, FunctionRegistryEntry> = {
35-
'simple-email': {
36-
moduleName: '@constructive-io/simple-email-fn',
37-
defaultPort: 8081
38-
},
39-
'send-email-link': {
40-
moduleName: '@constructive-io/send-email-link-fn',
41-
defaultPort: 8082
42-
}
43-
};
33+
const functionRegistry = loadFunctionRegistry();
4434

4535
const log = new Logger('knative-job-service');
4636
const requireFn = createRequire(__filename);

job/service/src/registry.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* Function registry loader for the in-process function server.
3+
*
4+
* Sources, in priority order:
5+
* 1. FUNCTIONS_REGISTRY env var
6+
* Format: "name:moduleName:port,..." (port optional)
7+
* Example: "simple-email:@org/simple-email-fn:8081,foo:@org/foo-fn"
8+
* 2. FUNCTIONS_MANIFEST_PATH env var pointing to a JSON file with shape
9+
* { functions: [{ name, dir, port, type, moduleName? }] }
10+
* 3. Default file: <cwd>/generated/functions-manifest.json
11+
*
12+
* If no source resolves, the registry is empty; callers throw on lookup of
13+
* an unknown function (preserves the legacy "Unknown function X" behaviour).
14+
*/
15+
import * as fs from 'fs';
16+
import * as path from 'path';
17+
18+
export interface FunctionRegistryEntry {
19+
moduleName: string;
20+
defaultPort: number;
21+
}
22+
23+
export type FunctionRegistry = Record<string, FunctionRegistryEntry>;
24+
25+
const DEFAULT_MODULE_PREFIX = '@constructive-io/';
26+
const DEFAULT_MODULE_SUFFIX = '-fn';
27+
28+
const conventionalModuleName = (name: string): string =>
29+
`${DEFAULT_MODULE_PREFIX}${name}${DEFAULT_MODULE_SUFFIX}`;
30+
31+
const parseEnvRegistry = (raw: string): FunctionRegistry => {
32+
const out: FunctionRegistry = {};
33+
for (const pair of raw.split(',')) {
34+
const trimmed = pair.trim();
35+
if (!trimmed) continue;
36+
const [name, moduleName, portStr] = trimmed.split(':').map((s) => s.trim());
37+
if (!name) continue;
38+
const portNumber = portStr ? Number(portStr) : NaN;
39+
out[name] = {
40+
moduleName: moduleName || conventionalModuleName(name),
41+
defaultPort: Number.isFinite(portNumber) ? portNumber : 0,
42+
};
43+
}
44+
return out;
45+
};
46+
47+
interface ManifestEntry {
48+
name: string;
49+
dir?: string;
50+
port?: number;
51+
type?: string;
52+
moduleName?: string;
53+
}
54+
55+
const fromManifestEntry = (entry: ManifestEntry): FunctionRegistryEntry => ({
56+
moduleName: entry.moduleName ?? conventionalModuleName(entry.name),
57+
defaultPort: typeof entry.port === 'number' ? entry.port : 0,
58+
});
59+
60+
const loadManifestFile = (manifestPath: string): FunctionRegistry => {
61+
const raw = fs.readFileSync(manifestPath, 'utf-8');
62+
const parsed = JSON.parse(raw) as { functions?: ManifestEntry[] };
63+
const out: FunctionRegistry = {};
64+
for (const entry of parsed.functions ?? []) {
65+
if (!entry.name) continue;
66+
out[entry.name] = fromManifestEntry(entry);
67+
}
68+
return out;
69+
};
70+
71+
export const loadFunctionRegistry = (
72+
env: NodeJS.ProcessEnv = process.env,
73+
cwd: string = process.cwd()
74+
): FunctionRegistry => {
75+
if (env.FUNCTIONS_REGISTRY) {
76+
return parseEnvRegistry(env.FUNCTIONS_REGISTRY);
77+
}
78+
const manifestPath =
79+
env.FUNCTIONS_MANIFEST_PATH ?? path.join(cwd, 'generated', 'functions-manifest.json');
80+
if (fs.existsSync(manifestPath)) {
81+
return loadManifestFile(manifestPath);
82+
}
83+
return {};
84+
};

0 commit comments

Comments
 (0)