Skip to content

Commit 1baffe2

Browse files
committed
cnc jobs up and combined server
1 parent e4df3ec commit 1baffe2

16 files changed

Lines changed: 749 additions & 38 deletions

File tree

jobs/knative-job-fn/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,6 @@ export default {
263263

264264
res.status(200).json({ message: error.message });
265265
});
266-
app.listen(port, cb);
266+
return app.listen(port, cb);
267267
}
268268
};

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@constructive-io/graphql-env": "workspace:^",
5151
"@constructive-io/graphql-explorer": "workspace:^",
5252
"@constructive-io/graphql-server": "workspace:^",
53+
"@constructive-io/server": "workspace:^",
5354
"@inquirerer/utils": "^3.1.2",
5455
"@pgpmjs/core": "workspace:^",
5556
"@pgpmjs/logger": "workspace:^",

packages/cli/src/commands.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ParsedArgs } from 'minimist';
66
import codegen from './commands/codegen';
77
import explorer from './commands/explorer';
88
import getGraphqlSchema from './commands/get-graphql-schema';
9+
import jobs from './commands/jobs';
910
import server from './commands/server';
1011
import { usageText } from './utils';
1112

@@ -14,7 +15,8 @@ const createCommandMap = (): Record<string, Function> => {
1415
server,
1516
explorer,
1617
'get-graphql-schema': getGraphqlSchema,
17-
codegen
18+
codegen,
19+
jobs
1820
};
1921
};
2022

packages/cli/src/commands/jobs.ts

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import { existsSync } from 'fs';
2+
import { resolve } from 'path';
3+
import {
4+
CombinedServer,
5+
CombinedServerOptions,
6+
FunctionName,
7+
FunctionServiceConfig
8+
} from '@constructive-io/server';
9+
import { cliExitWithError, extractFirst } from '@inquirerer/utils';
10+
import { CLIOptions, Inquirerer, Question } from 'inquirerer';
11+
12+
const jobsUsageText = `
13+
Constructive Jobs:
14+
15+
cnc jobs <subcommand> [OPTIONS]
16+
17+
Start or manage Constructive jobs services.
18+
19+
Subcommands:
20+
up Start combined server (jobs runtime)
21+
22+
Options:
23+
--help, -h Show this help message
24+
--cwd <directory> Working directory (default: current directory)
25+
--with-graphql-server Enable GraphQL server (default: disabled; flag-only)
26+
--with-jobs-svc Enable jobs service (default: disabled; flag-only)
27+
--functions <list> Comma-separated functions, optionally with ports (e.g. "fn=8080")
28+
29+
Examples:
30+
cnc jobs up
31+
cnc jobs up --cwd /path/to/constructive
32+
cnc jobs up --with-graphql-server --functions simple-email,send-email-link=8082
33+
`;
34+
35+
const questions: Question[] = [
36+
{
37+
name: 'withGraphqlServer',
38+
alias: 'with-graphql-server',
39+
message: 'Enable GraphQL server?',
40+
type: 'confirm',
41+
required: false,
42+
default: false,
43+
useDefault: true
44+
},
45+
{
46+
name: 'withJobsSvc',
47+
alias: 'with-jobs-svc',
48+
message: 'Enable jobs service?',
49+
type: 'confirm',
50+
required: false,
51+
default: false,
52+
useDefault: true
53+
}
54+
];
55+
56+
const ensureCwd = (cwd: string): string => {
57+
const resolved = resolve(cwd);
58+
if (!existsSync(resolved)) {
59+
throw new Error(`Working directory does not exist: ${resolved}`);
60+
}
61+
process.chdir(resolved);
62+
return resolved;
63+
};
64+
65+
type ParsedFunctionsArg = {
66+
mode: 'all' | 'list';
67+
names: string[];
68+
ports: Record<string, number>;
69+
};
70+
71+
const parseFunctionsArg = (value: unknown): ParsedFunctionsArg | undefined => {
72+
if (value === undefined) return undefined;
73+
74+
const values = Array.isArray(value) ? value : [value];
75+
76+
const tokens: string[] = [];
77+
for (const value of values) {
78+
if (value === true) {
79+
tokens.push('all');
80+
continue;
81+
}
82+
if (value === false || value === undefined || value === null) continue;
83+
const raw = String(value);
84+
raw
85+
.split(',')
86+
.map((part) => part.trim())
87+
.filter(Boolean)
88+
.forEach((part) => tokens.push(part));
89+
}
90+
91+
if (!tokens.length) {
92+
return { mode: 'list', names: [], ports: {} };
93+
}
94+
95+
const hasAll = tokens.some((token) => {
96+
const normalized = token.trim().toLowerCase();
97+
return normalized === 'all' || normalized === '*';
98+
});
99+
100+
if (hasAll) {
101+
if (tokens.length > 1) {
102+
throw new Error('Use "all" without other function names.');
103+
}
104+
return { mode: 'all', names: [], ports: {} };
105+
}
106+
107+
const names: string[] = [];
108+
const ports: Record<string, number> = {};
109+
110+
for (const token of tokens) {
111+
const trimmed = token.trim();
112+
if (!trimmed) continue;
113+
114+
const separatorIndex = trimmed.search(/[:=]/);
115+
if (separatorIndex === -1) {
116+
names.push(trimmed);
117+
continue;
118+
}
119+
120+
const name = trimmed.slice(0, separatorIndex).trim();
121+
const portText = trimmed.slice(separatorIndex + 1).trim();
122+
123+
if (!name) {
124+
throw new Error(`Missing function name in "${token}".`);
125+
}
126+
if (!portText) {
127+
throw new Error(`Missing port for function "${name}".`);
128+
}
129+
130+
const port = Number(portText);
131+
if (!Number.isFinite(port) || port <= 0) {
132+
throw new Error(`Invalid port "${portText}" for function "${name}".`);
133+
}
134+
135+
names.push(name);
136+
ports[name] = port;
137+
}
138+
139+
const uniqueNames: string[] = [];
140+
const seen = new Set<string>();
141+
for (const name of names) {
142+
if (seen.has(name)) continue;
143+
seen.add(name);
144+
uniqueNames.push(name);
145+
}
146+
147+
return { mode: 'list', names: uniqueNames, ports };
148+
};
149+
150+
const buildCombinedServerOptions = (
151+
args: Partial<Record<string, any>>
152+
): CombinedServerOptions => {
153+
const parsedFunctions = parseFunctionsArg(args.functions);
154+
155+
let functions: CombinedServerOptions['functions'];
156+
if (parsedFunctions) {
157+
if (parsedFunctions.mode === 'all') {
158+
functions = { enabled: true };
159+
} else if (parsedFunctions.names.length) {
160+
const services: FunctionServiceConfig[] = parsedFunctions.names.map(
161+
(name) => ({
162+
name: name as FunctionName,
163+
port: parsedFunctions.ports[name]
164+
})
165+
);
166+
functions = { enabled: true, services };
167+
} else {
168+
functions = undefined;
169+
}
170+
}
171+
172+
return {
173+
graphql: { enabled: args.withGraphqlServer === true },
174+
jobs: { enabled: args.withJobsSvc === true },
175+
functions
176+
};
177+
};
178+
179+
export default async (
180+
argv: Partial<Record<string, any>>,
181+
prompter: Inquirerer,
182+
_options: CLIOptions
183+
) => {
184+
if (argv.help || argv.h) {
185+
console.log(jobsUsageText);
186+
process.exit(0);
187+
}
188+
189+
const { first: subcommand, newArgv } = extractFirst(argv);
190+
const args = newArgv as Partial<Record<string, any>>;
191+
192+
if (!subcommand) {
193+
console.log(jobsUsageText);
194+
await cliExitWithError('No subcommand provided. Use "up".');
195+
return;
196+
}
197+
198+
switch (subcommand) {
199+
case 'up': {
200+
try {
201+
ensureCwd((args.cwd as string) || process.cwd());
202+
const promptAnswers = await prompter.prompt(args, questions);
203+
await CombinedServer(buildCombinedServerOptions(promptAnswers));
204+
} catch (error) {
205+
await cliExitWithError(
206+
`Failed to start combined server: ${(error as Error).message}`
207+
);
208+
}
209+
break;
210+
}
211+
212+
default:
213+
console.log(jobsUsageText);
214+
await cliExitWithError(`Unknown subcommand: ${subcommand}. Use "up".`);
215+
}
216+
};

packages/cli/src/utils/display.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ export const usageText = `
1212
codegen Generate TypeScript types and SDK from GraphQL schema
1313
get-graphql-schema Fetch or build GraphQL schema SDL
1414
15+
Jobs:
16+
jobs up Start combined server (jobs runtime)
17+
1518
Global Options:
1619
-h, --help Display this help information
1720
-v, --version Display version information
@@ -27,6 +30,7 @@ export const usageText = `
2730
cnc explorer Launch GraphiQL explorer
2831
cnc codegen --schema schema.graphql Generate types from schema
2932
cnc get-graphql-schema --out schema.graphql Export schema SDL
33+
cnc jobs up Start combined server (jobs runtime)
3034
3135
Database Operations:
3236
For database migrations, packages, and deployment, use pgpm:

packages/server/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Changelog
2+
3+
## 0.1.0
4+
5+
- Initial combined server for GraphQL, jobs, and functions.

packages/server/README.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# @constructive-io/server
2+
3+
<p align="center" width="100%">
4+
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5+
</p>
6+
7+
<p align="center" width="100%">
8+
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
9+
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
10+
</a>
11+
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12+
</p>
13+
14+
**Constructive Combined Server** starts GraphQL, jobs runtime, and Knative-style functions from a single entrypoint.
15+
16+
## Quick Start
17+
18+
### Use as SDK
19+
20+
```ts
21+
import { CombinedServer } from '@constructive-io/server';
22+
23+
await CombinedServer({
24+
graphql: { enabled: true },
25+
functions: {
26+
enabled: true,
27+
services: [
28+
{ name: 'simple-email', port: 8081 },
29+
{ name: 'send-email-link', port: 8082 }
30+
]
31+
},
32+
jobs: { enabled: true }
33+
});
34+
```
35+
36+
### Local Development (this repo)
37+
38+
```bash
39+
pnpm install
40+
cd packages/server
41+
pnpm dev
42+
```
43+
44+
## Environment Configuration
45+
46+
The `src/run.ts` entrypoint reads a small set of env flags for quick local orchestration:
47+
48+
| Env var | Purpose | Default |
49+
| --- | --- | --- |
50+
| `CONSTRUCTIVE_GRAPHQL_ENABLED` | Start the GraphQL server | `true` |
51+
| `CONSTRUCTIVE_JOBS_ENABLED` | Start the jobs runtime | `false` |
52+
| `CONSTRUCTIVE_FUNCTIONS` | Comma-separated function list or `all` | empty |
53+
| `CONSTRUCTIVE_FUNCTION_PORTS` | Port map (`name=port,name=port`) | none |
54+
55+
Examples:
56+
57+
```bash
58+
# Start GraphQL only
59+
CONSTRUCTIVE_GRAPHQL_ENABLED=true pnpm dev
60+
61+
# Start only the simple-email function
62+
CONSTRUCTIVE_GRAPHQL_ENABLED=false \
63+
CONSTRUCTIVE_FUNCTIONS=simple-email \
64+
CONSTRUCTIVE_FUNCTION_PORTS=simple-email=8081 \
65+
pnpm dev
66+
67+
# Start all functions + jobs
68+
CONSTRUCTIVE_FUNCTIONS=all \
69+
CONSTRUCTIVE_JOBS_ENABLED=true \
70+
CONSTRUCTIVE_FUNCTION_PORTS=simple-email=8081,send-email-link=8082 \
71+
pnpm dev
72+
```
73+
74+
## Default Function Ports
75+
76+
- `simple-email`: `8081`
77+
- `send-email-link`: `8082`

packages/server/jest.config.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/** @type {import('ts-jest').JestConfigWithTsJest} */
2+
module.exports = {
3+
preset: 'ts-jest',
4+
testEnvironment: 'node',
5+
transform: {
6+
'^.+\\.tsx?$': [
7+
'ts-jest',
8+
{
9+
babelConfig: false,
10+
tsconfig: 'tsconfig.json',
11+
},
12+
],
13+
},
14+
transformIgnorePatterns: [`/node_modules/*`],
15+
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$',
16+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
17+
modulePathIgnorePatterns: ['dist/*']
18+
};

0 commit comments

Comments
 (0)