@kindgi/handler-runtime 0.0.0-bootstrap.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +218 -2
  3. package/dist/build-extensions.d.ts +75 -0
  4. package/dist/build-extensions.d.ts.map +1 -0
  5. package/dist/build-extensions.js +27 -0
  6. package/dist/build-extensions.js.map +1 -0
  7. package/dist/discovery.d.ts +19 -0
  8. package/dist/discovery.d.ts.map +1 -0
  9. package/dist/discovery.js +81 -0
  10. package/dist/discovery.js.map +1 -0
  11. package/dist/entrypoint.d.ts +10 -0
  12. package/dist/entrypoint.d.ts.map +1 -0
  13. package/dist/entrypoint.js +24 -0
  14. package/dist/entrypoint.js.map +1 -0
  15. package/dist/handler-runner.d.ts +127 -0
  16. package/dist/handler-runner.d.ts.map +1 -0
  17. package/dist/handler-runner.js +318 -0
  18. package/dist/handler-runner.js.map +1 -0
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +7 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/kindgi-index-main.d.ts +2 -0
  24. package/dist/kindgi-index-main.d.ts.map +1 -0
  25. package/dist/kindgi-index-main.js +15 -0
  26. package/dist/kindgi-index-main.js.map +1 -0
  27. package/dist/kindgi-index.d.ts +316 -0
  28. package/dist/kindgi-index.d.ts.map +1 -0
  29. package/dist/kindgi-index.js +1201 -0
  30. package/dist/kindgi-index.js.map +1 -0
  31. package/dist/pack-env.d.ts +66 -0
  32. package/dist/pack-env.d.ts.map +1 -0
  33. package/dist/pack-env.js +97 -0
  34. package/dist/pack-env.js.map +1 -0
  35. package/dist/pack-service/index.d.ts +7 -0
  36. package/dist/pack-service/index.d.ts.map +1 -0
  37. package/dist/pack-service/index.js +6 -0
  38. package/dist/pack-service/index.js.map +1 -0
  39. package/dist/pack-service/main.d.ts +47 -0
  40. package/dist/pack-service/main.d.ts.map +1 -0
  41. package/dist/pack-service/main.js +201 -0
  42. package/dist/pack-service/main.js.map +1 -0
  43. package/dist/pack-service/service.d.ts +61 -0
  44. package/dist/pack-service/service.d.ts.map +1 -0
  45. package/dist/pack-service/service.js +341 -0
  46. package/dist/pack-service/service.js.map +1 -0
  47. package/dist/pack-service/supervisor.d.ts +138 -0
  48. package/dist/pack-service/supervisor.d.ts.map +1 -0
  49. package/dist/pack-service/supervisor.js +424 -0
  50. package/dist/pack-service/supervisor.js.map +1 -0
  51. package/dist/protocol.d.ts +104 -0
  52. package/dist/protocol.d.ts.map +1 -0
  53. package/dist/protocol.js +116 -0
  54. package/dist/protocol.js.map +1 -0
  55. package/package.json +87 -4
  56. package/src/build-extensions.ts +100 -0
  57. package/src/discovery.ts +89 -0
  58. package/src/entrypoint.ts +23 -0
  59. package/src/handler-runner.ts +500 -0
  60. package/src/index.ts +66 -0
  61. package/src/kindgi-index-main.ts +17 -0
  62. package/src/kindgi-index.ts +1605 -0
  63. package/src/pack-env.ts +148 -0
  64. package/src/pack-service/index.ts +17 -0
  65. package/src/pack-service/main.ts +246 -0
  66. package/src/pack-service/service.ts +478 -0
  67. package/src/pack-service/supervisor.ts +600 -0
  68. package/src/protocol.ts +214 -0
@@ -0,0 +1,148 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The process environment a pack's code reads: names its handlers and the
6
+ * libraries they import take from `process.env` (a database URL a client
7
+ * reads at import, a bucket name). Declared once per pack, in
8
+ * `kindgi.config` (`env: { required, optional }`; Python:
9
+ * `[tool.kindgi.env]`), and carried in `index.json`, so the pack service
10
+ * in an image knows what it needs.
11
+ *
12
+ * A deployment injects exactly these names: `required` must be there for
13
+ * the pack service to be ready, `optional` is injected when the target
14
+ * has a value. Nothing else from an env file reaches the process.
15
+ *
16
+ * Distinct from `needsSpec.env` / `ctx.env`, the per-call values the
17
+ * runtime resolves for a call's tenant, and from `needsSpec.secrets` /
18
+ * `ctx.secrets`.
19
+ */
20
+
21
+ /** A pack's declared process environment, as `index.json` carries it. */
22
+ export interface PackEnvDeclaration {
23
+ /** Names the pack service needs, set and non-empty, to be ready. Sorted. */
24
+ readonly required: readonly string[];
25
+ /** Names the pack reads when present. Sorted. */
26
+ readonly optional: readonly string[];
27
+ }
28
+
29
+ /** `env` in `kindgi.config`, as the author writes it. */
30
+ export interface PackEnvConfig {
31
+ readonly required?: readonly string[];
32
+ readonly optional?: readonly string[];
33
+ }
34
+
35
+ /** A process environment variable name. */
36
+ export const PACK_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
37
+
38
+ /** The prefix of the names that configure Kindgi itself; never a pack's. */
39
+ export const RESERVED_ENV_PREFIX = 'KINDGI_';
40
+
41
+ export type PackEnvResult =
42
+ | { readonly kind: 'ok'; readonly value: PackEnvDeclaration | undefined }
43
+ | { readonly kind: 'err'; readonly message: string };
44
+
45
+ /**
46
+ * Validate a config's `env` and normalize it to what the index carries:
47
+ * both lists, each sorted (code-unit order). `undefined` when nothing is
48
+ * declared, so an index without `env` stays byte-identical.
49
+ *
50
+ * Refused: a name that isn't an environment variable name, a `KINDGI_*`
51
+ * name, a name listed twice, and a name in both lists.
52
+ */
53
+ export function resolvePackEnv(env: unknown): PackEnvResult {
54
+ if (env === undefined) return { kind: 'ok', value: undefined };
55
+ if (env === null || typeof env !== 'object' || Array.isArray(env)) {
56
+ return {
57
+ kind: 'err',
58
+ message: '`env` must be an object: { required?: string[], optional?: string[] }',
59
+ };
60
+ }
61
+ const unknownKeys = Object.keys(env).filter((k) => k !== 'required' && k !== 'optional');
62
+ if (unknownKeys.length > 0) {
63
+ return {
64
+ kind: 'err',
65
+ message: `\`env\` takes only \`required\` and \`optional\`, not ${unknownKeys.map((k) => `\`${k}\``).join(', ')}`,
66
+ };
67
+ }
68
+ const { required, optional } = env as {
69
+ readonly required?: unknown;
70
+ readonly optional?: unknown;
71
+ };
72
+ const lists = { required, optional };
73
+ const problems: string[] = [];
74
+ const seen = new Map<string, string>();
75
+ for (const [list, names] of Object.entries(lists)) {
76
+ if (names === undefined) continue;
77
+ if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
78
+ problems.push(`\`env.${list}\` must be a list of names`);
79
+ continue;
80
+ }
81
+ for (const name of names as string[]) {
82
+ if (!PACK_ENV_NAME.test(name)) {
83
+ problems.push(`"${name}" in \`env.${list}\` isn't an environment variable name`);
84
+ } else if (name.startsWith(RESERVED_ENV_PREFIX)) {
85
+ problems.push(
86
+ `"${name}" in \`env.${list}\`: \`${RESERVED_ENV_PREFIX}*\` names configure Kindgi, not the pack`,
87
+ );
88
+ } else if (seen.has(name)) {
89
+ const first = seen.get(name);
90
+ problems.push(
91
+ first === list
92
+ ? `"${name}" is listed twice in \`env.${list}\``
93
+ : `"${name}" is in both \`env.required\` and \`env.optional\``,
94
+ );
95
+ } else {
96
+ seen.set(name, list);
97
+ }
98
+ }
99
+ }
100
+ if (problems.length > 0) return { kind: 'err', message: problems.join('; ') };
101
+
102
+ const sorted = (names: unknown): string[] =>
103
+ Array.isArray(names) ? [...(names as string[])].sort(byCodeUnit) : [];
104
+ const declaration = { optional: sorted(optional), required: sorted(required) };
105
+ if (declaration.required.length === 0 && declaration.optional.length === 0) {
106
+ return { kind: 'ok', value: undefined };
107
+ }
108
+ return { kind: 'ok', value: declaration };
109
+ }
110
+
111
+ /**
112
+ * The required names `environment` doesn't provide: not set, or set to
113
+ * the empty string. Sorted, like the declaration.
114
+ */
115
+ export function missingPackEnv(
116
+ declaration: PackEnvDeclaration | undefined,
117
+ environment: Readonly<Record<string, string | undefined>>,
118
+ ): readonly string[] {
119
+ if (declaration === undefined) return [];
120
+ return declaration.required.filter((name) => {
121
+ const value = environment[name];
122
+ return value === undefined || value === '';
123
+ });
124
+ }
125
+
126
+ /** How the pack service treats missing required names (`KINDGI_PACK_ENV_CHECK`). */
127
+ export type PackEnvCheck = 'strict' | 'warn';
128
+
129
+ /** The variable that sets {@link PackEnvCheck}. */
130
+ export const PACK_ENV_CHECK_VAR = 'KINDGI_PACK_ENV_CHECK';
131
+
132
+ /** `KINDGI_PACK_ENV_CHECK`'s value; unset or empty is `strict`. */
133
+ export function parsePackEnvCheck(
134
+ raw: string | undefined,
135
+ ):
136
+ | { readonly kind: 'ok'; readonly value: PackEnvCheck }
137
+ | { readonly kind: 'err'; readonly message: string } {
138
+ if (raw === undefined || raw === '' || raw === 'strict') return { kind: 'ok', value: 'strict' };
139
+ if (raw === 'warn') return { kind: 'ok', value: 'warn' };
140
+ return {
141
+ kind: 'err',
142
+ message: `${PACK_ENV_CHECK_VAR} must be \`strict\` or \`warn\`, not "${raw}"`,
143
+ };
144
+ }
145
+
146
+ function byCodeUnit(a: string, b: string): number {
147
+ return a < b ? -1 : a > b ? 1 : 0;
148
+ }
@@ -0,0 +1,17 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ export { createPackService } from './service.js';
5
+ export type { PackService, PackServiceLogEvent, PackServiceOptions } from './service.js';
6
+ export { PACK_SERVICE_DRAIN_MS, main, readPackServiceConfig, startPackService } from './main.js';
7
+ export type { PackServiceConfig, RunningPackService } from './main.js';
8
+ export { createPackServiceSupervisor } from './supervisor.js';
9
+ export type {
10
+ BootFailure,
11
+ PackRelay,
12
+ PackRelayCall,
13
+ PackRelayOutcome,
14
+ PackServiceSupervisor,
15
+ PackServiceSupervisorEvent,
16
+ PackServiceSupervisorOptions,
17
+ } from './supervisor.js';
@@ -0,0 +1,246 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (C) 2026 Kindgi Inc.
3
+
4
+ /**
5
+ * The pack service process: `node dist/pack-service/main.js
6
+ * [--index <path>] [--module-root <dir>] [--bundle-map <path>]
7
+ * [--host <address>]` (package export
8
+ * `@kindgi/handler-runtime/pack-service-main`).
9
+ *
10
+ * Configuration (arguments win over environment):
11
+ * - index: `--index`, else `KINDGI_PACK_INDEX`, else `/app/index.json`
12
+ * - module root: `--module-root`, else the index's directory — where
13
+ * the index's module paths resolve
14
+ * - bundle map: `--bundle-map` (optional) — a build's map of each
15
+ * source path the index names to its bundle, relative
16
+ * to the module root (`kindgi build` writes
17
+ * `dist/bundle-map.json`): modules load from the bundles
18
+ * - token: `KINDGI_PACK_SERVICE_TOKEN` (required)
19
+ * - concurrency: `KINDGI_PACK_SERVICE_MAX_CONCURRENCY` (default 32)
20
+ * - env check: `KINDGI_PACK_ENV_CHECK`, `strict` (default) | `warn`:
21
+ * whether a required env name the index declares and the
22
+ * process lacks keeps the service from being ready
23
+ * - port: `PORT` (platform convention; default 8080; `0` picks one)
24
+ * - host: `--host`, else every interface (a container platform
25
+ * such as Cloud Run needs that); a local supervisor
26
+ * passes `127.0.0.1`
27
+ *
28
+ * Boot fails (exit 1, listing every problem) when the index can't be
29
+ * read, a module it names is missing, or a module fails to import — so a
30
+ * broken build never becomes ready. SIGTERM drains in-flight calls
31
+ * (readyz answers 503 meanwhile) and exits 0.
32
+ *
33
+ * Logs are JSON lines on stderr. The `listening` line carries the bound
34
+ * port, for callers that start it with `PORT=0`.
35
+ */
36
+
37
+ import { access, readFile } from 'node:fs/promises';
38
+ import { type Server, createServer } from 'node:http';
39
+ import type { AddressInfo } from 'node:net';
40
+ import { dirname, isAbsolute, resolve } from 'node:path';
41
+ import { isProcessEntrypoint } from '../entrypoint.js';
42
+ import type { Index } from '../kindgi-index.js';
43
+ import { INDEX_ENVELOPE_VERSION, readBundleMap } from '../kindgi-index.js';
44
+ import { PACK_ENV_CHECK_VAR, type PackEnvCheck, parsePackEnvCheck } from '../pack-env.js';
45
+ import { type PackService, type PackServiceLogEvent, createPackService } from './service.js';
46
+
47
+ export interface PackServiceConfig {
48
+ readonly indexPath: string;
49
+ readonly moduleRoot: string;
50
+ /** A build's bundle map (source path → bundle path under `moduleRoot`). */
51
+ readonly bundleMapPath?: string;
52
+ readonly token: string;
53
+ readonly port: number;
54
+ /** The listen address. Absent: every interface. */
55
+ readonly host?: string;
56
+ readonly maxConcurrency?: number;
57
+ /** Default `strict`. */
58
+ readonly envCheck?: PackEnvCheck;
59
+ }
60
+
61
+ type ConfigOutcome =
62
+ | { readonly kind: 'ok'; readonly value: PackServiceConfig }
63
+ | { readonly kind: 'err'; readonly problems: readonly string[] };
64
+
65
+ /** Read the process configuration from arguments and environment. */
66
+ export function readPackServiceConfig(
67
+ argv: readonly string[],
68
+ env: Readonly<Record<string, string | undefined>>,
69
+ ): ConfigOutcome {
70
+ const arg = (name: string): string | undefined => {
71
+ const at = argv.indexOf(name);
72
+ return at >= 0 ? argv[at + 1] : undefined;
73
+ };
74
+ const problems: string[] = [];
75
+ const indexPath = resolve(arg('--index') ?? env.KINDGI_PACK_INDEX ?? '/app/index.json');
76
+ const moduleRoot = resolve(arg('--module-root') ?? dirname(indexPath));
77
+ const bundleMap = arg('--bundle-map');
78
+ if (bundleMap === '') problems.push('--bundle-map needs a path');
79
+ const host = arg('--host');
80
+ if (host === '') problems.push('--host needs an address');
81
+ const token = env.KINDGI_PACK_SERVICE_TOKEN ?? '';
82
+ if (token.length === 0) problems.push('KINDGI_PACK_SERVICE_TOKEN is required');
83
+ const port = Number(env.PORT ?? '8080');
84
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
85
+ problems.push(`PORT must be a port number, got ${JSON.stringify(env.PORT)}`);
86
+ }
87
+ const rawConcurrency = env.KINDGI_PACK_SERVICE_MAX_CONCURRENCY;
88
+ const maxConcurrency = rawConcurrency === undefined ? undefined : Number(rawConcurrency);
89
+ if (maxConcurrency !== undefined && (!Number.isInteger(maxConcurrency) || maxConcurrency < 1)) {
90
+ problems.push('KINDGI_PACK_SERVICE_MAX_CONCURRENCY must be a positive integer');
91
+ }
92
+ const envCheck = parsePackEnvCheck(env[PACK_ENV_CHECK_VAR]);
93
+ if (envCheck.kind === 'err') problems.push(envCheck.message);
94
+ if (problems.length > 0 || envCheck.kind === 'err') return { kind: 'err', problems };
95
+ return {
96
+ kind: 'ok',
97
+ value: {
98
+ indexPath,
99
+ moduleRoot,
100
+ ...(bundleMap && { bundleMapPath: resolve(bundleMap) }),
101
+ token,
102
+ port,
103
+ ...(host && { host }),
104
+ ...(maxConcurrency !== undefined && { maxConcurrency }),
105
+ envCheck: envCheck.value,
106
+ },
107
+ };
108
+ }
109
+
110
+ /** How long SIGTERM lets in-flight calls finish (sized for Cloud Run's 10 s SIGTERM→SIGKILL window). */
111
+ export const PACK_SERVICE_DRAIN_MS = 8_000;
112
+
113
+ export interface RunningPackService {
114
+ readonly service: PackService;
115
+ readonly server: Server;
116
+ readonly port: number;
117
+ /** Drain in-flight calls (up to `graceMs`), then stop listening. */
118
+ stop(graceMs?: number): Promise<void>;
119
+ }
120
+
121
+ type StartOutcome =
122
+ | { readonly kind: 'ok'; readonly value: RunningPackService }
123
+ | { readonly kind: 'err'; readonly problems: readonly string[] };
124
+
125
+ /** Load the index, check and prewarm every module, then listen. */
126
+ export async function startPackService(
127
+ config: PackServiceConfig,
128
+ logger: (event: PackServiceLogEvent | Record<string, unknown>) => void = logJson,
129
+ ): Promise<StartOutcome> {
130
+ let index: Index;
131
+ try {
132
+ index = JSON.parse(await readFile(config.indexPath, 'utf8')) as Index;
133
+ } catch (cause) {
134
+ return { kind: 'err', problems: [`Cannot read the pack index: ${describe(cause)}`] };
135
+ }
136
+ if (index.v !== INDEX_ENVELOPE_VERSION) {
137
+ return {
138
+ kind: 'err',
139
+ problems: [`Index envelope v${String(index.v)} is not v${INDEX_ENVELOPE_VERSION}`],
140
+ };
141
+ }
142
+ let bundles: Readonly<Record<string, string>> = {};
143
+ if (config.bundleMapPath !== undefined) {
144
+ const read = await readBundleMap(config.bundleMapPath);
145
+ if (read.kind === 'err') return { kind: 'err', problems: [read.message] };
146
+ bundles = read.value;
147
+ }
148
+ // A module the index names loads from its bundle when the build mapped it.
149
+ const resolveModule = (modulePath: string): string => {
150
+ const target = bundles[modulePath] ?? modulePath;
151
+ return isAbsolute(target) ? target : resolve(config.moduleRoot, target);
152
+ };
153
+
154
+ const missing: string[] = [];
155
+ const paths = [
156
+ ...index.tools.map((t) => t.modulePath),
157
+ ...index.guardrails.map((g) => g.checkModulePath),
158
+ ];
159
+ for (const p of paths) {
160
+ await access(resolveModule(p)).catch(() => missing.push(`Missing module: ${p}`));
161
+ }
162
+ if (missing.length > 0) return { kind: 'err', problems: missing };
163
+
164
+ const service = createPackService({
165
+ index,
166
+ resolveModule,
167
+ token: config.token,
168
+ ...(config.maxConcurrency !== undefined && { maxConcurrency: config.maxConcurrency }),
169
+ ...(config.envCheck !== undefined && { envCheck: config.envCheck }),
170
+ logger,
171
+ });
172
+ const failures = await service.prewarm();
173
+ if (failures.length > 0) return { kind: 'err', problems: failures.map((f) => f.message) };
174
+
175
+ const server = createServer(service.handle);
176
+ await new Promise<void>((ready) =>
177
+ config.host === undefined
178
+ ? server.listen(config.port, ready)
179
+ : server.listen(config.port, config.host, ready),
180
+ );
181
+ const port = (server.address() as AddressInfo).port;
182
+ logger({
183
+ kind: 'listening',
184
+ port,
185
+ packId: index.packId,
186
+ artifactVersion: index.artifactVersion,
187
+ });
188
+ return {
189
+ kind: 'ok',
190
+ value: {
191
+ service,
192
+ server,
193
+ port,
194
+ async stop(graceMs = PACK_SERVICE_DRAIN_MS) {
195
+ await service.drain(graceMs);
196
+ // Calls have finished (or the grace ran out). What is left is an idle
197
+ // keep-alive or a caller that went away mid-call, whose half-open
198
+ // connection would otherwise hold `close` for seconds.
199
+ const closed = new Promise<void>((done) => server.close(() => done()));
200
+ server.closeAllConnections();
201
+ await closed;
202
+ },
203
+ },
204
+ };
205
+ }
206
+
207
+ /** Process entry. Resolves with the exit code. */
208
+ export async function main(
209
+ argv: readonly string[] = process.argv.slice(2),
210
+ env: Readonly<Record<string, string | undefined>> = process.env,
211
+ ): Promise<number> {
212
+ const config = readPackServiceConfig(argv, env);
213
+ if (config.kind === 'err') {
214
+ logJson({ kind: 'config-invalid', problems: config.problems });
215
+ return 1;
216
+ }
217
+ // The token is for the service's callers. The pack's code, loaded
218
+ // next, runs in this process and has no use for it — and a dependency
219
+ // that read it could call the pack's tools around the runtime.
220
+ Reflect.deleteProperty(process.env, 'KINDGI_PACK_SERVICE_TOKEN');
221
+ const started = await startPackService(config.value);
222
+ if (started.kind === 'err') {
223
+ logJson({ kind: 'boot-failed', problems: started.problems });
224
+ return 1;
225
+ }
226
+ await new Promise<void>((stopped) => {
227
+ process.once('SIGTERM', () => {
228
+ logJson({ kind: 'draining' });
229
+ void started.value.stop().then(stopped);
230
+ });
231
+ });
232
+ logJson({ kind: 'stopped' });
233
+ return 0;
234
+ }
235
+
236
+ function logJson(event: PackServiceLogEvent | Record<string, unknown>): void {
237
+ process.stderr.write(`${JSON.stringify(event)}\n`);
238
+ }
239
+
240
+ function describe(cause: unknown): string {
241
+ return cause instanceof Error ? cause.message : String(cause);
242
+ }
243
+
244
+ if (isProcessEntrypoint(import.meta.url)) {
245
+ void main().then((code) => process.exit(code));
246
+ }