| name | adding-functions |
|---|---|
| description | Step-by-step guide for adding a new serverless function to the constructive-functions project |
- Node.js 22+, pnpm 10+
- Understanding of the
FunctionHandlertype from@constructive-io/fn-runtime
Reference implementations: See functions/send-email/ (env vars, external packages, dry-run mode) and functions/send-verification-link/ (GraphQL queries, context usage) as working examples.
Create functions/<name>/handler.json:
{
"name": "<name>",
"version": "1.0.0",
"type": "node-graphql",
"port": <next-available-port>,
"description": "What this function does",
"dependencies": {
"some-package": "^1.0.0"
}
}| Field | Required | Description |
|---|---|---|
name |
Yes | Function identifier (used in job queue, k8s service names, Docker images) |
version |
Yes | Semver version |
type |
No | Template type, defaults to node-graphql |
port |
No | Local dev port (auto-assigned from 8081+ if omitted) |
description |
No | Human-readable description |
dependencies |
No | NPM dependencies merged into the generated package.json |
Naming convention: The name field is the canonical identifier — it's used for job queue task names, k8s service/deployment names, and the generated package name (@constructive-io/<name>-fn). The directory name under functions/ is just for local organization. They don't have to match (e.g., functions/example/ has "name": "knative-job-example"), but keeping them consistent avoids confusion.
Port convention: Check existing functions/*/handler.json files for used ports. Pick the next available (8081, 8082, 8083, ...). Port 8080 is reserved for job-service.
Create functions/<name>/handler.ts:
import type { FunctionHandler } from '@constructive-io/fn-runtime';
interface MyPayload {
// Define your expected job payload
}
const handler: FunctionHandler<MyPayload> = async (params, context) => {
// context provides: { client, meta, job, log, env }
// client — GraphQL client for the database's API
// meta — GraphQL client for metadata API
// job — { jobId, workerId, databaseId, actorId }
// log — structured logger (info, error, warn, debug)
// env — process.env
// Your implementation here
return { complete: true };
};
export default handler;Key patterns:
- Return
{ complete: true }on success — the job service marks the job done - Throw an error on failure — the job service retries with backoff
- Return an error object like
{ missing: 'field' }for validation failures that should not retry
If your function imports modules that need TypeScript type stubs, add a types.d.ts in the function directory:
declare module '@some-untyped-package';pnpm generateThis produces everything in generated/<name>/:
package.json— workspace package with merged dependenciesindex.ts— Express wrapper around your handlertsconfig.json+tsconfig.esm.json— TypeScript configDockerfile— multi-stage production buildk8s/local-deployment.yaml— K8s Deployment + Service for local devk8s/knative-service.yaml— Knative Service for productionk8s/skaffold-overlay/— per-function kustomize overlay for SkaffoldREADME.mdhandler.ts— symlink to your source
It also updates:
skaffold.yaml— adds a per-function profile and updates aggregate profilesk8s/overlays/local-simple/job-service.yaml— adds function to JOBS_SUPPORTED and gateway mapgenerated/functions-manifest.json— function registry used by dev.ts
pnpm install # picks up the new workspace package
pnpm build # builds all packages including the new functionCreate functions/<name>/__tests__/handler.test.ts:
import { createMockContext } from '../../../tests/helpers/mock-context';
const loadHandler = () => {
const mod = require('../handler');
return mod.default ?? mod;
};
describe('<name> handler', () => {
beforeEach(() => {
jest.resetModules();
});
it('should process valid payload', async () => {
const handler = loadHandler();
const result = await handler({ /* test payload */ }, createMockContext());
expect(result).toEqual({ complete: true });
});
it('should reject invalid payload', async () => {
const handler = loadHandler();
await expect(
handler({}, createMockContext())
).rejects.toThrow();
});
});Why require() + resetModules(): Handlers often read env vars at module scope (e.g., parseEnvBoolean(process.env.SOME_FLAG)). Using require() with jest.resetModules() ensures each test gets a fresh module evaluation, so env var changes in beforeEach take effect.
Use tests/helpers/mock-context.ts to create test contexts. If your function uses external packages, add mocks in tests/__mocks__/ and register them in jest.config.ts under moduleNameMapper.
Run: pnpm test:unit
Create tests/e2e/__tests__/<name>.e2e.test.ts:
import {
getTestConnections,
closeConnections,
getDatabaseId,
TestClient,
} from '../utils/db';
import { addJob, waitForJobComplete, deleteTestJobs } from '../utils/jobs';
const TEST_PREFIX = 'k8s-e2e-<name>';
describe('E2E: <name>', () => {
let pg: TestClient;
let databaseId: string;
beforeAll(async () => {
const connections = await getTestConnections();
pg = connections.pg;
databaseId = await getDatabaseId(pg);
});
afterAll(async () => {
if (pg) await deleteTestJobs(pg, TEST_PREFIX);
await closeConnections();
});
it('should process a <name> job from the queue', async () => {
const job = await addJob(pg, databaseId, '<name>', {
// Your test payload
});
expect(job.id).toBeDefined();
const result = await waitForJobComplete(pg, job.id, { timeout: 30000 });
expect(['completed', 'failed']).toContain(result.status);
});
});Important: The e2e test filename must match the function name (<name>.e2e.test.ts) for the CI matrix to pick it up automatically.
make dev # start postgres, mailpit, graphql-server
pnpm dev:fn --only=<name> # run just your functionmake skaffold-dev-<name> # deploys infra + just your functionThe following CI workflows auto-discover functions — no manual edits needed:
- docker.yaml — discovers
functions/*/handler.json, builds Docker image per function - test-k8s-deployment.yaml — discovers functions with matching
*.e2e.test.ts, runs per-function k8s e2e tests - test.yaml — runs
pnpm test:unitwhich picks up your__tests__/directory - ci.yaml — runs
pnpm buildwhich builds your generated package
-
functions/<name>/handler.jsoncreated with name, version, port -
functions/<name>/handler.tscreated withFunctionHandlerexport -
job/service/src/types.ts— function name added toFunctionNameunion -
job/service/src/index.ts— entry added tofunctionRegistry -
pnpm generateran successfully -
pnpm install && pnpm buildsucceeds - Unit tests in
functions/<name>/__tests__/handler.test.ts - E2e test in
tests/e2e/__tests__/<name>.e2e.test.ts -
pnpm test:unitpasses - Local dev works (
make dev && pnpm dev:fn --only=<name>) - No manual edits needed to skaffold.yaml, dev.ts, or job-service k8s config (all auto-generated)