@adhd/apigen-runtime 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.
@@ -0,0 +1,4 @@
1
+ import { MiddlewareDef, ApiPackageOptions, ApiPackageResult } from './types';
2
+
3
+ export declare function assertNoSelfSubscription(middlewares: readonly MiddlewareDef[]): void;
4
+ export declare function createApiPackage<M extends readonly MiddlewareDef[]>(options: ApiPackageOptions<M>): ApiPackageResult;
@@ -0,0 +1 @@
1
+ export declare function apigenRuntime(): string;
@@ -0,0 +1,4 @@
1
+ import { EventBus } from './event-bus';
2
+ import { MiddlewareDef } from './types';
3
+
4
+ export declare function buildContext(middlewares: readonly MiddlewareDef[], envelope: Record<string, unknown>, bus: EventBus): Promise<object>;
@@ -0,0 +1,5 @@
1
+ import { MiddlewareDef } from './types';
2
+
3
+ export declare function defineMiddleware<TId extends string, TEnvelope extends object, TContext extends object>(def: MiddlewareDef<TEnvelope, TContext> & {
4
+ id: TId;
5
+ }): typeof def;
@@ -0,0 +1,18 @@
1
+ export interface ParamInfo {
2
+ name: string;
3
+ type: string;
4
+ required: boolean;
5
+ }
6
+ /**
7
+ * Extract a composed schema entry's domain parameters for logging.
8
+ *
9
+ * Params live under `input.properties.data.properties` — the `data` envelope
10
+ * wrapper is always present (see [def:ComposedSchemas]). Returns both a
11
+ * structured list (for JSON logs) and a `name?: type` summary string.
12
+ */
13
+ export declare function describeParams(schema: {
14
+ input?: unknown;
15
+ } | undefined): {
16
+ params: ParamInfo[];
17
+ text: string;
18
+ };
@@ -0,0 +1,11 @@
1
+ import { ComposedSchemas } from './types';
2
+
3
+ /** Returns true when the composed schema has `field` in input.properties (i.e. an envelope field). */
4
+ export declare function needsEnvelopeField(fnSchema: ComposedSchemas[string], field: string): boolean;
5
+ /** Returns ordered domain parameter names (keys of the data: {} sub-object). */
6
+ export declare function dataParamNames(fnSchema: ComposedSchemas[string]): string[];
7
+ /**
8
+ * Single canonical dispatch path used by ALL plugins in both generate and run modes.
9
+ * No plugin may inline this logic. [inv:dispatch-single-path]
10
+ */
11
+ export declare function dispatch(fns: Record<string, (...args: unknown[]) => unknown>, createClient: ((e: Record<string, unknown>) => Promise<unknown>) | undefined, schema: ComposedSchemas[string], fnName: string, envelope: Record<string, unknown>, domainArgs: Record<string, unknown>): Promise<unknown>;
@@ -0,0 +1,8 @@
1
+ import { MiddlewareDef, MiddlewareEvent } from './types';
2
+
3
+ export declare class EventBus {
4
+ private handlers;
5
+ on(selector: string, handler: (event: MiddlewareEvent) => void | Promise<void>): void;
6
+ emit(event: MiddlewareEvent): Promise<void>;
7
+ }
8
+ export declare function wireObservers(middlewares: readonly MiddlewareDef[], bus: EventBus): void;
@@ -0,0 +1,16 @@
1
+ export type AnyFn = (...args: unknown[]) => unknown;
2
+ /**
3
+ * Build a `name → function` table from an imported module namespace, matching how
4
+ * schema extraction names functions so `dispatch` can always resolve them.
5
+ *
6
+ * Robust to every export + interop shape:
7
+ * - **named exports** (`export function f` / `export const f = …`) → keyed by name.
8
+ * - **single default-exported function** (`export default f`) → keyed by the
9
+ * function's declaration name (ESM keys it `default`; CJS-compiled deps double-
10
+ * wrap it as `default.default` / `module.exports.default`). We unwrap those
11
+ * layers and key every function found by its `.name`.
12
+ * - **default object** (`export default { a, b }`) → each function keyed by name.
13
+ *
14
+ * Earlier keys win; explicit named exports are never overwritten by unwrapped ones.
15
+ */
16
+ export declare function buildFnTable(mod: Record<string, unknown>): Record<string, AnyFn>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Instance registry for class-export instance dispatch (SPEC §10).
3
+ *
4
+ * When a `kind:'constructor'` op fires, the runtime constructs the target class
5
+ * and stores the live instance here under a unique `instanceId`. Subsequent
6
+ * `kind:'instance-method'` ops look up the instance by `instanceId` and
7
+ * dispatch the method against it.
8
+ *
9
+ * Lifecycle (TTL + dispose):
10
+ * - Every entry carries an optional TTL (milliseconds). A sweeper clears
11
+ * expired entries automatically; the sweep interval is configurable.
12
+ * - Calling `dispose(instanceId)` removes the entry immediately and runs the
13
+ * instance's `dispose()` method if it exists.
14
+ * - `disposeAll()` tears down every live instance and stops the sweeper.
15
+ *
16
+ * Stateful caveat: the registry lives in-process; horizontal scaling without
17
+ * sticky routing or an external store will route requests to the wrong instance.
18
+ * Document this to API consumers (SPEC §10 note).
19
+ *
20
+ * Usage:
21
+ * ```ts
22
+ * const registry = new InstanceRegistry({ defaultTtlMs: 30_000 })
23
+ *
24
+ * // constructor op handler
25
+ * const { instanceId } = registry.create(Counter, [0])
26
+ *
27
+ * // instance-method op handler
28
+ * const counter = registry.get<Counter>(instanceId)
29
+ * counter.increment()
30
+ *
31
+ * // explicit teardown
32
+ * await registry.dispose(instanceId)
33
+ * await registry.disposeAll()
34
+ * ```
35
+ */
36
+ /** Any class constructor (unknown args → unknown instance). */
37
+ export type AnyConstructor = new (...args: unknown[]) => unknown;
38
+ /** Options for the registry. */
39
+ export interface InstanceRegistryOptions {
40
+ /**
41
+ * Default TTL in milliseconds for each instance. When omitted, instances
42
+ * live until explicitly `dispose()`d or `disposeAll()` is called.
43
+ */
44
+ defaultTtlMs?: number;
45
+ /**
46
+ * How often (ms) the background sweeper checks for expired entries.
47
+ * Defaults to `Math.max(1_000, defaultTtlMs / 4)` when a TTL is set,
48
+ * or `5_000` otherwise. Pass `0` to disable the automatic sweeper.
49
+ */
50
+ sweepIntervalMs?: number;
51
+ }
52
+ /** The result returned by `registry.create()` — the `instanceId` is what the
53
+ * client passes back on every `kind:'instance-method'` call. */
54
+ export interface CreateResult {
55
+ instanceId: string;
56
+ }
57
+ /**
58
+ * In-process instance store for SPEC §10 class-instance dispatch.
59
+ *
60
+ * Thread-safe within a single Node.js event-loop (single-threaded JS).
61
+ * Stateful: does NOT survive process restarts; scale with sticky routing.
62
+ */
63
+ export declare class InstanceRegistry {
64
+ private readonly _store;
65
+ private readonly _defaultTtlMs;
66
+ private _sweeper;
67
+ constructor(opts?: InstanceRegistryOptions);
68
+ /**
69
+ * Construct an instance of `Ctor` with `args` and store it in the registry.
70
+ *
71
+ * @param Ctor - The class to instantiate.
72
+ * @param args - Constructor arguments (positional).
73
+ * @param ttlMs - Per-entry TTL override. Falls back to `defaultTtlMs`.
74
+ * @returns `{ instanceId }` — pass this to `dispatch()` / `get()`.
75
+ */
76
+ create(Ctor: AnyConstructor, args?: unknown[], ttlMs?: number): CreateResult;
77
+ /**
78
+ * Retrieve the instance stored under `instanceId`.
79
+ *
80
+ * @throws When `instanceId` is unknown or the entry has expired.
81
+ */
82
+ get<T = unknown>(instanceId: string): T;
83
+ /**
84
+ * Look up `instanceId` and call `method` on it with `args`.
85
+ *
86
+ * @throws When `instanceId` is unknown/expired, or `method` is not a function
87
+ * on the instance.
88
+ */
89
+ dispatch(instanceId: string, method: string, args?: unknown[]): Promise<unknown>;
90
+ /**
91
+ * Remove `instanceId` from the registry and call `instance.dispose()` if the
92
+ * instance exposes one (opt-in lifecycle hook).
93
+ */
94
+ dispose(instanceId: string): Promise<void>;
95
+ /**
96
+ * Dispose every live instance and stop the background sweeper. Call this
97
+ * when the server is shutting down.
98
+ */
99
+ disposeAll(): Promise<void>;
100
+ /** Number of live (non-expired) entries currently in the registry. */
101
+ get size(): number;
102
+ private _sweep;
103
+ private _evict;
104
+ }
@@ -0,0 +1,112 @@
1
+ import { ComposedSchemas } from './types';
2
+ import { Operation } from '@adhd/apigen-core';
3
+
4
+ /**
5
+ * A symbol-keyed or constructor-keyed typed extension map. Each value is
6
+ * stored under a unique token so Layers can share typed data without a mutable
7
+ * property bag. Modeled after Tower/`http::Extensions` — the only `ctx` shape
8
+ * expressible under Rust's borrow checker, so TS matches it for dual-host
9
+ * alignment.
10
+ */
11
+ export declare class LayerContext {
12
+ private readonly _map;
13
+ /** Store `value` under `token` (a constructor or a unique symbol). */
14
+ set<T>(token: abstract new (...args: never[]) => T, value: T): void;
15
+ set<T>(token: symbol, value: T): void;
16
+ /**
17
+ * Retrieve the value stored under `token`. Returns `undefined` when the
18
+ * token has not been inserted — callers are responsible for presence checks.
19
+ */
20
+ get<T>(token: abstract new (...args: never[]) => T): T | undefined;
21
+ get<T>(token: symbol): T | undefined;
22
+ /** Returns true when the token has a stored value. */
23
+ has(token: unknown): boolean;
24
+ }
25
+ /** The resolved domain call, threaded through the Layer stack. */
26
+ export interface Call {
27
+ /**
28
+ * The canonical operation descriptor. For v1 callers that do not have a
29
+ * full Operation, only `id` is guaranteed; the remaining fields are optional.
30
+ */
31
+ operation: Pick<Operation, 'id'> & Partial<Operation>;
32
+ /** Typed extension map — insert/read per §8.1 rule 3. */
33
+ ctx: LayerContext;
34
+ /** Middleware envelope (side-channel from transport metadata). */
35
+ envelope: Record<string, unknown>;
36
+ /** Domain arguments (the `data` sub-object from the composed input). */
37
+ domainArgs: Record<string, unknown>;
38
+ /** Cancellation signal (AbortSignal) — §11. */
39
+ signal?: AbortSignal;
40
+ }
41
+ /**
42
+ * The primitive result a Layer or dispatch can return.
43
+ *
44
+ * §11: a streaming operation returns `AsyncIterable<unknown>`; a normal
45
+ * operation returns `unknown`. Both are covered by this union.
46
+ */
47
+ export type LayerResult = unknown | AsyncIterable<unknown>;
48
+ /**
49
+ * `next` continuation — the inner Layer or dispatch Service.
50
+ *
51
+ * Returns a promise of either a scalar result or an AsyncIterable stream
52
+ * (§11). A Layer that short-circuits (§8.1 rule 1) never calls this.
53
+ */
54
+ export type Next = () => Promise<LayerResult>;
55
+ /**
56
+ * A Layer in the TS harness (§8.1 normative closure signature).
57
+ *
58
+ * ```ts
59
+ * const logger: Layer = async (call, next) => {
60
+ * const t = Date.now()
61
+ * try { const r = await next(); return r }
62
+ * catch (e) { throw e } // error unwinds outward — §8.1 rule 2
63
+ * }
64
+ * ```
65
+ *
66
+ * Rule 1 (short-circuit): return a value WITHOUT calling `next`.
67
+ * Rule 2 (error propagation): `throw` propagates to the enclosing Layer.
68
+ * Rule 4 (poll_ready): not present — TS host omits it entirely (host-optional).
69
+ * Rule 5 (streaming): `next()` return type includes `AsyncIterable<unknown>`.
70
+ * Rule 6 (codegen-weave): composition is a pure function; same semantics.
71
+ */
72
+ export type Layer = (call: Call, next: Next) => Promise<LayerResult>;
73
+ /**
74
+ * Runtime context needed by `invoke` to reach the core dispatch Service.
75
+ *
76
+ * v1 callers supply `fns` + `createClient` + `schemas` directly; future
77
+ * callers may pre-build an invoker from an Operation descriptor.
78
+ */
79
+ export interface InvokeOptions {
80
+ /** The live function table (fn-name → implementation). */
81
+ fns: Record<string, (...args: unknown[]) => unknown>;
82
+ /** Optional client factory (for session-ctx middleware). */
83
+ createClient?: (envelope: Record<string, unknown>) => Promise<unknown>;
84
+ /** Composed schemas for the target namespace. */
85
+ schemas: ComposedSchemas;
86
+ }
87
+ /**
88
+ * The composed invoke function returned by {@link createInvoker}.
89
+ *
90
+ * Accepts an operation name, a {@link Call}, and runtime dispatch options.
91
+ * Returns the Service result, streaming or scalar.
92
+ */
93
+ export type InvokeFn = (fnName: string, call: Call, opts: InvokeOptions) => Promise<LayerResult>;
94
+ /**
95
+ * Compose a Layer stack and return a typed `invoke` function.
96
+ *
97
+ * Layers are applied **outermost-first**: `layers[0]` wraps `layers[1]`
98
+ * which wraps … which wraps `dispatch`. Equivalent to Tower's
99
+ * `ServiceBuilder::layer` / `layer_fn` composition order.
100
+ *
101
+ * ```ts
102
+ * const invoke = createInvoker([authLayer, loggerLayer])
103
+ * const result = await invoke('getUser', call, opts)
104
+ * ```
105
+ *
106
+ * When `layers` is empty, `invoke` calls `dispatch` directly.
107
+ *
108
+ * §8.1 rule 6 (codegen-weave): static hosts receive the composed list at
109
+ * codegen time; `createInvoker` is called once per plugin instantiation, not
110
+ * once per request.
111
+ */
112
+ export declare function createInvoker(layers?: readonly Layer[]): InvokeFn;
@@ -0,0 +1,29 @@
1
+ import { Logger } from 'pino';
2
+
3
+ export type { Logger };
4
+ /** Log output format. `pretty` is colorized human-readable; `json` is raw jsonl. */
5
+ export type LogFormat = 'json' | 'pretty';
6
+ export interface CreateLoggerOptions {
7
+ /** pino log level. Default: `info`. */
8
+ level?: string;
9
+ /**
10
+ * Output format. Default: `pretty` when the destination is a TTY, else `json`.
11
+ * `pretty` routes through pino-pretty (colorized, timestamped); `json` emits jsonl.
12
+ */
13
+ format?: LogFormat;
14
+ /**
15
+ * Where logs are written. Default: stderr (fd 2) — NEVER stdout, which is the
16
+ * MCP stdio JSON-RPC channel. Pass a filesystem path to write logs to a file.
17
+ */
18
+ destination?: string;
19
+ }
20
+ /**
21
+ * Build the shared apigen pino logger.
22
+ *
23
+ * Logging always targets stderr or a file — never stdout — so the MCP stdio
24
+ * transport's JSON-RPC channel on stdout stays free of log noise.
25
+ *
26
+ * @param opts - level, format (`json` | `pretty`), and destination (stderr or a file path).
27
+ * @returns a configured pino {@link Logger}.
28
+ */
29
+ export declare function createLogger(opts?: CreateLoggerOptions): Logger;
@@ -0,0 +1,88 @@
1
+ import { AfterFirstChunkError, BeforeFirstChunkError } from '@adhd/apigen-errors';
2
+
3
+ /** A strongly-typed async iterable chunk stream (§11 consumer-pull). */
4
+ export type ApiStream<T = unknown> = AsyncIterable<T>;
5
+ /**
6
+ * Options for {@link createStream}.
7
+ *
8
+ * @template T - the chunk type yielded by the producer
9
+ */
10
+ export interface CreateStreamOptions<T = unknown> {
11
+ /**
12
+ * The producer: an async generator that yields chunks.
13
+ * It receives the AbortSignal so it can honour cancellation directly;
14
+ * the harness also terminates iteration externally when the signal fires.
15
+ */
16
+ produce: (signal: AbortSignal) => AsyncGenerator<T>;
17
+ /**
18
+ * Optional cancellation signal (§11).
19
+ * When aborted, iteration terminates after the current `yield` without
20
+ * surfacing an error — each Layer's **end** path runs.
21
+ */
22
+ signal?: AbortSignal;
23
+ }
24
+ /**
25
+ * Wrap an async generator producer in a stream-lifecycle–aware `ApiStream`.
26
+ *
27
+ * The returned `AsyncIterable` is **consumer-pull**: the producer is driven
28
+ * one chunk at a time by the consumer's `for await`. Backpressure is
29
+ * natural — the producer only runs when the consumer is ready for the next
30
+ * value.
31
+ *
32
+ * Cancellation: when `options.signal` fires, the next `next()` call returns
33
+ * `{ done: true }` cleanly. Any pending `await` inside the producer is
34
+ * interrupted by the same signal (the producer must honour it).
35
+ *
36
+ * Error-after-first-chunk: if the producer throws after yielding at least
37
+ * one chunk, the `ApiStream` propagates the error as a normal iterator
38
+ * rejection (the consumer's `for await` body receives a thrown `ApiError`
39
+ * or plain error). Transport adapters catch this and deliver it in-band
40
+ * per the §11 carrier table.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * const stream = createStream({
45
+ * produce: async function* (signal) {
46
+ * for (const item of items) {
47
+ * if (signal.aborted) return
48
+ * yield item
49
+ * }
50
+ * },
51
+ * signal: call.signal,
52
+ * })
53
+ * for await (const chunk of stream) { ... }
54
+ * ```
55
+ */
56
+ export declare function createStream<T = unknown>(options: CreateStreamOptions<T>): ApiStream<T>;
57
+ /**
58
+ * Collect all chunks from an `ApiStream` into an array.
59
+ *
60
+ * Used by CLI adapters and test helpers. Surfaces error-after-first-chunk
61
+ * as a thrown `ApiError` / plain error — callers wrap with try/catch.
62
+ */
63
+ export declare function drainStream<T>(stream: ApiStream<T>): Promise<T[]>;
64
+ /** Result returned by {@link collectWithPhase}. */
65
+ export type CollectResult<T> = {
66
+ ok: true;
67
+ chunks: T[];
68
+ } | {
69
+ ok: false;
70
+ carrier: BeforeFirstChunkError | AfterFirstChunkError;
71
+ };
72
+ /**
73
+ * Drain `stream`, tracking whether any chunks were emitted before an error.
74
+ *
75
+ * Returns a discriminated result:
76
+ * - `{ ok: true, chunks }` — stream completed cleanly.
77
+ * - `{ ok: false, carrier }` — stream threw; `carrier.phase` indicates
78
+ * whether the error was before or after the first chunk was produced.
79
+ *
80
+ * Transport adapters use this to select the correct §11 in-band carrier.
81
+ */
82
+ export declare function collectWithPhase<T>(stream: ApiStream<T>): Promise<CollectResult<T>>;
83
+ /**
84
+ * Returns true when `value` is an `AsyncIterable` (i.e. an `ApiStream`).
85
+ *
86
+ * Used by Layer harness dispatch to distinguish streaming from scalar results.
87
+ */
88
+ export declare function isApiStream(value: unknown): value is ApiStream<unknown>;
package/lib/types.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { GeneratedSchemas, ComposedSchemas } from '@adhd/apigen-core';
2
+
3
+ export type { GeneratedSchemas, ComposedSchemas };
4
+ export interface MiddlewareDef<TEnvelope extends object = object, TContext extends object = object> {
5
+ id: string;
6
+ envelope?: Record<string, unknown>;
7
+ createContext?: (ctx: object) => TContext | Promise<TContext>;
8
+ eventMapping?: Record<string, (event: MiddlewareEvent) => void | Promise<void>>;
9
+ }
10
+ export interface MiddlewareEvent {
11
+ module: string;
12
+ method: string;
13
+ lifecycle: 'start' | 'complete' | 'error';
14
+ ctx: object;
15
+ error?: unknown;
16
+ }
17
+ export interface ApiPackageOptions<M extends readonly MiddlewareDef[]> {
18
+ domainSchemas: GeneratedSchemas;
19
+ middlewares: readonly [...M];
20
+ overrides?: Partial<Record<string, Partial<Record<string, boolean>>>>;
21
+ strict?: boolean;
22
+ }
23
+ export interface ApiPackageResult {
24
+ schemas: ComposedSchemas;
25
+ createClient: (envelope: Record<string, unknown>) => Promise<object>;
26
+ }
27
+ export declare class ConfigurationError extends Error {
28
+ constructor(message: string);
29
+ }
@@ -0,0 +1,54 @@
1
+ import { ComposedSchemas } from './types';
2
+ import { Layer } from './invoke';
3
+
4
+ /**
5
+ * A compose-time Layer that validates `call.domainArgs` and `call.envelope`
6
+ * against the operation's composed input schema before forwarding to dispatch.
7
+ *
8
+ * Place this Layer **innermost** (last in the `layers` array passed to
9
+ * `createInvoker`) so it runs immediately before dispatch, after all
10
+ * authentication / authorization Layers have had their chance to inspect (and
11
+ * reject) the call.
12
+ *
13
+ * Short-circuits with `ApiError{ code: 'invalid_argument' }` on any schema
14
+ * violation. Delegates to `next()` on success.
15
+ *
16
+ * @remarks
17
+ * This validates **shape**, not **domain correctness** (SPEC §6 necessary-but-
18
+ * not-sufficient boundary — see module JSDoc).
19
+ */
20
+ export declare const validateLayer: Layer;
21
+ /**
22
+ * Typed ctx extension token that carries the `ComposedSchemas` through the
23
+ * Layer stack to `validateLayer`.
24
+ *
25
+ * Usage (by the caller / transport adapter):
26
+ * ```ts
27
+ * call.ctx.set(ValidateSchemasToken, schemas)
28
+ * ```
29
+ *
30
+ * `validateLayer` reads it back with `call.ctx.get(ValidateSchemasToken)`.
31
+ */
32
+ export declare const ValidateSchemasToken: unique symbol;
33
+ declare module './invoke' {
34
+ interface LayerContext {
35
+ get(token: typeof ValidateSchemasToken): ComposedSchemas | undefined;
36
+ set(token: typeof ValidateSchemasToken, value: ComposedSchemas): void;
37
+ }
38
+ }
39
+ /**
40
+ * Factory that produces a validation Layer **pre-bound** to a `ComposedSchemas`
41
+ * map. Prefer this over the raw `validateLayer` singleton when composing a
42
+ * static invoker at plugin instantiation time — it avoids the ctx-token
43
+ * ceremony and makes the Layer entirely self-contained.
44
+ *
45
+ * ```ts
46
+ * const invoke = createInvoker([makeValidateLayer(schemas), authLayer])
47
+ * ```
48
+ *
49
+ * Validation is still necessary-but-not-sufficient (SPEC §6) — it validates
50
+ * shape, not domain correctness.
51
+ *
52
+ * @param schemas - The composed schemas for the target namespace.
53
+ */
54
+ export declare function makeValidateLayer(schemas: ComposedSchemas): Layer;
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@adhd/apigen-runtime",
3
+ "version": "0.1.0",
4
+ "dependencies": {
5
+ "@adhd/apigen-core": "^0.1.0",
6
+ "pino": "10.3.1"
7
+ },
8
+ "main": "./index.js",
9
+ "module": "./index.mjs",
10
+ "typings": "./index.d.ts",
11
+ "publishConfig": {
12
+ "access": "public"
13
+ }
14
+ }