titanpl 1.0.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 (74) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +254 -0
  3. package/index.js +24 -0
  4. package/package.json +83 -0
  5. package/packages/cli/index.js +141 -0
  6. package/templates/common/.env +1 -0
  7. package/templates/common/Dockerfile +66 -0
  8. package/templates/common/_dockerignore +35 -0
  9. package/templates/common/_gitignore +33 -0
  10. package/templates/common/app/t.native.d.ts +2043 -0
  11. package/templates/common/app/t.native.js +39 -0
  12. package/templates/extension/README.md +65 -0
  13. package/templates/extension/index.d.ts +27 -0
  14. package/templates/extension/index.js +28 -0
  15. package/templates/extension/jsconfig.json +14 -0
  16. package/templates/extension/native/Cargo.toml +9 -0
  17. package/templates/extension/native/src/lib.rs +5 -0
  18. package/templates/extension/package-lock.json +522 -0
  19. package/templates/extension/package.json +26 -0
  20. package/templates/extension/titan.json +18 -0
  21. package/templates/js/app/actions/getuser.js +9 -0
  22. package/templates/js/app/app.js +7 -0
  23. package/templates/js/eslint.config.js +5 -0
  24. package/templates/js/jsconfig.json +27 -0
  25. package/templates/js/package.json +27 -0
  26. package/templates/rust-js/app/actions/getuser.js +9 -0
  27. package/templates/rust-js/app/actions/rust_hello.rs +14 -0
  28. package/templates/rust-js/app/app.js +9 -0
  29. package/templates/rust-js/eslint.config.js +5 -0
  30. package/templates/rust-js/jsconfig.json +27 -0
  31. package/templates/rust-js/package.json +27 -0
  32. package/templates/rust-js/titan/bundle.js +157 -0
  33. package/templates/rust-js/titan/dev.js +323 -0
  34. package/templates/rust-js/titan/titan.js +126 -0
  35. package/templates/rust-ts/app/actions/getuser.ts +9 -0
  36. package/templates/rust-ts/app/actions/rust_hello.rs +14 -0
  37. package/templates/rust-ts/app/app.ts +9 -0
  38. package/templates/rust-ts/eslint.config.js +12 -0
  39. package/templates/rust-ts/package.json +29 -0
  40. package/templates/rust-ts/titan/bundle.js +163 -0
  41. package/templates/rust-ts/titan/dev.js +435 -0
  42. package/templates/rust-ts/titan/titan.d.ts +19 -0
  43. package/templates/rust-ts/titan/titan.js +124 -0
  44. package/templates/rust-ts/tsconfig.json +28 -0
  45. package/templates/ts/app/actions/getuser.ts +9 -0
  46. package/templates/ts/app/app.ts +7 -0
  47. package/templates/ts/eslint.config.js +12 -0
  48. package/templates/ts/package.json +29 -0
  49. package/templates/ts/tsconfig.json +28 -0
  50. package/titanpl-sdk/LICENSE +15 -0
  51. package/titanpl-sdk/README.md +109 -0
  52. package/titanpl-sdk/assets/titanpl-sdk.png +0 -0
  53. package/titanpl-sdk/bin/run.js +274 -0
  54. package/titanpl-sdk/index.js +5 -0
  55. package/titanpl-sdk/package-lock.json +28 -0
  56. package/titanpl-sdk/package.json +40 -0
  57. package/titanpl-sdk/templates/app/actions/hello.js +5 -0
  58. package/titanpl-sdk/templates/app/app.js +7 -0
  59. package/titanpl-sdk/templates/jsconfig.json +19 -0
  60. package/titanpl-sdk/templates/server/Cargo.toml +52 -0
  61. package/titanpl-sdk/templates/server/src/action_management.rs +175 -0
  62. package/titanpl-sdk/templates/server/src/errors.rs +12 -0
  63. package/titanpl-sdk/templates/server/src/extensions/builtin.rs +1038 -0
  64. package/titanpl-sdk/templates/server/src/extensions/external.rs +338 -0
  65. package/titanpl-sdk/templates/server/src/extensions/mod.rs +580 -0
  66. package/titanpl-sdk/templates/server/src/extensions/titan_core.js +249 -0
  67. package/titanpl-sdk/templates/server/src/fast_path.rs +719 -0
  68. package/titanpl-sdk/templates/server/src/main.rs +607 -0
  69. package/titanpl-sdk/templates/server/src/runtime.rs +284 -0
  70. package/titanpl-sdk/templates/server/src/utils.rs +33 -0
  71. package/titanpl-sdk/templates/titan/bundle.js +259 -0
  72. package/titanpl-sdk/templates/titan/dev.js +390 -0
  73. package/titanpl-sdk/templates/titan/error-box.js +277 -0
  74. package/titanpl-sdk/templates/titan/titan.js +129 -0
@@ -0,0 +1,719 @@
1
+ //! Static Action Detection via OXC Semantic Analysis
2
+ //!
3
+ //! Purpose:
4
+ //! Bypass V8 entirely for actions that return constant/static values.
5
+ //! Uses OXC (Oxidation Compiler) to parse JavaScript into a real AST and
6
+ //! perform semantic analysis with constant propagation.
7
+ //!
8
+ //! Mechanism:
9
+ //! 1. Parses bundled action files (.jsbundle) with OXC.
10
+ //! 2. Builds semantic data (symbol table, scopes).
11
+ //! 3. Evaluates `t.response.json/text/html()` calls for static constancy.
12
+ //! 4. If all calls produce the same static value, the action is fast-pathed.
13
+ //!
14
+ //! Dependencies:
15
+ //! Requires `oxc` crate with "semantic" feature.
16
+
17
+ use bytes::Bytes;
18
+ use std::collections::HashMap;
19
+ use std::fs;
20
+ use std::path::Path;
21
+
22
+ use oxc::allocator::Allocator;
23
+ use oxc::ast::AstKind;
24
+ use oxc::ast::ast::*;
25
+ use oxc::parser::Parser;
26
+ use oxc::semantic::SemanticBuilder;
27
+ use oxc::span::SourceType;
28
+
29
+ /// A pre-computed HTTP response for a static action.
30
+ #[derive(Clone, Debug)]
31
+ pub struct StaticResponse {
32
+ pub body: Bytes,
33
+ pub content_type: &'static str,
34
+ pub status: u16,
35
+ pub extra_headers: Vec<(String, String)>,
36
+ }
37
+
38
+ impl PartialEq for StaticResponse {
39
+ fn eq(&self, other: &Self) -> bool {
40
+ self.body == other.body
41
+ && self.content_type == other.content_type
42
+ && self.status == other.status
43
+ && self.extra_headers == other.extra_headers
44
+ }
45
+ }
46
+
47
+ /// Options extracted from the second argument of t.response.*() call.
48
+ #[derive(Clone, Debug, Default)]
49
+ struct ResponseOptions {
50
+ status: u16,
51
+ headers: Vec<(String, String)>,
52
+ }
53
+
54
+ /// Registry of actions that have been detected as static.
55
+ #[derive(Clone)]
56
+ pub struct FastPathRegistry {
57
+ actions: HashMap<String, StaticResponse>,
58
+ }
59
+
60
+ impl FastPathRegistry {
61
+ /// Build a FastPathRegistry by scanning action files in the given directory.
62
+ pub fn build(actions_dir: &Path) -> Self {
63
+ let mut actions = HashMap::new();
64
+
65
+ if !actions_dir.exists() || !actions_dir.is_dir() {
66
+ return Self { actions };
67
+ }
68
+
69
+ if let Ok(entries) = fs::read_dir(actions_dir) {
70
+ for entry in entries.flatten() {
71
+ let path = entry.path();
72
+ if !path.is_file() {
73
+ continue;
74
+ }
75
+
76
+ let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
77
+ if ext != "js" && ext != "jsbundle" {
78
+ continue;
79
+ }
80
+
81
+ let name = path
82
+ .file_stem()
83
+ .and_then(|s| s.to_str())
84
+ .unwrap_or("")
85
+ .to_string();
86
+
87
+ if name.is_empty() {
88
+ continue;
89
+ }
90
+
91
+ if let Ok(source) = fs::read_to_string(&path) {
92
+ if let Some(resp) = analyze_action_source(&source) {
93
+ let header_info = if resp.extra_headers.is_empty() {
94
+ String::new()
95
+ } else {
96
+ format!(" +{}h", resp.extra_headers.len())
97
+ };
98
+ let status_info = if resp.status != 200 {
99
+ format!(" [{}]", resp.status)
100
+ } else {
101
+ String::new()
102
+ };
103
+ println!(
104
+ "\x1b[36m[Titan FastPath]\x1b[0m \x1b[32m✔\x1b[0m Action '{}' → static {} ({} bytes{}{})",
105
+ name, resp.content_type, resp.body.len(), status_info, header_info
106
+ );
107
+ actions.insert(name, resp);
108
+ }
109
+ }
110
+ }
111
+ }
112
+
113
+ if !actions.is_empty() {
114
+ println!(
115
+ "\x1b[36m[Titan FastPath]\x1b[0m {} action(s) will bypass V8",
116
+ actions.len()
117
+ );
118
+ }
119
+
120
+ Self { actions }
121
+ }
122
+
123
+ /// Check if an action has a fast-path static response.
124
+ #[inline(always)]
125
+ pub fn get(&self, action_name: &str) -> Option<&StaticResponse> {
126
+ self.actions.get(action_name)
127
+ }
128
+
129
+ /// Number of registered fast-path actions.
130
+ pub fn len(&self) -> usize {
131
+ self.actions.len()
132
+ }
133
+ }
134
+
135
+ impl StaticResponse {
136
+ /// Convert to an Axum response. Uses Bytes::clone() which is O(1) ref-count bump.
137
+ #[inline(always)]
138
+ pub fn to_axum_response(&self) -> axum::response::Response<axum::body::Body> {
139
+ let mut builder = axum::response::Response::builder()
140
+ .status(self.status)
141
+ .header("content-type", self.content_type)
142
+ .header("server", "TitanPL");
143
+
144
+ for (key, val) in &self.extra_headers {
145
+ let lower = key.to_lowercase();
146
+ if lower == "content-type" || lower == "server" {
147
+ continue;
148
+ }
149
+ builder = builder.header(key.as_str(), val.as_str());
150
+ }
151
+
152
+ builder
153
+ .body(axum::body::Body::from(self.body.clone()))
154
+ .unwrap()
155
+ }
156
+ }
157
+
158
+ /// A pre-computed response for static reply routes (t.get("/").reply("ok")).
159
+ #[derive(Clone, Debug)]
160
+ pub struct PrecomputedRoute {
161
+ pub body: Bytes,
162
+ pub content_type: &'static str,
163
+ }
164
+
165
+ impl PrecomputedRoute {
166
+ /// Create from a JSON serde_json::Value (for .reply({...}) routes)
167
+ pub fn from_json(val: &serde_json::Value) -> Self {
168
+ let body = serde_json::to_vec(val).unwrap_or_default();
169
+ Self {
170
+ body: Bytes::from(body),
171
+ content_type: "application/json",
172
+ }
173
+ }
174
+
175
+ /// Create from a text string (for .reply("text") routes)
176
+ pub fn from_text(text: &str) -> Self {
177
+ Self {
178
+ body: Bytes::from(text.to_string()),
179
+ content_type: "text/plain; charset=utf-8",
180
+ }
181
+ }
182
+
183
+ /// Convert to Axum response. O(1) body clone via Bytes refcount.
184
+ #[inline(always)]
185
+ pub fn to_axum_response(&self) -> axum::response::Response<axum::body::Body> {
186
+ axum::response::Response::builder()
187
+ .status(200u16)
188
+ .header("content-type", self.content_type)
189
+ .header("server", "TitanPL")
190
+ .body(axum::body::Body::from(self.body.clone()))
191
+ .unwrap()
192
+ }
193
+ }
194
+
195
+ /// Maximum recursion depth for static expression evaluation.
196
+ const MAX_EVAL_DEPTH: usize = 16;
197
+
198
+ /// Analyze a bundled action's source code using OXC semantic analysis.
199
+ fn analyze_action_source(source: &str) -> Option<StaticResponse> {
200
+ // Phase 1: Parse
201
+ let allocator = Allocator::default();
202
+ let source_type = SourceType::mjs(); // ES module JavaScript
203
+ let parser_ret = Parser::new(&allocator, source, source_type).parse();
204
+
205
+ if parser_ret.panicked {
206
+ return None;
207
+ }
208
+
209
+ // Phase 2: Semantic analysis
210
+ // Builds symbol table, resolves all identifier references to their
211
+ // declaring symbols, and tracks read/write counts per symbol.
212
+ let semantic_ret = SemanticBuilder::new().build(&parser_ret.program);
213
+ let semantic = &semantic_ret.semantic;
214
+
215
+ // Phase 3: Find and evaluate t.response.json/text/html() calls
216
+ let mut responses: Vec<StaticResponse> = Vec::new();
217
+ let mut has_dynamic = false;
218
+
219
+ for node in semantic.nodes().iter() {
220
+ if let AstKind::CallExpression(call) = node.kind() {
221
+ if let Some(method) = detect_response_method(call) {
222
+ analyze_response_call(call, method, semantic, &mut responses, &mut has_dynamic);
223
+ }
224
+ }
225
+ }
226
+
227
+ if has_dynamic || responses.is_empty() {
228
+ return None;
229
+ }
230
+
231
+ unique_response(&responses)
232
+ }
233
+
234
+ /// Detect if a CallExpression is `t.response.json(...)`, `t.response.text(...)`,
235
+ /// or `t.response.html(...)`. Returns the method name if matched.
236
+ fn detect_response_method<'a>(call: &CallExpression<'a>) -> Option<&'a str> {
237
+ // Callee must be: t.response.<method>
238
+ let outer = match &call.callee {
239
+ Expression::StaticMemberExpression(m) => m.as_ref(),
240
+ _ => return None,
241
+ };
242
+
243
+ let method = outer.property.name.as_str();
244
+ if method != "json" && method != "text" && method != "html" {
245
+ return None;
246
+ }
247
+
248
+ let inner = match &outer.object {
249
+ Expression::StaticMemberExpression(m) => m.as_ref(),
250
+ _ => return None,
251
+ };
252
+
253
+ if inner.property.name.as_str() != "response" {
254
+ return None;
255
+ }
256
+
257
+ match &inner.object {
258
+ Expression::Identifier(ident) if ident.name.as_str() == "t" => Some(method),
259
+ _ => None,
260
+ }
261
+ }
262
+
263
+ /// Analyze a single t.response.*() call and attempt to produce a StaticResponse.
264
+ fn analyze_response_call<'a>(
265
+ call: &CallExpression<'a>,
266
+ method: &str,
267
+ semantic: &oxc::semantic::Semantic<'a>,
268
+ responses: &mut Vec<StaticResponse>,
269
+ has_dynamic: &mut bool,
270
+ ) {
271
+ // First argument: the body (required)
272
+ let body_arg = match call.arguments.first() {
273
+ Some(arg) => arg,
274
+ None => return,
275
+ };
276
+
277
+ let body_expr = match body_arg {
278
+ Argument::SpreadElement(_) => {
279
+ *has_dynamic = true;
280
+ return;
281
+ }
282
+ arg => arg.as_expression().unwrap(),
283
+ };
284
+
285
+ // Second argument: options { headers: {...}, status: N } (optional)
286
+ let opts_expr = call.arguments.get(1).and_then(|arg| match arg {
287
+ Argument::SpreadElement(_) => None,
288
+ arg => arg.as_expression(),
289
+ });
290
+
291
+ // Evaluate the body statically
292
+ let body_value = match eval_static(body_expr, semantic, 0) {
293
+ Some(v) => v,
294
+ None => {
295
+ *has_dynamic = true;
296
+ return;
297
+ }
298
+ };
299
+
300
+ // Evaluate options if present
301
+ let options = if let Some(opts) = opts_expr {
302
+ match eval_static(opts, semantic, 0) {
303
+ Some(v) => extract_response_options(&v),
304
+ None => {
305
+ *has_dynamic = true;
306
+ return;
307
+ }
308
+ }
309
+ } else {
310
+ ResponseOptions {
311
+ status: 200,
312
+ headers: Vec::new(),
313
+ }
314
+ };
315
+
316
+ // Build the StaticResponse based on the method type
317
+ let (serialized_body, content_type) = match method {
318
+ "json" => {
319
+ match serde_json::to_vec(&body_value) {
320
+ Ok(bytes) => (bytes, "application/json"),
321
+ Err(_) => {
322
+ *has_dynamic = true;
323
+ return;
324
+ }
325
+ }
326
+ }
327
+ "text" => {
328
+ match body_value.as_str() {
329
+ Some(s) => (s.as_bytes().to_vec(), "text/plain"),
330
+ None => {
331
+ *has_dynamic = true;
332
+ return;
333
+ }
334
+ }
335
+ }
336
+ "html" => {
337
+ match body_value.as_str() {
338
+ Some(s) => (s.as_bytes().to_vec(), "text/html"),
339
+ None => {
340
+ *has_dynamic = true;
341
+ return;
342
+ }
343
+ }
344
+ }
345
+ _ => {
346
+ *has_dynamic = true;
347
+ return;
348
+ }
349
+ };
350
+
351
+ responses.push(StaticResponse {
352
+ body: Bytes::from(serialized_body),
353
+ content_type,
354
+ status: options.status,
355
+ extra_headers: options.headers,
356
+ });
357
+ }
358
+
359
+ /// If all responses are identical, return that response. Otherwise None.
360
+ fn unique_response(responses: &[StaticResponse]) -> Option<StaticResponse> {
361
+ if responses.is_empty() {
362
+ return None;
363
+ }
364
+ let first = &responses[0];
365
+ if responses.iter().all(|r| r == first) {
366
+ Some(first.clone())
367
+ } else {
368
+ None
369
+ }
370
+ }
371
+
372
+ /// Recursively evaluate a JavaScript expression to a serde_json::Value.
373
+ ///
374
+ /// Returns `Some(value)` if the expression is provably static (constant).
375
+ /// Returns `None` if the expression depends on runtime values (dynamic).
376
+ fn eval_static<'a>(
377
+ expr: &Expression<'a>,
378
+ semantic: &oxc::semantic::Semantic<'a>,
379
+ depth: usize,
380
+ ) -> Option<serde_json::Value> {
381
+ use serde_json::Value;
382
+
383
+ if depth > MAX_EVAL_DEPTH {
384
+ return None;
385
+ }
386
+
387
+ match expr {
388
+ // Literals
389
+ Expression::StringLiteral(lit) => {
390
+ Some(Value::String(lit.value.to_string()))
391
+ }
392
+ Expression::NumericLiteral(lit) => {
393
+ number_to_json(lit.value)
394
+ }
395
+ Expression::BooleanLiteral(lit) => {
396
+ Some(Value::Bool(lit.value))
397
+ }
398
+ Expression::NullLiteral(_) => {
399
+ Some(Value::Null)
400
+ }
401
+
402
+ // Object Expression
403
+ Expression::ObjectExpression(obj) => {
404
+ let mut map = serde_json::Map::with_capacity(obj.properties.len());
405
+
406
+ for prop in &obj.properties {
407
+ match prop {
408
+ ObjectPropertyKind::ObjectProperty(p) => {
409
+ let key = property_key_to_string(&p.key)?;
410
+ let val = eval_static(&p.value, semantic, depth + 1)?;
411
+ map.insert(key, val);
412
+ }
413
+ ObjectPropertyKind::SpreadProperty(_) => return None,
414
+ }
415
+ }
416
+ Some(Value::Object(map))
417
+ }
418
+
419
+ // Array Expression
420
+ Expression::ArrayExpression(arr) => {
421
+ let mut vec = Vec::with_capacity(arr.elements.len());
422
+
423
+ for elem in &arr.elements {
424
+ match elem {
425
+ ArrayExpressionElement::SpreadElement(_) => return None,
426
+ ArrayExpressionElement::Elision(_) => {
427
+ vec.push(Value::Null); // holes become null in JSON
428
+ }
429
+ _ => {
430
+ if let Some(expr) = elem.as_expression() {
431
+ vec.push(eval_static(expr, semantic, depth + 1)?);
432
+ } else {
433
+ return None;
434
+ }
435
+ }
436
+ }
437
+ }
438
+ Some(Value::Array(vec))
439
+ }
440
+
441
+ // Identifier Reference
442
+ Expression::Identifier(ident) => {
443
+ resolve_identifier(ident, semantic, depth)
444
+ }
445
+
446
+ // Template Literal
447
+ Expression::TemplateLiteral(tpl) => {
448
+ if tpl.expressions.is_empty() {
449
+ let s = tpl.quasis.iter()
450
+ .filter_map(|q| q.value.cooked.as_ref())
451
+ .map(|a| a.as_str())
452
+ .collect::<String>();
453
+ return Some(Value::String(s));
454
+ }
455
+
456
+ let mut result = String::new();
457
+
458
+ for (i, quasi) in tpl.quasis.iter().enumerate() {
459
+ if let Some(cooked) = &quasi.value.cooked {
460
+ result.push_str(cooked.as_str());
461
+ } else {
462
+ return None;
463
+ }
464
+
465
+ if i < tpl.expressions.len() {
466
+ let val = eval_static(&tpl.expressions[i], semantic, depth + 1)?;
467
+ match val {
468
+ Value::String(s) => result.push_str(&s),
469
+ Value::Number(n) => result.push_str(&n.to_string()),
470
+ Value::Bool(b) => result.push_str(if b { "true" } else { "false" }),
471
+ Value::Null => result.push_str("null"),
472
+ _ => return None,
473
+ }
474
+ }
475
+ }
476
+ Some(Value::String(result))
477
+ }
478
+
479
+ // Binary Expression
480
+ Expression::BinaryExpression(bin) => {
481
+ if bin.operator != BinaryOperator::Addition {
482
+ return None;
483
+ }
484
+
485
+ let left = eval_static(&bin.left, semantic, depth + 1)?;
486
+ let right = eval_static(&bin.right, semantic, depth + 1)?;
487
+
488
+ match (&left, &right) {
489
+ (Value::String(l), Value::String(r)) => {
490
+ Some(Value::String(format!("{}{}", l, r)))
491
+ }
492
+ (Value::String(l), Value::Number(r)) => {
493
+ Some(Value::String(format!("{}{}", l, r)))
494
+ }
495
+ (Value::Number(l), Value::String(r)) => {
496
+ Some(Value::String(format!("{}{}", l, r)))
497
+ }
498
+ (Value::Number(l), Value::Number(r)) => {
499
+ let lv = l.as_f64()?;
500
+ let rv = r.as_f64()?;
501
+ number_to_json(lv + rv)
502
+ }
503
+ _ => None,
504
+ }
505
+ }
506
+
507
+ // Unary Expression
508
+ Expression::UnaryExpression(unary) => {
509
+ if unary.operator != UnaryOperator::UnaryNegation {
510
+ return None;
511
+ }
512
+ let val = eval_static(&unary.argument, semantic, depth + 1)?;
513
+ match val {
514
+ Value::Number(n) => {
515
+ let v = n.as_f64()?;
516
+ number_to_json(-v)
517
+ }
518
+ _ => None,
519
+ }
520
+ }
521
+
522
+ // Parenthesized
523
+ Expression::ParenthesizedExpression(paren) => {
524
+ eval_static(&paren.expression, semantic, depth)
525
+ }
526
+
527
+ _ => None,
528
+ }
529
+ }
530
+
531
+ /// Resolve an IdentifierReference to a static value using OXC's semantic analysis.
532
+ fn resolve_identifier<'a>(
533
+ ident: &IdentifierReference<'a>,
534
+ semantic: &oxc::semantic::Semantic<'a>,
535
+ depth: usize,
536
+ ) -> Option<serde_json::Value> {
537
+ if depth > MAX_EVAL_DEPTH {
538
+ return None;
539
+ }
540
+
541
+ let ref_id = ident.reference_id.get()?;
542
+ let scoping = semantic.scoping();
543
+ let reference = scoping.get_reference(ref_id);
544
+ let symbol_id = reference.symbol_id()?;
545
+
546
+ if scoping.symbol_is_mutated(symbol_id) {
547
+ return None;
548
+ }
549
+
550
+ let decl_node_id = scoping.symbol_declaration(symbol_id);
551
+ let decl_node = semantic.nodes().get_node(decl_node_id);
552
+
553
+ match decl_node.kind() {
554
+ AstKind::VariableDeclarator(declarator) => {
555
+ if let Some(init) = &declarator.init {
556
+ match init {
557
+ Expression::ArrayExpression(_) | Expression::ObjectExpression(_) => {
558
+ if is_object_mutated_in_ast(symbol_id, semantic) {
559
+ None
560
+ } else {
561
+ eval_static(init, semantic, depth + 1)
562
+ }
563
+ }
564
+ _ => eval_static(init, semantic, depth + 1),
565
+ }
566
+ } else {
567
+ Some(serde_json::Value::Null)
568
+ }
569
+ }
570
+ _ => None,
571
+ }
572
+ }
573
+
574
+ /// Check if an array or object variable is mutated anywhere in the AST.
575
+ fn is_object_mutated_in_ast<'a>(
576
+ symbol_id: oxc::semantic::SymbolId,
577
+ semantic: &oxc::semantic::Semantic<'a>,
578
+ ) -> bool {
579
+ let scoping = semantic.scoping();
580
+
581
+ const MUTATING_METHODS: &[&str] = &[
582
+ "push", "pop", "shift", "unshift", "splice",
583
+ "sort", "reverse", "fill", "copyWithin",
584
+ "set", "delete", "clear",
585
+ ];
586
+
587
+ for node in semantic.nodes().iter() {
588
+ match node.kind() {
589
+ // symbol.mutatingMethod(...)
590
+ AstKind::CallExpression(call) => {
591
+ if let Expression::StaticMemberExpression(member) = &call.callee {
592
+ let method_name = member.property.name.as_str();
593
+ if MUTATING_METHODS.contains(&method_name) {
594
+ if is_identifier_for_symbol(&member.object, symbol_id, scoping) {
595
+ return true;
596
+ }
597
+ }
598
+ }
599
+ }
600
+ // symbol.prop = value
601
+ AstKind::AssignmentExpression(assign) => {
602
+ if is_assignment_target_our_symbol(&assign.left, symbol_id, scoping) {
603
+ return true;
604
+ }
605
+ }
606
+ // delete symbol.prop
607
+ AstKind::UnaryExpression(unary) => {
608
+ if unary.operator == UnaryOperator::Delete {
609
+ if let Expression::StaticMemberExpression(member) = &unary.argument {
610
+ if is_identifier_for_symbol(&member.object, symbol_id, scoping) {
611
+ return true;
612
+ }
613
+ }
614
+ if let Expression::ComputedMemberExpression(member) = &unary.argument {
615
+ if is_identifier_for_symbol(&member.object, symbol_id, scoping) {
616
+ return true;
617
+ }
618
+ }
619
+ }
620
+ }
621
+ _ => {}
622
+ }
623
+ }
624
+
625
+ false
626
+ }
627
+
628
+ /// Check if an Expression is an IdentifierReference that resolves to the given symbol.
629
+ fn is_identifier_for_symbol(
630
+ expr: &Expression<'_>,
631
+ symbol_id: oxc::semantic::SymbolId,
632
+ scoping: &oxc::semantic::Scoping,
633
+ ) -> bool {
634
+ if let Expression::Identifier(ident) = expr {
635
+ if let Some(ref_id) = ident.reference_id.get() {
636
+ let reference = scoping.get_reference(ref_id);
637
+ return reference.symbol_id() == Some(symbol_id);
638
+ }
639
+ }
640
+ false
641
+ }
642
+
643
+ /// Check if an AssignmentTarget contains a member expression on our symbol.
644
+ fn is_assignment_target_our_symbol(
645
+ target: &AssignmentTarget<'_>,
646
+ symbol_id: oxc::semantic::SymbolId,
647
+ scoping: &oxc::semantic::Scoping,
648
+ ) -> bool {
649
+ match target {
650
+ AssignmentTarget::StaticMemberExpression(member) => {
651
+ is_identifier_for_symbol(&member.object, symbol_id, scoping)
652
+ }
653
+ AssignmentTarget::ComputedMemberExpression(member) => {
654
+ is_identifier_for_symbol(&member.object, symbol_id, scoping)
655
+ }
656
+ _ => false,
657
+ }
658
+ }
659
+
660
+ /// Extract a property key as a String.
661
+ fn property_key_to_string(key: &PropertyKey<'_>) -> Option<String> {
662
+ match key {
663
+ PropertyKey::StaticIdentifier(ident) => {
664
+ Some(ident.name.to_string())
665
+ }
666
+ PropertyKey::StringLiteral(lit) => {
667
+ Some(lit.value.to_string())
668
+ }
669
+ PropertyKey::NumericLiteral(lit) => {
670
+ Some(lit.value.to_string())
671
+ }
672
+ _ => None,
673
+ }
674
+ }
675
+
676
+ /// Convert a f64 number to a serde_json::Value::Number.
677
+ fn number_to_json(v: f64) -> Option<serde_json::Value> {
678
+ if v.is_nan() || v.is_infinite() {
679
+ return None;
680
+ }
681
+ if v.fract() == 0.0 && v >= i64::MIN as f64 && v <= i64::MAX as f64 {
682
+ Some(serde_json::Value::Number((v as i64).into()))
683
+ } else {
684
+ serde_json::Number::from_f64(v).map(serde_json::Value::Number)
685
+ }
686
+ }
687
+
688
+ /// Extract ResponseOptions (status + headers) from a serde_json::Value.
689
+ fn extract_response_options(val: &serde_json::Value) -> ResponseOptions {
690
+ let mut opts = ResponseOptions {
691
+ status: 200,
692
+ headers: Vec::new(),
693
+ };
694
+
695
+ let obj = match val.as_object() {
696
+ Some(o) => o,
697
+ None => return opts,
698
+ };
699
+
700
+ if let Some(status) = obj.get("status") {
701
+ if let Some(n) = status.as_u64() {
702
+ if n >= 100 && n <= 599 {
703
+ opts.status = n as u16;
704
+ }
705
+ }
706
+ }
707
+
708
+ if let Some(headers) = obj.get("headers") {
709
+ if let Some(h_obj) = headers.as_object() {
710
+ for (key, val) in h_obj {
711
+ if let Some(v_str) = val.as_str() {
712
+ opts.headers.push((key.clone(), v_str.to_string()));
713
+ }
714
+ }
715
+ }
716
+ }
717
+
718
+ opts
719
+ }