@zudojs/api 0.1.0 → 1.0.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -9
  3. package/dist/api/constants.d.ts +32 -2
  4. package/dist/api/constants.js +32 -2
  5. package/dist/api/context/context.type.d.ts +38 -0
  6. package/dist/api/context/context.type.js +86 -5
  7. package/dist/api/context/contextKey.type.d.ts +13 -0
  8. package/dist/api/context/contextKey.type.js +6 -0
  9. package/dist/api/errors/index.d.ts +1 -1
  10. package/dist/api/errors/index.js +1 -1
  11. package/dist/api/executor/executor.core.d.ts +81 -11
  12. package/dist/api/executor/executor.core.js +267 -29
  13. package/dist/api/executor/index.d.ts +1 -0
  14. package/dist/api/handler/handler.type.d.ts +5 -2
  15. package/dist/api/interceptors/interceptor.type.d.ts +18 -1
  16. package/dist/api/operation/operation.type.d.ts +90 -0
  17. package/dist/api/operation/operation.type.js +115 -3
  18. package/dist/api/registry/operationRegistry.core.d.ts +15 -2
  19. package/dist/api/registry/operationRegistry.core.js +32 -4
  20. package/dist/index.d.ts +21 -12
  21. package/dist/index.js +19 -12
  22. package/package.json +24 -17
  23. package/dist/.tsbuildinfo +0 -1
  24. package/dist/api/constants.d.ts.map +0 -1
  25. package/dist/api/constants.js.map +0 -1
  26. package/dist/api/context/context.type.d.ts.map +0 -1
  27. package/dist/api/context/context.type.js.map +0 -1
  28. package/dist/api/context/contextKey.type.d.ts.map +0 -1
  29. package/dist/api/context/contextKey.type.js.map +0 -1
  30. package/dist/api/errors/index.d.ts.map +0 -1
  31. package/dist/api/errors/index.js.map +0 -1
  32. package/dist/api/executor/executor.core.d.ts.map +0 -1
  33. package/dist/api/executor/executor.core.js.map +0 -1
  34. package/dist/api/executor/index.d.ts.map +0 -1
  35. package/dist/api/executor/index.js.map +0 -1
  36. package/dist/api/handler/handler.type.d.ts.map +0 -1
  37. package/dist/api/handler/handler.type.js.map +0 -1
  38. package/dist/api/interceptors/interceptor.type.d.ts.map +0 -1
  39. package/dist/api/interceptors/interceptor.type.js.map +0 -1
  40. package/dist/api/operation/operation.type.d.ts.map +0 -1
  41. package/dist/api/operation/operation.type.js.map +0 -1
  42. package/dist/api/registry/index.d.ts.map +0 -1
  43. package/dist/api/registry/index.js.map +0 -1
  44. package/dist/api/registry/operationRegistry.core.d.ts.map +0 -1
  45. package/dist/api/registry/operationRegistry.core.js.map +0 -1
  46. package/dist/api/result/apiResult.type.d.ts.map +0 -1
  47. package/dist/api/result/apiResult.type.js.map +0 -1
  48. package/dist/index.d.ts.map +0 -1
  49. package/dist/index.js.map +0 -1
@@ -1,62 +1,300 @@
1
+ import { apiFailure, apiSuccess } from "../result/apiResult.type.js";
2
+ import { resolveOperationTimeout } from "../operation/operation.type.js";
3
+ import { APIError, APIInternalError, APITimeoutError, APIValidationError, createAPIError, ErrorCode, isAPIError, } from "../errors/index.js";
4
+ import { MAX_INTERCEPTORS, MAX_VALIDATION_ISSUES, MAX_VALIDATION_ISSUE_LENGTH, } from "../constants.js";
1
5
  /**
2
- * Error normalizer for converting unknown errors into Error instances.
6
+ * Error normalizer for converting unknown errors into APIError instances.
7
+ *
8
+ * APIErrors pass through untouched. Everything else is wrapped in an
9
+ * `APIInternalError` (`expose: false`) carrying a generic message; the
10
+ * original error is preserved on `cause` for logging.
11
+ *
12
+ * The wrapper's own message is deliberately *not* a copy of the original.
13
+ * `BaseError.toJSON()` in `@zudojs/errors` 0.1.0 emits `message`, `stack`
14
+ * and the serialized `cause` regardless of `expose`, so a transport doing
15
+ * `res.json(result.error)` would otherwise ship the raw driver message
16
+ * (connection strings, constraint names, file paths, tokens) to a client.
3
17
  */
4
18
  export function normalizeAPIError(error, operationName) {
5
- if (error instanceof Error) {
19
+ if (isAPIError(error)) {
6
20
  return error;
7
21
  }
8
- return new Error(`Unexpected non-error thrown in operation "${operationName}": ${String(error)}`);
22
+ const where = operationName !== undefined && operationName !== ""
23
+ ? `operation "${operationName}"`
24
+ : "an API operation";
25
+ const wrapped = error instanceof Error
26
+ ? new APIInternalError(`An unexpected internal error occurred in ${where}.`)
27
+ : new APIInternalError(`A non-error value (${describeValueType(error)}) was thrown in ${where}.`);
28
+ // `APIInternalError`'s subclass constructor forwards only
29
+ // `{ endpoint, method }`, so `cause` cannot be passed through it.
30
+ // `APIError` / `createAPIError` *do* accept `cause`, but constructing
31
+ // through them would lose the `APIInternalError` class identity that
32
+ // consumers match on. `cause` is a declared writable class field on
33
+ // `BaseError`, so assigning it after construction is equivalent for
34
+ // `toJSON()` and for `error.cause` reads; only the native `[[cause]]`
35
+ // slot differs.
36
+ wrapped.cause = error;
37
+ return wrapped;
38
+ }
39
+ function describeValueType(value) {
40
+ if (value === null) {
41
+ return "null";
42
+ }
43
+ if (Array.isArray(value)) {
44
+ return "array";
45
+ }
46
+ return typeof value;
47
+ }
48
+ function isStandardSchema(value) {
49
+ return (typeof value === "object" &&
50
+ value !== null &&
51
+ typeof value["~standard"]
52
+ ?.validate === "function");
53
+ }
54
+ /**
55
+ * A schema result counts as a failure only when it carries at least one
56
+ * issue. Some adapters always populate `issues` and return an empty array
57
+ * on success; treating that as a failure produces a 422 with an empty
58
+ * issue list and no way to learn what was wrong.
59
+ */
60
+ function hasIssues(result) {
61
+ return Array.isArray(result.issues) && result.issues.length > 0;
9
62
  }
10
63
  /**
11
64
  * Executes an API operation through its interceptor pipeline.
65
+ *
66
+ * Enforces the operation timeout, honors the context AbortSignal, and
67
+ * validates input and output when the operation's `input` / `output` is a
68
+ * Standard Schema.
12
69
  */
13
70
  export class APIExecutor {
14
71
  interceptors;
15
- constructor(interceptors = []) {
72
+ exposeValidationMessages;
73
+ maxValidationIssues;
74
+ constructor(optionsOrInterceptors = {}) {
75
+ const options = Array.isArray(optionsOrInterceptors)
76
+ ? { interceptors: optionsOrInterceptors }
77
+ : optionsOrInterceptors;
78
+ const interceptors = options.interceptors ?? [];
79
+ if (interceptors.length > MAX_INTERCEPTORS) {
80
+ throw new RangeError(`Interceptor pipeline exceeds MAX_INTERCEPTORS (${MAX_INTERCEPTORS}).`);
81
+ }
82
+ const maxValidationIssues = options.maxValidationIssues ?? MAX_VALIDATION_ISSUES;
83
+ if (!Number.isInteger(maxValidationIssues) ||
84
+ maxValidationIssues < 1 ||
85
+ maxValidationIssues > MAX_VALIDATION_ISSUES) {
86
+ throw new RangeError(`maxValidationIssues must be an integer between 1 and ${MAX_VALIDATION_ISSUES}, received ${String(maxValidationIssues)}.`);
87
+ }
16
88
  this.interceptors = Object.freeze([...interceptors]);
89
+ this.exposeValidationMessages = options.exposeValidationMessages === true;
90
+ this.maxValidationIssues = maxValidationIssues;
17
91
  }
18
92
  /**
19
93
  * Executes an operation with the given input and context.
20
94
  */
21
95
  async execute(operation, input, context) {
22
- const executionContext = {
23
- operation,
24
- input,
25
- context,
26
- };
27
- const executeNext = async () => {
96
+ if (context.signal?.aborted) {
97
+ return apiFailure(abortedError(operation.name));
98
+ }
99
+ let effectiveInput = input;
100
+ if (isStandardSchema(operation.input)) {
28
101
  try {
29
- const output = await operation.handler(input, context);
30
- return { ok: true, data: output };
102
+ const validation = await operation.input["~standard"].validate(input);
103
+ if (hasIssues(validation)) {
104
+ return apiFailure(new APIValidationError(`Invalid input for operation "${operation.name}".`, this.clientIssues(validation.issues)));
105
+ }
106
+ effectiveInput = validation.value;
31
107
  }
32
108
  catch (error) {
33
- return {
34
- ok: false,
35
- error: normalizeAPIError(error, operation.name),
36
- };
109
+ return apiFailure(normalizeAPIError(error, operation.name));
37
110
  }
111
+ }
112
+ const executionContext = {
113
+ operation,
114
+ input: effectiveInput,
115
+ context,
38
116
  };
39
- return this.runPipeline(executionContext, executeNext);
117
+ // Reads `executionContext.input` at call time, so an interceptor that
118
+ // replaces the input before calling `next()` actually changes what the
119
+ // handler receives.
120
+ const executeHandler = () => this.invokeHandler(operation, executionContext.input, context);
121
+ try {
122
+ return await this.runPipeline(executionContext, executeHandler);
123
+ }
124
+ catch (error) {
125
+ return apiFailure(normalizeAPIError(error, operation.name));
126
+ }
127
+ }
128
+ /**
129
+ * Invokes the operation handler under its timeout and abort signal, and
130
+ * validates the handler's output when `operation.output` is a Standard
131
+ * Schema.
132
+ */
133
+ async invokeHandler(operation, input, context) {
134
+ if (context.signal?.aborted) {
135
+ return apiFailure(abortedError(operation.name));
136
+ }
137
+ const timeoutMs = resolveOperationTimeout(operation);
138
+ let output;
139
+ try {
140
+ output = await withDeadline(operation.handler(input, context), timeoutMs, operation.name, context.signal);
141
+ }
142
+ catch (error) {
143
+ return apiFailure(normalizeAPIError(error, operation.name));
144
+ }
145
+ return this.validateOutput(operation, output);
146
+ }
147
+ /**
148
+ * Validates handler output against `operation.output`.
149
+ *
150
+ * A response that does not match its declared schema is a server bug,
151
+ * not a client mistake, so failures surface as an `APIInternalError`
152
+ * (500, `expose: false`) naming only the failing paths — never the
153
+ * offending values, which are exactly the fields (password hashes,
154
+ * internal audit columns) that should not reach a client.
155
+ */
156
+ async validateOutput(operation, output) {
157
+ if (!isStandardSchema(operation.output)) {
158
+ return apiSuccess(output);
159
+ }
160
+ let validation;
161
+ try {
162
+ validation = await operation.output["~standard"].validate(output);
163
+ }
164
+ catch (error) {
165
+ return apiFailure(normalizeAPIError(error, operation.name));
166
+ }
167
+ if (hasIssues(validation)) {
168
+ const paths = validation.issues
169
+ .slice(0, this.maxValidationIssues)
170
+ .map(formatIssuePath)
171
+ .join(", ");
172
+ return apiFailure(new APIInternalError(`Invalid output for operation "${operation.name}" at: ${paths}.`));
173
+ }
174
+ return apiSuccess(validation.value);
40
175
  }
41
176
  /**
42
- * Runs the interceptor pipeline.
177
+ * Converts schema issues into the capped, redacted list carried on the
178
+ * client-facing `APIValidationError`.
43
179
  */
44
- async runPipeline(context, next) {
180
+ clientIssues(issues) {
181
+ const limit = this.maxValidationIssues;
182
+ const shown = issues.slice(0, limit).map((issue) => this.exposeValidationMessages
183
+ ? truncate(issue.message, MAX_VALIDATION_ISSUE_LENGTH)
184
+ : `${formatIssuePath(issue)}: invalid`);
185
+ const omitted = issues.length - shown.length;
186
+ if (omitted > 0) {
187
+ shown.push(`… and ${omitted} more issue(s) omitted.`);
188
+ }
189
+ return shown;
190
+ }
191
+ /**
192
+ * Runs the interceptor pipeline (Koa-style dispatch).
193
+ *
194
+ * Each interceptor's `next()` may be awaited at most once; a second
195
+ * call rejects instead of silently re-executing the handler while
196
+ * bypassing downstream interceptors.
197
+ *
198
+ * `context.result` is assigned as each level resolves, so an
199
+ * interceptor reading it after `await next()` sees exactly what the
200
+ * level below returned — including when a downstream interceptor
201
+ * short-circuits without calling `next()`.
202
+ */
203
+ runPipeline(context, terminal) {
45
204
  const interceptors = this.interceptors;
46
- let index = 0;
47
- const runNext = async () => {
205
+ let lastDispatched = -1;
206
+ const dispatch = async (index) => {
207
+ if (index <= lastDispatched) {
208
+ throw new APIInternalError("next() called multiple times in interceptor pipeline.");
209
+ }
210
+ lastDispatched = index;
48
211
  if (index >= interceptors.length) {
49
- return next();
212
+ const result = await terminal();
213
+ context.result = result;
214
+ return result;
50
215
  }
51
216
  const interceptor = interceptors[index];
52
- index += 1;
53
- if (!interceptor) {
54
- return next();
55
- }
56
- return interceptor.intercept(context, runNext);
217
+ const result = await interceptor.intercept(context, () => dispatch(index + 1));
218
+ context.result = result;
219
+ return result;
57
220
  };
58
- const result = await runNext();
59
- return result;
221
+ return dispatch(0);
60
222
  }
61
223
  }
224
+ // ─── Internal helpers ─────────────────────────────────────────────────────
225
+ function truncate(value, max) {
226
+ const text = typeof value === "string" ? value : String(value);
227
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
228
+ }
229
+ /**
230
+ * Renders a Standard Schema issue path as a dotted string. Path segments
231
+ * are field names, never submitted values, so they are safe to expose.
232
+ */
233
+ function formatIssuePath(issue) {
234
+ const path = issue.path;
235
+ if (path === undefined || path.length === 0) {
236
+ return "(root)";
237
+ }
238
+ return path
239
+ .map((segment) => {
240
+ const key = typeof segment === "object" && segment !== null && "key" in segment
241
+ ? segment.key
242
+ : segment;
243
+ return truncate(String(key), 64);
244
+ })
245
+ .join(".");
246
+ }
247
+ /**
248
+ * Error for an execution cancelled by the caller's `AbortSignal`.
249
+ *
250
+ * Carries `ErrorCode.OPERATION_CANCELLED` — branch on that, not on the
251
+ * status code. `statusCode` is 499, an nginx convention ("Client Closed
252
+ * Request") rather than an IANA status; this package is
253
+ * transport-agnostic, so each adapter should map the *code* onto whatever
254
+ * its protocol calls "cancelled" (gRPC `CANCELLED`, a dropped queue
255
+ * message, a non-zero CLI exit) rather than passing 499 to the wire.
256
+ */
257
+ function abortedError(operationName) {
258
+ return createAPIError(`Operation "${operationName}" was aborted.`, {
259
+ code: ErrorCode.OPERATION_CANCELLED,
260
+ statusCode: 499,
261
+ expose: true,
262
+ });
263
+ }
264
+ /**
265
+ * Awaits a handler promise, rejecting when the timeout elapses or the
266
+ * abort signal fires. The handler itself keeps running (promises are not
267
+ * cancellable), but the caller stops waiting and resources are released.
268
+ *
269
+ * `timeoutMs` is always a positive integer here — `defineOperation`
270
+ * rejects anything else and `resolveOperationTimeout` substitutes the
271
+ * default for hand-rolled operations — so the deadline can never be
272
+ * silently disabled.
273
+ */
274
+ function withDeadline(promise, timeoutMs, operationName, signal) {
275
+ return new Promise((resolve, reject) => {
276
+ let timer;
277
+ const cleanup = () => {
278
+ if (timer !== undefined)
279
+ clearTimeout(timer);
280
+ signal?.removeEventListener("abort", onAbort);
281
+ };
282
+ const onAbort = () => {
283
+ cleanup();
284
+ reject(abortedError(operationName));
285
+ };
286
+ timer = setTimeout(() => {
287
+ cleanup();
288
+ reject(new APITimeoutError(timeoutMs));
289
+ }, timeoutMs);
290
+ signal?.addEventListener("abort", onAbort, { once: true });
291
+ promise.then((value) => {
292
+ cleanup();
293
+ resolve(value);
294
+ }, (error) => {
295
+ cleanup();
296
+ reject(error);
297
+ });
298
+ });
299
+ }
62
300
  //# sourceMappingURL=executor.core.js.map
@@ -1,3 +1,4 @@
1
1
  export { APIExecutor } from "./executor.core.js";
2
2
  export { normalizeAPIError } from "./executor.core.js";
3
+ export type { APIExecutorOptions } from "./executor.core.js";
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -2,8 +2,11 @@ import type { APIContext } from "../context/context.type.js";
2
2
  /**
3
3
  * Handler for an API operation.
4
4
  *
5
- * Receives validated input and the execution context,
6
- * and returns the operation output.
5
+ * Receives the operation input and the execution context, and returns
6
+ * the operation output. Input is validated by the executor only when the
7
+ * operation's `input` is a Standard Schema (https://standardschema.dev,
8
+ * implemented by Zod, Valibot, ArkType, …); otherwise it is passed
9
+ * through as-is and the handler must validate it itself.
7
10
  */
8
11
  export type APIHandler<TInput = unknown, TOutput = unknown> = (input: TInput, context: APIContext) => Promise<TOutput>;
9
12
  //# sourceMappingURL=handler.type.d.ts.map
@@ -6,8 +6,25 @@ import type { APIOperation } from "../operation/operation.type.js";
6
6
  */
7
7
  export interface APIExecutionContext<TInput = unknown, TOutput = unknown> {
8
8
  readonly operation: APIOperation<TInput, TOutput>;
9
- readonly input: TInput;
9
+ /**
10
+ * The input the handler will receive.
11
+ *
12
+ * Writable on purpose: an interceptor may replace it before calling
13
+ * `next()` (to sanitize, scope to a tenant, or apply a default) and the
14
+ * handler receives the replacement. The executor reads this field at
15
+ * handler-invocation time, so a replacement made by any interceptor in
16
+ * the chain takes effect.
17
+ */
18
+ input: TInput;
10
19
  readonly context: APIContext;
20
+ /**
21
+ * The result produced by the level below this one.
22
+ *
23
+ * The executor assigns it as each pipeline level resolves, so after
24
+ * `await next()` this holds exactly what `next()` returned — including
25
+ * when a downstream interceptor short-circuits without running the
26
+ * handler. It is `undefined` before `next()` has resolved.
27
+ */
11
28
  readonly result?: APIResult<TOutput>;
12
29
  }
13
30
  /**
@@ -7,6 +7,10 @@ export interface APIOperationMetadata {
7
7
  readonly tags?: readonly string[];
8
8
  readonly deprecated?: boolean;
9
9
  readonly version?: string;
10
+ /**
11
+ * Operation timeout in milliseconds. Superseded by
12
+ * {@link APIOperation.timeout} when both are present.
13
+ */
10
14
  readonly timeout?: number;
11
15
  readonly idempotent?: boolean;
12
16
  }
@@ -18,25 +22,111 @@ export interface APIOperationMetadata {
18
22
  */
19
23
  export interface APIOperation<TInput = unknown, TOutput = unknown> {
20
24
  readonly name: string;
25
+ /**
26
+ * Input schema. When this is a Standard Schema
27
+ * (https://standardschema.dev), the executor validates input against it
28
+ * before invoking the handler; other values are documentation-only.
29
+ */
21
30
  readonly input?: unknown;
31
+ /**
32
+ * Output schema. When this is a Standard Schema
33
+ * (https://standardschema.dev), the executor validates the handler's
34
+ * return value against it and returns the validated (possibly
35
+ * transformed) value as the result data; a handler returning the wrong
36
+ * shape fails with an `APIInternalError` (`expose: false`) rather than
37
+ * leaking through to the transport. Other values are
38
+ * documentation-only.
39
+ */
22
40
  readonly output?: unknown;
23
41
  readonly handler: APIHandler<TInput, TOutput>;
24
42
  readonly metadata?: APIOperationMetadata;
43
+ /**
44
+ * Operation timeout in milliseconds. Must be a positive, finite integer
45
+ * no greater than {@link MAX_OPERATION_TIMEOUT}. There is no way to
46
+ * disable the deadline: `0` and negative values are rejected by
47
+ * {@link defineOperation} rather than silently running unbounded.
48
+ */
25
49
  readonly timeout?: number;
26
50
  }
51
+ /**
52
+ * An operation of any input/output type.
53
+ *
54
+ * `APIOperation<unknown, unknown>` is *not* a supertype of a typed
55
+ * operation — a handler taking `{ id: string }` cannot be called with an
56
+ * `unknown` input — so APIs that merely store or catalogue operations
57
+ * (the registry) accept this instead.
58
+ */
59
+ export type AnyAPIOperation = APIOperation<never, unknown>;
27
60
  /**
28
61
  * Options for defining an API operation.
29
62
  */
30
63
  export interface DefineOperationOptions<TInput = unknown, TOutput = unknown> {
31
64
  readonly name: string;
65
+ /** @see {@link APIOperation.input} */
32
66
  readonly input?: unknown;
67
+ /** @see {@link APIOperation.output} */
33
68
  readonly output?: unknown;
34
69
  readonly handler: APIHandler<TInput, TOutput>;
35
70
  readonly metadata?: APIOperationMetadata;
71
+ /** @see {@link APIOperation.timeout} */
36
72
  readonly timeout?: number;
37
73
  }
74
+ /**
75
+ * Validates an operation timeout.
76
+ *
77
+ * @throws {TypeError} if the value is not a number.
78
+ * @throws {RangeError} if the value is not a positive, finite integer of
79
+ * at most {@link MAX_OPERATION_TIMEOUT} milliseconds.
80
+ */
81
+ export declare function assertValidTimeout(timeout: unknown, label: string): number;
82
+ /**
83
+ * Resolves the effective timeout for an operation.
84
+ *
85
+ * This is the single owner of the precedence rule
86
+ * (`timeout` > `metadata.timeout` > {@link DEFAULT_OPERATION_TIMEOUT});
87
+ * `defineOperation` and the executor both go through it.
88
+ *
89
+ * Unusable values are skipped rather than disabling the deadline: the
90
+ * first usable candidate wins, and {@link DEFAULT_OPERATION_TIMEOUT}
91
+ * applies when none is. `defineOperation` rejects unusable values
92
+ * outright, so that fallback only applies to hand-rolled `APIOperation`
93
+ * objects.
94
+ */
95
+ export declare function resolveOperationTimeout(source: {
96
+ readonly timeout?: number;
97
+ readonly metadata?: {
98
+ readonly timeout?: number;
99
+ };
100
+ }): number;
101
+ /**
102
+ * Validates the identity-bearing fields of an operation.
103
+ *
104
+ * Called by `defineOperation` and re-checked by
105
+ * `APIOperationRegistry.register`, since `APIOperation` is a bare
106
+ * interface that callers can satisfy without `defineOperation`.
107
+ *
108
+ * @throws {TypeError} if `name` or `handler` has the wrong type.
109
+ * @throws {RangeError} if `name` is empty, over-long, or contains
110
+ * characters outside `[A-Za-z0-9._:/-]`.
111
+ */
112
+ export declare function assertValidOperationShape(operation: {
113
+ readonly name?: unknown;
114
+ readonly handler?: unknown;
115
+ }): void;
116
+ /**
117
+ * Deeply freezes an operation's metadata so a registered operation cannot
118
+ * be rewritten process-wide through `metadata.tags` or `metadata.timeout`.
119
+ */
120
+ export declare function freezeOperationMetadata(metadata: APIOperationMetadata | undefined): APIOperationMetadata | undefined;
38
121
  /**
39
122
  * Creates a new API operation definition.
123
+ *
124
+ * Validates the definition eagerly — a bad name, a missing handler, or an
125
+ * unusable timeout fails here, at startup, rather than on the first
126
+ * request that reaches the operation.
127
+ *
128
+ * @throws {TypeError} if `name` or `handler` has the wrong type.
129
+ * @throws {RangeError} if `name` or a supplied `timeout` is out of range.
40
130
  */
41
131
  export declare function defineOperation<TInput = unknown, TOutput = unknown>(options: DefineOperationOptions<TInput, TOutput>): APIOperation<TInput, TOutput>;
42
132
  //# sourceMappingURL=operation.type.d.ts.map
@@ -1,11 +1,123 @@
1
- import { DEFAULT_OPERATION_TIMEOUT } from "../constants.js";
1
+ import { DEFAULT_OPERATION_TIMEOUT, MAX_OPERATION_NAME_LENGTH, MAX_OPERATION_TIMEOUT, } from "../constants.js";
2
+ const OPERATION_NAME_PATTERN = /^[A-Za-z0-9._:/-]+$/;
3
+ /**
4
+ * Validates an operation timeout.
5
+ *
6
+ * @throws {TypeError} if the value is not a number.
7
+ * @throws {RangeError} if the value is not a positive, finite integer of
8
+ * at most {@link MAX_OPERATION_TIMEOUT} milliseconds.
9
+ */
10
+ export function assertValidTimeout(timeout, label) {
11
+ if (typeof timeout !== "number") {
12
+ throw new TypeError(`${label} must be a number, received ${typeof timeout}.`);
13
+ }
14
+ if (!Number.isFinite(timeout) || !Number.isInteger(timeout)) {
15
+ throw new RangeError(`${label} must be a finite integer number of milliseconds, received ${String(timeout)}.`);
16
+ }
17
+ if (timeout <= 0) {
18
+ throw new RangeError(`${label} must be greater than 0 ms, received ${timeout}. The deadline cannot be disabled.`);
19
+ }
20
+ if (timeout > MAX_OPERATION_TIMEOUT) {
21
+ throw new RangeError(`${label} must be at most ${MAX_OPERATION_TIMEOUT} ms, received ${timeout}.`);
22
+ }
23
+ return timeout;
24
+ }
25
+ function isUsableTimeout(timeout) {
26
+ return (typeof timeout === "number" &&
27
+ Number.isInteger(timeout) &&
28
+ timeout > 0 &&
29
+ timeout <= MAX_OPERATION_TIMEOUT);
30
+ }
31
+ /**
32
+ * Resolves the effective timeout for an operation.
33
+ *
34
+ * This is the single owner of the precedence rule
35
+ * (`timeout` > `metadata.timeout` > {@link DEFAULT_OPERATION_TIMEOUT});
36
+ * `defineOperation` and the executor both go through it.
37
+ *
38
+ * Unusable values are skipped rather than disabling the deadline: the
39
+ * first usable candidate wins, and {@link DEFAULT_OPERATION_TIMEOUT}
40
+ * applies when none is. `defineOperation` rejects unusable values
41
+ * outright, so that fallback only applies to hand-rolled `APIOperation`
42
+ * objects.
43
+ */
44
+ export function resolveOperationTimeout(source) {
45
+ for (const candidate of [source.timeout, source.metadata?.timeout]) {
46
+ if (isUsableTimeout(candidate)) {
47
+ return candidate;
48
+ }
49
+ }
50
+ return DEFAULT_OPERATION_TIMEOUT;
51
+ }
52
+ /**
53
+ * Validates the identity-bearing fields of an operation.
54
+ *
55
+ * Called by `defineOperation` and re-checked by
56
+ * `APIOperationRegistry.register`, since `APIOperation` is a bare
57
+ * interface that callers can satisfy without `defineOperation`.
58
+ *
59
+ * @throws {TypeError} if `name` or `handler` has the wrong type.
60
+ * @throws {RangeError} if `name` is empty, over-long, or contains
61
+ * characters outside `[A-Za-z0-9._:/-]`.
62
+ */
63
+ export function assertValidOperationShape(operation) {
64
+ const { name, handler } = operation;
65
+ if (typeof name !== "string") {
66
+ throw new TypeError(`Operation name must be a string, received ${typeof name}.`);
67
+ }
68
+ if (name.length === 0) {
69
+ throw new RangeError("Operation name must not be empty.");
70
+ }
71
+ if (name.length > MAX_OPERATION_NAME_LENGTH) {
72
+ throw new RangeError(`Operation name must be at most ${MAX_OPERATION_NAME_LENGTH} characters, received ${name.length}.`);
73
+ }
74
+ if (!OPERATION_NAME_PATTERN.test(name)) {
75
+ throw new RangeError(`Operation name "${name}" contains characters outside ${OPERATION_NAME_PATTERN.source}.`);
76
+ }
77
+ if (typeof handler !== "function") {
78
+ throw new TypeError(`Operation "${name}" must have a handler function, received ${typeof handler}.`);
79
+ }
80
+ }
81
+ /**
82
+ * Deeply freezes an operation's metadata so a registered operation cannot
83
+ * be rewritten process-wide through `metadata.tags` or `metadata.timeout`.
84
+ */
85
+ export function freezeOperationMetadata(metadata) {
86
+ if (metadata === undefined) {
87
+ return undefined;
88
+ }
89
+ if (Array.isArray(metadata.tags)) {
90
+ Object.freeze(metadata.tags);
91
+ }
92
+ return Object.freeze(metadata);
93
+ }
2
94
  /**
3
95
  * Creates a new API operation definition.
96
+ *
97
+ * Validates the definition eagerly — a bad name, a missing handler, or an
98
+ * unusable timeout fails here, at startup, rather than on the first
99
+ * request that reaches the operation.
100
+ *
101
+ * @throws {TypeError} if `name` or `handler` has the wrong type.
102
+ * @throws {RangeError} if `name` or a supplied `timeout` is out of range.
4
103
  */
5
104
  export function defineOperation(options) {
105
+ assertValidOperationShape(options);
106
+ if (options.timeout !== undefined) {
107
+ assertValidTimeout(options.timeout, "Operation timeout");
108
+ }
109
+ if (options.metadata?.timeout !== undefined) {
110
+ assertValidTimeout(options.metadata.timeout, "Operation metadata.timeout");
111
+ }
112
+ // Explicit field list rather than `...options`: an operation carries
113
+ // exactly the contract fields, never arbitrary extra properties.
6
114
  const operation = {
7
- ...options,
8
- timeout: options.timeout ?? options.metadata?.timeout ?? DEFAULT_OPERATION_TIMEOUT,
115
+ name: options.name,
116
+ input: options.input,
117
+ output: options.output,
118
+ handler: options.handler,
119
+ metadata: freezeOperationMetadata(options.metadata),
120
+ timeout: resolveOperationTimeout(options),
9
121
  };
10
122
  return Object.freeze(operation);
11
123
  }
@@ -1,8 +1,11 @@
1
- import type { APIOperation } from "../operation/operation.type.js";
1
+ import type { AnyAPIOperation, APIOperation } from "../operation/operation.type.js";
2
2
  /**
3
3
  * Registry for API operations.
4
4
  *
5
5
  * Enforces uniqueness and provides O(1) lookup by operation name.
6
+ *
7
+ * Every failure leaving this class is an `APIError`, so a transport can
8
+ * map it by `statusCode` / `code` without special-casing the registry.
6
9
  */
7
10
  export declare class APIOperationRegistry {
8
11
  private readonly operations;
@@ -10,9 +13,15 @@ export declare class APIOperationRegistry {
10
13
  /**
11
14
  * Registers an operation.
12
15
  *
16
+ * The operation and its metadata are frozen on registration, so a
17
+ * registered operation cannot be rewritten through `metadata.tags` or
18
+ * `metadata.timeout` after the fact.
19
+ *
13
20
  * @throws {APIDuplicateOperationError} if an operation with the same name is already registered.
21
+ * @throws {APIError} if the registry is frozen.
22
+ * @throws {TypeError | RangeError} if the operation's name or handler is invalid.
14
23
  */
15
- register(operation: APIOperation): void;
24
+ register(operation: AnyAPIOperation): void;
16
25
  /**
17
26
  * Retrieves an operation by name.
18
27
  */
@@ -23,6 +32,8 @@ export declare class APIOperationRegistry {
23
32
  has(name: string): boolean;
24
33
  /**
25
34
  * Retrieves an operation by name or throws.
35
+ *
36
+ * @throws {APIOperationNotFoundError} (404) if no operation is registered under `name`.
26
37
  */
27
38
  require(name: string): APIOperation;
28
39
  /**
@@ -35,6 +46,8 @@ export declare class APIOperationRegistry {
35
46
  findByTag(tag: string): readonly APIOperation[];
36
47
  /**
37
48
  * Unregisters an operation.
49
+ *
50
+ * @throws {APIError} if the registry is frozen.
38
51
  */
39
52
  unregister(name: string): boolean;
40
53
  /**