@powerduck/openapi-request 0.2.2

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.
package/README.md ADDED
@@ -0,0 +1,711 @@
1
+ # @powerduck/openapi-request
2
+
3
+ OpenAPI 3.2-first protocol debugger for HTTP, SSE, WebSocket, GraphQL, gRPC and MCP.
4
+ One client surface, one event model, one write-back pipeline.
5
+
6
+ ## Features
7
+
8
+ - **OpenAPI 3.2** as the single source of truth — every protocol is an operation with extensions.
9
+ - **Incremental SSE / ndjson streaming** with `onEvent` callbacks and `onResponseStart` early classification.
10
+ - **Manual sessions** for WebSocket, gRPC (all four RPC modes) and MCP (Streamable HTTP + stdio).
11
+ - **gRPC** via reflection or `.proto` files, with metadata / status / trailers surfaced as events.
12
+ - **MCP** tools, prompts, resources and resource templates over HTTP or stdio.
13
+ - **GraphQL** introspection, query/mutation generation and subscription over WebSocket.
14
+ - **Postman-runtime** under the hood for HTTP, with full script (pre-request / test) support.
15
+ - **Response write-back** — merges live responses into the OpenAPI document as `responses` objects.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install @powerduck/openapi-request
21
+ ```
22
+
23
+ Requires Node.js >= 18.17.
24
+
25
+ ## Quick Start
26
+
27
+ ```ts
28
+ import { createClient } from "@powerduck/openapi-request";
29
+
30
+ const client = createClient();
31
+
32
+ // 1. Decide how to render before sending anything.
33
+ const prepared = client.prepare({
34
+ spec: openApiDoc,
35
+ target: { path: "/users/{id}", method: "get" },
36
+ });
37
+ console.log(prepared.display.mode); // "response" | "event-list" | "duplex-session"
38
+ console.log(prepared.stream.kind); // "none" | "sse" | "grpc-unary" | ...
39
+
40
+ // 2. Send a one-shot request (HTTP / GraphQL / MCP one-shot).
41
+ const result = await client.send({
42
+ spec: openApiDoc,
43
+ target: { path: "/users/{id}", method: "get" },
44
+ values: { path: { id: "42" } },
45
+ serverUrl: "https://api.example.com",
46
+ onResponseStart: (info) => {
47
+ console.log("streaming:", info.streaming); // true for SSE, switch UI early
48
+ },
49
+ onEvent: (event) => {
50
+ console.log(event.event, event.data); // incremental SSE events
51
+ },
52
+ });
53
+
54
+ // 3. Open a long-lived session (WebSocket / gRPC / MCP).
55
+ const session = client.connect({ kind: "websocket", url: "wss://api.example.com/ws" });
56
+ await session.open();
57
+ session.onEvent((e) => console.log(e));
58
+ await session.send({ type: "ping" });
59
+ await session.close();
60
+ ```
61
+
62
+ ---
63
+
64
+ ## API Reference
65
+
66
+ ### `createClient(options?)`
67
+
68
+ Returns a client with `prepare`, `send`, `sendMany`, `connect`, `discover`, `writeback`, `dispose`, `probeStreamingResponse`.
69
+
70
+ | Option | Type | Default | Description |
71
+ |--------|------|---------|-------------|
72
+ | `writeBack` | `WriteBackOptions` | `{}` | Controls how responses are merged into the spec. |
73
+ | `response` | `ToResponseOptions` | `{}` | Controls how a live response becomes an OpenAPI Response object. |
74
+
75
+ ### `client.prepare(options)` → `PreparedRequest`
76
+
77
+ Pure, synchronous. No I/O. Returns everything the UI needs to choose a renderer.
78
+
79
+ | Field | Type | Description |
80
+ |-------|------|-------------|
81
+ | `protocol` | `string` | `"http"` \| `"sse"` \| `"websocket"` \| `"grpc"` \| `"graphql"` \| `"mcp"` |
82
+ | `transport` | `string` | `"http"` \| `"websocket"` \| `"grpc"` \| `"stdio"` |
83
+ | `display.mode` | `"response"` \| `"event-list"` \| `"duplex-session"` | How to render the result. |
84
+ | `stream.kind` | `StreamKind` | Fine-grained streaming taxonomy. |
85
+ | `stream.expected` | `boolean` | True when the operation declares or implies streaming. |
86
+ | `openapi.extensions` | `Record<string, unknown>` | The seven `x-*` extensions derived from the operation. |
87
+ | `warnings` | `string[]` | Non-fatal issues found during preparation. |
88
+
89
+ ### `client.send(options)` → `Promise<SendResult>`
90
+
91
+ One-shot execution through the full pipeline: build collection → run → parse → write back.
92
+
93
+ ### `client.connect(options)` → `ManualSession`
94
+
95
+ Opens a long-lived session for WebSocket, gRPC or MCP. See [Manual Sessions](#manual-sessions).
96
+
97
+ ### `client.discover(options)` → `Promise<DiscoveryResult>`
98
+
99
+ Discovers schema/capabilities for MCP or gRPC.
100
+
101
+ | Protocol | Options |
102
+ |----------|---------|
103
+ | `mcp` | `{ protocol: "mcp", endpoint, headers?, transport?, command?, args?, cwd? }` |
104
+ | `grpc` | `{ protocol: "grpc", address, reflection?, protoPaths?, includeDirs?, metadata?, channelOptions? }` |
105
+
106
+ ### `client.writeback(spec, prepared, result, options?)` → `OpenApiDocument`
107
+
108
+ Merges a result's response into the spec at the operation's path+method.
109
+
110
+ ---
111
+
112
+ ## SendOptions (all protocols)
113
+
114
+ The full option shape passed to `client.send()`. Protocol-specific fields are nested under `runner`, `websocket`, `graphql`, `mcp`, `grpc`.
115
+
116
+ | Field | Type | Default | Description |
117
+ |-------|------|---------|-------------|
118
+ | `spec` | `OpenApiDocument` | **required** | The OpenAPI 3.2 document. |
119
+ | `target` | `OperationTarget` | **required** | `{ path?, method?, operationId? }` — identifies the operation. |
120
+ | `values` | `RequestValues` | `{}` | Path/query/header/cookie/body values to inject. |
121
+ | `serverUrl` | `string` | `spec.servers[0].url` | Overrides the server URL. |
122
+ | `serverVariables` | `Record<string,string>` | `{}` | Variables for `{var}` placeholders in the server URL. |
123
+ | `variables` | `Record<string,string>` | `{}` | Postman environment variables (`{{name}}`). |
124
+ | `globals` | `Record<string,string>` | `{}` | Postman global variables. |
125
+ | `localVariables` | `Record<string,string>` | `{}` | Postman local variables. |
126
+ | `auth` | `AuthConfig` | — | `{ type: "bearer"\|"basic"\|"apikey"\|"none", ... }`. |
127
+ | `scripts` | `ScriptConfig` | `{}` | Pre-request / test scripts and `x-postman-scripts` handling. |
128
+ | `runner` | `RuntimeRunOptions` | `{}` | Full postman-runtime passthrough. Highest precedence. |
129
+ | `websocket` | `WebSocketOptions` | `{}` | WebSocket-specific options. |
130
+ | `graphql` | `GraphQLOptions` | `{}` | GraphQL-specific options. |
131
+ | `mcp` | `McpOptions` | `{}` | MCP-specific options. |
132
+ | `grpc` | `any` | `{}` | gRPC-specific options (used by the gRPC adapter). |
133
+ | `timeout` | `number` | `30000` | Convenience shortcut for `runner.timeout.request`. `0` = unlimited. |
134
+ | `maxEvents` | `number` | `100` | Maximum streaming events to retain. |
135
+ | `maxStreamMs` | `number` | `30000` | Maximum streaming duration before sampling stops. |
136
+ | `maxResponseSize` | `number` | — | Hard byte ceiling for the response body. Rejected if `<= 0`. |
137
+ | `writeBack` | `boolean` | `true` | Set `false` to skip OpenAPI write-back. |
138
+ | `signal` | `AbortSignal` | — | Cancels the run; returns a partial result. |
139
+ | `onEvent` | `(event: StreamEvent) => void` | — | Incremental SSE / ndjson events. Called defensively (errors swallowed). |
140
+ | `onResponseStart` | `(info: ResponseStartInfo) => void` | — | Fires when headers arrive. `info.streaming` is the early SSE flag. |
141
+ | `onConsole` | `(log: ConsoleLog) => void` | — | Script `console.*` output. |
142
+ | `onAssertion` | `(a: AssertionResult) => void` | — | Individual test assertion results. |
143
+ | `onOpen` | `(info: {url, protocol?, headers}) => void` | — | WebSocket connection established. |
144
+
145
+ ### StreamParserOptions (also on SendOptions)
146
+
147
+ | Field | Type | Default | Description |
148
+ |-------|------|---------|-------------|
149
+ | `maxBufferChars` | `number` | `4194304` (4 Mi) | Max chars buffered while waiting for an event boundary. |
150
+ | `maxEventChars` | `number` | `1048576` (1 Mi) | Max chars in a single event's `data`. |
151
+ | `inheritEventId` | `boolean` | `true` | Attach the last seen `id` to events that omit one. Set `false` for spec-faithful streams. |
152
+
153
+ ---
154
+
155
+ ## HTTP / SSE
156
+
157
+ ### Request JSON structure
158
+
159
+ An HTTP operation is a standard OpenAPI path item. SSE is detected automatically from the response content type or the `x-response-stream` extension.
160
+
161
+ ```json
162
+ {
163
+ "openapi": "3.2.0",
164
+ "info": { "title": "example", "version": "1.0.0" },
165
+ "servers": [{ "url": "https://api.example.com" }],
166
+ "paths": {
167
+ "/users/{id}": {
168
+ "get": {
169
+ "operationId": "getUser",
170
+ "parameters": [
171
+ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
172
+ ],
173
+ "responses": {
174
+ "200": {
175
+ "description": "ok",
176
+ "content": { "application/json": {} }
177
+ }
178
+ }
179
+ }
180
+ },
181
+ "/events": {
182
+ "get": {
183
+ "operationId": "streamEvents",
184
+ "responses": {
185
+ "200": {
186
+ "description": "ok",
187
+ "content": { "text/event-stream": {} }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+ ```
195
+
196
+ ### `runner.requester` options (HTTP transport)
197
+
198
+ These control the underlying HTTP client. Pass via `send({ runner: { requester: { ... } } })`.
199
+
200
+ | Field | Type | Default | Description |
201
+ |-------|------|---------|-------------|
202
+ | `followRedirects` | `boolean` | `true` | Follow HTTP 3xx redirects. |
203
+ | `followOriginalHttpMethod` | `boolean` | `false` | Keep the original method on redirect (e.g. POST stays POST). When `false`, 301/302/303 downgrade to GET. |
204
+ | `maxRedirects` | `number` | `10` | Maximum redirect hops. Must be a non-negative integer. |
205
+ | `protocolVersion` | `"http1" \| "http2" \| "auto"` | `"http1"` | HTTP protocol version. `"http1"` is required for SSE chunked streaming; use `"auto"` for HTTP/2 endpoints. |
206
+ | `strictSSL` | `boolean` | `true` | Reject self-signed / invalid TLS certificates. |
207
+ | `insecureHTTPParser` | `boolean` | `false` | Allow invalid HTTP responses (e.g. malformed headers). |
208
+ | `maxResponseSize` | `number` | — | Hard byte ceiling for the response body. `0` is rejected (would silently yield empty streams). Omit for unbounded. |
209
+ | `maxHeaderSize` | `number` | — | Maximum response header size in bytes. |
210
+ | `useWhatWGUrlParser` | `boolean` | `true` | Use the WHATWG URL parser for request URLs. |
211
+ | `removeRefererHeaderOnRedirect` | `boolean` | `false` | Strip the `Referer` header when following a redirect. |
212
+ | `timings` | `boolean` | `true` | Collect detailed timing data (powers `firstByteMs`, `networkDurationMs`). |
213
+ | `verbose` | `boolean` | `true` | Keep request/response history (powers `replays`). |
214
+ | `implicitCacheControl` | `boolean` | `true` | Add `Cache-Control: no-cache` implicitly. |
215
+ | `implicitTraceHeader` | `boolean` | `true` | Add a trace header implicitly. |
216
+ | `disableCookies` | `boolean` | `false` | Disable the cookie jar. |
217
+ | `cookieJar` | `any` | — | Custom cookie jar instance. |
218
+ | `systemHeaders` | `Record<string,string>` | — | Headers added to every request (e.g. `User-Agent`). |
219
+ | `extendedRootCA` | `string` | — | Path to an additional CA bundle. |
220
+ | `encoding` | `null \| string` | `null` | Response body encoding. **Must be `null` for incremental SSE streaming** — the default `utf8` buffers the body to decode multi-byte boundaries. |
221
+ | `agents` | `{ http?, https? }` | — | Custom HTTP/HTTPS agents. Supplying agents disables the library's socket tracking (stream cancellation falls back to `run.abort()`). |
222
+ | `network.hostLookup` | `{ type, hostIpMap? }` | — | Custom DNS resolution. |
223
+ | `network.restrictedAddresses` | `Record<string,boolean>` | — | Block specific IP addresses (SSRF protection). |
224
+ | `maxInvokableNestedRequests` | `number` | `5` | Max nested requests from scripts (`pm.sendRequest`). |
225
+ | `sslKeyLogFile` | `string` | — | Path to write TLS session keys (for Wireshark debugging). |
226
+
227
+ ### `runner` top-level options
228
+
229
+ | Field | Type | Default | Description |
230
+ |-------|------|---------|-------------|
231
+ | `timeout.request` | `number` | `30000` | Per-request timeout in ms. `0` = unlimited. |
232
+ | `timeout.script` | `number` | `15000` | Script execution timeout in ms. |
233
+ | `timeout.global` | `number` | computed | Global run timeout. Derived from `request + streamBudget + 15000` slack. |
234
+ | `iterationCount` | `number` | `1` | Number of iterations. Must be >= 1. |
235
+ | `data` | `Array<Record<string,unknown>>` | — | Data-driven iteration values. When provided, `iterationCount` defaults to `data.length`. |
236
+ | `stopOnError` | `boolean` | `false` | Halt the run on a request error. |
237
+ | `abortOnError` | `boolean` | `false` | Abort immediately on error (no cleanup callbacks). |
238
+ | `stopOnFailure` | `boolean` | `false` | Halt on a failed test assertion. |
239
+ | `abortOnFailure` | `boolean` | `false` | Abort immediately on assertion failure. |
240
+ | `environment` | `any` | built from `variables` | Postman environment. Override to supply a custom VariableScope. |
241
+ | `globals` | `any` | built from `globals` | Postman globals. |
242
+ | `localVariables` | `any` | built from `localVariables` | Postman local variables. |
243
+ | `proxies` | `any` | — | Postman proxy configuration list. |
244
+ | `systemProxy` | `(url, cb) => void` | — | Callback to resolve the system proxy for a URL. |
245
+ | `ignoreProxyEnvironmentVariables` | `boolean` | `false` | Ignore `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` env vars. |
246
+ | `certificates` | `any` | — | Client certificate list. |
247
+ | `delay.item` | `number` | `0` | Delay between requests in ms. |
248
+ | `delay.iteration` | `number` | `0` | Delay between iterations in ms. |
249
+ | `entrypoint.execute` | `string` | — | Name of the item to start from. |
250
+ | `entrypoint.lookupStrategy` | `"idOrName" \| "path"` | — | How to resolve the entrypoint. |
251
+ | `secretResolver` | `(ctx, cb) => void` | — | Resolve secrets referenced in the collection. |
252
+ | `fileResolver` | `unknown` | — | Resolve file references in request bodies. |
253
+
254
+ ### SSE streaming example
255
+
256
+ ```ts
257
+ const result = await client.send({
258
+ spec,
259
+ target: { path: "/events", method: "get" },
260
+ maxStreamMs: 60000,
261
+ maxEvents: 100,
262
+ onResponseStart: (info) => {
263
+ if (info.streaming) {
264
+ // Switch the UI to the event-list view immediately — do not wait
265
+ // for send() to resolve.
266
+ renderEventList();
267
+ }
268
+ },
269
+ onEvent: (event) => {
270
+ appendEvent(event); // called incrementally, ~every chunk
271
+ },
272
+ });
273
+
274
+ // result.response.events contains all retained events.
275
+ // result.response.truncated / stopReason explain why sampling ended.
276
+ ```
277
+
278
+ ---
279
+
280
+ ## WebSocket
281
+
282
+ ### Request JSON structure
283
+
284
+ ```json
285
+ {
286
+ "paths": {
287
+ "/ws": {
288
+ "get": {
289
+ "operationId": "wsConnect",
290
+ "x-protocol": "websocket",
291
+ "x-ws": {
292
+ "url": "wss://api.example.com/ws",
293
+ "subprotocols": ["graphql-transport-ws"]
294
+ },
295
+ "responses": { "200": { "description": "duplex" } }
296
+ }
297
+ }
298
+ }
299
+ }
300
+ ```
301
+
302
+ ### `websocket` options
303
+
304
+ | Field | Type | Default | Description |
305
+ |-------|------|---------|-------------|
306
+ | `url` | `string` | from `x-ws.url` | Absolute `ws://` or `wss://` URL. Overrides the spec. |
307
+ | `subprotocols` | `string[]` | `[]` | WebSocket subprotocols to negotiate. |
308
+ | `headers` | `Record<string,string>` | `{}` | Extra handshake headers. |
309
+ | `send` | `Array<string \| object \| Uint8Array>` | — | Messages sent immediately after open. |
310
+ | `sendDelayMs` | `number` | — | Delay between consecutive `send` messages. |
311
+ | `maxMessages` | `number` | — | Stop after this many inbound messages. |
312
+ | `maxSessionMs` | `number` | — | Hard cap on total session duration. |
313
+ | `idleTimeoutMs` | `number` | — | Close when no message arrives within this window. |
314
+ | `keepAlive` | `{ intervalMs, payload? }` | — | Application-level ping on an interval. |
315
+ | `closeCode` | `number` | `1000` | Close code sent when terminating. |
316
+ | `closeReason` | `string` | `"Closed"` | Close reason text. |
317
+ | `closeTimeoutMs` | `number` | — | Grace period for the peer's close frame before destroying the socket. |
318
+ | `rejectUnauthorized` | `boolean` | `true` | Reject self-signed TLS certificates. |
319
+ | `maxPayloadBytes` | `number` | — | Cap on retained payload size per frame. |
320
+ | `handshakeTimeoutMs` | `number` | `15000` | Handshake timeout. |
321
+ | `clientOptions` | `Record<string,unknown>` | `{}` | Extra options forwarded to the `ws` constructor. |
322
+
323
+ ### Manual WebSocket session
324
+
325
+ ```ts
326
+ const session = client.connect({
327
+ kind: "websocket",
328
+ url: "wss://api.example.com/ws",
329
+ subprotocols: ["chat"],
330
+ headers: { Authorization: "Bearer xxx" },
331
+ });
332
+
333
+ session.onEvent((e) => {
334
+ // e.kind: "open" | "text" | "binary" | "error" | "close" | "upgrade"
335
+ // e.direction: "in" | "out" | "meta"
336
+ // e.data: text payload (or base64 for binary)
337
+ });
338
+
339
+ await session.open();
340
+ await session.send({ type: "subscribe", channel: "updates" });
341
+ await session.close({ code: 1000, reason: "done" });
342
+ ```
343
+
344
+ ---
345
+
346
+ ## GraphQL
347
+
348
+ ### Request JSON structure
349
+
350
+ ```json
351
+ {
352
+ "paths": {
353
+ "/graphql": {
354
+ "post": {
355
+ "operationId": "graphqlQuery",
356
+ "x-protocol": "graphql",
357
+ "x-graphql": {
358
+ "endpoint": "https://api.example.com/graphql",
359
+ "operationType": "query",
360
+ "operationName": "GetUser",
361
+ "query": "query GetUser($id: ID!) { user(id: $id) { name } }",
362
+ "variablesSchema": {
363
+ "type": "object",
364
+ "properties": { "id": { "type": "string" } },
365
+ "required": ["id"]
366
+ }
367
+ },
368
+ "requestBody": {
369
+ "content": { "application/json": { "schema": { "type": "object" } } }
370
+ },
371
+ "responses": { "200": { "description": "ok" } }
372
+ }
373
+ }
374
+ }
375
+ }
376
+ ```
377
+
378
+ ### `graphql` options
379
+
380
+ | Field | Type | Default | Description |
381
+ |-------|------|---------|-------------|
382
+ | `endpoint` | `string` | from `x-graphql.endpoint` | Absolute HTTP(S) URL of the GraphQL endpoint. |
383
+ | `query` | `string` | from `x-graphql.query` | Query or mutation document. Overrides the spec. |
384
+ | `operationName` | `string` | from `x-graphql.operationName` | Required when `query` declares more than one operation. |
385
+ | `variables` | `Record<string,unknown>` | `{}` | GraphQL variables. Merged over sampled values. |
386
+ | `headers` | `Record<string,string>` | `{}` | Extra headers, merged over `values.header` and auth. |
387
+ | `useGet` | `boolean` | `false` | Use HTTP GET with querystring-encoded `query`/`variables` instead of POST. |
388
+
389
+ ### GraphQL discovery (introspection)
390
+
391
+ ```ts
392
+ const result = await client.discover({
393
+ protocol: "graphql",
394
+ endpoint: "https://countries.trevorblades.com/",
395
+ headers: { Authorization: "Bearer xxx" },
396
+ });
397
+ // result.discovery contains the introspected schema
398
+ ```
399
+
400
+ ---
401
+
402
+ ## gRPC
403
+
404
+ ### Request JSON structure
405
+
406
+ ```json
407
+ {
408
+ "paths": {
409
+ "/grpc": {
410
+ "post": {
411
+ "operationId": "grpcCall",
412
+ "x-protocol": "grpc",
413
+ "x-grpc": {
414
+ "address": "127.0.0.1:50051",
415
+ "service": "demo.echo.Echo",
416
+ "method": "Say",
417
+ "kind": "unary",
418
+ "reflection": true
419
+ },
420
+ "responses": { "200": { "description": "ok" } }
421
+ }
422
+ }
423
+ }
424
+ }
425
+ ```
426
+
427
+ ### gRPC method kinds
428
+
429
+ | Kind | Streaming | Interaction | Button label |
430
+ |------|-----------|-------------|-------------|
431
+ | `unary` | none | One request → one response. Session closes automatically. | "Send and await" |
432
+ | `server_streaming` | server → client | One request → N responses. Session closes on stream end. | "Send and await" |
433
+ | `client_streaming` | client → server | N requests → one response. Send messages, then Finish. | "Send message" + "Finish" |
434
+ | `bidi_streaming` | both | N requests ↔ N responses. Full duplex. | "Send message" + "Finish" |
435
+
436
+ ### Manual gRPC session options
437
+
438
+ | Field | Type | Default | Description |
439
+ |-------|------|---------|-------------|
440
+ | `address` | `string` | **required** | `host:port`, no scheme. |
441
+ | `service` | `string` | **required** | Fully-qualified service name, e.g. `"demo.echo.Echo"`. |
442
+ | `method` | `string` | **required** | Method name as declared in proto. Matched case-insensitively as fallback. |
443
+ | `reflection` | `boolean` | — | Set `true` to use server reflection. Mutually exclusive with `protoPaths`. |
444
+ | `protoPaths` | `string[]` | — | `.proto` files or directories. Directories are walked recursively. |
445
+ | `includeDirs` | `string[]` | derived | Import roots for proto-loader. |
446
+ | `metadata` | `Record<string, string \| string[]>` | `{}` | gRPC metadata sent on every call. |
447
+ | `deadlineMs` | `number` | — | Per-call deadline. Enforced by the server (yields DEADLINE_EXCEEDED). |
448
+ | `channelOptions` | `Record<string,unknown>` | `{}` | grpc-js channel options. |
449
+ | `tls` | `boolean \| GrpcTlsOptions` | — | `false`/undefined = insecure; `true` = TLS with system roots; object = custom TLS. |
450
+ | `loaderOptions` | `Record<string,unknown>` | `{}` | Extra proto-loader options. |
451
+ | `reflectionTimeoutMs` | `number` | `5000` | Budget for the whole reflection session. |
452
+ | `reflectionVersion` | `"v1" \| "v1alpha"` | try v1, fall back | Pin a reflection version. |
453
+ | `reflectionHost` | `string` | — | `host` field on reflection requests (virtual-hosted servers). |
454
+
455
+ ### gRPC TLS options
456
+
457
+ | Field | Type | Description |
458
+ |-------|------|-------------|
459
+ | `rootCerts` | `Buffer` | CA bundle contents (not a path). Pass `await readFile(p)`. |
460
+ | `privateKey` | `Buffer` | Client key for mTLS. Must be given with `certChain`. |
461
+ | `certChain` | `Buffer` | Client certificate chain for mTLS. Must be given with `privateKey`. |
462
+ | `skipHostnameVerification` | `boolean` | Skip hostname verification only. Chain is still verified. Always produces a warning. |
463
+
464
+ ### gRPC session events
465
+
466
+ | `kind` | `direction` | Payload |
467
+ |--------|-------------|---------|
468
+ | `open` | `meta` | `{ address, service, method, kind, descriptorSource }` |
469
+ | `metadata` | `meta` | Response headers (initial metadata). |
470
+ | `data` | `out` | Outbound request message. |
471
+ | `data` | `in` | Inbound response message. |
472
+ | `status` | `meta` | Terminal status: `{ code, statusName, details, metadata }` (trailers). |
473
+ | `error` | `meta` | Error: `{ code, statusName, details, error }`. |
474
+ | `end` | `meta` | Stream ended. |
475
+ | `close` | `meta` | Session closed by the client. |
476
+
477
+ ### gRPC example
478
+
479
+ ```ts
480
+ const session = client.connect({
481
+ kind: "grpc",
482
+ address: "127.0.0.1:50051",
483
+ reflection: true,
484
+ service: "demo.echo.Echo",
485
+ method: "Say",
486
+ });
487
+
488
+ session.onEvent((e) => {
489
+ if (e.kind === "status") {
490
+ console.log("status:", e.meta.code, e.meta.statusName);
491
+ }
492
+ });
493
+
494
+ await session.open();
495
+ await session.send({ text: "hello" });
496
+ // session.state === "closed" after a unary call
497
+ ```
498
+
499
+ ---
500
+
501
+ ## MCP (Model Context Protocol)
502
+
503
+ ### Request JSON structure
504
+
505
+ ```json
506
+ {
507
+ "paths": {
508
+ "/mcp": {
509
+ "post": {
510
+ "operationId": "mcpCall",
511
+ "x-protocol": "mcp",
512
+ "x-transport": "http",
513
+ "x-mcp": {
514
+ "endpoint": "http://127.0.0.1:4200/mcp",
515
+ "method": "tools/call",
516
+ "name": "get_weather",
517
+ "arguments": { "city": "SF" }
518
+ },
519
+ "responses": { "200": { "description": "ok" } }
520
+ }
521
+ }
522
+ }
523
+ }
524
+ ```
525
+
526
+ ### `mcp` options
527
+
528
+ | Field | Type | Default | Description |
529
+ |-------|------|---------|-------------|
530
+ | `transport` | `"streamable-http" \| "stdio"` | `"streamable-http"` | Wire transport. |
531
+ | `endpoint` | `string` | from `x-mcp.endpoint` | Absolute http(s) URL for Streamable HTTP transport. |
532
+ | `headers` | `Record<string,string>` | `{}` | Extra HTTP headers. |
533
+ | `command` | `string` | — | stdio command (e.g. `"npx"`). Required for stdio. |
534
+ | `args` | `string[]` | `[]` | stdio command arguments. |
535
+ | `cwd` | `string` | — | Working directory for the stdio child. |
536
+ | `env` | `Record<string,string \| undefined>` | — | Environment for the stdio child. |
537
+ | `timeoutMs` | `number` | `30000` | Per-request timeout. `0` disables. |
538
+ | `maxBufferBytes` | `number` | — | stdio stdout buffer cap. |
539
+ | `maxStderrBytes` | `number` | — | stdio stderr cap before the child is killed. |
540
+ | `method` | `string` | from `x-mcp.method` | JSON-RPC method, e.g. `"tools/call"`. |
541
+ | `name` | `string` | from `x-mcp.name` | Target tool/prompt/resource name. |
542
+ | `arguments` | `Record<string,unknown>` | `{}` | JSON-RPC params. |
543
+ | `sessionId` | `string` | — | Reuse an existing MCP session ID. |
544
+ | `protocolVersion` | `string` | — | MCP protocol version to negotiate. |
545
+ | `clientInfo` | `{ name, version }` | `{ name: "powerduck", version: "0.1.0" }` | Client info sent in `initialize`. |
546
+
547
+ ### Manual MCP session
548
+
549
+ ```ts
550
+ const session = client.connect({
551
+ kind: "mcp",
552
+ transport: "streamable-http",
553
+ endpoint: "http://127.0.0.1:4200/mcp",
554
+ });
555
+
556
+ await session.open();
557
+
558
+ // List capabilities
559
+ const tools = await session.listTools();
560
+ const prompts = await session.listPrompts();
561
+ const resources = await session.listResources();
562
+
563
+ // Call a tool
564
+ const result = await session.callTool("get_weather", { city: "SF" });
565
+
566
+ // Raw JSON-RPC
567
+ const raw = await session.request("tools/call", { name: "x", arguments: {} });
568
+
569
+ await session.close();
570
+ ```
571
+
572
+ ### MCP stdio session
573
+
574
+ ```ts
575
+ const session = client.connect({
576
+ kind: "mcp",
577
+ transport: "stdio",
578
+ command: "npx",
579
+ args: ["-y", "@modelcontextprotocol/server-everything"],
580
+ cwd: "/path/to/project",
581
+ });
582
+ await session.open();
583
+ ```
584
+
585
+ ---
586
+
587
+ ## Manual Sessions (unified API)
588
+
589
+ All manual sessions (WebSocket, gRPC, MCP) share this contract:
590
+
591
+ | Method / Property | Description |
592
+ |-------------------|-------------|
593
+ | `protocol` | `"websocket"` \| `"grpc"` \| `"mcp"` |
594
+ | `state` | `"idle"` \| `"connecting"` \| `"open"` \| `"closing"` \| `"closed"` \| `"error"` |
595
+ | `events` | Read-only array of all events (ring-buffered at 1000 by default). |
596
+ | `onEvent(listener)` | Subscribe to events. Returns `{ unsubscribe() }`. |
597
+ | `open()` | Connect / initialize. Rejects from non-idle states. |
598
+ | `send(message, options?)` | Send a message. Rejects when not open. |
599
+ | `close(options?)` | Close gracefully. Idempotent. |
600
+ | `waitForClose()` | Promise that resolves when the session closes. |
601
+
602
+ ### SessionEventDTO
603
+
604
+ | Field | Type | Description |
605
+ |-------|------|-------------|
606
+ | `protocol` | `string` | Protocol name. |
607
+ | `transport` | `string` | Transport label. |
608
+ | `sessionId` | `string` | Unique session identifier. |
609
+ | `direction` | `"in" \| "out" \| "meta"` | Message direction. |
610
+ | `kind` | `string` | Event discriminator (protocol-specific). |
611
+ | `at` | `number` | Epoch ms timestamp. |
612
+ | `state` | `SessionState?` | Session state at the time of the event. |
613
+ | `data` | `Cloneable?` | Message payload. |
614
+ | `error` | `Cloneable?` | Error payload. |
615
+ | `meta` | `Cloneable?` | Metadata (headers, status code, etc.). |
616
+
617
+ ---
618
+
619
+ ## SendResult
620
+
621
+ | Field | Type | Description |
622
+ |-------|------|-------------|
623
+ | `protocol` | `ProtocolName` | `"http"` \| `"sse"` \| etc. |
624
+ | `request` | `{ method, url, headers, body? }` | The request that was sent. |
625
+ | `response.status` | `number` | HTTP status code. `0` if no response. |
626
+ | `response.statusText` | `string` | Status text. |
627
+ | `response.headers` | `Record<string,string>` | Response headers (case preserved). |
628
+ | `response.contentType` | `string?` | Parsed content type. |
629
+ | `response.body` | `unknown` | Parsed body (non-streaming). |
630
+ | `response.text` | `string?` | Raw body text. |
631
+ | `response.events` | `StreamEvent[]` | Collected streaming events. |
632
+ | `response.timings` | `{ startedAt, endedAt, durationMs, firstByteMs?, networkDurationMs? }` | Timing data. |
633
+ | `response.sizeBytes` | `number` | Body bytes received. |
634
+ | `response.truncated` | `boolean?` | True if sampling stopped before natural completion. |
635
+ | `response.stopReason` | `StopReason?` | Which limit fired: `"maxEvents" \| "maxStreamMs" \| "maxResponseSize" \| "aborted" \| "hardTimeout"`. |
636
+ | `response.droppedEvents` | `number?` | Events discarded by the parser's size caps. |
637
+ | `scripts` | `ScriptReport?` | Pre-request / test script results. |
638
+ | `cookies` | `Array<{name, value, domain?, path?}>` | Cookies set by the response. |
639
+ | `replays` | `ReplayRecord[]` | Auxiliary traffic (OAuth refreshes, redirects). |
640
+ | `error` | `{ message, code?, name?, stack? }?` | Fatal error, if any. |
641
+ | `collection` | `any` | Generated Postman collection (HTTP only). |
642
+ | `environment` | `any` | Generated Postman environment. |
643
+ | `responseFragment` | `any` | OpenAPI Response object derived from the live call. |
644
+ | `responseStatusCode` | `string` | Status code the fragment was filed under. |
645
+ | `patchedSpec` | `OpenApiDocument?` | Spec with the response merged in. |
646
+ | `writeBackSkippedReason` | `string?` | Why write-back was skipped. |
647
+
648
+ ---
649
+
650
+ ## Error Handling
651
+
652
+ All errors are thrown as `ProtoKitError` with a machine-readable `code`:
653
+
654
+ | Code | Meaning |
655
+ |------|---------|
656
+ | `BAD_OPTIONS` | Missing or invalid send options. |
657
+ | `BAD_RUN_OPTIONS` | Invalid runtime options (e.g. `maxResponseSize: 0`). |
658
+ | `BAD_COLLECTION` | Failed to construct the Postman collection. |
659
+ | `RUNTIME_INIT` | postman-runtime failed to initialize. |
660
+ | `RUNTIME_RUN` | The run failed with a done error. |
661
+ | `EXECUTION_FAILED` | A protocol adapter threw. |
662
+ | `BAD_MCP_ENDPOINT` | Invalid MCP endpoint URL. |
663
+ | `BAD_MCP_STDIO_COMMAND` | Missing stdio command. |
664
+ | `MCP_TIMEOUT` | MCP request or initialize timed out. |
665
+ | `MCP_NOT_OPEN` | Called before `open()`. |
666
+ | `MCP_SESSION_CLOSED` | Called on a closed session. |
667
+ | `MCP_SESSION_EXPIRED` | Server returned 404 for the session. |
668
+ | `MCP_RPC_ERROR` | JSON-RPC error response. |
669
+ | `MCP_EMPTY_RESPONSE` | No JSON-RPC message in the response. |
670
+
671
+ ```ts
672
+ import { ProtoKitError } from "@powerduck/openapi-request";
673
+
674
+ try {
675
+ await client.send(options);
676
+ } catch (err) {
677
+ if (err instanceof ProtoKitError) {
678
+ console.error(err.code, err.message);
679
+ }
680
+ }
681
+ ```
682
+
683
+ ---
684
+
685
+ ## Development
686
+
687
+ ```bash
688
+ npm install
689
+ npm run build # tsup → dist/
690
+ npm test # vitest run (107 tests)
691
+ npm run typecheck # tsc --noEmit
692
+ ```
693
+
694
+ ### Test layout
695
+
696
+ | File | Coverage |
697
+ |------|----------|
698
+ | `tests/sse-parser.test.ts` | 29 tests — SSE parser edge cases, size caps, chunk boundaries. |
699
+ | `tests/runner-options.test.ts` | 19 tests — HTTP option validation and defaults. |
700
+ | `tests/session-hub.test.ts` | 9 tests — event hub, listener safety, ring buffer. |
701
+ | `tests/grpc-session.test.ts` | 10 tests — unary success/error state, streaming, lifecycle. |
702
+ | `tests/ws-session.test.ts` | 10 tests — connect, send/receive, error state, lifecycle. |
703
+ | `tests/client.test.ts` | 8 tests — prepare() protocol inference. |
704
+ | `tests/session.test.ts` | 6 tests — unified createManualSession routing. |
705
+ | `tests/integration.test.ts` | 4 tests — end-to-end HTTP + SSE streaming. |
706
+ | `tests/clone.test.ts` | 8 tests — payload cloning. |
707
+ | `tests/detect.test.ts` | 4 tests — SSE content-type detection. |
708
+
709
+ ## License
710
+
711
+ MIT