@zudojs/api 1.1.1 → 1.2.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 (78) hide show
  1. package/README.md +162 -6
  2. package/dist/api/bindings/cli/apiCli.argv.d.ts +36 -0
  3. package/dist/api/bindings/cli/apiCli.argv.js +78 -0
  4. package/dist/api/bindings/cli/apiCli.binding.d.ts +45 -0
  5. package/dist/api/bindings/cli/apiCli.binding.js +77 -0
  6. package/dist/api/bindings/cli/apiCli.exitCode.d.ts +31 -0
  7. package/dist/api/bindings/cli/apiCli.exitCode.js +43 -0
  8. package/dist/api/bindings/cli/apiCli.values.d.ts +24 -0
  9. package/dist/api/bindings/cli/apiCli.values.js +71 -0
  10. package/dist/api/bindings/cli/index.d.ts +12 -0
  11. package/dist/api/bindings/cli/index.js +9 -0
  12. package/dist/api/bindings/fetch/apiFetch.handler.d.ts +35 -0
  13. package/dist/api/bindings/fetch/apiFetch.handler.js +99 -0
  14. package/dist/api/bindings/fetch/apiFetch.input.d.ts +29 -0
  15. package/dist/api/bindings/fetch/apiFetch.input.js +74 -0
  16. package/dist/api/bindings/fetch/index.d.ts +11 -0
  17. package/dist/api/bindings/fetch/index.js +9 -0
  18. package/dist/api/bindings/index.d.ts +25 -0
  19. package/dist/api/bindings/index.js +18 -0
  20. package/dist/api/bindings/openapi/apiOpenAPI.descriptor.d.ts +37 -0
  21. package/dist/api/bindings/openapi/apiOpenAPI.descriptor.js +67 -0
  22. package/dist/api/bindings/openapi/apiOpenAPI.schemas.d.ts +32 -0
  23. package/dist/api/bindings/openapi/apiOpenAPI.schemas.js +62 -0
  24. package/dist/api/bindings/openapi/index.d.ts +9 -0
  25. package/dist/api/bindings/openapi/index.js +8 -0
  26. package/dist/api/bindings/queue/apiQueue.binding.d.ts +39 -0
  27. package/dist/api/bindings/queue/apiQueue.binding.js +65 -0
  28. package/dist/api/bindings/queue/index.d.ts +7 -0
  29. package/dist/api/bindings/queue/index.js +6 -0
  30. package/dist/api/bindings/route/apiRoute.resolver.d.ts +33 -0
  31. package/dist/api/bindings/route/apiRoute.resolver.js +109 -0
  32. package/dist/api/bindings/route/apiRoute.table.d.ts +53 -0
  33. package/dist/api/bindings/route/apiRoute.table.js +95 -0
  34. package/dist/api/bindings/route/apiRoute.type.d.ts +65 -0
  35. package/dist/api/bindings/route/apiRoute.type.js +8 -0
  36. package/dist/api/bindings/route/index.d.ts +11 -0
  37. package/dist/api/bindings/route/index.js +9 -0
  38. package/dist/api/bindings/rpc/apiRpc.binding.d.ts +46 -0
  39. package/dist/api/bindings/rpc/apiRpc.binding.js +63 -0
  40. package/dist/api/bindings/rpc/apiRpc.errors.d.ts +20 -0
  41. package/dist/api/bindings/rpc/apiRpc.errors.js +64 -0
  42. package/dist/api/bindings/rpc/index.d.ts +9 -0
  43. package/dist/api/bindings/rpc/index.js +8 -0
  44. package/dist/api/bindings/shared/apiBinding.helper.d.ts +44 -0
  45. package/dist/api/bindings/shared/apiBinding.helper.js +66 -0
  46. package/dist/api/bindings/shared/apiBinding.type.d.ts +37 -0
  47. package/dist/api/bindings/shared/apiBinding.type.js +2 -0
  48. package/dist/api/bindings/shared/apiWireResult.helper.d.ts +47 -0
  49. package/dist/api/bindings/shared/apiWireResult.helper.js +38 -0
  50. package/dist/api/bindings/shared/index.d.ts +11 -0
  51. package/dist/api/bindings/shared/index.js +8 -0
  52. package/dist/api/context/context.type.d.ts +4 -4
  53. package/dist/api/context/context.type.js +2 -2
  54. package/dist/api/context/contextKey.type.d.ts +8 -0
  55. package/dist/api/context/contextKey.type.js +2 -0
  56. package/dist/api/executor/executor.core.d.ts +20 -63
  57. package/dist/api/executor/executor.core.js +36 -193
  58. package/dist/api/executor/executor.deadline.d.ts +40 -0
  59. package/dist/api/executor/executor.deadline.js +93 -0
  60. package/dist/api/executor/executor.issues.d.ts +19 -0
  61. package/dist/api/executor/executor.issues.js +46 -0
  62. package/dist/api/executor/executor.normalize.d.ts +16 -0
  63. package/dist/api/executor/executor.normalize.js +45 -0
  64. package/dist/api/executor/executor.type.d.ts +33 -0
  65. package/dist/api/executor/executor.type.js +2 -0
  66. package/dist/api/executor/executor.validation.d.ts +31 -0
  67. package/dist/api/executor/executor.validation.js +58 -0
  68. package/dist/api/executor/index.d.ts +9 -2
  69. package/dist/api/executor/index.js +8 -1
  70. package/dist/api/handler/handler.type.d.ts +7 -4
  71. package/dist/api/interceptors/interceptor.type.d.ts +6 -5
  72. package/dist/api/operation/operation.type.d.ts +18 -1
  73. package/dist/api/operation/operation.type.js +5 -11
  74. package/dist/api/operation/operationSchema.type.d.ts +56 -0
  75. package/dist/api/operation/operationSchema.type.js +2 -0
  76. package/dist/index.d.ts +7 -3
  77. package/dist/index.js +7 -2
  78. package/package.json +16 -6
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zudojs/api
2
2
 
3
- Higher-level API layer — operation definitions, execution context, interceptors, and a transport-agnostic executor. Sits above `@zudojs/http` and `@zudojs/cqrs`.
3
+ Higher-level API layer — operation definitions, execution context, interceptors, a transport-agnostic executor, and bindings that expose one operation over HTTP, RPC, queues and the CLI. `@zudojs/http` mounts it; it never imports `@zudojs/http`.
4
4
 
5
5
  <!-- zudo-docs:start -->
6
6
 
@@ -12,7 +12,7 @@ Higher-level API layer — operation definitions, execution context, interceptor
12
12
 
13
13
  Import this when you need:
14
14
 
15
- - define an operation once and call it from HTTP, RPC, queue, or CLI
15
+ - define an operation once and serve it over HTTP (`createApiFetchHandler`), RPC (`registerApiRpcProcedures`), a queue (`bindApiQueue`), and the CLI (`runApiCli`)
16
16
  - apply the same interceptors (auth, logging, retry) regardless of transport
17
17
  - typed `APIContext` flowing through every handler
18
18
 
@@ -68,6 +68,8 @@ import {
68
68
  } from "@zudojs/api";
69
69
  ```
70
70
 
71
+ The bindings add `createApiFetchHandler`, `describeApiRoutes`, `resolveApiRoute`, `registerApiRpcProcedures`, `createApiRpcProcedure`, `apiErrorToRPCError`, `bindApiQueue`, `createApiQueueProcessor`, `runApiCli`, `parseApiCliArgs`, `APICliExitCode`, `toApiWireResult`, `toApiWireError`, `TransportContextKey` and the types `APIOperationRoute`, `APIOperationHttpOptions`, `APIWireResult`, `APIWireError` and each binding's options — see [Bindings](#bindings-one-operation-many-transports).
72
+
71
73
  Every API error class from `@zudojs/errors` (`APIError`, `APIValidationError`, `APIInternalError`, `APIOperationNotFoundError`, …) plus `createAPIError`, `isAPIError` and `ErrorCode` are re-exported for convenience.
72
74
 
73
75
  ## Usage
@@ -120,6 +122,147 @@ if (result.ok) {
120
122
 
121
123
  Results are frozen `{ ok: true, data }` / `{ ok: false, error }` objects — `execute` never throws for an operation failure.
122
124
 
125
+ ## Bindings: one operation, many transports
126
+
127
+ Every binding runs calls through an `APIExecutor` you pass as `executor` (so its interceptors apply everywhere), validates input with the operation's schema (after the interceptors), sets `TransportContextKey` (`"http" | "rpc" | "queue" | "cli"`) on the context, and reports each non-exposed failure to `onInternalError(error, requestId)`. The `state` option builds `context.state` from what the transport hands over (the `Request`, the `RPCContext`, the `Job`, the CLI invocation); throw an `APIError` there, such as `APIAuthenticationError`, to refuse the call.
128
+
129
+ Failures take one client-safe shape everywhere, `APIWireError`: `{ code, message, statusCode, requestId, issues? }`. `message` is the error's own only when it is `expose: true`, otherwise `"An internal error occurred."`. Stack traces, causes and metadata never leave the process.
130
+
131
+ ```typescript
132
+ import { schema } from "@zudojs/schema";
133
+ import { APIExecutor, APIOperationRegistry, defineOperation } from "@zudojs/api";
134
+
135
+ const getUser = defineOperation({
136
+ name: "users.get",
137
+ input: schema.object({ id: schema.string() }),
138
+ metadata: { http: { method: "GET", path: "/users/:id" } }, // default: POST /users.get
139
+ handler: async (input) => db.findUser(input.id),
140
+ });
141
+
142
+ const registry = new APIOperationRegistry();
143
+ registry.register(getUser);
144
+ registry.freeze();
145
+ const executor = new APIExecutor({ interceptors: [logging] });
146
+ ```
147
+
148
+ ### HTTP: a web-standard fetch handler
149
+
150
+ ```typescript
151
+ import { createApiFetchHandler } from "@zudojs/api";
152
+
153
+ const handle = createApiFetchHandler(registry, {
154
+ executor,
155
+ basePath: "/api",
156
+ state: (request) => ({ user: verify(request.headers.get("authorization")) }),
157
+ });
158
+
159
+ // Any Fetch API server: @zudojs/http, Bun.serve, Deno.serve, an edge runtime.
160
+ const response = await handle(new Request("https://app.test/api/users/u1"));
161
+ // 200 { "ok": true, "data": { ... } }
162
+ ```
163
+
164
+ On `@zudojs/http`, mount it with no glue code. `mountFetchHandler` strips the mount prefix by default, so leave `basePath` unset:
165
+
166
+ ```typescript
167
+ import { mountFetchHandler } from "@zudojs/http";
168
+
169
+ mountFetchHandler(router, "/api", createApiFetchHandler(registry, { executor }));
170
+ ```
171
+
172
+ `GET` and `DELETE` routes read input from the query string (values are strings, so use coercing schemas); `POST`, `PUT` and `PATCH` read a JSON body (at most `maxBodyBytes`, 1 MiB by default). Path parameters are merged over either and win. A `__proto__`, `constructor` or `prototype` key anywhere in the query or body is refused with 400, and a body route answers 415 to any declared content type other than JSON, even with an empty body, so a cross-site HTML form cannot trigger it. Every binding (RPC, queue, CLI) refuses such keys in its input too, with a validation error. Errors use their status: 422 validation, 404 unknown route, 405 wrong method (with `Allow`), 413/415/400 for a bad body, 500 internal. The request id comes from a safe `x-request-id` header and is echoed back; the call runs under `request.signal`.
173
+
174
+ ### Route contract for `@zudojs/http` and OpenAPI
175
+
176
+ `describeApiRoutes(operations, { basePath })` returns one frozen `APIOperationRoute` per operation and throws on invalid or conflicting routes:
177
+
178
+ ```typescript
179
+ interface APIOperationRoute {
180
+ readonly operationId: string; // operation name
181
+ readonly method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
182
+ readonly path: string; // "/api/users/:id"
183
+ readonly pathParams: readonly string[]; // ["id"]
184
+ readonly inputSource: "query" | "body";
185
+ readonly input?: unknown; // the operation's input schema
186
+ readonly output?: unknown; // the operation's output schema
187
+ readonly description?: string;
188
+ readonly tags?: readonly string[];
189
+ readonly deprecated?: boolean;
190
+ readonly version?: string;
191
+ readonly successStatus: 200;
192
+ }
193
+ ```
194
+
195
+ ### OpenAPI
196
+
197
+ `toOpenAPIRouteDescriptors(operations, { basePath })` turns the same routes into `@zudojs/openapi` descriptors, so one call documents what the fetch handler serves:
198
+
199
+ ```typescript
200
+ import { createOpenAPIDocumentFromRoutes } from "@zudojs/openapi";
201
+ import { toOpenAPIRouteDescriptors } from "@zudojs/api";
202
+
203
+ const document = createOpenAPIDocumentFromRoutes(
204
+ toOpenAPIRouteDescriptors(registry, { basePath: "/api" }),
205
+ { info: { title: "Users", version: "1.0.0" } },
206
+ );
207
+ ```
208
+
209
+ Each descriptor carries `operationId`, method, path, description, tags and `deprecated`. The input schema becomes `query` (`GET` / `DELETE`) or `body`, with path-bound fields moved to `params`. `responses` documents 200 with the `{ ok: true, data }` envelope around the output schema, and 422, 404, 409, 500 and 504 with the `{ ok: false, error }` body. Only `@zudojs/schema` schemas are converted; with another schema library, input stays undocumented and `data` is `unknown`.
210
+
211
+ ### RPC
212
+
213
+ ```typescript
214
+ import { RPCClient, RPCServer, createRPCMemoryTransport } from "@zudojs/rpc";
215
+ import { registerApiRpcProcedures } from "@zudojs/api";
216
+
217
+ const server = new RPCServer();
218
+ registerApiRpcProcedures(server, registry, {
219
+ executor,
220
+ state: (rpc) => ({ user: rpc.auth?.userId }), // transport-verified, never frame metadata
221
+ });
222
+
223
+ const client = new RPCClient(createRPCMemoryTransport(server));
224
+ await client.call("users.get", { id: "u1" });
225
+ ```
226
+
227
+ Procedure names default to the operation name, which must then be a valid RPC name (`"users.get"`); pass `procedureName` to map others. API errors become their RPC equivalents (`RPC_VALIDATION_ERROR` with issues, `RPC_UNAUTHENTICATED`, `RPC_FORBIDDEN`, `RPC_RATE_LIMITED`, `RPC_TIMEOUT`, …); any other keeps its code, such as `ERR_API_CONFLICT`. Serve the same server over HTTP with `createRPCFetchHandler(server)`, for example `mountFetchHandler(router, "/rpc", createRPCFetchHandler(server))` on `@zudojs/http`.
228
+
229
+ ### Queues
230
+
231
+ ```typescript
232
+ import { createInMemoryQueue, createQueueName } from "@zudojs/queue";
233
+ import { bindApiQueue } from "@zudojs/api";
234
+
235
+ const queue = createInMemoryQueue(createQueueName("operations"));
236
+ bindApiQueue(queue, registry, { executor });
237
+
238
+ await queue.add("users.get", { id: "u1" }, { attempts: 1 });
239
+ ```
240
+
241
+ The job name is the operation name and `job.data` is the input. Success completes the job with the output. A failure throws an `APIError` carrying the client-safe message, so the queue retries and dead-letters it as usual. Validation failures are retried too, so enqueue unchecked input with `attempts: 1`.
242
+
243
+ ### CLI
244
+
245
+ ```typescript
246
+ #!/usr/bin/env node
247
+ import { runApiCli } from "@zudojs/api";
248
+
249
+ const controller = new AbortController();
250
+ process.once("SIGINT", () => controller.abort());
251
+ process.exitCode = await runApiCli(registry, process.argv.slice(2), {
252
+ executor,
253
+ programName: "app",
254
+ signal: controller.signal,
255
+ });
256
+ ```
257
+
258
+ ```bash
259
+ app users.get --id u1 # prints the output as JSON, exit 0
260
+ app users.create --json '{"name":"Ann"}' --address.city Paris --admin
261
+ app --help # lists operations
262
+ ```
263
+
264
+ The operation name comes first (or pass `operation` for a single-purpose binary). `--field value` and `--field=value` set fields; kebab-case becomes camelCase, dots nest, repeats collect arrays, `--flag` is `true` and `--no-flag` is `false`. JSON-looking values (numbers, `true`, `false`, `null`, `{…}`, `[…]`) are parsed; anything else stays a string. On failure the `{ ok: false, error }` body goes to stderr and the exit code follows `APICliExitCode`: 64 usage, 65 invalid input, 69 unavailable, 70 internal, 75 timeout, 77 permission, 130 cancelled, 1 otherwise.
265
+
123
266
  ## Input validation
124
267
 
125
268
  The executor accepts two kinds of schema for `input` and `output`, recognised structurally so the package depends on no validation library:
@@ -143,7 +286,9 @@ const charge = defineOperation({
143
286
  });
144
287
  ```
145
288
 
146
- The executor validates the input before the handler runs and passes the schema's _transformed_ value to the handler. Failures return an `APIValidationError` (422).
289
+ The executor validates the input immediately before the handler runs, after every interceptor, and passes the schema's _transformed_ value to the handler. Failures return an `APIValidationError` (422). Because the interceptors run first, an authentication interceptor refuses an anonymous call with its own error (401) before the input is inspected, so a 422 describing the schema never reaches an unauthenticated caller; logging, metrics and rate-limit interceptors see invalid calls (`context.result` is the 422); and an interceptor that replaces `context.input` cannot bypass the schema, since the replacement is what gets validated.
290
+
291
+ Without type arguments, `defineOperation` infers the handler's `input` from the `input` schema: a Standard Schema's declared output type, or the `data` of a `safeParse` schema's success result. That works inline too, as in `registry.register(defineOperation({ input: TodoInput, handler: async (input) => input.title }))` or an operation list passed to a binding. Explicit type arguments (`defineOperation<TInput, TOutput>(...)`) are still honoured as given. `InferAPISchemaOutput<typeof schema>` names the inferred type.
147
292
 
148
293
  Schema issue messages routinely interpolate the value that failed, so by default the executor does **not** copy them into the client-facing error: each issue becomes `"<path>: invalid"` (e.g. `"user.email: invalid"`), naming where validation failed without echoing what was submitted. The list is capped at `MAX_VALIDATION_ISSUES` entries with a trailing `"… and N more issue(s) omitted."` marker.
149
294
 
@@ -161,14 +306,25 @@ When `operation.output` is set, the handler's return value is validated too, and
161
306
 
162
307
  Every operation has a deadline. `timeout` must be a positive, finite integer of at most `MAX_OPERATION_TIMEOUT` milliseconds; `defineOperation` rejects `0`, negatives, `NaN` and non-integers rather than silently running unbounded. Precedence is `timeout` → `metadata.timeout` → `DEFAULT_OPERATION_TIMEOUT`, resolved by `resolveOperationTimeout`.
163
308
 
164
- The handler itself is not cancellable — pass `context.signal` into anything that supports it. An execution cancelled through the signal fails with `ErrorCode.OPERATION_CANCELLED`; branch on that code rather than on the (nginx-convention) 499 status.
309
+ The handler always receives a `context.signal`, even when the caller supplied none. It aborts when the deadline elapses (its `reason` is the `APITimeoutError` the call fails with, 504) or when the caller's signal aborts (its `reason` is the `ErrorCode.OPERATION_CANCELLED` error the call fails with), so pass it into anything that supports it (`fetch`, a driver query, a loop check) and the work stops instead of running on and repeating side effects after the caller has given up. The signal is not aborted when the handler completes normally. Everything else on the handler's context (`requestId`, `state`, `get`/`set`, `metadata`) is the caller's context. Branch on `ErrorCode.OPERATION_CANCELLED` rather than on the (nginx-convention) 499 status.
310
+
311
+ ```typescript
312
+ const exportReport = defineOperation({
313
+ name: "reports.export",
314
+ timeout: 10_000,
315
+ handler: async (input, context) => {
316
+ const rows = await fetch(reportUrl, { signal: context.signal }); // stops at the deadline
317
+ return rows.json();
318
+ },
319
+ });
320
+ ```
165
321
 
166
322
  ## Interceptors
167
323
 
168
324
  ```typescript
169
325
  const timing: APIInterceptor = {
170
326
  async intercept(context, next) {
171
- context.input = sanitize(context.input); // reaches the handler
327
+ context.input = sanitize(context.input); // validated, then reaches the handler
172
328
  const started = Date.now();
173
329
  const result = await next();
174
330
  // context.result === result, including when a downstream interceptor
@@ -181,7 +337,7 @@ const timing: APIInterceptor = {
181
337
  new APIExecutor([timing]); // or new APIExecutor({ interceptors: [timing] })
182
338
  ```
183
339
 
184
- At most `MAX_INTERCEPTORS` interceptors per executor, and each `next()` may be awaited once.
340
+ At most `MAX_INTERCEPTORS` interceptors per executor, and each `next()` may be awaited once. Interceptors run outermost first and wrap input validation: `context.input` is the input as the caller sent it (not yet validated or transformed), and whatever an interceptor leaves there is validated before the handler sees it.
185
341
 
186
342
  ## Context
187
343
 
@@ -0,0 +1,36 @@
1
+ import type { Serializer } from "@zudojs/serialization";
2
+ /**
3
+ * Parsed command line: the operation named on it (when the CLI serves
4
+ * several), the input object built from flags, and whether help was asked
5
+ * for. A malformed command line is reported as `{ ok: false, message }`.
6
+ */
7
+ export type APICliParseResult = {
8
+ readonly ok: true;
9
+ readonly operation?: string;
10
+ readonly input: unknown;
11
+ readonly help: boolean;
12
+ } | {
13
+ readonly ok: false;
14
+ readonly message: string;
15
+ };
16
+ /**
17
+ * Parses CLI arguments into operation input.
18
+ *
19
+ * - `--name value`, `--name=value`: sets `input.name`. Kebab-case becomes
20
+ * camelCase (`--first-name` → `firstName`); dots nest
21
+ * (`--address.city Paris`).
22
+ * - A value that is a JSON number, `true`, `false` or `null`, or starts
23
+ * with `{`, `[` or `"`, is parsed as JSON; anything else stays a string.
24
+ * - `--flag` with no value is `true`; `--no-flag` is `false`.
25
+ * - A repeated flag collects an array.
26
+ * - `--json '<object>'` supplies a base input object the flags merge over,
27
+ * or any JSON input when no other flags are given.
28
+ * - `--help` / `-h` requests help.
29
+ *
30
+ * @param expectOperation Whether the first positional argument names the
31
+ * operation.
32
+ * @param serializer JSON parser for values. Defaults to a size- and
33
+ * depth-limited `@zudojs/serialization` JSON serializer.
34
+ */
35
+ export declare function parseApiCliArgs(argv: readonly string[], expectOperation: boolean, serializer?: Serializer<unknown, string>): APICliParseResult;
36
+ //# sourceMappingURL=apiCli.argv.d.ts.map
@@ -0,0 +1,78 @@
1
+ import { createSerializer } from "@zudojs/serialization";
2
+ import { isPlainObject } from "@zudojs/types";
3
+ import { assignOption, camelCase, coerceOptionValue, parseJson } from "./apiCli.values.js";
4
+ const DEFAULT_SERIALIZER = createSerializer("json", { maxSize: 1024 * 1024, maxDepth: 64 });
5
+ const KEY_SEGMENT = /^[A-Za-z_$][\w$-]*$/;
6
+ const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
7
+ /**
8
+ * Parses CLI arguments into operation input.
9
+ *
10
+ * - `--name value`, `--name=value`: sets `input.name`. Kebab-case becomes
11
+ * camelCase (`--first-name` → `firstName`); dots nest
12
+ * (`--address.city Paris`).
13
+ * - A value that is a JSON number, `true`, `false` or `null`, or starts
14
+ * with `{`, `[` or `"`, is parsed as JSON; anything else stays a string.
15
+ * - `--flag` with no value is `true`; `--no-flag` is `false`.
16
+ * - A repeated flag collects an array.
17
+ * - `--json '<object>'` supplies a base input object the flags merge over,
18
+ * or any JSON input when no other flags are given.
19
+ * - `--help` / `-h` requests help.
20
+ *
21
+ * @param expectOperation Whether the first positional argument names the
22
+ * operation.
23
+ * @param serializer JSON parser for values. Defaults to a size- and
24
+ * depth-limited `@zudojs/serialization` JSON serializer.
25
+ */
26
+ export function parseApiCliArgs(argv, expectOperation, serializer = DEFAULT_SERIALIZER) {
27
+ const fields = {};
28
+ let base;
29
+ let operation;
30
+ let help = false;
31
+ let flagCount = 0;
32
+ for (let i = 0; i < argv.length; i += 1) {
33
+ const token = argv[i];
34
+ if (token === "--help" || token === "-h") {
35
+ help = true;
36
+ continue;
37
+ }
38
+ if (!token.startsWith("--")) {
39
+ if (expectOperation && operation === undefined) {
40
+ operation = token;
41
+ continue;
42
+ }
43
+ return { ok: false, message: `Unexpected argument "${token}".` };
44
+ }
45
+ const eq = token.indexOf("=");
46
+ const rawKey = eq === -1 ? token.slice(2) : token.slice(2, eq);
47
+ const next = argv[i + 1];
48
+ let raw = eq === -1 ? undefined : token.slice(eq + 1);
49
+ if (raw === undefined && next !== undefined && !next.startsWith("--")) {
50
+ raw = next;
51
+ i += 1;
52
+ }
53
+ if (rawKey === "json") {
54
+ const parsed = parseJson(raw, serializer);
55
+ if (!parsed.ok) {
56
+ return { ok: false, message: "--json must be followed by valid JSON." };
57
+ }
58
+ base = parsed.value;
59
+ continue;
60
+ }
61
+ const negated = raw === undefined && rawKey.startsWith("no-");
62
+ const path = (negated ? rawKey.slice(3) : rawKey).split(".").map(camelCase);
63
+ if (path.some((segment) => !KEY_SEGMENT.test(segment) || UNSAFE_KEYS.has(segment))) {
64
+ return { ok: false, message: `Invalid option "--${rawKey}".` };
65
+ }
66
+ const value = raw === undefined ? !negated : coerceOptionValue(raw, serializer);
67
+ if (!assignOption(fields, path, value)) {
68
+ return { ok: false, message: `Option "--${rawKey}" conflicts with another option.` };
69
+ }
70
+ flagCount += 1;
71
+ }
72
+ if (flagCount > 0 && base !== undefined && !isPlainObject(base)) {
73
+ return { ok: false, message: "--json must be an object when combined with other options." };
74
+ }
75
+ const input = flagCount === 0 ? (base ?? {}) : { ...(isPlainObject(base) ? base : {}), ...fields };
76
+ return { ok: true, ...(operation !== undefined ? { operation } : {}), input, help };
77
+ }
78
+ //# sourceMappingURL=apiCli.argv.js.map
@@ -0,0 +1,45 @@
1
+ import type { APIBindingOptions, APIOperationSource } from "../shared/apiBinding.type.js";
2
+ import type { APICliExitCodeValue } from "./apiCli.exitCode.js";
3
+ /**
4
+ * One CLI call as the `state` hook sees it.
5
+ */
6
+ export interface APICliInvocation {
7
+ readonly argv: readonly string[];
8
+ readonly input: unknown;
9
+ }
10
+ /**
11
+ * Where the CLI writes. Defaults to `process.stdout` / `process.stderr`.
12
+ */
13
+ export interface APICliIO {
14
+ readonly stdout: (text: string) => void;
15
+ readonly stderr: (text: string) => void;
16
+ }
17
+ /**
18
+ * Options for {@link runApiCli}. `state` receives the invocation.
19
+ */
20
+ export interface APICliOptions extends APIBindingOptions<APICliInvocation> {
21
+ readonly io?: APICliIO;
22
+ /** Cancels the running operation (wire it to SIGINT). */
23
+ readonly signal?: AbortSignal;
24
+ /**
25
+ * Run this operation instead of reading its name from `argv[0]` — for a
26
+ * single-purpose binary.
27
+ */
28
+ readonly operation?: string;
29
+ /** Program name shown in help. Defaults to `"cli"`. */
30
+ readonly programName?: string;
31
+ }
32
+ /**
33
+ * Runs an operation from command-line arguments and returns the exit code.
34
+ *
35
+ * `argv` is `process.argv.slice(2)`: the operation name first (unless
36
+ * `options.operation` fixes it), then `--field value` options and/or
37
+ * `--json '{...}'` (see `parseApiCliArgs`). On success the output is
38
+ * printed to stdout as JSON and the exit code is 0. On failure the same
39
+ * client-safe `{ ok: false, error }` body the HTTP binding sends is
40
+ * printed to stderr, and the exit code reflects the failure (see
41
+ * {@link APICliExitCode}). Never calls `process.exit`, and never throws
42
+ * for a failed call — only for an invalid operation list.
43
+ */
44
+ export declare function runApiCli(operations: APIOperationSource, argv: readonly string[], options?: APICliOptions): Promise<APICliExitCodeValue>;
45
+ //# sourceMappingURL=apiCli.binding.d.ts.map
@@ -0,0 +1,77 @@
1
+ import { createSerializer } from "@zudojs/serialization";
2
+ import { createOperationRunner, listOperations } from "../shared/apiBinding.helper.js";
3
+ import { toApiWireResult } from "../shared/apiWireResult.helper.js";
4
+ import { normalizeAPIError } from "../../executor/executor.core.js";
5
+ import { parseApiCliArgs } from "./apiCli.argv.js";
6
+ import { APICliExitCode, apiCliExitCodeForStatus } from "./apiCli.exitCode.js";
7
+ const PRETTY = createSerializer("json", { pretty: true });
8
+ /**
9
+ * Runs an operation from command-line arguments and returns the exit code.
10
+ *
11
+ * `argv` is `process.argv.slice(2)`: the operation name first (unless
12
+ * `options.operation` fixes it), then `--field value` options and/or
13
+ * `--json '{...}'` (see `parseApiCliArgs`). On success the output is
14
+ * printed to stdout as JSON and the exit code is 0. On failure the same
15
+ * client-safe `{ ok: false, error }` body the HTTP binding sends is
16
+ * printed to stderr, and the exit code reflects the failure (see
17
+ * {@link APICliExitCode}). Never calls `process.exit`, and never throws
18
+ * for a failed call — only for an invalid operation list.
19
+ */
20
+ export async function runApiCli(operations, argv, options = {}) {
21
+ const io = options.io ?? defaultIO();
22
+ const known = listOperations(operations);
23
+ const fixed = options.operation;
24
+ const parsed = parseApiCliArgs(argv, fixed === undefined);
25
+ if (!parsed.ok) {
26
+ io.stderr(`${parsed.message}\n`);
27
+ return APICliExitCode.USAGE;
28
+ }
29
+ const name = fixed ?? parsed.operation;
30
+ if (parsed.help || name === undefined) {
31
+ (parsed.help ? io.stdout : io.stderr)(usage(known, options.programName ?? "cli", fixed));
32
+ return parsed.help ? APICliExitCode.OK : APICliExitCode.USAGE;
33
+ }
34
+ const operation = known.find((candidate) => candidate.name === name);
35
+ if (operation === undefined) {
36
+ io.stderr(`Unknown operation "${name}". Run with --help to list operations.\n`);
37
+ return APICliExitCode.USAGE;
38
+ }
39
+ const run = createOperationRunner(options);
40
+ const { result, requestId } = await run({
41
+ operation,
42
+ input: parsed.input,
43
+ source: { argv, input: parsed.input },
44
+ transport: "cli",
45
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
46
+ });
47
+ const wire = toApiWireResult(result, requestId);
48
+ if (!wire.ok) {
49
+ io.stderr(`${PRETTY.serialize(wire)}\n`);
50
+ return apiCliExitCodeForStatus(wire.error.statusCode);
51
+ }
52
+ try {
53
+ if (wire.data !== undefined) {
54
+ io.stdout(`${PRETTY.serialize(wire.data)}\n`);
55
+ }
56
+ return APICliExitCode.OK;
57
+ }
58
+ catch (error) {
59
+ const failure = normalizeAPIError(error, operation.name);
60
+ options.onInternalError?.(failure, requestId);
61
+ io.stderr(`${PRETTY.serialize(toApiWireResult({ ok: false, error: failure }, requestId))}\n`);
62
+ return APICliExitCode.INTERNAL;
63
+ }
64
+ }
65
+ function usage(operations, program, fixed) {
66
+ const shown = fixed === undefined ? operations : operations.filter((op) => op.name === fixed);
67
+ const lines = shown.map((op) => ` ${op.name}${op.metadata?.description !== undefined ? ` ${op.metadata.description}` : ""}`);
68
+ const call = fixed === undefined ? `${program} <operation>` : program;
69
+ return `Usage: ${call} [--field value ...] [--json '<input>']\n\nOperations:\n${lines.join("\n")}\n`;
70
+ }
71
+ function defaultIO() {
72
+ return {
73
+ stdout: (text) => void process.stdout.write(text),
74
+ stderr: (text) => void process.stderr.write(text),
75
+ };
76
+ }
77
+ //# sourceMappingURL=apiCli.binding.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Exit codes returned by `runApiCli`, following BSD `sysexits.h` where a
3
+ * code fits so shell scripts can branch on the kind of failure.
4
+ */
5
+ export declare const APICliExitCode: Readonly<{
6
+ /** The operation succeeded. */
7
+ OK: 0;
8
+ /** The operation failed with a client error that has no closer code. */
9
+ FAILURE: 1;
10
+ /** Bad command line: unknown operation, malformed option (EX_USAGE). */
11
+ USAGE: 64;
12
+ /** Input failed validation (EX_DATAERR). */
13
+ INVALID_INPUT: 65;
14
+ /** Rate limited or unavailable; try again later (EX_UNAVAILABLE). */
15
+ UNAVAILABLE: 69;
16
+ /** Internal error (EX_SOFTWARE). */
17
+ INTERNAL: 70;
18
+ /** Timed out (EX_TEMPFAIL). */
19
+ TIMEOUT: 75;
20
+ /** Not authenticated or not permitted (EX_NOPERM). */
21
+ PERMISSION: 77;
22
+ /** Cancelled through the abort signal (128 + SIGINT). */
23
+ CANCELLED: 130;
24
+ }>;
25
+ /** One of the {@link APICliExitCode} values. */
26
+ export type APICliExitCodeValue = (typeof APICliExitCode)[keyof typeof APICliExitCode];
27
+ /**
28
+ * Maps a failed result's HTTP-style status to an exit code.
29
+ */
30
+ export declare function apiCliExitCodeForStatus(status: number): APICliExitCodeValue;
31
+ //# sourceMappingURL=apiCli.exitCode.d.ts.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Exit codes returned by `runApiCli`, following BSD `sysexits.h` where a
3
+ * code fits so shell scripts can branch on the kind of failure.
4
+ */
5
+ export const APICliExitCode = Object.freeze({
6
+ /** The operation succeeded. */
7
+ OK: 0,
8
+ /** The operation failed with a client error that has no closer code. */
9
+ FAILURE: 1,
10
+ /** Bad command line: unknown operation, malformed option (EX_USAGE). */
11
+ USAGE: 64,
12
+ /** Input failed validation (EX_DATAERR). */
13
+ INVALID_INPUT: 65,
14
+ /** Rate limited or unavailable; try again later (EX_UNAVAILABLE). */
15
+ UNAVAILABLE: 69,
16
+ /** Internal error (EX_SOFTWARE). */
17
+ INTERNAL: 70,
18
+ /** Timed out (EX_TEMPFAIL). */
19
+ TIMEOUT: 75,
20
+ /** Not authenticated or not permitted (EX_NOPERM). */
21
+ PERMISSION: 77,
22
+ /** Cancelled through the abort signal (128 + SIGINT). */
23
+ CANCELLED: 130,
24
+ });
25
+ /**
26
+ * Maps a failed result's HTTP-style status to an exit code.
27
+ */
28
+ export function apiCliExitCodeForStatus(status) {
29
+ if (status === 400 || status === 422)
30
+ return APICliExitCode.INVALID_INPUT;
31
+ if (status === 401 || status === 403)
32
+ return APICliExitCode.PERMISSION;
33
+ if (status === 408 || status === 504)
34
+ return APICliExitCode.TIMEOUT;
35
+ if (status === 429 || status === 503)
36
+ return APICliExitCode.UNAVAILABLE;
37
+ if (status === 499)
38
+ return APICliExitCode.CANCELLED;
39
+ if (status >= 500)
40
+ return APICliExitCode.INTERNAL;
41
+ return APICliExitCode.FAILURE;
42
+ }
43
+ //# sourceMappingURL=apiCli.exitCode.js.map
@@ -0,0 +1,24 @@
1
+ import type { Serializer } from "@zudojs/serialization";
2
+ /** Converts a kebab-case option segment to camelCase. */
3
+ export declare function camelCase(segment: string): string;
4
+ /** Parses JSON text, reporting failure instead of throwing. */
5
+ export declare function parseJson(raw: string | undefined, serializer: Serializer<unknown, string>): {
6
+ readonly ok: true;
7
+ readonly value: unknown;
8
+ } | {
9
+ readonly ok: false;
10
+ };
11
+ /**
12
+ * Parses an option value that looks like JSON (a number, `true`, `false`,
13
+ * `null`, or text starting with `{`, `[` or `"`); keeps anything else, and
14
+ * any number JSON would round, as the string typed.
15
+ */
16
+ export declare function coerceOptionValue(raw: string, serializer: Serializer<unknown, string>): unknown;
17
+ /**
18
+ * Sets `value` at a dotted `path`, collecting repeats into an array.
19
+ * Returns `false` when the path collides with an existing scalar or
20
+ * object. Only own properties count, so `--to-string` cannot collide
21
+ * with `Object.prototype`.
22
+ */
23
+ export declare function assignOption(target: Record<string, unknown>, path: readonly string[], value: unknown): boolean;
24
+ //# sourceMappingURL=apiCli.values.d.ts.map
@@ -0,0 +1,71 @@
1
+ import { isPlainObject } from "@zudojs/types";
2
+ const JSON_LITERAL = /^(?:-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)$/;
3
+ /** Converts a kebab-case option segment to camelCase. */
4
+ export function camelCase(segment) {
5
+ return segment.replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase());
6
+ }
7
+ /** Parses JSON text, reporting failure instead of throwing. */
8
+ export function parseJson(raw, serializer) {
9
+ if (raw === undefined) {
10
+ return { ok: false };
11
+ }
12
+ try {
13
+ return { ok: true, value: serializer.deserialize(raw) };
14
+ }
15
+ catch {
16
+ return { ok: false };
17
+ }
18
+ }
19
+ /**
20
+ * Parses an option value that looks like JSON (a number, `true`, `false`,
21
+ * `null`, or text starting with `{`, `[` or `"`); keeps anything else, and
22
+ * any number JSON would round, as the string typed.
23
+ */
24
+ export function coerceOptionValue(raw, serializer) {
25
+ const looksJson = JSON_LITERAL.test(raw) || /^[[{"]/.test(raw);
26
+ if (!looksJson) {
27
+ return raw;
28
+ }
29
+ const parsed = parseJson(raw, serializer);
30
+ return parsed.ok && !isLossyNumber(parsed.value) ? parsed.value : raw;
31
+ }
32
+ /**
33
+ * A number JSON cannot hand back faithfully — an integer beyond 2^53 or
34
+ * an overflow to Infinity — is kept as the string the user typed.
35
+ */
36
+ function isLossyNumber(value) {
37
+ return (typeof value === "number" &&
38
+ (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))));
39
+ }
40
+ /**
41
+ * Sets `value` at a dotted `path`, collecting repeats into an array.
42
+ * Returns `false` when the path collides with an existing scalar or
43
+ * object. Only own properties count, so `--to-string` cannot collide
44
+ * with `Object.prototype`.
45
+ */
46
+ export function assignOption(target, path, value) {
47
+ let node = target;
48
+ for (const segment of path.slice(0, -1)) {
49
+ const child = Object.hasOwn(node, segment) ? node[segment] : undefined;
50
+ if (child === undefined) {
51
+ node[segment] = {};
52
+ }
53
+ else if (!isPlainObject(child)) {
54
+ return false;
55
+ }
56
+ node = node[segment];
57
+ }
58
+ const leaf = path[path.length - 1];
59
+ const existing = Object.hasOwn(node, leaf) ? node[leaf] : undefined;
60
+ if (existing === undefined) {
61
+ node[leaf] = value;
62
+ }
63
+ else if (isPlainObject(existing)) {
64
+ return false;
65
+ }
66
+ else {
67
+ node[leaf] = Array.isArray(existing) ? [...existing, value] : [existing, value];
68
+ }
69
+ return true;
70
+ }
71
+ //# sourceMappingURL=apiCli.values.js.map
@@ -0,0 +1,12 @@
1
+ /**
2
+ * CLI binding: parses command-line arguments into operation input, runs
3
+ * the operation, prints the result as JSON, and returns an exit code. No
4
+ * dependency on any CLI framework.
5
+ */
6
+ export type { APICliInvocation, APICliIO, APICliOptions } from "./apiCli.binding.js";
7
+ export { runApiCli } from "./apiCli.binding.js";
8
+ export type { APICliParseResult } from "./apiCli.argv.js";
9
+ export { parseApiCliArgs } from "./apiCli.argv.js";
10
+ export type { APICliExitCodeValue } from "./apiCli.exitCode.js";
11
+ export { APICliExitCode, apiCliExitCodeForStatus } from "./apiCli.exitCode.js";
12
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * CLI binding: parses command-line arguments into operation input, runs
3
+ * the operation, prints the result as JSON, and returns an exit code. No
4
+ * dependency on any CLI framework.
5
+ */
6
+ export { runApiCli } from "./apiCli.binding.js";
7
+ export { parseApiCliArgs } from "./apiCli.argv.js";
8
+ export { APICliExitCode, apiCliExitCodeForStatus } from "./apiCli.exitCode.js";
9
+ //# sourceMappingURL=index.js.map