@lovrozagar/honey 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/docs/api-ref/go.md +399 -0
- package/docs/api-ref/python.md +399 -0
- package/docs/api-ref/rust.md +399 -0
- package/docs/api-ref/typescript.md +399 -0
- package/docs/invalidate-spec.md +303 -0
- package/docs/sdk.md +722 -0
- package/package.json +120 -0
- package/src/accepts.ts +78 -0
- package/src/body-limit.ts +100 -0
- package/src/build/index.ts +174 -0
- package/src/cli-go/auth.go +15 -0
- package/src/cli-go/config.go +165 -0
- package/src/cli-go/errors.go +57 -0
- package/src/cli-go/multipart.go +55 -0
- package/src/cli-go/output.go +109 -0
- package/src/cli-go/root.go +30 -0
- package/src/cli-go/stream.go +88 -0
- package/src/cli-go/version.go +5 -0
- package/src/cli.ts +204 -0
- package/src/client/error.ts +27 -0
- package/src/client/http.ts +502 -0
- package/src/client/index.ts +127 -0
- package/src/client/sdk.ts +265 -0
- package/src/client/sse.ts +102 -0
- package/src/client/types.ts +211 -0
- package/src/client/ws.ts +115 -0
- package/src/client-go/errors.go +207 -0
- package/src/client-go/invalidation.go +373 -0
- package/src/client-go/realtime.go +317 -0
- package/src/client-go/result.go +20 -0
- package/src/client-go/runtime.go +413 -0
- package/src/client-go/sse.go +149 -0
- package/src/client-go/transport.go +452 -0
- package/src/client-go/ws.go +104 -0
- package/src/client-mcp/README.md +10 -0
- package/src/client-mcp/server.ts +94 -0
- package/src/client-mcp/types.ts +19 -0
- package/src/client-python/_errors.py +115 -0
- package/src/client-python/_invalidation.py +578 -0
- package/src/client-python/_realtime.py +156 -0
- package/src/client-python/_runtime.py +469 -0
- package/src/client-python/_sse.py +99 -0
- package/src/client-python/_transport.py +335 -0
- package/src/client-python/_ws.py +131 -0
- package/src/client-python/py.typed +0 -0
- package/src/client-rust/errors.rs +200 -0
- package/src/client-rust/invalidation.rs +441 -0
- package/src/client-rust/invalidation_sync.rs +306 -0
- package/src/client-rust/realtime.rs +695 -0
- package/src/client-rust/result.rs +22 -0
- package/src/client-rust/runtime.rs +547 -0
- package/src/client-rust/runtime_sync.rs +452 -0
- package/src/client-rust/sse.rs +167 -0
- package/src/client-rust/ws.rs +143 -0
- package/src/cloudflare/realtime.ts +236 -0
- package/src/codegen-go-cli.ts +1180 -0
- package/src/codegen-go.ts +1010 -0
- package/src/codegen-hash.ts +8 -0
- package/src/codegen-ir.ts +517 -0
- package/src/codegen-loaders.ts +53 -0
- package/src/codegen-mcp.ts +364 -0
- package/src/codegen-python.ts +1028 -0
- package/src/codegen-rust.ts +1652 -0
- package/src/codegen-sanitize.ts +67 -0
- package/src/codegen-schema-naming.ts +146 -0
- package/src/codegen.ts +5015 -0
- package/src/context.ts +210 -0
- package/src/cookie-sign.ts +58 -0
- package/src/cookie.ts +89 -0
- package/src/cors.ts +96 -0
- package/src/crypto.ts +19 -0
- package/src/csrf.ts +54 -0
- package/src/curl-logger.ts +235 -0
- package/src/detect-runtime.ts +19 -0
- package/src/error.ts +52 -0
- package/src/errors.ts +87 -0
- package/src/etag.ts +74 -0
- package/src/feature-load.ts +23 -0
- package/src/go-type-emitter.ts +404 -0
- package/src/honey-response.ts +354 -0
- package/src/i18n-slot.ts +20 -0
- package/src/i18n.ts +234 -0
- package/src/index.ts +3843 -0
- package/src/init.ts +149 -0
- package/src/input.ts +8 -0
- package/src/ip-restrict.ts +192 -0
- package/src/logger.ts +149 -0
- package/src/middleware.ts +199 -0
- package/src/node-request.ts +240 -0
- package/src/node.ts +212 -0
- package/src/openapi/register.ts +19 -0
- package/src/openapi/scalar.ts +33 -0
- package/src/openapi/spec-factory.ts +46 -0
- package/src/openapi/spec.ts +31 -0
- package/src/openapi/swagger.ts +34 -0
- package/src/plugin.ts +771 -0
- package/src/powered-by.ts +17 -0
- package/src/pretty-json.ts +39 -0
- package/src/proxy.ts +155 -0
- package/src/python-type-emitter.ts +144 -0
- package/src/realtime/buffer.ts +101 -0
- package/src/realtime/bus.ts +93 -0
- package/src/realtime/longpoll.ts +148 -0
- package/src/realtime/protocol.ts +128 -0
- package/src/realtime/route.ts +104 -0
- package/src/realtime/runtime.ts +430 -0
- package/src/request-id.ts +23 -0
- package/src/request-to-curl.ts +34 -0
- package/src/response.ts +414 -0
- package/src/rust-type-emitter.ts +412 -0
- package/src/secure-headers.ts +69 -0
- package/src/serve-register.ts +8 -0
- package/src/serve-slot.ts +20 -0
- package/src/serve.ts +141 -0
- package/src/server-timing.ts +66 -0
- package/src/static.ts +60 -0
- package/src/telemetry/otel.ts +139 -0
- package/src/testing.ts +114 -0
- package/src/timeout.ts +36 -0
- package/src/tree.ts +464 -0
- package/src/ts-type-emitter.ts +106 -0
- package/src/type-emitter.ts +524 -0
- package/src/type-extractor.ts +749 -0
- package/src/types.ts +638 -0
- package/src/validation.ts +300 -0
- package/src/ws/bun.ts +89 -0
- package/src/ws/cloudflare.ts +133 -0
- package/src/ws/deno.ts +109 -0
- package/src/ws/node.ts +161 -0
- package/src/yaml.ts +80 -0
package/docs/sdk.md
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
# Honey SDKs
|
|
2
|
+
|
|
3
|
+
OpenAPI-in, tier-1 SDKs out. One spec is parsed once into a shared IR, then four language printers emit idiomatic, feature-equivalent clients for **TypeScript, Python, Go, and Rust**.
|
|
4
|
+
|
|
5
|
+
Framework start: [README](../README.md).
|
|
6
|
+
|
|
7
|
+
## Four-language parity pledge
|
|
8
|
+
|
|
9
|
+
Every SDK exposes the same capability surface with native bindings. `context.Context` in Go, `AbortSignal` in TS, `asyncio.Task.cancel()` in Python, `tokio_util::sync::CancellationToken` in Rust — all four satisfy "cancellation" without translating each other's syntax. This page is the scannable index of the four-language SDK surface.
|
|
10
|
+
|
|
11
|
+
Entry points:
|
|
12
|
+
|
|
13
|
+
- `generateTypeScriptSDK(spec, { name, stem })` — emits `sdk.client.gen.ts`, `sdk.types.gen.ts`, `sdk.index.gen.ts`, `sdk.map.gen.ts`, and (when `x-realtime` is present) `sdk.runtime.gen.ts`.
|
|
14
|
+
- `generatePythonSDK(spec)` — emits a `sdk/` package with `client.py`, `_runtime.py`, `_errors.py`, `_realtime.py`, `_transport.py`, `_ws.py`.
|
|
15
|
+
- `generateGoSDK(spec, { modulePath })` — emits a Go module importable via a `replace` directive.
|
|
16
|
+
- `generateRustSDK(spec, { crateName })` — emits a Cargo crate referenced via `path = "..."`.
|
|
17
|
+
|
|
18
|
+
The rest of this page is the capability matrix and "how to do X" snippets. All snippets come from `../examples/`.
|
|
19
|
+
|
|
20
|
+
## Capability matrix
|
|
21
|
+
|
|
22
|
+
| § | Capability | TS | Python | Go | Rust |
|
|
23
|
+
| --- | ------------------------------------------------------ | --- | ------ | --- | ---- |
|
|
24
|
+
| 1 | Typed operations (request, response, params, bodies) | ✓ | ✓ | ✓ | ✓ |
|
|
25
|
+
| 2 | Typed error hierarchy + `.data` typed payload | ✓ | ✓ | ✓ | ✓ |
|
|
26
|
+
| 3 | `onAuthExpired` callback + 1x 401 retry | ✓ | ✓ | ✓ | ✓ |
|
|
27
|
+
| 4 | Cancellation (idiomatic per lang) | ✓ | ✓ | ✓ | ✓ |
|
|
28
|
+
| 5 | Per-call timeout override | ✓ | ✓ | ✓ | ✓ |
|
|
29
|
+
| 6 | Per-call headers merge | ✓ | ✓ | ✓ | ✓ |
|
|
30
|
+
| 7 | `onRequest` / `onResponse` hook chain | ✓ | ✓ | ✓ | ✓ |
|
|
31
|
+
| 8 | Invalidation + `isStale` | ✓ | ✓ | ✓ | ✓ |
|
|
32
|
+
| 9 | SSE iteration | ✓ | ✓ | ✓ | ✓ |
|
|
33
|
+
| 10 | Realtime (`x-realtime`) + `TransportAdapter` fallback | ✓ | ✓ | ✓ | ✓ |
|
|
34
|
+
| 11 | WebSocket (`x-websocket`) bidi | ✓ | ✓ | ✓ | ✓ |
|
|
35
|
+
| 12 | Streaming request body | ✓ | ✓ | ✓ | ✓ |
|
|
36
|
+
| 13 | Sync runtime | — | ✓ | — | ✓ |
|
|
37
|
+
| 14 | `onLog` lifecycle hook | ✓ | ✓ | ✓ | ✓ |
|
|
38
|
+
| 15 | `x-deprecated` | ✓ | ✓ | ✓ | ✓ |
|
|
39
|
+
| 16 | `x-idempotency-key` | ✓ | ✓ | ✓ | ✓ |
|
|
40
|
+
|
|
41
|
+
Sync runtime is not idiomatic in TS (no sync HTTP in browsers or workerd) or Go (goroutines + `context.Context` are the async model). Python and Rust ship dual async + sync generation.
|
|
42
|
+
|
|
43
|
+
## How to do X
|
|
44
|
+
|
|
45
|
+
Each subsection below shows the same capability realized in all four languages. Snippets are trimmed from `../examples/{lang}/example.*` — those files are the source of truth and contain the full frame plus imports.
|
|
46
|
+
|
|
47
|
+
### §1 — Client init + config
|
|
48
|
+
|
|
49
|
+
Config is mechanism, not policy. You pick retry, logging, auth refresh, and invalidation strategies; the SDK supplies the hook slots.
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const sdk = new MockSDK({
|
|
53
|
+
baseURL: BASE_URL,
|
|
54
|
+
headers: { Authorization: "Bearer expired-token" },
|
|
55
|
+
onAuthExpired: () => Promise.resolve("valid-token"),
|
|
56
|
+
onLog: (entry) => console.debug(entry.event, entry.operation, entry.duration_ms, entry.status),
|
|
57
|
+
onRequest: [addTrace, addApp],
|
|
58
|
+
onResponse: [inspect5xx],
|
|
59
|
+
throwOnError: true,
|
|
60
|
+
timeout: 10_000,
|
|
61
|
+
})
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```py
|
|
65
|
+
sdk = AsyncSDK(ClientConfig(
|
|
66
|
+
base_url=BASE_URL,
|
|
67
|
+
headers={"Authorization": "Bearer expired-token"},
|
|
68
|
+
on_auth_expired=refresh_token,
|
|
69
|
+
on_log=on_log,
|
|
70
|
+
on_request=[add_trace_hook, add_app_hook],
|
|
71
|
+
on_response=[inspect_response_hook],
|
|
72
|
+
invalidation=InvalidationConfig(stale_time=5.0),
|
|
73
|
+
timeout=10.0,
|
|
74
|
+
))
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```go
|
|
78
|
+
client := sdk.NewClient(sdk.Config{
|
|
79
|
+
BaseURL: baseURL,
|
|
80
|
+
Headers: map[string]string{"Authorization": "Bearer expired-token"},
|
|
81
|
+
OnAuthExpired: func(ctx context.Context) (string, error) { return "valid-token", nil },
|
|
82
|
+
OnRequest: []func(*sdk.RequestContext) error{addTrace, addApp},
|
|
83
|
+
OnResponse: []func(*sdk.ResponseContext) error{inspect5xx},
|
|
84
|
+
OnLog: func(e sdk.LogEntry) { fmt.Println(e.Event, e.Operation, e.DurationMs, e.Status) },
|
|
85
|
+
Invalidation: sdk.InvalidationConfig{StaleTime: 5 * time.Second},
|
|
86
|
+
Timeout: 10 * time.Second,
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
```rust
|
|
91
|
+
let client = Client::new(ClientConfig {
|
|
92
|
+
base_url,
|
|
93
|
+
bearer_token: Some("expired-token".into()),
|
|
94
|
+
on_auth_expired: Some(Arc::new(|| Box::pin(async { Ok(Some("valid-token".into())) }))),
|
|
95
|
+
on_log: Some(Arc::new(|e: LogEntry| println!("{} {} {}", e.event, e.operation, e.duration_ms))),
|
|
96
|
+
on_request: vec![trace_hook, app_hook],
|
|
97
|
+
on_response: vec![inspect_hook],
|
|
98
|
+
invalidation: InvalidationConfig { stale_time_ms: 5_000 },
|
|
99
|
+
timeout: Some(Duration::from_secs(10)),
|
|
100
|
+
..Default::default()
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### §2 — Typed operation call
|
|
105
|
+
|
|
106
|
+
Every OpenAPI operation becomes a typed method. Path / query / header params are typed per operation; request and response bodies are typed from the declared schemas.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
const user = await sdk.createUser({ json: { email: "a@b.com", name: "Alice" } })
|
|
110
|
+
console.log(user.id, user.name)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```py
|
|
114
|
+
user = await sdk.createUser(body={"name": "Alice", "email": "a@b.com"})
|
|
115
|
+
print(user["id"], user["name"])
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```go
|
|
119
|
+
user, err := client.CreateUser(ctx, sdk.UserCreate{Name: "Alice", Email: "a@b.com"}, nil)
|
|
120
|
+
if err == nil {
|
|
121
|
+
fmt.Println(user.Id, user.Name)
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```rust
|
|
126
|
+
let user = client
|
|
127
|
+
.create_user(
|
|
128
|
+
&UserCreate { name: "Alice".into(), email: "a@b.com".into() },
|
|
129
|
+
&CreateUserOpts::default(),
|
|
130
|
+
)
|
|
131
|
+
.await?;
|
|
132
|
+
println!("{} {}", user.id, user.name);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### §3 — Typed error catch
|
|
136
|
+
|
|
137
|
+
Every SDK emits the unified taxonomy: `BadRequestError`, `UnauthorizedError`, `NotFoundError`, `RateLimitError`, `InternalServerError`, and a `ClientError` / `Error` base. Each carries `status`, `body`, and (§4) a typed `data` payload.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
try {
|
|
141
|
+
await sdk.createUser({ json: input })
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (e instanceof BadRequestError) console.error("400", e.status)
|
|
144
|
+
else if (e instanceof UnauthorizedError) console.error("401")
|
|
145
|
+
else if (e instanceof NotFoundError) console.error("404")
|
|
146
|
+
else if (e instanceof RateLimitError) console.error("429")
|
|
147
|
+
else if (e instanceof InternalServerError) console.error("500")
|
|
148
|
+
else if (isClientError(e)) console.error("unknown:", e.status)
|
|
149
|
+
else throw e
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```py
|
|
154
|
+
try:
|
|
155
|
+
await sdk.createUser(body=input)
|
|
156
|
+
except BadRequestError as e:
|
|
157
|
+
print("400", e.status)
|
|
158
|
+
except UnauthorizedError:
|
|
159
|
+
print("401")
|
|
160
|
+
except NotFoundError:
|
|
161
|
+
print("404")
|
|
162
|
+
except RateLimitError:
|
|
163
|
+
print("429")
|
|
164
|
+
except InternalServerError:
|
|
165
|
+
print("500")
|
|
166
|
+
except ClientError as e:
|
|
167
|
+
print("unknown:", e.status)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```go
|
|
171
|
+
user, err := client.CreateUser(ctx, input, nil)
|
|
172
|
+
if err != nil {
|
|
173
|
+
var bre *sdk.BadRequestError
|
|
174
|
+
var ue *sdk.UnauthorizedError
|
|
175
|
+
var nfe *sdk.NotFoundError
|
|
176
|
+
switch {
|
|
177
|
+
case errors.As(err, &bre): fmt.Println("400", bre.Status())
|
|
178
|
+
case errors.As(err, &ue): fmt.Println("401")
|
|
179
|
+
case errors.As(err, &nfe): fmt.Println("404")
|
|
180
|
+
default: fmt.Println("unknown:", err)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```rust
|
|
186
|
+
match client.create_user(&input, &CreateUserOpts::default()).await {
|
|
187
|
+
Ok(user) => println!("{}", user.id),
|
|
188
|
+
Err(Error::Api(api_err)) => {
|
|
189
|
+
let dyn_err: &(dyn std::error::Error + 'static) = api_err.as_ref();
|
|
190
|
+
if let Some(bre) = dyn_err.downcast_ref::<BadRequestError>() {
|
|
191
|
+
println!("400 {}", bre.status_code);
|
|
192
|
+
} else if dyn_err.downcast_ref::<UnauthorizedError>().is_some() {
|
|
193
|
+
println!("401");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
Err(e) => eprintln!("{}", e),
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### §4 — Declared error payload (`err.data`)
|
|
201
|
+
|
|
202
|
+
When an operation declares a `4xx` response schema in OpenAPI, the parsed payload is eagerly populated on the error object at response time. `err.body` remains the raw bytes; `err.data` is typed.
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
try {
|
|
206
|
+
await sdk.createUser({ json: bad })
|
|
207
|
+
} catch (e) {
|
|
208
|
+
if (e instanceof BadRequestError) {
|
|
209
|
+
console.error("400 data:", e.data, "body:", e.body)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
```py
|
|
215
|
+
try:
|
|
216
|
+
await sdk.createUser(body=bad)
|
|
217
|
+
except BadRequestError as e:
|
|
218
|
+
print("400 data:", e.data, "body:", e.body)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```go
|
|
222
|
+
var bre *sdk.BadRequestError
|
|
223
|
+
if errors.As(err, &bre) {
|
|
224
|
+
fmt.Println("400 data:", bre.Data, "body:", string(bre.Body))
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
```rust
|
|
229
|
+
if let Some(bre) = dyn_err.downcast_ref::<BadRequestError>() {
|
|
230
|
+
println!("400 data: {:?} body_len: {}", bre.data, bre.body.len());
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### §5 — `onAuthExpired` callback
|
|
235
|
+
|
|
236
|
+
Exactly **one** retry on 401 with a freshly-fetched token. The callback owns storage; the SDK never writes to disk or env. Retry beyond 401 is a non-goal — wire 5xx / 429 retry via `onResponse`.
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
onAuthExpired: () => Promise.resolve("valid-token"),
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
```py
|
|
243
|
+
async def refresh_token() -> str:
|
|
244
|
+
return "valid-token"
|
|
245
|
+
|
|
246
|
+
ClientConfig(on_auth_expired=refresh_token, ...)
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
```go
|
|
250
|
+
OnAuthExpired: func(ctx context.Context) (string, error) {
|
|
251
|
+
return "valid-token", nil
|
|
252
|
+
},
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
```rust
|
|
256
|
+
on_auth_expired: Some(Arc::new(|| Box::pin(async {
|
|
257
|
+
Ok(Some("valid-token".into()))
|
|
258
|
+
}))),
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
### §6 — Cancellation
|
|
262
|
+
|
|
263
|
+
Each SDK uses the language's native cancellation primitive. Cancellation propagates into HTTP, SSE, WS, and `ResumableConnection` — never silently dropped.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
const ctrl = new AbortController()
|
|
267
|
+
setTimeout(() => ctrl.abort(), 25)
|
|
268
|
+
try {
|
|
269
|
+
await sdk.slow({ search: { ms: 500 }, signal: ctrl.signal })
|
|
270
|
+
} catch (e) {
|
|
271
|
+
console.error("cancelled:", (e as Error).name)
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
```py
|
|
276
|
+
task = asyncio.create_task(sdk.slow(ms=500))
|
|
277
|
+
await asyncio.sleep(0.01)
|
|
278
|
+
task.cancel()
|
|
279
|
+
try:
|
|
280
|
+
await task
|
|
281
|
+
except asyncio.CancelledError:
|
|
282
|
+
print("cancelled async")
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
```go
|
|
286
|
+
cancelCtx, cancel := context.WithTimeout(ctx, 25*time.Millisecond)
|
|
287
|
+
defer cancel()
|
|
288
|
+
_, err := client.Slow(cancelCtx, &sdk.SlowOpts{Ms: 500})
|
|
289
|
+
if err != nil {
|
|
290
|
+
fmt.Println("cancelled:", err)
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
```rust
|
|
295
|
+
let token = CancellationToken::new();
|
|
296
|
+
let token_clone = token.clone();
|
|
297
|
+
tokio::spawn(async move {
|
|
298
|
+
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
299
|
+
token_clone.cancel();
|
|
300
|
+
});
|
|
301
|
+
let _ = client
|
|
302
|
+
.slow(&SlowOpts { ms: Some(500), cancel_token: Some(token), ..Default::default() })
|
|
303
|
+
.await;
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### §7 — Per-call timeout
|
|
307
|
+
|
|
308
|
+
Config timeout is the default. Per-call `timeout` on any op is a hard override. Distinct from cancellation — timeout is a deadline, cancellation is an external signal.
|
|
309
|
+
|
|
310
|
+
```ts
|
|
311
|
+
await sdk.slow({ search: { ms: 200 }, timeout: 50 })
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
```py
|
|
315
|
+
await sdk.slow(ms=200, timeout=0.05)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
```go
|
|
319
|
+
_, err := client.Slow(ctx, &sdk.SlowOpts{Ms: 200, Timeout: 50 * time.Millisecond})
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
```rust
|
|
323
|
+
client
|
|
324
|
+
.slow(&SlowOpts {
|
|
325
|
+
ms: Some(200),
|
|
326
|
+
timeout: Some(Duration::from_millis(50)),
|
|
327
|
+
..Default::default()
|
|
328
|
+
})
|
|
329
|
+
.await?;
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
### §8 — Per-call headers
|
|
333
|
+
|
|
334
|
+
Per-call headers merge over config headers; per-call wins per key. Useful for request-scoped tracing IDs, idempotency keys, or one-off auth overrides.
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
await sdk.getUser({
|
|
338
|
+
headers: { "X-Both": "call-wins" },
|
|
339
|
+
params: { id: "u1" },
|
|
340
|
+
})
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
```py
|
|
344
|
+
await sdk.getUser("u1", headers={"X-Both": "call-wins"})
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
```go
|
|
348
|
+
_, _ = client.GetUser(ctx, "u1", &sdk.GetUserOpts{
|
|
349
|
+
Headers: map[string]string{"X-Both": "call-wins"},
|
|
350
|
+
})
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```rust
|
|
354
|
+
let _ = client
|
|
355
|
+
.get_user(
|
|
356
|
+
"u1",
|
|
357
|
+
&GetUserOpts {
|
|
358
|
+
headers: Some([("X-Both".into(), "call-wins".into())].into_iter().collect()),
|
|
359
|
+
..Default::default()
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
.await;
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### §9 — `onRequest` / `onResponse` hooks
|
|
366
|
+
|
|
367
|
+
Hook chains run in declaration order, awaited in order, and may mutate the context (headers pre-send, status inspection post-receive). They are the only built-in retry primitive beyond the 401 path — 5xx backoff, circuit breakers, tracing all live here.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
onRequest: [
|
|
371
|
+
(ctx) => { ctx.headers["X-Trace-Id"] = crypto.randomUUID(); return Promise.resolve() },
|
|
372
|
+
(ctx) => { ctx.headers["X-App"] = "example"; return Promise.resolve() },
|
|
373
|
+
],
|
|
374
|
+
onResponse: [
|
|
375
|
+
(ctx) => { if (ctx.status >= 500) console.warn("5xx:", ctx.status); return Promise.resolve() },
|
|
376
|
+
],
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
```py
|
|
380
|
+
async def add_trace_hook(ctx):
|
|
381
|
+
ctx.headers["X-Trace-Id"] = "trace-123"
|
|
382
|
+
|
|
383
|
+
async def inspect_response_hook(rctx):
|
|
384
|
+
if rctx.response.status_code >= 500:
|
|
385
|
+
print("5xx:", rctx.response.status_code)
|
|
386
|
+
|
|
387
|
+
ClientConfig(on_request=[add_trace_hook], on_response=[inspect_response_hook], ...)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
```go
|
|
391
|
+
OnRequest: []func(*sdk.RequestContext) error{
|
|
392
|
+
func(c *sdk.RequestContext) error { c.Headers.Set("X-Trace-Id", "trace-123"); return nil },
|
|
393
|
+
func(c *sdk.RequestContext) error { c.Headers.Set("X-App", "example"); return nil },
|
|
394
|
+
},
|
|
395
|
+
OnResponse: []func(*sdk.ResponseContext) error{
|
|
396
|
+
func(c *sdk.ResponseContext) error {
|
|
397
|
+
if c.Status >= 500 { fmt.Println("5xx:", c.Status) }
|
|
398
|
+
return nil
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
```rust
|
|
404
|
+
let trace_hook: OnRequestHook = Arc::new(|ctx| {
|
|
405
|
+
Box::pin(async move {
|
|
406
|
+
ctx.headers.insert("X-Trace-Id".into(), "trace-123".into());
|
|
407
|
+
Ok(())
|
|
408
|
+
})
|
|
409
|
+
});
|
|
410
|
+
let inspect_hook: OnResponseHook = Arc::new(|ctx| {
|
|
411
|
+
let status = ctx.status;
|
|
412
|
+
Box::pin(async move {
|
|
413
|
+
if status >= 500 { eprintln!("5xx: {}", status); }
|
|
414
|
+
Ok(())
|
|
415
|
+
})
|
|
416
|
+
});
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
### §10 — `onLog`
|
|
420
|
+
|
|
421
|
+
A single pluggable sink. `LogEntry` carries `{ level, event, operation, duration_ms, status?, error? }`. This is not a logger framework — wire it into whatever leveled logger the consumer already has.
|
|
422
|
+
|
|
423
|
+
```ts
|
|
424
|
+
onLog: (entry) => console.debug(entry.event, entry.operation, entry.duration_ms, entry.status),
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
```py
|
|
428
|
+
def on_log(entry: LogEntry) -> None:
|
|
429
|
+
print(entry.event, entry.operation, entry.duration_ms, entry.status)
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
```go
|
|
433
|
+
OnLog: func(e sdk.LogEntry) {
|
|
434
|
+
fmt.Println(e.Event, e.Operation, e.DurationMs, e.Status)
|
|
435
|
+
},
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
```rust
|
|
439
|
+
on_log: Some(Arc::new(|e: LogEntry| {
|
|
440
|
+
println!("{} {} {} {:?}", e.event, e.operation, e.duration_ms, e.status);
|
|
441
|
+
})),
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
### §11 — Invalidation + `isStale`
|
|
445
|
+
|
|
446
|
+
An `x-invalidate: ["GET /resource/:id"]` extension on a mutating op marks matching GET paths stale. Two-tier lookup (exact → pattern regex, cached). TTL config governs the stale window. The SDK stores staleness only — never cached bodies — so consumer code picks the refetch moment.
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
await sdk.updateUser({ json: { name: "Alice2" }, params: { id: "u1" } })
|
|
450
|
+
const stale = sdk.isStale("GET", "/users/u1")
|
|
451
|
+
console.log("users/u1 stale?", stale)
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
```py
|
|
455
|
+
await sdk.updateUser("u1", body={"name": "Alice2"})
|
|
456
|
+
print("users/u1 stale?", await sdk.is_stale("GET", "/users/u1"))
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
```go
|
|
460
|
+
_, _ = client.UpdateUser(ctx, "u1", sdk.UserUpdate{Name: "Alice2"}, nil)
|
|
461
|
+
fmt.Println("users/u1 stale?", client.IsStale("GET", "/users/u1"))
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
```rust
|
|
465
|
+
let _ = client
|
|
466
|
+
.update_user(
|
|
467
|
+
"u1",
|
|
468
|
+
&UserUpdate { name: Some("Alice2".into()), ..Default::default() },
|
|
469
|
+
&UpdateUserOpts::default(),
|
|
470
|
+
)
|
|
471
|
+
.await;
|
|
472
|
+
println!("users/u1 stale? {}", client.is_stale("GET", "/users/u1"));
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### §12 — SSE iteration
|
|
476
|
+
|
|
477
|
+
Operations with `produces: text/event-stream` return a typed async iterable of events. Reconnect on drop is consumer-driven for basic SSE (use `x-realtime` in §13 for auto-reconnect).
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
for await (const ev of sdk.streamEvents()) {
|
|
481
|
+
console.log("sse event:", ev)
|
|
482
|
+
break
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
```py
|
|
487
|
+
async for ev in sdk.streamEvents():
|
|
488
|
+
print("sse event:", ev)
|
|
489
|
+
break
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
```go
|
|
493
|
+
stream, err := client.StreamEvents(ctx, nil)
|
|
494
|
+
if err == nil {
|
|
495
|
+
for ev := range stream.Events() {
|
|
496
|
+
fmt.Println("sse event:", ev)
|
|
497
|
+
break
|
|
498
|
+
}
|
|
499
|
+
stream.Close()
|
|
500
|
+
}
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
```rust
|
|
504
|
+
let mut sse = client.stream_events(&StreamEventsOpts::default()).await?;
|
|
505
|
+
if let Some(ev) = sse.next().await {
|
|
506
|
+
println!("sse event: {:?}", ev);
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### §13 — WebSocket bidi
|
|
511
|
+
|
|
512
|
+
Operations with `x-websocket: true` yield a typed bidirectional channel: `Send` / `Recv` types come from the schema. Client close returns code 1000; server-initiated close surfaces a reason.
|
|
513
|
+
|
|
514
|
+
```ts
|
|
515
|
+
const ws = sdk.connectWs()
|
|
516
|
+
await new Promise<void>((resolve) => { ws.on("open", () => resolve()) })
|
|
517
|
+
ws.on("message", (data: string) => {
|
|
518
|
+
console.log("ws recv:", data)
|
|
519
|
+
ws.close(1000, "done")
|
|
520
|
+
})
|
|
521
|
+
ws.send("hello")
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
```py
|
|
525
|
+
async with sdk.connectWs() as ws:
|
|
526
|
+
await ws.send("hello")
|
|
527
|
+
async for msg in ws:
|
|
528
|
+
print("ws recv:", msg)
|
|
529
|
+
break
|
|
530
|
+
await ws.close(1000, "done")
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
```go
|
|
534
|
+
ws, err := client.ConnectWs(ctx, nil)
|
|
535
|
+
if err == nil {
|
|
536
|
+
_ = ws.Send("hello")
|
|
537
|
+
msg, _ := ws.Recv()
|
|
538
|
+
fmt.Println("ws recv:", msg)
|
|
539
|
+
_ = ws.Close(1000, "done")
|
|
540
|
+
}
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
```rust
|
|
544
|
+
let mut ws = client.connect_ws(&ConnectWsOpts::default()).await?;
|
|
545
|
+
ws.send("hello".to_string()).await?;
|
|
546
|
+
if let Some(msg) = ws.recv().await {
|
|
547
|
+
println!("ws recv: {:?}", msg);
|
|
548
|
+
}
|
|
549
|
+
ws.close(1000, "done").await?;
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
### §14 — Realtime with custom adapter
|
|
553
|
+
|
|
554
|
+
`x-realtime: true` opens a `ResumableConnection` with a pluggable `TransportAdapter`, an auto fallback chain (`[ws, sse, longpoll]` by default), and hidden proven-transport memoization. Consumers can reorder, drop, or inject entirely custom transports — browser WS, node `ws`, workerd sockets, `tokio-tungstenite`, `gorilla/websocket`, whatever.
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
function makeCustomAdapter(): TransportAdapter {
|
|
558
|
+
return {
|
|
559
|
+
connect(_url, _opts): TransportConnection {
|
|
560
|
+
let onFrame: (f: ServerFrame) => void = () => {}
|
|
561
|
+
const conn: TransportConnection = {
|
|
562
|
+
close() {},
|
|
563
|
+
onClose: () => {}, onError: () => {},
|
|
564
|
+
get onFrame() { return onFrame }, set onFrame(cb) { onFrame = cb },
|
|
565
|
+
send(_data) {},
|
|
566
|
+
}
|
|
567
|
+
queueMicrotask(() => onFrame({ data: { kind: "tick" }, id: 1, t: "msg" }))
|
|
568
|
+
return conn
|
|
569
|
+
},
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
const rc = createResumableConnection({
|
|
573
|
+
maxReconnectAttempts: 5,
|
|
574
|
+
reconnectDelayMs: 100,
|
|
575
|
+
transports: [makeCustomAdapter()],
|
|
576
|
+
url: `${BASE_URL}/rt`,
|
|
577
|
+
})
|
|
578
|
+
for await (const ev of rc) { console.log("rt:", ev); break }
|
|
579
|
+
rc.close()
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
```py
|
|
583
|
+
class TickAdapter:
|
|
584
|
+
def name(self): return "tick"
|
|
585
|
+
def kind(self): return TransportKind.WS
|
|
586
|
+
|
|
587
|
+
async def connect(self, _url, _opts):
|
|
588
|
+
return TickConn()
|
|
589
|
+
|
|
590
|
+
rc = ResumableConnection(f"{BASE_URL}/rt", [TickAdapter()], TransportOpts())
|
|
591
|
+
await rc.connect()
|
|
592
|
+
async for ev in rc:
|
|
593
|
+
print("rt event:", ev)
|
|
594
|
+
break
|
|
595
|
+
await rc.close()
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
```go
|
|
599
|
+
rc, err := client.ConnectRealtime(ctx, &sdk.ConnectRealtimeOpts{
|
|
600
|
+
Transports: []sdk.Transport{&tickTransport{}},
|
|
601
|
+
MaxReconnectAttempts: 5,
|
|
602
|
+
ReconnectDelayMs: 100,
|
|
603
|
+
})
|
|
604
|
+
if err == nil {
|
|
605
|
+
for ev := range rc.Events() {
|
|
606
|
+
fmt.Println("rt event:", ev)
|
|
607
|
+
break
|
|
608
|
+
}
|
|
609
|
+
rc.Close()
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
613
|
+
```rust
|
|
614
|
+
let tick_adapter: Arc<dyn Transport + Send + Sync> = Arc::new(TickAdapter);
|
|
615
|
+
let mut rc = client
|
|
616
|
+
.connect_realtime(&ConnectRealtimeOpts {
|
|
617
|
+
transports: vec![tick_adapter],
|
|
618
|
+
max_reconnect_attempts: 5,
|
|
619
|
+
reconnect_delay_ms: 100,
|
|
620
|
+
..Default::default()
|
|
621
|
+
})
|
|
622
|
+
.await?;
|
|
623
|
+
if let Some(ev) = rc.next().await {
|
|
624
|
+
println!("rt event: {:?}", ev);
|
|
625
|
+
}
|
|
626
|
+
rc.close().await;
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### §15 — Streaming upload
|
|
630
|
+
|
|
631
|
+
Binary or multipart uploads flow as a stream. No buffering — the SDK pipes the source directly into the underlying HTTP client. Critical on memory-bounded runtimes like CF Workers.
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
635
|
+
pull(controller) {
|
|
636
|
+
if (sent >= TOTAL) { controller.close(); return }
|
|
637
|
+
const chunk = new Uint8Array(Math.min(CHUNK, TOTAL - sent))
|
|
638
|
+
sent += chunk.length
|
|
639
|
+
controller.enqueue(chunk)
|
|
640
|
+
},
|
|
641
|
+
})
|
|
642
|
+
const uploaded = await sdk.uploadBlob({ body: stream })
|
|
643
|
+
console.log("uploaded:", uploaded.size, uploaded.hash)
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
```py
|
|
647
|
+
async def gen() -> AsyncIterator[bytes]:
|
|
648
|
+
for off in range(0, TOTAL, CHUNK):
|
|
649
|
+
yield buf[off:off + CHUNK]
|
|
650
|
+
|
|
651
|
+
uploaded = await sdk.uploadBlob(gen())
|
|
652
|
+
print("uploaded:", uploaded["size"], uploaded["hash"])
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
```go
|
|
656
|
+
uploaded, err := client.UploadBlob(ctx, bytes.NewReader(buf), nil)
|
|
657
|
+
if err == nil {
|
|
658
|
+
fmt.Println("uploaded:", uploaded.Size, uploaded.Hash)
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
```rust
|
|
663
|
+
let chunks: Vec<Bytes> = buf.chunks(CHUNK).map(|c| Bytes::copy_from_slice(c)).collect();
|
|
664
|
+
let body_stream = stream::iter(chunks.into_iter().map(Ok::<_, std::io::Error>));
|
|
665
|
+
let uploaded = client.upload_blob(body_stream, &UploadBlobOpts::default()).await?;
|
|
666
|
+
println!("uploaded: {} {}", uploaded.size, uploaded.hash);
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
### §16 — `x-idempotency-key`
|
|
670
|
+
|
|
671
|
+
Operations marked `x-idempotency-key: true` auto-send an `Idempotency-Key` header with a UUID when the caller doesn't supply one. Precedence: `headers["Idempotency-Key"]` wins over the explicit opts field, which wins over the auto UUID.
|
|
672
|
+
|
|
673
|
+
```ts
|
|
674
|
+
const auto = await sdk.idempotentCreate()
|
|
675
|
+
const explicit = await sdk.idempotentCreate({ idempotencyKey: "user-supplied-123" })
|
|
676
|
+
const viaHeader = await sdk.idempotentCreate({ headers: { "Idempotency-Key": "header-wins-456" } })
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
```py
|
|
680
|
+
auto = await sdk.idempotentCreate()
|
|
681
|
+
explicit = await sdk.idempotentCreate(idempotency_key="user-supplied-123")
|
|
682
|
+
via_header = await sdk.idempotentCreate(headers={"Idempotency-Key": "header-wins-456"})
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
```go
|
|
686
|
+
auto, _ := client.IdempotentCreate(ctx, nil)
|
|
687
|
+
explicit, _ := client.IdempotentCreate(ctx, &sdk.IdempotentCreateOpts{IdempotencyKey: "user-supplied-123"})
|
|
688
|
+
viaHeader, _ := client.IdempotentCreate(ctx, &sdk.IdempotentCreateOpts{
|
|
689
|
+
Headers: map[string]string{"Idempotency-Key": "header-wins-456"},
|
|
690
|
+
})
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
```rust
|
|
694
|
+
let auto = client.idempotent_create(&IdempotentCreateOpts::default()).await?;
|
|
695
|
+
let explicit = client
|
|
696
|
+
.idempotent_create(&IdempotentCreateOpts {
|
|
697
|
+
idempotency_key: Some("user-supplied-123".into()),
|
|
698
|
+
..Default::default()
|
|
699
|
+
})
|
|
700
|
+
.await?;
|
|
701
|
+
let via_header = client
|
|
702
|
+
.idempotent_create(&IdempotentCreateOpts {
|
|
703
|
+
headers: Some([("Idempotency-Key".into(), "header-wins-456".into())].into_iter().collect()),
|
|
704
|
+
..Default::default()
|
|
705
|
+
})
|
|
706
|
+
.await?;
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
## Links
|
|
710
|
+
|
|
711
|
+
- **End-to-end examples** — full per-lang files with every capability §1–16 in context:
|
|
712
|
+
- `../examples/typescript/example.ts`
|
|
713
|
+
- `../examples/python/example.py`
|
|
714
|
+
- `../examples/go/example.go`
|
|
715
|
+
- `../examples/rust/example.rs`
|
|
716
|
+
- `../examples/README.md` — examples index + parity surface map.
|
|
717
|
+
- **Master spec** — full capability surface lived in the iterating monorepo workerc specs; this page plus `./api-ref/` are the in-repo index.
|
|
718
|
+
- **Behavioral harnesses** — the tier-4 tests that verify what this page documents:
|
|
719
|
+
- `../tests/integration/sdk-harness/ts-harness.test.ts`
|
|
720
|
+
- `../tests/integration/sdk-harness/python-harness.test.ts`
|
|
721
|
+
- `../tests/integration/sdk-harness/go-harness.test.ts`
|
|
722
|
+
- `../tests/integration/sdk-harness/rust-harness.test.ts`
|