@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.
Files changed (131) hide show
  1. package/README.md +51 -0
  2. package/docs/api-ref/go.md +399 -0
  3. package/docs/api-ref/python.md +399 -0
  4. package/docs/api-ref/rust.md +399 -0
  5. package/docs/api-ref/typescript.md +399 -0
  6. package/docs/invalidate-spec.md +303 -0
  7. package/docs/sdk.md +722 -0
  8. package/package.json +120 -0
  9. package/src/accepts.ts +78 -0
  10. package/src/body-limit.ts +100 -0
  11. package/src/build/index.ts +174 -0
  12. package/src/cli-go/auth.go +15 -0
  13. package/src/cli-go/config.go +165 -0
  14. package/src/cli-go/errors.go +57 -0
  15. package/src/cli-go/multipart.go +55 -0
  16. package/src/cli-go/output.go +109 -0
  17. package/src/cli-go/root.go +30 -0
  18. package/src/cli-go/stream.go +88 -0
  19. package/src/cli-go/version.go +5 -0
  20. package/src/cli.ts +204 -0
  21. package/src/client/error.ts +27 -0
  22. package/src/client/http.ts +502 -0
  23. package/src/client/index.ts +127 -0
  24. package/src/client/sdk.ts +265 -0
  25. package/src/client/sse.ts +102 -0
  26. package/src/client/types.ts +211 -0
  27. package/src/client/ws.ts +115 -0
  28. package/src/client-go/errors.go +207 -0
  29. package/src/client-go/invalidation.go +373 -0
  30. package/src/client-go/realtime.go +317 -0
  31. package/src/client-go/result.go +20 -0
  32. package/src/client-go/runtime.go +413 -0
  33. package/src/client-go/sse.go +149 -0
  34. package/src/client-go/transport.go +452 -0
  35. package/src/client-go/ws.go +104 -0
  36. package/src/client-mcp/README.md +10 -0
  37. package/src/client-mcp/server.ts +94 -0
  38. package/src/client-mcp/types.ts +19 -0
  39. package/src/client-python/_errors.py +115 -0
  40. package/src/client-python/_invalidation.py +578 -0
  41. package/src/client-python/_realtime.py +156 -0
  42. package/src/client-python/_runtime.py +469 -0
  43. package/src/client-python/_sse.py +99 -0
  44. package/src/client-python/_transport.py +335 -0
  45. package/src/client-python/_ws.py +131 -0
  46. package/src/client-python/py.typed +0 -0
  47. package/src/client-rust/errors.rs +200 -0
  48. package/src/client-rust/invalidation.rs +441 -0
  49. package/src/client-rust/invalidation_sync.rs +306 -0
  50. package/src/client-rust/realtime.rs +695 -0
  51. package/src/client-rust/result.rs +22 -0
  52. package/src/client-rust/runtime.rs +547 -0
  53. package/src/client-rust/runtime_sync.rs +452 -0
  54. package/src/client-rust/sse.rs +167 -0
  55. package/src/client-rust/ws.rs +143 -0
  56. package/src/cloudflare/realtime.ts +236 -0
  57. package/src/codegen-go-cli.ts +1180 -0
  58. package/src/codegen-go.ts +1010 -0
  59. package/src/codegen-hash.ts +8 -0
  60. package/src/codegen-ir.ts +517 -0
  61. package/src/codegen-loaders.ts +53 -0
  62. package/src/codegen-mcp.ts +364 -0
  63. package/src/codegen-python.ts +1028 -0
  64. package/src/codegen-rust.ts +1652 -0
  65. package/src/codegen-sanitize.ts +67 -0
  66. package/src/codegen-schema-naming.ts +146 -0
  67. package/src/codegen.ts +5015 -0
  68. package/src/context.ts +210 -0
  69. package/src/cookie-sign.ts +58 -0
  70. package/src/cookie.ts +89 -0
  71. package/src/cors.ts +96 -0
  72. package/src/crypto.ts +19 -0
  73. package/src/csrf.ts +54 -0
  74. package/src/curl-logger.ts +235 -0
  75. package/src/detect-runtime.ts +19 -0
  76. package/src/error.ts +52 -0
  77. package/src/errors.ts +87 -0
  78. package/src/etag.ts +74 -0
  79. package/src/feature-load.ts +23 -0
  80. package/src/go-type-emitter.ts +404 -0
  81. package/src/honey-response.ts +354 -0
  82. package/src/i18n-slot.ts +20 -0
  83. package/src/i18n.ts +234 -0
  84. package/src/index.ts +3843 -0
  85. package/src/init.ts +149 -0
  86. package/src/input.ts +8 -0
  87. package/src/ip-restrict.ts +192 -0
  88. package/src/logger.ts +149 -0
  89. package/src/middleware.ts +199 -0
  90. package/src/node-request.ts +240 -0
  91. package/src/node.ts +212 -0
  92. package/src/openapi/register.ts +19 -0
  93. package/src/openapi/scalar.ts +33 -0
  94. package/src/openapi/spec-factory.ts +46 -0
  95. package/src/openapi/spec.ts +31 -0
  96. package/src/openapi/swagger.ts +34 -0
  97. package/src/plugin.ts +771 -0
  98. package/src/powered-by.ts +17 -0
  99. package/src/pretty-json.ts +39 -0
  100. package/src/proxy.ts +155 -0
  101. package/src/python-type-emitter.ts +144 -0
  102. package/src/realtime/buffer.ts +101 -0
  103. package/src/realtime/bus.ts +93 -0
  104. package/src/realtime/longpoll.ts +148 -0
  105. package/src/realtime/protocol.ts +128 -0
  106. package/src/realtime/route.ts +104 -0
  107. package/src/realtime/runtime.ts +430 -0
  108. package/src/request-id.ts +23 -0
  109. package/src/request-to-curl.ts +34 -0
  110. package/src/response.ts +414 -0
  111. package/src/rust-type-emitter.ts +412 -0
  112. package/src/secure-headers.ts +69 -0
  113. package/src/serve-register.ts +8 -0
  114. package/src/serve-slot.ts +20 -0
  115. package/src/serve.ts +141 -0
  116. package/src/server-timing.ts +66 -0
  117. package/src/static.ts +60 -0
  118. package/src/telemetry/otel.ts +139 -0
  119. package/src/testing.ts +114 -0
  120. package/src/timeout.ts +36 -0
  121. package/src/tree.ts +464 -0
  122. package/src/ts-type-emitter.ts +106 -0
  123. package/src/type-emitter.ts +524 -0
  124. package/src/type-extractor.ts +749 -0
  125. package/src/types.ts +638 -0
  126. package/src/validation.ts +300 -0
  127. package/src/ws/bun.ts +89 -0
  128. package/src/ws/cloudflare.ts +133 -0
  129. package/src/ws/deno.ts +109 -0
  130. package/src/ws/node.ts +161 -0
  131. package/src/yaml.ts +80 -0
@@ -0,0 +1,452 @@
1
+ /* Code generated by honey. DO NOT EDIT. */
2
+
3
+ #![allow(dead_code)]
4
+
5
+ /* WARNING: reqwest::blocking::Client WILL deadlock if called from inside a tokio async task.
6
+ Only use SyncClient and do_request_blocking from non-async contexts (e.g. plain threads,
7
+ test harnesses, CLI tools). Never call blocking methods from within an async runtime. */
8
+
9
+ use crate::errors::{raise_for_status, Error};
10
+ use crate::runtime::{LogEntry, OnLogHook};
11
+ use std::collections::HashMap;
12
+ use std::sync::Arc;
13
+ use std::sync::atomic::{AtomicBool, Ordering};
14
+ use std::time::Duration;
15
+
16
+ /// SyncOnRequestHook is a synchronous (non-async) request hook.
17
+ /// Returns Result<(), Error> directly — no Future, no Pin<Box<...>>.
18
+ pub type SyncOnRequestHook =
19
+ Arc<dyn Fn(&mut SyncRequestContext) -> Result<(), Error> + Send + Sync>;
20
+
21
+ /// SyncOnResponseHook is a synchronous (non-async) response hook.
22
+ pub type SyncOnResponseHook =
23
+ Arc<dyn Fn(&mut SyncResponseContext) -> Result<(), Error> + Send + Sync>;
24
+
25
+ /// SyncRequestContext carries mutable request metadata through SyncOnRequestHook invocations.
26
+ /// `selector` / `is_stale` / `invalidated_by` mirror the async RequestContext fields.
27
+ pub struct SyncRequestContext {
28
+ pub method: reqwest::Method,
29
+ pub url: String,
30
+ pub path: String,
31
+ pub headers: HashMap<String, String>,
32
+ pub is_retry: bool,
33
+ pub selector: String,
34
+ pub is_stale: bool,
35
+ pub invalidated_by: Vec<String>,
36
+ }
37
+
38
+ /// SyncResponseContext carries response metadata through SyncOnResponseHook invocations.
39
+ pub struct SyncResponseContext {
40
+ pub status: u16,
41
+ pub response: Vec<u8>,
42
+ pub headers: reqwest::header::HeaderMap,
43
+ pub is_retry: bool,
44
+ pub selector: String,
45
+ pub is_stale: bool,
46
+ pub invalidated_by: Vec<String>,
47
+ }
48
+
49
+ /// SyncRequestBody represents the different body types a blocking request can carry.
50
+ /// `Stream` carries an owned, single-shot `reqwest::blocking::Body` (built via
51
+ /// `reqwest::blocking::Body::new` over any `impl Read + Send + 'static`); like
52
+ /// `Multipart`, it cannot be retried on 401 because the underlying body is not
53
+ /// `Clone`.
54
+ pub enum SyncRequestBody<'a> {
55
+ None,
56
+ Json(&'a serde_json::Value),
57
+ FormUrl(&'a HashMap<String, String>),
58
+ Multipart(reqwest::blocking::multipart::Form),
59
+ Stream(reqwest::blocking::Body),
60
+ }
61
+
62
+ /// SyncClientConfig holds all configuration for the blocking SDK client.
63
+ #[derive(Clone)]
64
+ pub struct SyncClientConfig {
65
+ pub base_url: String,
66
+ pub bearer_token: Option<String>,
67
+ pub headers: HashMap<String, String>,
68
+ pub timeout: Duration,
69
+ pub throw_on_error: bool,
70
+ pub stale_max_entries: usize,
71
+ pub invalidation: Option<crate::runtime::InvalidationConfig>,
72
+ pub on_auth_expired:
73
+ Option<Arc<dyn Fn() -> Result<String, Error> + Send + Sync>>,
74
+ pub http_client: Option<reqwest::blocking::Client>,
75
+ pub auth_header_name: Option<String>,
76
+ pub auth_header_prefix: Option<String>,
77
+ pub on_request: Vec<SyncOnRequestHook>,
78
+ pub on_response: Vec<SyncOnResponseHook>,
79
+ pub on_log: Option<OnLogHook>,
80
+ pub state: Option<Arc<std::sync::Mutex<HashMap<String, serde_json::Value>>>>,
81
+ }
82
+
83
+ impl Default for SyncClientConfig {
84
+ fn default() -> Self {
85
+ SyncClientConfig {
86
+ base_url: String::new(),
87
+ bearer_token: None,
88
+ headers: HashMap::new(),
89
+ timeout: Duration::from_secs(30),
90
+ throw_on_error: true,
91
+ stale_max_entries: 500,
92
+ invalidation: None,
93
+ on_auth_expired: None,
94
+ http_client: None,
95
+ auth_header_name: None,
96
+ auth_header_prefix: None,
97
+ on_request: Vec::new(),
98
+ on_response: Vec::new(),
99
+ on_log: None,
100
+ state: None,
101
+ }
102
+ }
103
+ }
104
+
105
+ /* emit_log invokes the on_log callback if configured, swallowing any panic so a
106
+ faulty logger cannot break the request path. Mirrors runtime.rs sibling. */
107
+ fn emit_log(cfg: &SyncClientConfig, entry: LogEntry) {
108
+ if let Some(ref hook) = cfg.on_log {
109
+ let h = Arc::clone(hook);
110
+ let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || h(entry)));
111
+ }
112
+ }
113
+
114
+ /* err_status extracts an HTTP status from a typed Error::Api. */
115
+ fn err_status(err: &Error) -> Option<i32> {
116
+ if let Error::Api(api) = err {
117
+ Some(api.status() as i32)
118
+ } else {
119
+ None
120
+ }
121
+ }
122
+
123
+ /// SyncRequestResult carries the raw bytes, status, headers, and final URL from a blocking request.
124
+ #[allow(dead_code)]
125
+ pub(crate) struct SyncRequestResult {
126
+ pub body: Vec<u8>,
127
+ pub status: u16,
128
+ pub headers: reqwest::header::HeaderMap,
129
+ pub url: String,
130
+ }
131
+
132
+ /// do_request_blocking executes a blocking HTTP request, injects auth, merges headers,
133
+ /// handles on_auth_expired retry on 401, fires on_request/on_response hooks, and wraps
134
+ /// errors into typed Error variants. Emits on_log lifecycle entries (request_start once,
135
+ /// response_received once for the final response, error on terminal failure).
136
+ #[allow(clippy::too_many_arguments)]
137
+ pub(crate) fn do_request_blocking(
138
+ client: &reqwest::blocking::Client,
139
+ cfg: &SyncClientConfig,
140
+ method: reqwest::Method,
141
+ raw_url: &str,
142
+ query: &[(String, String)],
143
+ body: SyncRequestBody<'_>,
144
+ call_headers: Option<&HashMap<String, String>>,
145
+ timeout: Option<Duration>,
146
+ cancel_token: Option<Arc<AtomicBool>>,
147
+ operation: &str,
148
+ request_meta: Option<&crate::invalidation::RequestMeta>,
149
+ ) -> Result<SyncRequestResult, Error> {
150
+ let start = std::time::Instant::now();
151
+
152
+ /* Pre-flight cancellation check — short-circuit before any work. */
153
+ if let Some(ref tok) = cancel_token {
154
+ if tok.load(Ordering::Acquire) {
155
+ emit_log(
156
+ cfg,
157
+ LogEntry {
158
+ level: "error".to_string(),
159
+ event: "error".to_string(),
160
+ operation: operation.to_string(),
161
+ duration_ms: start.elapsed().as_millis() as i64,
162
+ status: None,
163
+ error: Some("Canceled".to_string()),
164
+ },
165
+ );
166
+ return Err(Error::Canceled);
167
+ }
168
+ }
169
+
170
+ emit_log(
171
+ cfg,
172
+ LogEntry {
173
+ level: "debug".to_string(),
174
+ event: "request_start".to_string(),
175
+ operation: operation.to_string(),
176
+ duration_ms: 0,
177
+ status: None,
178
+ error: None,
179
+ },
180
+ );
181
+
182
+ /* Reborrow body for retry path; Multipart + Stream are not Clone so cannot retry 401 */
183
+ let retry_body: Option<SyncRequestBody<'_>> = match &body {
184
+ SyncRequestBody::None => Some(SyncRequestBody::None),
185
+ SyncRequestBody::Json(v) => Some(SyncRequestBody::Json(v)),
186
+ SyncRequestBody::FormUrl(m) => Some(SyncRequestBody::FormUrl(m)),
187
+ SyncRequestBody::Multipart(_) => None,
188
+ SyncRequestBody::Stream(_) => None,
189
+ };
190
+
191
+ let mut result = match execute_request_blocking(
192
+ client,
193
+ cfg,
194
+ method.clone(),
195
+ raw_url,
196
+ query,
197
+ body,
198
+ call_headers,
199
+ cfg.bearer_token.as_deref(),
200
+ false,
201
+ timeout,
202
+ cancel_token.clone(),
203
+ request_meta,
204
+ ) {
205
+ Ok(r) => r,
206
+ Err(e) => {
207
+ emit_log(
208
+ cfg,
209
+ LogEntry {
210
+ level: "error".to_string(),
211
+ event: "error".to_string(),
212
+ operation: operation.to_string(),
213
+ duration_ms: start.elapsed().as_millis() as i64,
214
+ status: err_status(&e),
215
+ error: Some(format!("{:?}", e)),
216
+ },
217
+ );
218
+ return Err(e);
219
+ }
220
+ };
221
+
222
+ if result.status == 401 {
223
+ if let Some(ref hook) = cfg.on_auth_expired {
224
+ if let Some(retry) = retry_body {
225
+ let new_token = match hook() {
226
+ Ok(t) => t,
227
+ Err(e) => {
228
+ let mapped = Error::AuthExpired(e.to_string());
229
+ emit_log(
230
+ cfg,
231
+ LogEntry {
232
+ level: "error".to_string(),
233
+ event: "error".to_string(),
234
+ operation: operation.to_string(),
235
+ duration_ms: start.elapsed().as_millis() as i64,
236
+ status: err_status(&mapped),
237
+ error: Some(format!("{:?}", mapped)),
238
+ },
239
+ );
240
+ return Err(mapped);
241
+ }
242
+ };
243
+ result = match execute_request_blocking(
244
+ client,
245
+ cfg,
246
+ method,
247
+ raw_url,
248
+ query,
249
+ retry,
250
+ call_headers,
251
+ Some(&new_token),
252
+ true,
253
+ timeout,
254
+ cancel_token,
255
+ request_meta,
256
+ ) {
257
+ Ok(r) => r,
258
+ Err(e) => {
259
+ emit_log(
260
+ cfg,
261
+ LogEntry {
262
+ level: "error".to_string(),
263
+ event: "error".to_string(),
264
+ operation: operation.to_string(),
265
+ duration_ms: start.elapsed().as_millis() as i64,
266
+ status: err_status(&e),
267
+ error: Some(format!("{:?}", e)),
268
+ },
269
+ );
270
+ return Err(e);
271
+ }
272
+ };
273
+ }
274
+ /* Multipart/Stream 401: Form/Body is not Clone, cannot retry; fall through */
275
+ }
276
+ }
277
+
278
+ let status_i32 = result.status as i32;
279
+ let level = if result.status >= 400 { "warn" } else { "info" };
280
+ emit_log(
281
+ cfg,
282
+ LogEntry {
283
+ level: level.to_string(),
284
+ event: "response_received".to_string(),
285
+ operation: operation.to_string(),
286
+ duration_ms: start.elapsed().as_millis() as i64,
287
+ status: Some(status_i32),
288
+ error: None,
289
+ },
290
+ );
291
+
292
+ if let Some(err) = raise_for_status(result.status, result.body.clone()) {
293
+ emit_log(
294
+ cfg,
295
+ LogEntry {
296
+ level: "error".to_string(),
297
+ event: "error".to_string(),
298
+ operation: operation.to_string(),
299
+ duration_ms: start.elapsed().as_millis() as i64,
300
+ status: Some(status_i32),
301
+ error: Some(format!("{:?}", err)),
302
+ },
303
+ );
304
+ return Err(err);
305
+ }
306
+
307
+ Ok(result)
308
+ }
309
+
310
+ #[allow(clippy::too_many_arguments)]
311
+ fn execute_request_blocking(
312
+ client: &reqwest::blocking::Client,
313
+ cfg: &SyncClientConfig,
314
+ method: reqwest::Method,
315
+ raw_url: &str,
316
+ query: &[(String, String)],
317
+ body: SyncRequestBody<'_>,
318
+ call_headers: Option<&HashMap<String, String>>,
319
+ bearer_token: Option<&str>,
320
+ is_retry: bool,
321
+ per_call_timeout: Option<Duration>,
322
+ cancel_token: Option<Arc<AtomicBool>>,
323
+ request_meta: Option<&crate::invalidation::RequestMeta>,
324
+ ) -> Result<SyncRequestResult, Error> {
325
+ let url = build_url_sync(&cfg.base_url, raw_url, query)?;
326
+ let url_str = url.to_string();
327
+
328
+ /* build merged headers map for hook inspection */
329
+ let mut hook_headers: HashMap<String, String> = cfg.headers.clone();
330
+ if let Some(ch) = call_headers {
331
+ for (k, v) in ch {
332
+ hook_headers.insert(k.clone(), v.clone());
333
+ }
334
+ }
335
+ if let Some(token) = bearer_token {
336
+ if !token.is_empty() {
337
+ let name = cfg.auth_header_name.as_deref().unwrap_or("Authorization");
338
+ let prefix = cfg.auth_header_prefix.as_deref().unwrap_or("Bearer ");
339
+ hook_headers.insert(name.to_string(), format!("{}{}", prefix, token));
340
+ }
341
+ }
342
+
343
+ let (meta_selector, meta_is_stale, meta_by) = match request_meta {
344
+ Some(m) => (m.selector.clone(), m.is_stale, m.invalidated_by.clone()),
345
+ None => (String::new(), false, Vec::new()),
346
+ };
347
+
348
+ let mut req_ctx = SyncRequestContext {
349
+ method: method.clone(),
350
+ url: url_str.clone(),
351
+ path: raw_url.to_string(),
352
+ headers: hook_headers,
353
+ is_retry,
354
+ selector: meta_selector.clone(),
355
+ is_stale: meta_is_stale,
356
+ invalidated_by: meta_by.clone(),
357
+ };
358
+
359
+ /* fire on_request hooks */
360
+ for hook in cfg.on_request.iter() {
361
+ hook(&mut req_ctx)?;
362
+ }
363
+
364
+ let mut req = client.request(req_ctx.method.clone(), &req_ctx.url);
365
+
366
+ /* apply headers from context */
367
+ for (k, v) in &req_ctx.headers {
368
+ req = req.header(k.as_str(), v.as_str());
369
+ }
370
+
371
+ /* apply body and content-type */
372
+ req = match body {
373
+ SyncRequestBody::Json(v) => {
374
+ req = req.header("Content-Type", "application/json");
375
+ req.json(v)
376
+ }
377
+ SyncRequestBody::FormUrl(m) => req.form(m),
378
+ SyncRequestBody::Multipart(f) => req.multipart(f),
379
+ SyncRequestBody::Stream(b) => {
380
+ req = req.header("Content-Type", "application/octet-stream");
381
+ req.body(b)
382
+ }
383
+ SyncRequestBody::None => req,
384
+ };
385
+
386
+ /* apply per-call timeout (overrides cfg.timeout) */
387
+ let effective_timeout = per_call_timeout.unwrap_or(cfg.timeout);
388
+ req = req.timeout(effective_timeout);
389
+
390
+ /* Re-check cancellation just before the blocking send (covers the window
391
+ between do_request_blocking pre-flight check and actual send). */
392
+ if let Some(ref tok) = cancel_token {
393
+ if tok.load(Ordering::Acquire) {
394
+ return Err(Error::Canceled);
395
+ }
396
+ }
397
+
398
+ let resp = req.send().map_err(|e| {
399
+ if e.is_timeout() {
400
+ Error::Timeout
401
+ } else if e.is_request() {
402
+ Error::Canceled
403
+ } else {
404
+ Error::Transport(e)
405
+ }
406
+ })?;
407
+
408
+ let status = resp.status().as_u16();
409
+ let resp_headers = resp.headers().clone();
410
+ let resp_url = resp.url().to_string();
411
+ let body_bytes = resp.bytes().map_err(Error::Transport)?.to_vec();
412
+
413
+ let mut resp_ctx = SyncResponseContext {
414
+ status,
415
+ response: body_bytes.clone(),
416
+ headers: resp_headers.clone(),
417
+ is_retry,
418
+ selector: meta_selector,
419
+ is_stale: meta_is_stale,
420
+ invalidated_by: meta_by,
421
+ };
422
+
423
+ /* fire on_response hooks */
424
+ for hook in cfg.on_response.iter() {
425
+ hook(&mut resp_ctx)?;
426
+ }
427
+
428
+ Ok(SyncRequestResult {
429
+ body: body_bytes,
430
+ status,
431
+ headers: resp_headers,
432
+ url: resp_url,
433
+ })
434
+ }
435
+
436
+ /// build_url_sync constructs the full URL from base + path + query params.
437
+ pub(crate) fn build_url_sync(
438
+ base_url: &str,
439
+ path: &str,
440
+ query: &[(String, String)],
441
+ ) -> Result<url::Url, Error> {
442
+ let base = url::Url::parse(base_url)?;
443
+ let joined = base.join(path)?;
444
+ let mut url = joined;
445
+ if !query.is_empty() {
446
+ let mut pairs = url.query_pairs_mut();
447
+ for (k, v) in query {
448
+ pairs.append_pair(k, v);
449
+ }
450
+ }
451
+ Ok(url)
452
+ }
@@ -0,0 +1,167 @@
1
+ /* Code generated by honey. DO NOT EDIT. */
2
+
3
+ use crate::errors::{raise_for_status, Error};
4
+ use bytes::Bytes;
5
+ use futures_util::StreamExt;
6
+
7
+ /// SseEvent represents a single server-sent event.
8
+ pub struct SseEvent {
9
+ pub data: String,
10
+ pub event: String,
11
+ pub id: String,
12
+ pub retry: i32,
13
+ }
14
+
15
+ /// parse_sse_stream returns a Stream of SseEvent values read from a reqwest Response.
16
+ /// On non-2xx response it yields a typed error immediately then terminates.
17
+ /// Hand-parsed over reqwest bytes_stream; mirrors Go parseSSEStream line-for-line.
18
+ pub fn parse_sse_stream(
19
+ resp: reqwest::Response,
20
+ ) -> impl futures_util::Stream<Item = Result<SseEvent, Error>> {
21
+ async_stream::stream! {
22
+ let status = resp.status().as_u16();
23
+ if !(200..300).contains(&status) {
24
+ let body = resp.bytes().await.unwrap_or_default().to_vec();
25
+ if let Some(err) = raise_for_status(status, body) {
26
+ yield Err(err);
27
+ }
28
+ return;
29
+ }
30
+
31
+ let mut byte_stream = resp.bytes_stream();
32
+ let mut buf = Vec::<u8>::new();
33
+
34
+ while let Some(chunk) = byte_stream.next().await {
35
+ match chunk {
36
+ Err(e) => {
37
+ yield Err(Error::Transport(e));
38
+ return;
39
+ }
40
+ Ok(bytes) => {
41
+ buf.extend_from_slice(&bytes);
42
+ loop {
43
+ /* find next double-newline delimiter: \r\n\r\n, \n\n, \r\r */
44
+ let delim_pos = find_double_newline(&buf);
45
+ match delim_pos {
46
+ None => break,
47
+ Some((start, end)) => {
48
+ let block = buf[..start].to_vec();
49
+ buf.drain(..end);
50
+ let block_str = String::from_utf8_lossy(&block);
51
+ let trimmed = block_str.trim();
52
+ if !trimmed.is_empty() {
53
+ if let Some(ev) = parse_sse_block(trimmed) {
54
+ yield Ok(ev);
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ /* flush any remaining data */
65
+ if !buf.is_empty() {
66
+ let block_str = String::from_utf8_lossy(&buf);
67
+ let trimmed = block_str.trim();
68
+ if !trimmed.is_empty() {
69
+ if let Some(ev) = parse_sse_block(trimmed) {
70
+ yield Ok(ev);
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ /// find_double_newline returns (content_end, advance) positions for the first
78
+ /// occurrence of \r\n\r\n, \n\n, or \r\r in buf.
79
+ pub(crate) fn find_double_newline(buf: &[u8]) -> Option<(usize, usize)> {
80
+ let delimiters: &[&[u8]] = &[b"\r\n\r\n", b"\n\n", b"\r\r"];
81
+ for delim in delimiters {
82
+ let dlen = delim.len();
83
+ if buf.len() < dlen {
84
+ continue;
85
+ }
86
+ for i in 0..=(buf.len() - dlen) {
87
+ if &buf[i..i + dlen] == *delim {
88
+ return Some((i, i + dlen));
89
+ }
90
+ }
91
+ }
92
+ None
93
+ }
94
+
95
+ /// parse_sse_block parses a single SSE message block into an SseEvent.
96
+ /// Mirrors Go parseSSEBlock line-for-line:
97
+ /// - colon-prefix lines are comments and skipped
98
+ /// - field/val split on first colon; leading space stripped from val
99
+ /// - data lines joined with \n
100
+ /// - id containing null byte (\0) is ignored per spec
101
+ /// - retry parsed as non-negative i32
102
+ pub(crate) fn parse_sse_block(block: &str) -> Option<SseEvent> {
103
+ let mut data = String::new();
104
+ let mut has_data = false;
105
+ let mut event = String::new();
106
+ let mut id = String::new();
107
+ let mut retry: i32 = 0;
108
+ let mut is_comment = true;
109
+
110
+ for raw_line in block.split('\n') {
111
+ let line = raw_line.trim_end_matches('\r');
112
+ if line.starts_with(':') {
113
+ continue;
114
+ }
115
+ is_comment = false;
116
+
117
+ let (field, val) = if let Some(colon_idx) = line.find(':') {
118
+ let f = &line[..colon_idx];
119
+ let v = &line[colon_idx + 1..];
120
+ let v = v.strip_prefix(' ').unwrap_or(v);
121
+ (f, v)
122
+ } else {
123
+ (line, "")
124
+ };
125
+
126
+ match field {
127
+ "data" => {
128
+ if has_data {
129
+ data.push('\n');
130
+ data.push_str(val);
131
+ } else {
132
+ data = val.to_string();
133
+ has_data = true;
134
+ }
135
+ }
136
+ "event" => {
137
+ event = val.to_string();
138
+ }
139
+ "id" => {
140
+ /* per spec: id containing null byte must be ignored */
141
+ if !val.contains('\0') {
142
+ id = val.to_string();
143
+ }
144
+ }
145
+ "retry" => {
146
+ use std::str::FromStr;
147
+ if let Ok(n) = i32::from_str(val) {
148
+ if n >= 0 {
149
+ retry = n;
150
+ }
151
+ }
152
+ }
153
+ _ => {}
154
+ }
155
+ }
156
+
157
+ if is_comment || !has_data {
158
+ return None;
159
+ }
160
+
161
+ Some(SseEvent { data, event, id, retry })
162
+ }
163
+
164
+ /* suppress unused import warning — Bytes is part of the public API contract */
165
+ const _: fn() = || {
166
+ let _: Bytes = Bytes::new();
167
+ };