@zudojs/api 0.1.2 → 1.0.1

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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -9
  3. package/dist/api/constants.d.ts +31 -3
  4. package/dist/api/constants.js +31 -3
  5. package/dist/api/context/context.type.d.ts +38 -0
  6. package/dist/api/context/context.type.js +86 -6
  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 +68 -7
  12. package/dist/api/executor/executor.core.js +184 -54
  13. package/dist/api/executor/index.d.ts +1 -0
  14. package/dist/api/interceptors/interceptor.type.d.ts +16 -4
  15. package/dist/api/operation/operation.type.d.ts +85 -0
  16. package/dist/api/operation/operation.type.js +115 -3
  17. package/dist/api/registry/operationRegistry.core.d.ts +15 -2
  18. package/dist/api/registry/operationRegistry.core.js +32 -4
  19. package/dist/index.d.ts +21 -12
  20. package/dist/index.js +19 -12
  21. package/package.json +28 -17
  22. package/dist/.tsbuildinfo +0 -1
  23. package/dist/api/constants.d.ts.map +0 -1
  24. package/dist/api/constants.js.map +0 -1
  25. package/dist/api/context/context.type.d.ts.map +0 -1
  26. package/dist/api/context/context.type.js.map +0 -1
  27. package/dist/api/context/contextKey.type.d.ts.map +0 -1
  28. package/dist/api/context/contextKey.type.js.map +0 -1
  29. package/dist/api/errors/index.d.ts.map +0 -1
  30. package/dist/api/errors/index.js.map +0 -1
  31. package/dist/api/executor/executor.core.d.ts.map +0 -1
  32. package/dist/api/executor/executor.core.js.map +0 -1
  33. package/dist/api/executor/index.d.ts.map +0 -1
  34. package/dist/api/executor/index.js.map +0 -1
  35. package/dist/api/handler/handler.type.d.ts.map +0 -1
  36. package/dist/api/handler/handler.type.js.map +0 -1
  37. package/dist/api/interceptors/interceptor.type.d.ts.map +0 -1
  38. package/dist/api/interceptors/interceptor.type.js.map +0 -1
  39. package/dist/api/operation/operation.type.d.ts.map +0 -1
  40. package/dist/api/operation/operation.type.js.map +0 -1
  41. package/dist/api/registry/index.d.ts.map +0 -1
  42. package/dist/api/registry/index.js.map +0 -1
  43. package/dist/api/registry/operationRegistry.core.d.ts.map +0 -1
  44. package/dist/api/registry/operationRegistry.core.js.map +0 -1
  45. package/dist/api/result/apiResult.type.d.ts.map +0 -1
  46. package/dist/api/result/apiResult.type.js.map +0 -1
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/index.js.map +0 -1
@@ -1,27 +1,49 @@
1
- import { APIError, APIInternalError, APITimeoutError, APIValidationError, createAPIError, isAPIError, } from "../errors/index.js";
2
- import { DEFAULT_OPERATION_TIMEOUT, MAX_INTERCEPTORS } from "../constants.js";
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";
3
5
  /**
4
6
  * Error normalizer for converting unknown errors into APIError instances.
5
7
  *
6
- * APIErrors pass through untouched. Other errors are wrapped in
7
- * APIInternalError (`expose: false`), preserving the original message and
8
- * error for logging via `cause` while keeping internals out of anything
9
- * a transport would serialize to a client.
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.
10
17
  */
11
18
  export function normalizeAPIError(error, operationName) {
12
19
  if (isAPIError(error)) {
13
20
  return error;
14
21
  }
15
- if (error instanceof Error) {
16
- const wrapped = new APIInternalError(error.message);
17
- // The published @zudojs/errors 0.1.0 constructor doesn't accept
18
- // `cause`; the field is a plain writable property on BaseError.
19
- // Switch to `new APIInternalError(msg, { cause })` once this package
20
- // depends on the next @zudojs/errors release.
21
- wrapped.cause = error;
22
- return wrapped;
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";
23
45
  }
24
- return new APIInternalError(`Unexpected non-error thrown in operation "${operationName}": ${String(error)}`);
46
+ return typeof value;
25
47
  }
26
48
  function isStandardSchema(value) {
27
49
  return (typeof value === "object" &&
@@ -29,41 +51,62 @@ function isStandardSchema(value) {
29
51
  typeof value["~standard"]
30
52
  ?.validate === "function");
31
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;
62
+ }
32
63
  /**
33
64
  * Executes an API operation through its interceptor pipeline.
34
65
  *
35
66
  * Enforces the operation timeout, honors the context AbortSignal, and
36
- * validates input when the operation's `input` is a Standard Schema.
67
+ * validates input and output when the operation's `input` / `output` is a
68
+ * Standard Schema.
37
69
  */
38
70
  export class APIExecutor {
39
71
  interceptors;
40
- 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 ?? [];
41
79
  if (interceptors.length > MAX_INTERCEPTORS) {
42
80
  throw new RangeError(`Interceptor pipeline exceeds MAX_INTERCEPTORS (${MAX_INTERCEPTORS}).`);
43
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
+ }
44
88
  this.interceptors = Object.freeze([...interceptors]);
89
+ this.exposeValidationMessages = options.exposeValidationMessages === true;
90
+ this.maxValidationIssues = maxValidationIssues;
45
91
  }
46
92
  /**
47
93
  * Executes an operation with the given input and context.
48
94
  */
49
95
  async execute(operation, input, context) {
50
96
  if (context.signal?.aborted) {
51
- return { ok: false, error: abortedError(operation.name) };
97
+ return apiFailure(abortedError(operation.name));
52
98
  }
53
99
  let effectiveInput = input;
54
100
  if (isStandardSchema(operation.input)) {
55
101
  try {
56
102
  const validation = await operation.input["~standard"].validate(input);
57
- if (validation.issues) {
58
- return {
59
- ok: false,
60
- error: new APIValidationError(`Invalid input for operation "${operation.name}".`, validation.issues.map((issue) => issue.message)),
61
- };
103
+ if (hasIssues(validation)) {
104
+ return apiFailure(new APIValidationError(`Invalid input for operation "${operation.name}".`, this.clientIssues(validation.issues)));
62
105
  }
63
106
  effectiveInput = validation.value;
64
107
  }
65
108
  catch (error) {
66
- return { ok: false, error: normalizeAPIError(error, operation.name) };
109
+ return apiFailure(normalizeAPIError(error, operation.name));
67
110
  }
68
111
  }
69
112
  const executionContext = {
@@ -71,37 +114,79 @@ export class APIExecutor {
71
114
  input: effectiveInput,
72
115
  context,
73
116
  };
74
- const executeHandler = async () => {
75
- const result = await this.invokeHandler(operation, effectiveInput, context);
76
- executionContext.result = result;
77
- return result;
78
- };
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);
79
121
  try {
80
- const result = await this.runPipeline(executionContext, executeHandler);
81
- executionContext.result = result;
82
- return result;
122
+ return await this.runPipeline(executionContext, executeHandler);
83
123
  }
84
124
  catch (error) {
85
- return { ok: false, error: normalizeAPIError(error, operation.name) };
125
+ return apiFailure(normalizeAPIError(error, operation.name));
86
126
  }
87
127
  }
88
128
  /**
89
- * Invokes the operation handler under its timeout and abort signal.
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.
90
132
  */
91
133
  async invokeHandler(operation, input, context) {
92
134
  if (context.signal?.aborted) {
93
- return { ok: false, error: abortedError(operation.name) };
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);
94
159
  }
95
- const timeoutMs = operation.timeout ??
96
- operation.metadata?.timeout ??
97
- DEFAULT_OPERATION_TIMEOUT;
160
+ let validation;
98
161
  try {
99
- const output = await withDeadline(operation.handler(input, context), timeoutMs, operation.name, context.signal);
100
- return { ok: true, data: output };
162
+ validation = await operation.output["~standard"].validate(output);
101
163
  }
102
164
  catch (error) {
103
- return { ok: false, error: normalizeAPIError(error, operation.name) };
165
+ return apiFailure(normalizeAPIError(error, operation.name));
104
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);
175
+ }
176
+ /**
177
+ * Converts schema issues into the capped, redacted list carried on the
178
+ * client-facing `APIValidationError`.
179
+ */
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;
105
190
  }
106
191
  /**
107
192
  * Runs the interceptor pipeline (Koa-style dispatch).
@@ -109,6 +194,11 @@ export class APIExecutor {
109
194
  * Each interceptor's `next()` may be awaited at most once; a second
110
195
  * call rejects instead of silently re-executing the handler while
111
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()`.
112
202
  */
113
203
  runPipeline(context, terminal) {
114
204
  const interceptors = this.interceptors;
@@ -119,17 +209,54 @@ export class APIExecutor {
119
209
  }
120
210
  lastDispatched = index;
121
211
  if (index >= interceptors.length) {
122
- return terminal();
212
+ const result = await terminal();
213
+ context.result = result;
214
+ return result;
123
215
  }
124
216
  const interceptor = interceptors[index];
125
- return interceptor.intercept(context, () => dispatch(index + 1));
217
+ const result = await interceptor.intercept(context, () => dispatch(index + 1));
218
+ context.result = result;
219
+ return result;
126
220
  };
127
221
  return dispatch(0);
128
222
  }
129
223
  }
130
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
+ */
131
257
  function abortedError(operationName) {
132
258
  return createAPIError(`Operation "${operationName}" was aborted.`, {
259
+ code: ErrorCode.OPERATION_CANCELLED,
133
260
  statusCode: 499,
134
261
  expose: true,
135
262
  });
@@ -138,12 +265,13 @@ function abortedError(operationName) {
138
265
  * Awaits a handler promise, rejecting when the timeout elapses or the
139
266
  * abort signal fires. The handler itself keeps running (promises are not
140
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.
141
273
  */
142
274
  function withDeadline(promise, timeoutMs, operationName, signal) {
143
- const useTimeout = Number.isFinite(timeoutMs) && timeoutMs > 0;
144
- if (!useTimeout && !signal) {
145
- return promise;
146
- }
147
275
  return new Promise((resolve, reject) => {
148
276
  let timer;
149
277
  const cleanup = () => {
@@ -155,14 +283,16 @@ function withDeadline(promise, timeoutMs, operationName, signal) {
155
283
  cleanup();
156
284
  reject(abortedError(operationName));
157
285
  };
158
- if (useTimeout) {
159
- timer = setTimeout(() => {
160
- cleanup();
161
- reject(new APITimeoutError(timeoutMs));
162
- }, timeoutMs);
163
- }
286
+ timer = setTimeout(() => {
287
+ cleanup();
288
+ reject(new APITimeoutError(timeoutMs));
289
+ }, timeoutMs);
164
290
  signal?.addEventListener("abort", onAbort, { once: true });
165
- promise.then((value) => {
291
+ // `Promise.resolve` rather than `promise.then`: a hand-rolled operation
292
+ // whose handler returns synchronously is a legal JavaScript caller, and
293
+ // calling `.then` on its plain value failed with "promise.then is not a
294
+ // function" reported as an internal error of the operation.
295
+ Promise.resolve(promise).then((value) => {
166
296
  cleanup();
167
297
  resolve(value);
168
298
  }, (error) => {
@@ -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
@@ -6,12 +6,24 @@ 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;
11
20
  /**
12
- * The operation result. Populated by the executor once the handler has
13
- * run, so interceptor code after `await next()` can read it here as
14
- * well as from `next()`'s return value.
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.
15
27
  */
16
28
  readonly result?: APIResult<TOutput>;
17
29
  }
@@ -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
  }
@@ -24,24 +28,105 @@ export interface APIOperation<TInput = unknown, TOutput = unknown> {
24
28
  * before invoking the handler; other values are documentation-only.
25
29
  */
26
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
+ */
27
40
  readonly output?: unknown;
28
41
  readonly handler: APIHandler<TInput, TOutput>;
29
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
+ */
30
49
  readonly timeout?: number;
31
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>;
32
60
  /**
33
61
  * Options for defining an API operation.
34
62
  */
35
63
  export interface DefineOperationOptions<TInput = unknown, TOutput = unknown> {
36
64
  readonly name: string;
65
+ /** @see {@link APIOperation.input} */
37
66
  readonly input?: unknown;
67
+ /** @see {@link APIOperation.output} */
38
68
  readonly output?: unknown;
39
69
  readonly handler: APIHandler<TInput, TOutput>;
40
70
  readonly metadata?: APIOperationMetadata;
71
+ /** @see {@link APIOperation.timeout} */
41
72
  readonly timeout?: number;
42
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;
43
121
  /**
44
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.
45
130
  */
46
131
  export declare function defineOperation<TInput = unknown, TOutput = unknown>(options: DefineOperationOptions<TInput, TOutput>): APIOperation<TInput, TOutput>;
47
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
  /**