Skip to content

Commit a0cfd7e

Browse files
committed
feat(job-service): replace hardcoded function registry with manifest loader
Wave 4b of the portable-functions toolkit. The hardcoded registry at job/service/src/index.ts:34-43 was the biggest portability blocker — adding a function required editing this map. Now the registry is loaded at runtime from one of three sources, in priority order: 1. FUNCTIONS_REGISTRY env var Format: "name:moduleName:port,..." (moduleName + port optional; missing moduleName falls back to @constructive-io/<name>-fn). 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. 3. Default: <cwd>/generated/functions-manifest.json (the file the toolkit's fn-generator produces). If no source resolves, the registry is empty; lookups still throw the legacy "Unknown function X" error to preserve existing behaviour. Implementation lives in job/service/src/registry.ts (new), exporting loadFunctionRegistry(env, cwd) for testability. job/service/src/index.ts imports the loader and replaces the const at the top of the file; the rest of the file is unchanged. types.ts: FunctionName widened from a literal union to `string` since names are dynamic. All existing call sites continue to compile. Tests: tests/integration/job-registry.test.ts covers the three sources, override behaviour, and the moduleName convention. All 6 cases pass. Existing unit (4 suites, 19 tests) and integration (runtime.test.ts, 3 tests) still pass.
1 parent 72bdce1 commit a0cfd7e

4 files changed

Lines changed: 182 additions & 16 deletions

File tree

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+
};

job/service/src/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
export type FunctionName = 'simple-email' | 'send-email-link';
1+
/**
2+
* Function names are dynamic — looked up at runtime from the registry loaded
3+
* from generated/functions-manifest.json (or the FUNCTIONS_MANIFEST_PATH /
4+
* FUNCTIONS_REGISTRY env vars). Kept as an alias for narrowing intent.
5+
*/
6+
export type FunctionName = string;
27

38
export type FunctionServiceConfig = {
49
name: FunctionName;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
5+
import { loadFunctionRegistry } from '../../job/service/src/registry';
6+
7+
describe('loadFunctionRegistry', () => {
8+
let tmpDir: string;
9+
10+
beforeEach(() => {
11+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fn-registry-'));
12+
});
13+
14+
afterEach(() => {
15+
fs.rmSync(tmpDir, { recursive: true, force: true });
16+
});
17+
18+
it('returns empty when no env vars and no manifest file exist', () => {
19+
const reg = loadFunctionRegistry({}, tmpDir);
20+
expect(reg).toEqual({});
21+
});
22+
23+
it('reads from generated/functions-manifest.json by default', () => {
24+
const dir = path.join(tmpDir, 'generated');
25+
fs.mkdirSync(dir, { recursive: true });
26+
fs.writeFileSync(
27+
path.join(dir, 'functions-manifest.json'),
28+
JSON.stringify({
29+
functions: [
30+
{ name: 'simple-email', dir: 'simple-email', port: 8081, type: 'node-graphql' },
31+
{ name: 'send-email-link', dir: 'send-email-link', port: 8082, type: 'node-graphql' },
32+
],
33+
})
34+
);
35+
const reg = loadFunctionRegistry({}, tmpDir);
36+
expect(reg['simple-email']).toEqual({
37+
moduleName: '@constructive-io/simple-email-fn',
38+
defaultPort: 8081,
39+
});
40+
expect(reg['send-email-link']).toEqual({
41+
moduleName: '@constructive-io/send-email-link-fn',
42+
defaultPort: 8082,
43+
});
44+
});
45+
46+
it('honours FUNCTIONS_MANIFEST_PATH override', () => {
47+
const manifestPath = path.join(tmpDir, 'custom-manifest.json');
48+
fs.writeFileSync(
49+
manifestPath,
50+
JSON.stringify({ functions: [{ name: 'foo', port: 9000 }] })
51+
);
52+
const reg = loadFunctionRegistry({ FUNCTIONS_MANIFEST_PATH: manifestPath }, tmpDir);
53+
expect(reg.foo).toEqual({ moduleName: '@constructive-io/foo-fn', defaultPort: 9000 });
54+
});
55+
56+
it('respects an explicit moduleName field in the manifest', () => {
57+
const manifestPath = path.join(tmpDir, 'm.json');
58+
fs.writeFileSync(
59+
manifestPath,
60+
JSON.stringify({
61+
functions: [{ name: 'foo', moduleName: '@my-org/foo-handler', port: 9000 }],
62+
})
63+
);
64+
const reg = loadFunctionRegistry({ FUNCTIONS_MANIFEST_PATH: manifestPath }, tmpDir);
65+
expect(reg.foo.moduleName).toBe('@my-org/foo-handler');
66+
});
67+
68+
it('parses FUNCTIONS_REGISTRY env var (priority over manifest)', () => {
69+
const manifestPath = path.join(tmpDir, 'm.json');
70+
fs.writeFileSync(manifestPath, JSON.stringify({ functions: [{ name: 'foo', port: 1 }] }));
71+
const reg = loadFunctionRegistry(
72+
{
73+
FUNCTIONS_REGISTRY: 'foo:@org/foo:8081,bar:@org/bar',
74+
FUNCTIONS_MANIFEST_PATH: manifestPath,
75+
},
76+
tmpDir
77+
);
78+
expect(reg.foo).toEqual({ moduleName: '@org/foo', defaultPort: 8081 });
79+
// bar has no port → 0
80+
expect(reg.bar).toEqual({ moduleName: '@org/bar', defaultPort: 0 });
81+
});
82+
83+
it('uses convention when FUNCTIONS_REGISTRY entry omits moduleName', () => {
84+
const reg = loadFunctionRegistry({ FUNCTIONS_REGISTRY: 'baz::8083' }, tmpDir);
85+
expect(reg.baz).toEqual({ moduleName: '@constructive-io/baz-fn', defaultPort: 8083 });
86+
});
87+
});

0 commit comments

Comments
 (0)