@celerity-sdk/runtime 0.2.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/src/lib.rs ADDED
@@ -0,0 +1,561 @@
1
+ #![deny(clippy::all)]
2
+ #![allow(unexpected_cfgs)]
3
+
4
+ use std::{collections::HashMap, sync::Arc, time::Duration};
5
+
6
+ use axum::{
7
+ body::Body,
8
+ http::{request::Parts, Request, StatusCode},
9
+ response::IntoResponse,
10
+ };
11
+ use celerity_helpers::{
12
+ env::ProcessEnvVars,
13
+ request::{
14
+ cookies_from_headers, path_params_from_request_parts, query_from_uri, to_request_body,
15
+ },
16
+ runtime_types::{RuntimeCallMode, RuntimePlatform},
17
+ };
18
+ use celerity_runtime_core::{
19
+ application::Application,
20
+ auth_http::AuthClaims,
21
+ config::{
22
+ ApiConfig, AppConfig, ClientIpSource, HttpConfig, HttpHandlerDefinition, RuntimeConfig,
23
+ WebSocketConfig,
24
+ },
25
+ request::{MatchedRoute, RequestId, ResolvedClientIp, ResolvedUserAgent},
26
+ telemetry_utils::extract_trace_context,
27
+ };
28
+ use napi::bindgen_prelude::*;
29
+ use napi::threadsafe_function::ThreadsafeFunction;
30
+ use napi_derive::napi;
31
+ use serde::{Deserialize, Serialize};
32
+ use tokio::time;
33
+
34
+ const MAX_REQUEST_BODY_SIZE: usize = 10 * 1024 * 1024; // 10 MiB
35
+
36
+ /// A weak ThreadsafeFunction that does not prevent the Node.js event loop from exiting.
37
+ type WeakTsfn =
38
+ ThreadsafeFunction<JsRequestWrapper, Promise<Response>, JsRequestWrapper, Status, true, true>;
39
+
40
+ #[napi(object)]
41
+ pub struct CoreRuntimeConfig {
42
+ pub blueprint_config_path: String,
43
+ pub server_port: i32,
44
+ pub server_loopback_only: Option<bool>,
45
+ }
46
+
47
+ #[napi(object)]
48
+ pub struct CoreRuntimeAppConfig {
49
+ pub api: Option<CoreApiConfig>,
50
+ }
51
+
52
+ impl From<AppConfig> for CoreRuntimeAppConfig {
53
+ fn from(app_config: AppConfig) -> Self {
54
+ let api = app_config.api.map(|api_config| api_config.into());
55
+ Self { api }
56
+ }
57
+ }
58
+
59
+ #[napi(object)]
60
+ pub struct CoreApiConfig {
61
+ pub http: Option<CoreHttpConfig>,
62
+ pub websocket: Option<CoreWebsocketConfig>,
63
+ }
64
+
65
+ impl From<ApiConfig> for CoreApiConfig {
66
+ fn from(api_config: ApiConfig) -> Self {
67
+ let http = api_config.http.map(|http_config| http_config.into());
68
+ let websocket = api_config
69
+ .websocket
70
+ .map(|websocket_config| websocket_config.into());
71
+ Self { http, websocket }
72
+ }
73
+ }
74
+
75
+ #[napi(object)]
76
+ pub struct CoreHttpConfig {
77
+ pub handlers: Vec<CoreHttpHandlerDefinition>,
78
+ }
79
+
80
+ impl From<HttpConfig> for CoreHttpConfig {
81
+ fn from(http_config: HttpConfig) -> Self {
82
+ let handlers = http_config
83
+ .handlers
84
+ .into_iter()
85
+ .map(|handler| handler.into())
86
+ .collect::<Vec<_>>();
87
+ Self { handlers }
88
+ }
89
+ }
90
+
91
+ #[napi(object)]
92
+ pub struct CoreWebsocketConfig {}
93
+
94
+ impl From<WebSocketConfig> for CoreWebsocketConfig {
95
+ fn from(_: WebSocketConfig) -> Self {
96
+ Self {}
97
+ }
98
+ }
99
+
100
+ #[napi(object)]
101
+ pub struct CoreHttpHandlerDefinition {
102
+ pub path: String,
103
+ pub method: String,
104
+ pub location: String,
105
+ pub handler: String,
106
+ pub timeout: i64,
107
+ }
108
+
109
+ impl From<HttpHandlerDefinition> for CoreHttpHandlerDefinition {
110
+ fn from(handler: HttpHandlerDefinition) -> Self {
111
+ Self {
112
+ path: handler.path,
113
+ method: handler.method,
114
+ location: handler.location,
115
+ handler: handler.handler,
116
+ timeout: handler.timeout,
117
+ }
118
+ }
119
+ }
120
+
121
+ #[napi(object)]
122
+ pub struct Response {
123
+ pub status: u16,
124
+ pub headers: Option<HashMap<String, String>>,
125
+ pub body: Option<String>,
126
+ pub binary_body: Option<Buffer>,
127
+ }
128
+
129
+ impl IntoResponse for Response {
130
+ fn into_response(self) -> axum::response::Response<Body> {
131
+ let mut builder = axum::response::Response::builder();
132
+ for (key, value) in self.headers.unwrap_or_default() {
133
+ builder = builder.header(key, value);
134
+ }
135
+ builder = builder.status(self.status);
136
+ let body = if let Some(binary) = self.binary_body {
137
+ Body::from(binary.to_vec())
138
+ } else {
139
+ Body::from(self.body.unwrap_or_default())
140
+ };
141
+ builder.body(body).unwrap()
142
+ }
143
+ }
144
+
145
+ #[derive(Debug)]
146
+ pub enum JsRequestWrapperBody {
147
+ Text(String),
148
+ Binary(Vec<u8>),
149
+ EmptyBody,
150
+ }
151
+
152
+ #[napi(js_name = "Request")]
153
+ pub struct JsRequestWrapper {
154
+ inner_body: JsRequestWrapperBody,
155
+ inner_parts: Parts,
156
+ path_params: HashMap<String, String>,
157
+ query: HashMap<String, Vec<String>>,
158
+ cookies: HashMap<String, String>,
159
+ content_type: String,
160
+ req_path: String,
161
+ request_id: String,
162
+ request_time: String,
163
+ auth_claims: Option<serde_json::Value>,
164
+ client_ip: String,
165
+ trace_context: Option<HashMap<String, String>>,
166
+ user_agent: String,
167
+ matched_route: Option<String>,
168
+ }
169
+
170
+ #[napi]
171
+ impl JsRequestWrapper {
172
+ /// Allows the creation of requests, primarily for test purposes.
173
+ /// In normal circumstances, the request will be created by
174
+ /// the runtime and passed to the handler.
175
+ #[napi(constructor)]
176
+ pub fn new(method: String, uri: String, headers: HashMap<String, String>) -> Self {
177
+ let mut builder = Request::builder().method(method.as_str()).uri(uri.clone());
178
+ for (key, value) in headers {
179
+ builder = builder.header(key, value);
180
+ }
181
+ let request = builder.body(Body::empty()).unwrap();
182
+ let (parts, _) = request.into_parts();
183
+ Self {
184
+ inner_parts: parts,
185
+ inner_body: JsRequestWrapperBody::EmptyBody,
186
+ path_params: HashMap::new(),
187
+ query: HashMap::new(),
188
+ cookies: HashMap::new(),
189
+ content_type: String::new(),
190
+ req_path: uri,
191
+ request_id: String::new(),
192
+ request_time: String::new(),
193
+ auth_claims: None,
194
+ client_ip: String::new(),
195
+ trace_context: None,
196
+ user_agent: String::new(),
197
+ matched_route: None,
198
+ }
199
+ }
200
+
201
+ async fn from_axum_req(req: axum::extract::Request<Body>) -> Result<Self> {
202
+ let (mut parts, body) = req.into_parts();
203
+
204
+ // Extract pre-processed fields before consuming the body.
205
+ let path_params = path_params_from_request_parts(&mut parts)
206
+ .await
207
+ .unwrap_or_default();
208
+ let query = query_from_uri(&parts.uri).unwrap_or_default();
209
+ let cookies = cookies_from_headers(&parts.headers);
210
+ let req_path = parts.uri.path().to_string();
211
+ let request_id = parts
212
+ .extensions
213
+ .get::<RequestId>()
214
+ .map(|id| id.0.clone())
215
+ .unwrap_or_default();
216
+ let auth_claims = parts
217
+ .extensions
218
+ .get::<AuthClaims>()
219
+ .and_then(|ac| ac.0.clone());
220
+ let client_ip = parts
221
+ .extensions
222
+ .get::<ResolvedClientIp>()
223
+ .map(|rci| rci.0.to_string())
224
+ .unwrap_or_default();
225
+ let user_agent = parts
226
+ .extensions
227
+ .get::<ResolvedUserAgent>()
228
+ .map(|ua| ua.0.clone())
229
+ .unwrap_or_default();
230
+ let matched_route = parts
231
+ .extensions
232
+ .get::<MatchedRoute>()
233
+ .map(|mr| mr.0.clone());
234
+ let trace_context = extract_trace_context();
235
+ let request_time = chrono::Utc::now().to_rfc3339();
236
+
237
+ // Read and process the body.
238
+ let content_length = parts
239
+ .headers
240
+ .get("content-length")
241
+ .and_then(|value| value.to_str().ok())
242
+ .and_then(|value| value.parse::<usize>().ok())
243
+ .unwrap_or(0);
244
+
245
+ let (inner_body, content_type) = if content_length > 0 {
246
+ let bytes = axum::body::to_bytes(body, MAX_REQUEST_BODY_SIZE)
247
+ .await
248
+ .map_err(|err| {
249
+ Error::new(
250
+ Status::GenericFailure,
251
+ format!("failed to read request body, {err}"),
252
+ )
253
+ })?;
254
+ let ct_header = parts.headers.get("content-type").cloned();
255
+ let (text_body, binary_body, content_type_str) = to_request_body(&bytes, ct_header);
256
+ let body = if let Some(text) = text_body {
257
+ JsRequestWrapperBody::Text(text)
258
+ } else if let Some(_binary) = binary_body {
259
+ JsRequestWrapperBody::Binary(bytes.to_vec())
260
+ } else {
261
+ JsRequestWrapperBody::EmptyBody
262
+ };
263
+ (body, content_type_str)
264
+ } else {
265
+ (
266
+ JsRequestWrapperBody::EmptyBody,
267
+ parts
268
+ .headers
269
+ .get("content-type")
270
+ .and_then(|v| v.to_str().ok())
271
+ .unwrap_or("")
272
+ .to_string(),
273
+ )
274
+ };
275
+
276
+ Ok(Self {
277
+ inner_parts: parts,
278
+ inner_body,
279
+ path_params,
280
+ query,
281
+ cookies,
282
+ content_type,
283
+ req_path,
284
+ request_id,
285
+ request_time,
286
+ auth_claims,
287
+ client_ip,
288
+ trace_context,
289
+ user_agent,
290
+ matched_route,
291
+ })
292
+ }
293
+
294
+ /// The HTTP version used for the request.
295
+ #[napi(getter)]
296
+ pub fn http_version(&self) -> String {
297
+ format!("{:?}", self.inner_parts.version)
298
+ }
299
+
300
+ /// The HTTP method of the request.
301
+ #[napi(getter)]
302
+ pub fn method(&self) -> String {
303
+ self.inner_parts.method.to_string()
304
+ }
305
+
306
+ /// The URI of the request.
307
+ #[napi(getter)]
308
+ pub fn uri(&self) -> String {
309
+ self.inner_parts.uri.to_string()
310
+ }
311
+
312
+ /// The headers of the request as a map of header name to list of values.
313
+ #[napi(getter)]
314
+ pub fn headers(&self) -> HashMap<String, Vec<String>> {
315
+ let mut map: HashMap<String, Vec<String>> = HashMap::new();
316
+ for (key, value) in self.inner_parts.headers.iter() {
317
+ map
318
+ .entry(key.as_str().to_string())
319
+ .or_default()
320
+ .push(value.to_str().unwrap_or_default().to_string());
321
+ }
322
+ map
323
+ }
324
+
325
+ /// The path of the request (e.g. "/orders/123").
326
+ #[napi(getter)]
327
+ pub fn path(&self) -> String {
328
+ self.req_path.clone()
329
+ }
330
+
331
+ /// Path parameters extracted from the URL (e.g. { "orderId": "123" }).
332
+ #[napi(getter)]
333
+ pub fn path_params(&self) -> HashMap<String, String> {
334
+ self.path_params.clone()
335
+ }
336
+
337
+ /// Query parameters, supporting multiple values per key.
338
+ #[napi(getter)]
339
+ pub fn query(&self) -> HashMap<String, Vec<String>> {
340
+ self.query.clone()
341
+ }
342
+
343
+ /// Cookies from the request.
344
+ #[napi(getter)]
345
+ pub fn cookies(&self) -> HashMap<String, String> {
346
+ self.cookies.clone()
347
+ }
348
+
349
+ /// The content type of the request body.
350
+ #[napi(getter)]
351
+ pub fn content_type(&self) -> String {
352
+ self.content_type.clone()
353
+ }
354
+
355
+ /// The request ID (from x-request-id header or auto-generated).
356
+ #[napi(getter)]
357
+ pub fn request_id(&self) -> String {
358
+ self.request_id.clone()
359
+ }
360
+
361
+ /// The request time as an ISO 8601 string.
362
+ #[napi(getter)]
363
+ pub fn request_time(&self) -> String {
364
+ self.request_time.clone()
365
+ }
366
+
367
+ /// Authentication claims from the auth middleware, or null if no auth.
368
+ #[napi(getter)]
369
+ pub fn auth(&self) -> Option<serde_json::Value> {
370
+ self.auth_claims.clone()
371
+ }
372
+
373
+ /// The client IP address resolved by the runtime.
374
+ #[napi(getter)]
375
+ pub fn client_ip(&self) -> String {
376
+ self.client_ip.clone()
377
+ }
378
+
379
+ /// The trace context for distributed tracing propagation.
380
+ /// Contains "traceparent" (W3C) and optionally "xray_trace_id" (AWS).
381
+ #[napi(getter)]
382
+ pub fn trace_context(&self) -> Option<HashMap<String, String>> {
383
+ self.trace_context.clone()
384
+ }
385
+
386
+ /// The user-agent string from the request.
387
+ #[napi(getter)]
388
+ pub fn user_agent(&self) -> String {
389
+ self.user_agent.clone()
390
+ }
391
+
392
+ /// The matched route pattern (e.g. "/orders/{orderId}"), or null if unavailable.
393
+ #[napi(getter)]
394
+ pub fn matched_route(&self) -> Option<String> {
395
+ self.matched_route.clone()
396
+ }
397
+
398
+ /// The text body of the request, or null if the body is empty or binary.
399
+ #[napi(getter)]
400
+ pub fn text_body(&self) -> Option<String> {
401
+ match &self.inner_body {
402
+ JsRequestWrapperBody::Text(text) => Some(text.clone()),
403
+ _ => None,
404
+ }
405
+ }
406
+
407
+ /// The binary body of the request as a Buffer, or null if the body is empty or text.
408
+ #[napi(getter)]
409
+ pub fn binary_body(&self) -> Option<Buffer> {
410
+ match &self.inner_body {
411
+ JsRequestWrapperBody::Binary(bytes) => Some(Buffer::from(bytes.clone())),
412
+ _ => None,
413
+ }
414
+ }
415
+ }
416
+
417
+ #[napi]
418
+ pub struct CoreRuntimeApplication {
419
+ inner: Application,
420
+ tsfn_cache: Vec<Arc<WeakTsfn>>,
421
+ }
422
+
423
+ #[napi]
424
+ impl CoreRuntimeApplication {
425
+ #[napi(constructor)]
426
+ pub fn new(runtime_config: CoreRuntimeConfig) -> Self {
427
+ let native_runtime_config = RuntimeConfig {
428
+ blueprint_config_path: runtime_config.blueprint_config_path,
429
+ runtime_call_mode: RuntimeCallMode::Ffi,
430
+ server_loopback_only: runtime_config.server_loopback_only,
431
+ server_port: runtime_config.server_port,
432
+ local_api_port: 8259,
433
+ use_custom_health_check: None,
434
+ service_name: "CelerityTestService".to_string(),
435
+ platform: RuntimePlatform::Local,
436
+ trace_otlp_collector_endpoint: "http://localhost:4317".to_string(),
437
+ runtime_max_diagnostics_level: tracing::Level::INFO,
438
+ test_mode: false,
439
+ api_resource: None,
440
+ consumer_app: None,
441
+ schedule_app: None,
442
+ resource_store_verify_tls: true,
443
+ resource_store_cache_entry_ttl: 600,
444
+ resource_store_cleanup_interval: 3600,
445
+ client_ip_source: ClientIpSource::ConnectInfo,
446
+ };
447
+ let inner = Application::new(native_runtime_config, Box::new(ProcessEnvVars::new()));
448
+ CoreRuntimeApplication {
449
+ inner,
450
+ tsfn_cache: vec![],
451
+ }
452
+ }
453
+
454
+ #[napi]
455
+ pub fn setup(&mut self) -> Result<CoreRuntimeAppConfig> {
456
+ let app_config = self.inner.setup().map_err(|err| {
457
+ Error::new(
458
+ Status::GenericFailure,
459
+ format!("failed to setup core runtime, {err}"),
460
+ )
461
+ })?;
462
+ Ok(app_config.into())
463
+ }
464
+
465
+ #[napi]
466
+ pub fn register_http_handler(
467
+ &mut self,
468
+ path: String,
469
+ method: String,
470
+ timeout_seconds: Option<i64>,
471
+ #[napi(ts_arg_type = "(err: Error | null, request: Request) => Promise<Response>")]
472
+ handler: WeakTsfn,
473
+ ) -> Result<()> {
474
+ let tsfn = Arc::new(handler);
475
+ self.tsfn_cache.push(tsfn.clone());
476
+ let timeout_secs = timeout_seconds.unwrap_or(60) as u64;
477
+
478
+ let handler = move |req| {
479
+ let tsfn = tsfn.clone();
480
+ async move {
481
+ let js_req_wrapper = JsRequestWrapper::from_axum_req(req)
482
+ .await
483
+ .map_err(|err| HandlerError::new(err.to_string()))?;
484
+ let promise = tsfn
485
+ .call_async(Ok(js_req_wrapper))
486
+ .await
487
+ .map_err(|err| HandlerError::new(err.to_string()))?;
488
+ let sleep = time::sleep(Duration::from_secs(timeout_secs));
489
+ tokio::select! {
490
+ _ = sleep => {
491
+ Err(HandlerError::timeout())
492
+ }
493
+ value = promise => {
494
+ Ok::<Response, HandlerError>(value.map_err(|err| HandlerError::new(err.to_string()))?)
495
+ }
496
+ }
497
+ }
498
+ };
499
+ self.inner.register_http_handler(&path, &method, handler);
500
+ Ok(())
501
+ }
502
+
503
+ #[allow(clippy::missing_safety_doc)]
504
+ #[napi]
505
+ pub async unsafe fn run(&mut self, block: bool) -> Result<()> {
506
+ self.inner.run(block).await.map_err(|err| {
507
+ Error::new(
508
+ Status::GenericFailure,
509
+ format!("failed to start core runtime, {err}"),
510
+ )
511
+ })?;
512
+ Ok(())
513
+ }
514
+
515
+ #[napi]
516
+ pub fn shutdown(&mut self) -> Result<()> {
517
+ self.inner.shutdown();
518
+ self.tsfn_cache.clear();
519
+ Ok(())
520
+ }
521
+ }
522
+
523
+ #[derive(Debug, Serialize, Deserialize)]
524
+ pub struct HandlerError {
525
+ pub message: String,
526
+ #[serde(skip)]
527
+ pub is_timeout: bool,
528
+ }
529
+
530
+ impl HandlerError {
531
+ pub fn new(message: String) -> Self {
532
+ Self {
533
+ message,
534
+ is_timeout: false,
535
+ }
536
+ }
537
+
538
+ pub fn timeout() -> Self {
539
+ Self {
540
+ message: "handler timed out".to_string(),
541
+ is_timeout: true,
542
+ }
543
+ }
544
+ }
545
+
546
+ impl std::fmt::Display for HandlerError {
547
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548
+ write!(f, "{}", self.message)
549
+ }
550
+ }
551
+
552
+ impl IntoResponse for HandlerError {
553
+ fn into_response(self) -> axum::response::Response<Body> {
554
+ let status = if self.is_timeout {
555
+ StatusCode::GATEWAY_TIMEOUT
556
+ } else {
557
+ StatusCode::INTERNAL_SERVER_ERROR
558
+ };
559
+ (status, axum::response::Json(self)).into_response()
560
+ }
561
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true
10
+ },
11
+ "include": ["__test__/**/*.ts", "index.d.ts"]
12
+ }