@tishlang/tish 1.13.1 → 2.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 (106) hide show
  1. package/Cargo.toml +2 -0
  2. package/bin/tish +0 -0
  3. package/crates/js_to_tish/src/transform/expr.rs +1 -0
  4. package/crates/tish/Cargo.toml +11 -3
  5. package/crates/tish/build.rs +21 -0
  6. package/crates/tish/src/cli_help.rs +15 -4
  7. package/crates/tish/src/main.rs +93 -21
  8. package/crates/tish/src/repl_completion.rs +0 -1
  9. package/crates/tish/tests/error_source_location.rs +36 -0
  10. package/crates/tish/tests/fixtures/runtime_error_location.tish +5 -0
  11. package/crates/tish/tests/fixtures/trycatch_runtime_errors.tish +15 -0
  12. package/crates/tish/tests/fixtures/tty_capability.tish +9 -0
  13. package/crates/tish/tests/integration_test.rs +402 -91
  14. package/crates/tish/tests/trycatch_runtime_errors.rs +45 -0
  15. package/crates/tish/tests/tty_capability.rs +43 -0
  16. package/crates/tish_ast/src/ast.rs +37 -8
  17. package/crates/tish_builtins/Cargo.toml +2 -0
  18. package/crates/tish_builtins/src/array.rs +375 -13
  19. package/crates/tish_builtins/src/collections.rs +481 -0
  20. package/crates/tish_builtins/src/construct.rs +59 -19
  21. package/crates/tish_builtins/src/date.rs +538 -0
  22. package/crates/tish_builtins/src/globals.rs +86 -6
  23. package/crates/tish_builtins/src/iterator.rs +129 -0
  24. package/crates/tish_builtins/src/lib.rs +5 -0
  25. package/crates/tish_builtins/src/number.rs +96 -0
  26. package/crates/tish_builtins/src/object.rs +2 -2
  27. package/crates/tish_builtins/src/string.rs +19 -20
  28. package/crates/tish_builtins/src/symbol.rs +1 -1
  29. package/crates/tish_builtins/src/typedarrays.rs +298 -0
  30. package/crates/tish_bytecode/src/chunk.rs +69 -1
  31. package/crates/tish_bytecode/src/compiler.rs +933 -89
  32. package/crates/tish_bytecode/src/encoding.rs +2 -0
  33. package/crates/tish_bytecode/src/lib.rs +2 -1
  34. package/crates/tish_bytecode/src/opcode.rs +47 -4
  35. package/crates/tish_bytecode/src/serialize.rs +31 -1
  36. package/crates/tish_compile/Cargo.toml +1 -0
  37. package/crates/tish_compile/src/check.rs +774 -0
  38. package/crates/tish_compile/src/codegen.rs +2334 -349
  39. package/crates/tish_compile/src/infer.rs +1395 -6
  40. package/crates/tish_compile/src/lib.rs +50 -8
  41. package/crates/tish_compile/src/resolve.rs +584 -21
  42. package/crates/tish_compile/src/types.rs +106 -2
  43. package/crates/tish_compile_js/src/codegen.rs +67 -0
  44. package/crates/tish_compile_js/src/tests_jsx.rs +64 -0
  45. package/crates/tish_core/Cargo.toml +7 -1
  46. package/crates/tish_core/src/console_style.rs +11 -1
  47. package/crates/tish_core/src/json.rs +81 -38
  48. package/crates/tish_core/src/lib.rs +3 -0
  49. package/crates/tish_core/src/shape.rs +85 -0
  50. package/crates/tish_core/src/value.rs +679 -25
  51. package/crates/tish_core/src/vmref.rs +13 -8
  52. package/crates/tish_cranelift/src/link.rs +17 -4
  53. package/crates/tish_cranelift_runtime/Cargo.toml +1 -0
  54. package/crates/tish_eval/Cargo.toml +6 -0
  55. package/crates/tish_eval/src/eval.rs +665 -117
  56. package/crates/tish_eval/src/http.rs +4 -1
  57. package/crates/tish_eval/src/natives.rs +165 -13
  58. package/crates/tish_eval/src/value.rs +31 -13
  59. package/crates/tish_eval/src/value_convert.rs +10 -4
  60. package/crates/tish_ffi/Cargo.toml +26 -0
  61. package/crates/tish_ffi/src/lib.rs +518 -0
  62. package/crates/tish_ffi/tests/fixtures/testmod/Cargo.toml +18 -0
  63. package/crates/tish_ffi/tests/fixtures/testmod/src/lib.rs +46 -0
  64. package/crates/tish_ffi/tests/loader.rs +65 -0
  65. package/crates/tish_fmt/src/lib.rs +43 -5
  66. package/crates/tish_lexer/src/lib.rs +397 -9
  67. package/crates/tish_lexer/src/token.rs +7 -0
  68. package/crates/tish_lint/src/lib.rs +2 -10
  69. package/crates/tish_lsp/src/import_goto.rs +2 -0
  70. package/crates/tish_lsp/src/main.rs +439 -26
  71. package/crates/tish_native/src/build.rs +55 -1
  72. package/crates/tish_opt/src/lib.rs +126 -23
  73. package/crates/tish_parser/src/lib.rs +55 -1
  74. package/crates/tish_parser/src/parser.rs +456 -34
  75. package/crates/tish_pg/src/lib.rs +3 -3
  76. package/crates/tish_resolve/src/lib.rs +99 -59
  77. package/crates/tish_runtime/Cargo.toml +4 -0
  78. package/crates/tish_runtime/src/http.rs +66 -17
  79. package/crates/tish_runtime/src/http_fetch.rs +29 -8
  80. package/crates/tish_runtime/src/http_hyper.rs +25 -2
  81. package/crates/tish_runtime/src/lib.rs +299 -44
  82. package/crates/tish_runtime/src/promise.rs +328 -18
  83. package/crates/tish_runtime/src/timers.rs +13 -7
  84. package/crates/tish_runtime/src/tty.rs +226 -0
  85. package/crates/tish_runtime/src/ws.rs +35 -18
  86. package/crates/tish_runtime/tests/fetch_readable_stream.rs +2 -2
  87. package/crates/tish_ui/src/jsx.rs +10 -0
  88. package/crates/tish_ui/src/runtime/hooks.rs +19 -15
  89. package/crates/tish_ui/src/runtime/mod.rs +15 -12
  90. package/crates/tish_vm/Cargo.toml +14 -1
  91. package/crates/tish_vm/src/jit.rs +1050 -0
  92. package/crates/tish_vm/src/lib.rs +2 -0
  93. package/crates/tish_vm/src/vm.rs +1546 -202
  94. package/crates/tish_vm/tests/concurrent_shared_state.rs +140 -0
  95. package/crates/tish_wasm/src/lib.rs +6 -2
  96. package/crates/tish_wasm_runtime/src/gpu.rs +17 -1
  97. package/crates/tishlang_cargo_bindgen/src/classify.rs +1 -3
  98. package/crates/tishlang_cargo_bindgen/src/lib.rs +2 -2
  99. package/crates/tishlang_cargo_bindgen/src/metadata.rs +1 -1
  100. package/justfile +8 -0
  101. package/package.json +1 -1
  102. package/platform/darwin-arm64/tish +0 -0
  103. package/platform/darwin-x64/tish +0 -0
  104. package/platform/linux-arm64/tish +0 -0
  105. package/platform/linux-x64/tish +0 -0
  106. package/platform/win32-x64/tish.exe +0 -0
@@ -203,13 +203,13 @@ fn row_to_value_direct(row: &Row) -> tishlang_runtime::Value {
203
203
  .try_get::<_, Option<&str>>(i)
204
204
  .ok()
205
205
  .flatten()
206
- .map(|s| RtValue::String(StdArcInner::from(s)))
206
+ .map(|s| RtValue::String(tishlang_runtime::ArcStr::from(s)))
207
207
  .unwrap_or(RtValue::Null),
208
208
  _ => {
209
209
  // Anything else goes through the JSON path for backwards
210
210
  // compat. Hot TFB rows never hit this branch.
211
211
  if let Ok(s) = row.try_get::<_, Option<String>>(i) {
212
- s.map(|s| RtValue::String(StdArcInner::from(s.as_str())))
212
+ s.map(|s| RtValue::String(tishlang_runtime::ArcStr::from(s.as_str())))
213
213
  .unwrap_or(RtValue::Null)
214
214
  } else {
215
215
  RtValue::Null
@@ -853,7 +853,7 @@ mod tish_sync {
853
853
  return tish_err("migrate: unknown client handle");
854
854
  };
855
855
 
856
- let dir = std::path::PathBuf::from(dir.as_ref());
856
+ let dir = std::path::PathBuf::from(dir.as_str());
857
857
  let entries = match std::fs::read_dir(&dir) {
858
858
  Ok(e) => e,
859
859
  Err(e) => return tish_err(format!("migrate: read_dir({:?}): {e}", dir)),
@@ -151,6 +151,11 @@ fn collect_stmt(
151
151
  collect_stmt(s, source, lsp_line, lsp_char, best);
152
152
  }
153
153
  }
154
+ Statement::Multi { statements, .. } => {
155
+ for s in statements {
156
+ collect_stmt(s, source, lsp_line, lsp_char, best);
157
+ }
158
+ }
154
159
  Statement::FunDecl {
155
160
  name,
156
161
  name_span,
@@ -306,18 +311,16 @@ fn collect_destruct_pattern(
306
311
  ) {
307
312
  match p {
308
313
  DestructPattern::Array(elements) => {
309
- for el in elements {
310
- if let Some(el) = el {
311
- match el {
312
- DestructElement::Ident(n, sp) => {
313
- consider(source, lsp_line, lsp_char, sp, n.clone(), best);
314
- }
315
- DestructElement::Pattern(inner) => {
316
- collect_destruct_pattern(inner, source, lsp_line, lsp_char, best);
317
- }
318
- DestructElement::Rest(n, sp) => {
319
- consider(source, lsp_line, lsp_char, sp, n.clone(), best);
320
- }
314
+ for el in elements.iter().flatten() {
315
+ match el {
316
+ DestructElement::Ident(n, sp) => {
317
+ consider(source, lsp_line, lsp_char, sp, n.clone(), best);
318
+ }
319
+ DestructElement::Pattern(inner) => {
320
+ collect_destruct_pattern(inner, source, lsp_line, lsp_char, best);
321
+ }
322
+ DestructElement::Rest(n, sp) => {
323
+ consider(source, lsp_line, lsp_char, sp, n.clone(), best);
321
324
  }
322
325
  }
323
326
  }
@@ -484,11 +487,8 @@ fn collect_expr(
484
487
  } => {
485
488
  for p in props {
486
489
  match p {
487
- tishlang_ast::JsxProp::Attr { value, .. } => match value {
488
- tishlang_ast::JsxAttrValue::Expr(e) => {
489
- collect_expr(e, source, lsp_line, lsp_char, best)
490
- }
491
- _ => {}
490
+ tishlang_ast::JsxProp::Attr { value, .. } => if let tishlang_ast::JsxAttrValue::Expr(e) = value {
491
+ collect_expr(e, source, lsp_line, lsp_char, best)
492
492
  },
493
493
  tishlang_ast::JsxProp::Spread(e) => {
494
494
  collect_expr(e, source, lsp_line, lsp_char, best)
@@ -784,6 +784,7 @@ fn member_chain_collect_fun_param(
784
784
  }
785
785
  }
786
786
 
787
+ #[allow(clippy::only_used_in_recursion)] // params threaded to recurse the pattern tree (mirrors sibling collectors)
787
788
  fn member_chain_collect_destruct_pattern(
788
789
  pattern: &DestructPattern,
789
790
  source: &str,
@@ -793,15 +794,13 @@ fn member_chain_collect_destruct_pattern(
793
794
  ) {
794
795
  match pattern {
795
796
  DestructPattern::Array(elements) => {
796
- for el in elements {
797
- if let Some(el) = el {
798
- match el {
799
- DestructElement::Ident(_, _) => {}
800
- DestructElement::Pattern(inner) => member_chain_collect_destruct_pattern(
801
- inner, source, lsp_line, lsp_char, best,
802
- ),
803
- DestructElement::Rest(_, _) => {}
804
- }
797
+ for el in elements.iter().flatten() {
798
+ match el {
799
+ DestructElement::Ident(_, _) => {}
800
+ DestructElement::Pattern(inner) => member_chain_collect_destruct_pattern(
801
+ inner, source, lsp_line, lsp_char, best,
802
+ ),
803
+ DestructElement::Rest(_, _) => {}
805
804
  }
806
805
  }
807
806
  }
@@ -887,6 +886,11 @@ fn member_chain_collect_stmt(
887
886
  member_chain_collect_stmt(s, source, lsp_line, lsp_char, best);
888
887
  }
889
888
  }
889
+ Statement::Multi { statements, .. } => {
890
+ for s in statements {
891
+ member_chain_collect_stmt(s, source, lsp_line, lsp_char, best);
892
+ }
893
+ }
890
894
  Statement::FunDecl { params, body, .. } => {
891
895
  for p in params {
892
896
  member_chain_collect_fun_param(p, source, lsp_line, lsp_char, best);
@@ -993,13 +997,11 @@ fn define_fun_param_stack(p: &FunParam, scopes: &mut ScopeStack) {
993
997
  fn define_pattern_stack(pattern: &DestructPattern, scopes: &mut ScopeStack) {
994
998
  match pattern {
995
999
  DestructPattern::Array(elements) => {
996
- for el in elements {
997
- if let Some(el) = el {
998
- match el {
999
- DestructElement::Ident(n, sp) => scopes.define(n.as_ref(), *sp),
1000
- DestructElement::Pattern(inner) => define_pattern_stack(inner, scopes),
1001
- DestructElement::Rest(n, sp) => scopes.define(n.as_ref(), *sp),
1002
- }
1000
+ for el in elements.iter().flatten() {
1001
+ match el {
1002
+ DestructElement::Ident(n, sp) => scopes.define(n.as_ref(), *sp),
1003
+ DestructElement::Pattern(inner) => define_pattern_stack(inner, scopes),
1004
+ DestructElement::Rest(n, sp) => scopes.define(n.as_ref(), *sp),
1003
1005
  }
1004
1006
  }
1005
1007
  }
@@ -1310,6 +1312,17 @@ fn walk_stmt_resolve(
1310
1312
  scopes.pop();
1311
1313
  out
1312
1314
  }
1315
+ // Transparent group (comma-declarators): same scope, no push/pop.
1316
+ Statement::Multi { statements, .. } => {
1317
+ let mut out = None;
1318
+ for s in statements {
1319
+ if let Some(x) = walk_stmt_resolve(s, scopes, target) {
1320
+ out = Some(x);
1321
+ break;
1322
+ }
1323
+ }
1324
+ out
1325
+ }
1313
1326
  Statement::FunDecl {
1314
1327
  name,
1315
1328
  name_span,
@@ -1525,7 +1538,17 @@ pub fn is_runtime_global_ident(name: &str) -> bool {
1525
1538
  | "Array"
1526
1539
  | "String"
1527
1540
  | "Date"
1541
+ | "Set"
1542
+ | "Map"
1543
+ | "Float64Array"
1544
+ | "Float32Array"
1545
+ | "Int8Array"
1528
1546
  | "Uint8Array"
1547
+ | "Uint8ClampedArray"
1548
+ | "Int16Array"
1549
+ | "Uint16Array"
1550
+ | "Int32Array"
1551
+ | "Uint32Array"
1529
1552
  | "AudioContext"
1530
1553
  | "RegExp"
1531
1554
  | "setTimeout"
@@ -1804,6 +1827,12 @@ fn check_unresolved_stmt(
1804
1827
  }
1805
1828
  scopes.pop();
1806
1829
  }
1830
+ // Transparent group (comma-declarators): same scope, no push/pop.
1831
+ Statement::Multi { statements, .. } => {
1832
+ for s in statements {
1833
+ check_unresolved_stmt(s, scopes, out);
1834
+ }
1835
+ }
1807
1836
  Statement::FunDecl {
1808
1837
  name,
1809
1838
  name_span,
@@ -1965,25 +1994,23 @@ fn enumerate_pattern_bindings(
1965
1994
  ) {
1966
1995
  match pattern {
1967
1996
  DestructPattern::Array(elements) => {
1968
- for el in elements {
1969
- if let Some(el) = el {
1970
- match el {
1971
- DestructElement::Ident(n, sp) => out.push(BindingSite {
1972
- name: n.clone(),
1973
- span: *sp,
1974
- kind,
1975
- exported,
1976
- }),
1977
- DestructElement::Pattern(inner) => {
1978
- enumerate_pattern_bindings(inner, kind, exported, out);
1979
- }
1980
- DestructElement::Rest(n, sp) => out.push(BindingSite {
1981
- name: n.clone(),
1982
- span: *sp,
1983
- kind,
1984
- exported,
1985
- }),
1997
+ for el in elements.iter().flatten() {
1998
+ match el {
1999
+ DestructElement::Ident(n, sp) => out.push(BindingSite {
2000
+ name: n.clone(),
2001
+ span: *sp,
2002
+ kind,
2003
+ exported,
2004
+ }),
2005
+ DestructElement::Pattern(inner) => {
2006
+ enumerate_pattern_bindings(inner, kind, exported, out);
1986
2007
  }
2008
+ DestructElement::Rest(n, sp) => out.push(BindingSite {
2009
+ name: n.clone(),
2010
+ span: *sp,
2011
+ kind,
2012
+ exported,
2013
+ }),
1987
2014
  }
1988
2015
  }
1989
2016
  }
@@ -2300,6 +2327,11 @@ fn enumerate_stmt(stmt: &Statement, exported: bool, out: &mut Vec<BindingSite>)
2300
2327
  enumerate_stmt(s, exported, out);
2301
2328
  }
2302
2329
  }
2330
+ Statement::Multi { statements, .. } => {
2331
+ for s in statements {
2332
+ enumerate_stmt(s, exported, out);
2333
+ }
2334
+ }
2303
2335
  Statement::FunDecl {
2304
2336
  name,
2305
2337
  name_span,
@@ -2617,15 +2649,13 @@ fn param_layer_names(params: &[FunParam], rest_param: &Option<TypedParam>) -> Ve
2617
2649
  fn collect_pattern_binding_names(pattern: &DestructPattern, out: &mut Vec<Arc<str>>) {
2618
2650
  match pattern {
2619
2651
  DestructPattern::Array(elements) => {
2620
- for el in elements {
2621
- if let Some(el) = el {
2622
- match el {
2623
- DestructElement::Ident(n, _) => out.push(n.clone()),
2624
- DestructElement::Pattern(inner) => {
2625
- collect_pattern_binding_names(inner, out)
2626
- }
2627
- DestructElement::Rest(n, _) => out.push(n.clone()),
2652
+ for el in elements.iter().flatten() {
2653
+ match el {
2654
+ DestructElement::Ident(n, _) => out.push(n.clone()),
2655
+ DestructElement::Pattern(inner) => {
2656
+ collect_pattern_binding_names(inner, out)
2628
2657
  }
2658
+ DestructElement::Rest(n, _) => out.push(n.clone()),
2629
2659
  }
2630
2660
  }
2631
2661
  }
@@ -2912,6 +2942,11 @@ fn walk_stmt_completion(
2912
2942
  walk_stmt_completion(s, source, lsp_line, lsp_char, stack, best);
2913
2943
  }
2914
2944
  }
2945
+ Statement::Multi { statements, .. } => {
2946
+ for s in statements {
2947
+ walk_stmt_completion(s, source, lsp_line, lsp_char, stack, best);
2948
+ }
2949
+ }
2915
2950
  Statement::FunDecl {
2916
2951
  params,
2917
2952
  rest_param,
@@ -3181,6 +3216,11 @@ fn refs_stmt(
3181
3216
  refs_stmt(s, program, source, name, def_span, out);
3182
3217
  }
3183
3218
  }
3219
+ Statement::Multi { statements, .. } => {
3220
+ for s in statements {
3221
+ refs_stmt(s, program, source, name, def_span, out);
3222
+ }
3223
+ }
3184
3224
  Statement::FunDecl { params, body, .. } => {
3185
3225
  for p in params {
3186
3226
  if let FunParam::Simple(tp) = p {
@@ -56,6 +56,8 @@ http-hyper = [
56
56
  ]
57
57
  fs = []
58
58
  process = []
59
+ # Interactive terminal I/O (issue #101): raw mode, key/resize events, size, alt screen.
60
+ tty = ["dep:crossterm"]
59
61
  regex = ["tishlang_core/regex"]
60
62
  # WebSocket (JS-like API). Modular: import { WebSocket, Server } from 'tish:ws'.
61
63
  # Requires `send-values` because the WS server keeps an `Arc<dyn Fn + Send +
@@ -84,6 +86,8 @@ tiny_http = { version = "0.12.0", optional = true }
84
86
  socket2 = { version = "0.5", features = ["all"], optional = true }
85
87
  libc = { version = "0.2", optional = true }
86
88
  arc-swap = { version = "1", optional = true }
89
+ # Interactive terminal I/O (feature = tty): cross-platform raw mode, key/resize events, size.
90
+ crossterm = { version = "0.28", optional = true }
87
91
  # hyper backend (feature = http-hyper)
88
92
  hyper = { version = "1", default-features = false, features = ["server", "http1", "http2"], optional = true }
89
93
  hyper-util = { version = "0.1", features = ["server", "tokio"], optional = true }
@@ -226,7 +226,27 @@ impl RequestPrimitive {
226
226
  && req.body_length().map(|n| n > 0).unwrap_or(true);
227
227
  let mut body = String::new();
228
228
  if has_body {
229
- let _ = req.as_reader().read_to_string(&mut body);
229
+ // Bounded read: cap at max_request_body so a huge advertised/chunked body can't
230
+ // exhaust memory. (`take` is unavailable on `&mut dyn Read`; read in chunks via
231
+ // the object-safe `read`, then decode once to keep UTF-8 boundaries intact.)
232
+ let max = max_request_body();
233
+ let reader = req.as_reader();
234
+ let mut buf: Vec<u8> = Vec::new();
235
+ let mut chunk = [0u8; 8192];
236
+ while buf.len() < max {
237
+ match reader.read(&mut chunk) {
238
+ Ok(0) => break,
239
+ Ok(n) => {
240
+ let take = n.min(max - buf.len());
241
+ buf.extend_from_slice(&chunk[..take]);
242
+ if take < n {
243
+ break; // hit the cap mid-chunk
244
+ }
245
+ }
246
+ Err(_) => break,
247
+ }
248
+ }
249
+ body = String::from_utf8_lossy(&buf).into_owned();
230
250
  }
231
251
  let headers = req
232
252
  .headers()
@@ -355,7 +375,7 @@ impl ResponsePrimitive {
355
375
  }
356
376
  } else if let Some(b) = obj_ref.strings.get("body") {
357
377
  match b {
358
- Value::String(s) => ResponseBody::Text(Arc::clone(&s)),
378
+ Value::String(s) => ResponseBody::Text(Arc::from(s.as_str())),
359
379
  Value::Array(a) => {
360
380
  let borrow = a.borrow();
361
381
  if !borrow.is_empty()
@@ -418,7 +438,7 @@ impl ResponsePrimitive {
418
438
  Value::String(s) => Self {
419
439
  status: default_status,
420
440
  headers: vec![],
421
- body: ResponseBody::Text(Arc::clone(s)),
441
+ body: ResponseBody::Text(Arc::from(s.as_str())),
422
442
  },
423
443
  _ => Self {
424
444
  status: default_status,
@@ -447,7 +467,10 @@ pub fn value_to_response(value: &Value) -> (u16, Vec<(String, String)>, String)
447
467
  (r.status, r.headers, body)
448
468
  }
449
469
 
450
- fn extract_file_from_response(value: &Value) -> Option<(u16, Vec<(String, String)>, String)> {
470
+ /// `(status, headers, file path)` extracted from a response object's `file` key.
471
+ type FileResponse = (u16, Vec<(String, String)>, String);
472
+
473
+ fn extract_file_from_response(value: &Value) -> Option<FileResponse> {
451
474
  let Value::Object(obj) = value else {
452
475
  return None;
453
476
  };
@@ -504,7 +527,7 @@ pub fn send_response(
504
527
  mut headers: Vec<(String, String)>,
505
528
  body: String,
506
529
  ) {
507
- send_response_arc(request, status, headers.drain(..).collect(), Arc::from(body));
530
+ send_response_arc(request, status, std::mem::take(&mut headers), Arc::from(body));
508
531
  }
509
532
 
510
533
  pub fn send_response_arc(
@@ -525,13 +548,39 @@ pub fn send_response_arc(
525
548
  None,
526
549
  );
527
550
  for (key, value) in headers {
528
- if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes()) {
551
+ if let Some(header) = safe_response_header(key.as_bytes(), value.as_bytes()) {
529
552
  response = response.with_header(header);
530
553
  }
531
554
  }
532
555
  let _ = request.respond(response);
533
556
  }
534
557
 
558
+ /// Max request body bytes read before truncating — bounds per-request memory so a huge
559
+ /// `Content-Length` / chunked stream can't OOM the worker. Override with `TISH_HTTP_MAX_BODY`
560
+ /// (bytes); default 16 MiB. (A future refinement: reply 413 instead of truncating.)
561
+ fn max_request_body() -> usize {
562
+ use std::sync::OnceLock;
563
+ static MAX: OnceLock<usize> = OnceLock::new();
564
+ *MAX.get_or_init(|| {
565
+ std::env::var("TISH_HTTP_MAX_BODY")
566
+ .ok()
567
+ .and_then(|v| v.parse().ok())
568
+ .unwrap_or(16 * 1024 * 1024)
569
+ })
570
+ }
571
+
572
+ /// Build a response header, rejecting any key/value with control bytes (CR/LF/NUL).
573
+ /// Prevents response-splitting / header injection when a handler reflects untrusted input
574
+ /// into a header — `tiny_http::Header::from_bytes` only checks ASCII, and CR/LF are ASCII,
575
+ /// so it would otherwise accept them.
576
+ fn safe_response_header(key: &[u8], value: &[u8]) -> Option<tiny_http::Header> {
577
+ let bad = |b: &u8| matches!(*b, b'\r' | b'\n' | 0);
578
+ if key.iter().any(bad) || value.iter().any(bad) {
579
+ return None;
580
+ }
581
+ tiny_http::Header::from_bytes(key, value).ok()
582
+ }
583
+
535
584
  struct ArcBytesReader {
536
585
  bytes: Arc<[u8]>,
537
586
  pos: usize,
@@ -573,7 +622,7 @@ pub fn send_response_bytes(
573
622
  None,
574
623
  );
575
624
  for (key, value) in headers {
576
- if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes()) {
625
+ if let Some(header) = safe_response_header(key.as_bytes(), value.as_bytes()) {
577
626
  response = response.with_header(header);
578
627
  }
579
628
  }
@@ -601,7 +650,7 @@ fn send_file_response(
601
650
  let status_code = tiny_http::StatusCode(status);
602
651
  let mut response = tiny_http::Response::from_file(file).with_status_code(status_code);
603
652
  for (key, value) in headers {
604
- if let Ok(header) = tiny_http::Header::from_bytes(key.as_bytes(), value.as_bytes()) {
653
+ if let Some(header) = safe_response_header(key.as_bytes(), value.as_bytes()) {
605
654
  response = response.with_header(header);
606
655
  }
607
656
  }
@@ -791,7 +840,7 @@ pub fn serve<F>(args: &[Value], handler: F) -> Value
791
840
  where
792
841
  F: Fn(&[Value]) -> Value + Send + Sync + 'static,
793
842
  {
794
- serve_impl_with_factory(args, None, Some(Arc::new(handler)))
843
+ serve_impl_with_factory(args, None, Some(tishlang_core::native_fn(handler)))
795
844
  }
796
845
 
797
846
  /// `serve(port, { onWorker, workers? })` — each accept thread calls the
@@ -804,12 +853,12 @@ where
804
853
  #[cfg(feature = "send-values")]
805
854
  pub fn serve_per_worker<FF>(args: &[Value], factory: FF) -> Value
806
855
  where
807
- FF: Fn(usize) -> Arc<dyn Fn(&[Value]) -> Value + Send + Sync> + Send + Sync + 'static,
856
+ FF: Fn(usize) -> tishlang_core::NativeFn + Send + Sync + 'static,
808
857
  {
809
858
  serve_impl_with_factory(
810
859
  args,
811
860
  Some(Arc::new(factory)
812
- as Arc<dyn Fn(usize) -> Arc<dyn Fn(&[Value]) -> Value + Send + Sync> + Send + Sync>),
861
+ as Arc<dyn Fn(usize) -> tishlang_core::NativeFn + Send + Sync>),
813
862
  None,
814
863
  )
815
864
  }
@@ -829,9 +878,9 @@ where
829
878
  fn serve_impl_with_factory(
830
879
  args: &[Value],
831
880
  factory: Option<
832
- Arc<dyn Fn(usize) -> Arc<dyn Fn(&[Value]) -> Value + Send + Sync> + Send + Sync>,
881
+ Arc<dyn Fn(usize) -> tishlang_core::NativeFn + Send + Sync>,
833
882
  >,
834
- shared_handler: Option<Arc<dyn Fn(&[Value]) -> Value + Send + Sync>>,
883
+ shared_handler: Option<tishlang_core::NativeFn>,
835
884
  ) -> Value {
836
885
  debug_assert!(factory.is_some() ^ shared_handler.is_some());
837
886
  let port = match args.first() {
@@ -958,7 +1007,7 @@ fn serve_impl_with_factory(
958
1007
  let stop = Arc::clone(&stop);
959
1008
  let processed = Arc::clone(&processed);
960
1009
  let max = max_requests;
961
- let worker_handler: Arc<dyn Fn(&[Value]) -> Value + Send + Sync> =
1010
+ let worker_handler: tishlang_core::NativeFn =
962
1011
  if let Some(f) = &factory {
963
1012
  f(global_worker_base + idx)
964
1013
  } else {
@@ -1063,7 +1112,7 @@ where
1063
1112
  let mut count = 0usize;
1064
1113
  while let Ok((req_prim, resp_tx)) = rx.recv() {
1065
1114
  let req_value = req_prim.into_value();
1066
- let response_value = handler(&[req_value]);
1115
+ let response_value = handler.call(&[req_value]);
1067
1116
  let resp_prim = ResponsePrimitive::from_value(&response_value);
1068
1117
  let _ = resp_tx.send(resp_prim);
1069
1118
 
@@ -1092,7 +1141,7 @@ where
1092
1141
  #[cfg(feature = "send-values")]
1093
1142
  fn worker_loop_direct(
1094
1143
  listener: std::net::TcpListener,
1095
- handler: Arc<dyn Fn(&[Value]) -> Value + Send + Sync>,
1144
+ handler: tishlang_core::NativeFn,
1096
1145
  stop: Arc<AtomicBool>,
1097
1146
  processed: Arc<AtomicUsize>,
1098
1147
  max_requests: Option<usize>,
@@ -1117,7 +1166,7 @@ fn worker_loop_direct(
1117
1166
  } else {
1118
1167
  let req_prim = RequestPrimitive::from_tiny_http(&mut request);
1119
1168
  let req_value = req_prim.into_value();
1120
- let response_value = handler(&[req_value]);
1169
+ let response_value = handler.call(&[req_value]);
1121
1170
  let resp_prim = ResponsePrimitive::from_value(&response_value);
1122
1171
  respond_from_primitive(request, resp_prim);
1123
1172
  }
@@ -35,6 +35,7 @@ impl TishPromise for FetchResponsePromise {
35
35
  }
36
36
 
37
37
  struct FetchAllResponsesPromise {
38
+ #[allow(clippy::type_complexity)]
38
39
  rx: Mutex<
39
40
  Option<
40
41
  tokio::sync::oneshot::Receiver<Result<Vec<Result<reqwest::Response, String>>, String>>,
@@ -158,6 +159,7 @@ impl HttpBody {
158
159
  }
159
160
  }
160
161
 
162
+ #[allow(clippy::type_complexity)]
161
163
  fn take_stream(
162
164
  &self,
163
165
  ) -> Result<Pin<Box<dyn Stream<Item = Result<Bytes, reqwest::Error>> + Send>>, String> {
@@ -229,7 +231,7 @@ impl TishOpaque for HttpReadableStream {
229
231
  return None;
230
232
  }
231
233
  let body = Arc::clone(&self.body);
232
- Some(Arc::new(move |_args: &[Value]| match body.take_stream() {
234
+ Some(tishlang_core::native_fn(move |_args: &[Value]| match body.take_stream() {
233
235
  Ok(stream) => {
234
236
  let inner = Arc::new(tokio::sync::Mutex::new(StreamSlot { stream }));
235
237
  Value::Opaque(Arc::new(HttpStreamReader {
@@ -270,7 +272,7 @@ impl TishOpaque for HttpStreamReader {
270
272
  }
271
273
  let inner = Arc::clone(&self.inner);
272
274
  let body = Arc::clone(&self.body);
273
- Some(Arc::new(move |_args: &[Value]| {
275
+ Some(tishlang_core::native_fn(move |_args: &[Value]| {
274
276
  let inner = Arc::clone(&inner);
275
277
  let body = Arc::clone(&body);
276
278
  let (tx, rx) = tokio::sync::oneshot::channel();
@@ -313,13 +315,13 @@ pub fn response_value_from_reqwest(response: reqwest::Response) -> Value {
313
315
  let ok = response.status().is_success();
314
316
  let headers_val = headers_to_value(response.headers());
315
317
  let body_holder = Arc::new(HttpBody::new(response));
316
- let stream = Arc::new(HttpReadableStream {
318
+ let stream = HttpReadableStream {
317
319
  body: Arc::clone(&body_holder),
318
- });
319
- let body_stream_val = Value::Opaque(stream);
320
+ };
321
+ let body_stream_val = Value::Opaque(Arc::new(stream));
320
322
  let bh_text = Arc::clone(&body_holder);
321
323
  let bh_json = Arc::clone(&body_holder);
322
- let text_fn: NativeFn = Arc::new(move |_args: &[Value]| {
324
+ let text_fn: NativeFn = tishlang_core::native_fn(move |_args: &[Value]| {
323
325
  let bh = Arc::clone(&bh_text);
324
326
  let (tx, rx) = tokio::sync::oneshot::channel();
325
327
  crate::http::RUNTIME.with(|rt| {
@@ -330,7 +332,7 @@ pub fn response_value_from_reqwest(response: reqwest::Response) -> Value {
330
332
  });
331
333
  crate::promise_io::string_result_promise(rx)
332
334
  });
333
- let json_fn: NativeFn = Arc::new(move |_args: &[Value]| {
335
+ let json_fn: NativeFn = tishlang_core::native_fn(move |_args: &[Value]| {
334
336
  let bh = Arc::clone(&bh_json);
335
337
  let (tx, rx) = tokio::sync::oneshot::channel();
336
338
  crate::http::RUNTIME.with(|rt| {
@@ -353,13 +355,31 @@ pub fn response_value_from_reqwest(response: reqwest::Response) -> Value {
353
355
  Value::object(obj)
354
356
  }
355
357
 
358
+ /// Shared, hardened outbound HTTP client: request + connect timeouts (a no-timeout fetch is
359
+ /// an outbound resource-pinning / slowloris vector) and a bounded redirect chain. Cached so
360
+ /// the connection pool is reused across requests.
361
+ /// NOTE: this does NOT yet block internal/metadata IPs — full SSRF defense (deny loopback /
362
+ /// link-local / RFC1918 after DNS resolution) is a follow-up that needs a policy decision.
363
+ fn fetch_client() -> &'static reqwest::Client {
364
+ use std::sync::OnceLock;
365
+ static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
366
+ CLIENT.get_or_init(|| {
367
+ reqwest::Client::builder()
368
+ .timeout(std::time::Duration::from_secs(30))
369
+ .connect_timeout(std::time::Duration::from_secs(10))
370
+ .redirect(reqwest::redirect::Policy::limited(5))
371
+ .build()
372
+ .unwrap_or_else(|_| reqwest::Client::new())
373
+ })
374
+ }
375
+
356
376
  async fn send_request_parts(
357
377
  url: String,
358
378
  method: String,
359
379
  headers: Vec<(String, String)>,
360
380
  body: Option<String>,
361
381
  ) -> Result<reqwest::Response, String> {
362
- let client = reqwest::Client::new();
382
+ let client = fetch_client();
363
383
  let mut req = match method.as_str() {
364
384
  "GET" => client.get(&url),
365
385
  "POST" => client.post(&url),
@@ -416,6 +436,7 @@ pub fn fetch_all_promise_from_args(args: Vec<Value>) -> Value {
416
436
  }));
417
437
  }
418
438
  };
439
+ #[allow(clippy::type_complexity)]
419
440
  let mut parts: Vec<(String, String, Vec<(String, String)>, Option<String>)> = Vec::new();
420
441
  for req in requests {
421
442
  let (url, opt) = match &req {
@@ -186,6 +186,8 @@ where
186
186
  let mut count = 0usize;
187
187
  while let Ok((req_prim, resp_tx)) = rx.recv() {
188
188
  let req_value = req_prim.into_value_pub();
189
+ // `handler` is the generic `F: Fn(&[Value]) -> Value` — call it directly. (`.call(..)` here
190
+ // would bind the *unstable* `Fn::call` trait method, which takes a tuple, not `&[Value]`.)
189
191
  let response_value = handler(&[req_value]);
190
192
  let resp_prim = ResponsePrimitive::from_value_pub(&response_value);
191
193
  let _ = resp_tx.send(resp_prim);
@@ -259,6 +261,12 @@ fn worker_thread(
259
261
  });
260
262
  if let Err(e) = http1::Builder::new()
261
263
  .keep_alive(true)
264
+ // A `Timer` MUST be installed whenever a timeout is set, or hyper 1.x panics
265
+ // ("header_read_timeout set, but no timer set") on the first connection.
266
+ .timer(hyper_util::rt::TokioTimer::new())
267
+ // Slowloris guard: drop a connection that dribbles its request
268
+ // headers slower than this instead of pinning the worker task.
269
+ .header_read_timeout(std::time::Duration::from_secs(30))
262
270
  .serve_connection(io, svc)
263
271
  .await
264
272
  {
@@ -338,13 +346,28 @@ async fn extract_request(
338
346
  .iter()
339
347
  .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
340
348
  .collect();
341
- let body_bytes = match http_body_util::BodyExt::collect(req.into_body()).await {
349
+ // Cap the collected body so a huge/chunked request can't exhaust memory.
350
+ let limited = http_body_util::Limited::new(req.into_body(), hyper_max_body());
351
+ let body_bytes = match http_body_util::BodyExt::collect(limited).await {
342
352
  Ok(c) => c.to_bytes().to_vec(),
343
- Err(_) => Vec::new(),
353
+ Err(_) => Vec::new(), // includes "length limit exceeded"
344
354
  };
345
355
  (method, url, path, query, headers, body_bytes)
346
356
  }
347
357
 
358
+ /// Max request body bytes for the hyper backend (mirrors the tiny_http cap).
359
+ /// Override with `TISH_HTTP_MAX_BODY` (bytes); default 16 MiB.
360
+ fn hyper_max_body() -> usize {
361
+ use std::sync::OnceLock;
362
+ static MAX: OnceLock<usize> = OnceLock::new();
363
+ *MAX.get_or_init(|| {
364
+ std::env::var("TISH_HTTP_MAX_BODY")
365
+ .ok()
366
+ .and_then(|v| v.parse().ok())
367
+ .unwrap_or(16 * 1024 * 1024)
368
+ })
369
+ }
370
+
348
371
  fn primitive_to_hyper(resp: ResponsePrimitive) -> Response<Full<Bytes>> {
349
372
  let (body_bytes, default_ct): (Bytes, Option<&str>) = match resp.body {
350
373
  ResponseBody::Text(s) => (Bytes::copy_from_slice(s.as_bytes()), Some("text/plain")),