Merge branch 'worktree/network'

repl
Ondřej Hruška 3 weeks ago
commit 6e5a6dcc92
  1. 439
      Cargo.lock
  2. 10
      Cargo.toml
  3. 175
      docs/concurrency.md
  4. 348
      docs/http.md
  5. 99
      lua/coroutines-demo.lua
  6. 66
      lua/stdlib/http.lua
  7. 529
      src/stdlib/http.rs
  8. 4
      src/stdlib/mod.rs
  9. 46
      src/stdlib/task.rs
  10. 4
      src/stdlib/utils.rs

439
Cargo.lock generated

@ -8,11 +8,14 @@ version = "0.1.0"
dependencies = [
"chrono",
"clap",
"digest_auth",
"env_logger",
"futures",
"log",
"mlua",
"reqwest",
"rusqlite",
"rustls",
"serde",
"serde_json",
"thiserror",
@ -117,28 +120,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.22.1"
@ -157,6 +138,15 @@ version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bstr"
version = "1.12.1"
@ -186,8 +176,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@ -197,12 +185,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.45"
@ -256,15 +238,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
@ -307,6 +280,25 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "defmt"
version = "1.1.0"
@ -339,6 +331,29 @@ dependencies = [
"thiserror",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "digest_auth"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3054f4e81d395e50822796c5e99ca522e6ba7be98947d6d4b0e5e61640bdb894"
dependencies = [
"digest",
"hex",
"md-5",
"rand",
"sha2",
]
[[package]]
name = "displaydoc"
version = "0.2.6"
@ -350,12 +365,6 @@ dependencies = [
"syn",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "either"
version = "1.16.0"
@ -455,10 +464,19 @@ dependencies = [
]
[[package]]
name = "fs_extra"
version = "1.3.0"
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
@ -467,6 +485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@ -475,6 +494,34 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
@ -493,37 +540,36 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.17"
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.3.4"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
"wasi",
]
[[package]]
@ -575,6 +621,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "1.4.2"
@ -904,16 +956,6 @@ dependencies = [
"syn",
]
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "js-sys"
version = "0.3.102"
@ -963,12 +1005,6 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lua-src"
version = "550.0.0"
@ -988,6 +1024,16 @@ dependencies = [
"which",
]
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.8.2"
@ -1187,62 +1233,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"thiserror",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.45"
@ -1252,27 +1242,22 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.4"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
@ -1280,11 +1265,11 @@ dependencies = [
[[package]]
name = "rand_core"
version = "0.9.5"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.3.4",
"getrandom",
]
[[package]]
@ -1347,7 +1332,6 @@ dependencies = [
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
@ -1371,7 +1355,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"getrandom",
"libc",
"untrusted",
"windows-sys 0.52.0",
@ -1412,8 +1396,8 @@ version = "0.23.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
dependencies = [
"aws-lc-rs",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
@ -1438,7 +1422,6 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"web-time",
"zeroize",
]
@ -1475,7 +1458,6 @@ version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -1593,6 +1575,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@ -1747,21 +1740,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
dependencies = [
"tinyvec_macros",
]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
@ -1889,6 +1867,12 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -1962,15 +1946,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.125"
@ -2036,21 +2011,11 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.7"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
dependencies = [
"rustls-pki-types",
]
@ -2149,16 +2114,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.5",
"windows-targets",
]
[[package]]
@ -2176,31 +2132,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
"windows-link",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
"windows_i686_gnullvm 0.53.1",
"windows_i686_msvc 0.53.1",
"windows_x86_64_gnu 0.53.1",
"windows_x86_64_gnullvm 0.53.1",
"windows_x86_64_msvc 0.53.1",
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
@ -2209,102 +2148,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.3"

@ -9,10 +9,18 @@ chrono = "0.4.45"
env_logger = "0.11"
log = "0.4"
clap = { version = "4.6.1", features = ["derive"] }
futures = "0.3"
mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]}
reqwest = "0.13.4"
# reqwest's default rustls provider (aws-lc-rs) null-derefs during the TLS
# handshake in this environment, and native-tls would link system OpenSSL.
# Use rustls with the self-contained, vendored `ring` provider (installed as the
# process default in stdlib::http). `rustls-no-provider` stops reqwest from
# pulling aws-lc-rs back in.
reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
thiserror = "2.0.18"
tokio = { version = "1.52.3", features = ["full"] }
digest_auth = "0.3"

@ -0,0 +1,175 @@
# Concurrency
`a` runs every script on an async core ([tokio](https://tokio.rs/)). The async
stdlib calls — `os.sleep`, the `http` client, `sqlite` queries — don't block the
OS thread while they wait: they suspend and let other work run. This page covers
how that interacts with Lua coroutines and how to run several pieces of work at
once with `task.join`.
Plain Lua coroutines behave exactly as in stock Lua. The moment a coroutine needs
to wait on something async — a sleep, a request, a query — run it through
`task.join` rather than driving it yourself, for the reason in
[Self-driven coroutines](#self-driven-coroutines).
```lua
-- Three requests that would take ~3s back-to-back finish in ~1s.
local a, b, c = task.join(
function() return http.get("https://httpbingo.org/delay/1").status end,
function() return http.get("https://httpbingo.org/delay/1").status end,
function() return http.get("https://httpbingo.org/delay/1").status end
)
print(a, b, c) --> 200 200 200
```
## Plain coroutines
Stock Lua coroutines are pure VM machinery and behave normally:
```lua
local function squares(n)
for i = 1, n do
coroutine.yield(i * i)
end
end
local gen = coroutine.wrap(squares)
print(gen(3), gen(), gen()) --> 1 4 9
```
This is ordinary cooperative scheduling: nothing runs concurrently, and the tokio
runtime is never involved.
## Async calls suspend, they don't block
An async stdlib call — at the top level of your script, or inside a `task.join`
coroutine — suspends until it is ready without tying up the thread:
```lua
local t0 = os.microtime()
os.sleep(0.10)
print(string.format("waited %.3fs", os.microtime() - t0)) --> waited ~0.100s
```
While that sleep is pending, any sibling tasks keep making progress.
## Running work concurrently
### `task.join(fn1, fn2, ...)`
Run each function as its own coroutine, drive them **concurrently** on the
runtime, and return each one's first result positionally once all have finished.
If a task raises an error, `task.join` re-raises the first one.
Because async calls suspend instead of blocking, the tasks overlap — the
wall-clock time is the *longest* task, not the sum:
```lua
local function worker(name, secs)
return function()
os.sleep(secs) -- suspends; siblings run meanwhile
return name
end
end
local t0 = os.microtime()
local a, b, c = task.join(
worker("slow", 0.30),
worker("med", 0.20),
worker("fast", 0.10)
)
print(a, b, c) --> slow med fast
print(string.format("%.3fs", os.microtime() - t0)) --> ~0.300s, not 0.600s
```
### Returning results
Each task's first return value lands in the matching slot, so several results come
back together:
```lua
local me, repos = task.join(
function() return http.getJSON("https://api.github.com/users/torvalds") end,
function() return http.getJSON("https://api.github.com/users/torvalds/repos") end
)
print(me.name, #repos) -- both fetched concurrently
```
### Nesting
`task.join` suspends like any other async call, so a task may itself call
`task.join`:
```lua
local total = task.join(
function()
local x, y = task.join(
function() os.sleep(0.05); return 21 end,
function() os.sleep(0.05); return 21 end
)
return x + y -- 42
end,
function() os.sleep(0.10); return "sibling" end
)
print(total) --> 42
```
## Self-driven coroutines
Async stdlib functions suspend correctly only when the runtime is driving the
coroutine — at the top level of your script, or inside a `task.join` coroutine. If
you drive a coroutine **yourself** with `coroutine.resume` or `coroutine.wrap`, an
async call does *not* wait:
```lua
-- DON'T do this:
local co = coroutine.wrap(function()
os.sleep(0.50)
return "awoke"
end)
local v = co() -- returns immediately with an opaque value; the 0.5s wait never
-- happens, because coroutine.wrap can't drive the runtime.
```
Under the hood an async call yields a private marker that only the runtime's
scheduler understands; a hand-written `resume`/`wrap` loop just receives that
marker and moves on.
**Rule of thumb:** use plain `coroutine.*` for pure-Lua generators; the moment a
coroutine needs to `os.sleep`, hit the network, or touch the database, run it
through `task.join` instead.
## How it works
`task.join` wraps each function in a Lua coroutine and polls them all on the tokio
runtime. The concurrency comes from the **reactor** — timers and I/O yielding
control while they wait — not from extra threads.
- **Concurrency, not parallelism.** Everything runs on one thread; there are
simply many operations in flight at once.
- **I/O-bound work overlaps.** Sleeps, HTTP requests, and database queries all
wait at the same time.
- **CPU-bound work does not yield.** A tight compute loop with no async calls
blocks its siblings until it finishes or reaches an async call.
## Full example
```lua
-- Fetch several resources at once, then combine them.
local function get(url)
return function() return http.getJSON(url) end
end
local user, repos = task.join(
get("https://api.github.com/users/torvalds"),
get("https://api.github.com/users/torvalds/repos")
)
print(user.name)
print(#repos .. " public repos")
-- Both requests ran concurrently: ~1 round-trip of latency, not 2.
```
A runnable version of these patterns ships in
[`lua/coroutines-demo.lua`](../lua/coroutines-demo.lua) — run it with
`a lua/coroutines-demo.lua`.

@ -0,0 +1,348 @@
# `http`
The `http` module is an HTTP/HTTPS client for calling APIs, scraping pages, and
submitting forms. TLS is compiled into the binary (rustls with the `ring`
provider), so HTTPS works with no system library. There are two ways in: plain
functions for one-off requests, and a **session** object that carries a cookie
jar across requests for stateful flows like logging in.
```lua
local resp = http.get("https://example.com")
print(resp.status, resp.ok) --> 200 true
print(resp.body) --> "<!doctype html>…"
-- JSON in one step
local data = http.getJSON("https://api.example.com/users")
for _, user in ipairs(data) do
print(user.name)
end
```
## Making requests
Every helper takes a URL and an optional `opts` table (see [Request
options](#request-options)) and returns a [response](#the-response) table.
```lua
http.get(url [, opts])
http.post(url [, opts])
http.put(url [, opts])
http.patch(url [, opts])
http.delete(url [, opts])
http.head(url [, opts])
```
These are thin shorthands over the one primitive:
### `http.request(method, url [, opts])`
Send a request with an explicit method (any verb, e.g. `"GET"`, `"OPTIONS"`) and
return the response. The shorthands above are just `http.request` with the method
filled in.
```lua
local resp = http.request("DELETE", "https://api.example.com/items/42")
```
## The response
Every request returns a table describing the response:
| Field | Type | Description |
|-----------|-----------|-------------------------------------------------------------------|
| `status` | integer | HTTP status code, e.g. `200`, `404`. |
| `ok` | boolean | `true` when `status` is in the 2xx range. |
| `headers` | table | Response headers, keyed by **lowercase** name. |
| `body` | string | The raw response body (Lua strings are byte sequences). |
| `json` | function | `resp.json()` parses `body` as JSON. See [JSON](#json). |
Header names are lowercased so you can look them up without guessing the server's
capitalization. If a header appears more than once, the first value wins.
```lua
local resp = http.get("https://example.com")
print(resp.headers["content-type"]) --> "text/html; charset=utf-8"
```
A non-2xx status is **not** an error — `resp.status` and `resp.ok` simply report
it. Only a failure to get a response at all (DNS, connection, TLS, timeout) raises
a Lua error. See [Errors](#errors).
## Request options
The optional `opts` table accepts these fields, all optional:
| Field | Type | Behaviour |
|-----------|--------|--------------------------------------------------------------------------|
| `headers` | table | Extra request headers, `{["X-Foo"] = "bar"}`. |
| `body` | string | Raw request body; set `Content-Type` yourself via `headers`. |
| `json` | any | Serialized to JSON; sets `Content-Type: application/json`. |
| `form` | table | URL-encoded; sets `Content-Type: application/x-www-form-urlencoded`. |
| `cookies` | table | Cookies for this request, `{session = "abc"}``Cookie:` header. |
| `timeout` | number | Per-request timeout in seconds (default `30`). |
| `auth` | table | Basic or digest credentials. See [Authentication](#authentication). |
### Bodies
`json`, `form`, and `body` are three ways to set the request body; if more than
one is given, the first present in that order wins.
```lua
-- JSON body (table serialized to an object)
http.post("https://api.example.com/items", { json = { name = "test", count = 5 } })
-- Form submission (application/x-www-form-urlencoded)
http.post("https://example.com/login", { form = { user = "alice", pass = "secret" } })
-- Raw body with an explicit content type
http.post("https://example.com/ingest", {
body = "id,name\n1,alice\n",
headers = { ["Content-Type"] = "text/csv" },
})
```
`json` serializes the same way as `utils.toJSON`: a table with
sequential integer keys becomes a JSON array, otherwise an object, and
`utils.NULL` becomes JSON `null` (a literal Lua `nil` cannot live in
a table).
### Headers and cookies
```lua
local resp = http.get("https://example.com", {
headers = { ["Accept"] = "application/json", ["X-Token"] = "xyz" },
cookies = { session = "abc123" },
timeout = 10,
})
```
Per-request `cookies` are sent as a `Cookie` header. With a [session](#sessions),
they are merged with the jar and take precedence on a name collision.
## JSON
### `resp.json()`
Parse the response body as JSON and return the resulting Lua value. It is the
counterpart of `utils.fromJSON`: JSON objects become tables, arrays
become array-tables, and `null` becomes `utils.NULL`. Calling it on a
body that is not valid JSON raises an error.
```lua
local resp = http.post("https://api.example.com/echo", { json = { hello = "world" } })
print(resp.json().hello) --> "world"
```
### `http.getJSON(url [, opts])`
Shorthand for a GET that parses the body. Returns **two** values: the parsed body
and the full response.
```lua
local data, resp = http.getJSON("https://api.example.com/users")
print(resp.status, #data)
```
### `http.postJSON(url, body [, opts])`
Shorthand for a POST with a JSON body — equivalent to setting `opts.json = body`.
Returns the response.
```lua
local resp = http.postJSON("https://api.example.com/items", { name = "test", count = 5 })
if resp.ok then print(resp.json().id) end
```
## Authentication
Pass credentials in `opts.auth`. The `scheme` is `"basic"` (the default) or
`"digest"`.
| Field | Type | Description |
|------------|--------|----------------------------------------------|
| `username` | string | Required. |
| `password` | string | Required. |
| `scheme` | string | `"basic"` (default) or `"digest"`. |
```lua
-- HTTP Basic
local resp = http.get("https://api.example.com/private", {
auth = { username = "alice", password = "secret" },
})
-- HTTP Digest — the 401 challenge is answered automatically
local resp = http.get("https://api.example.com/private", {
auth = { username = "alice", password = "secret", scheme = "digest" },
})
```
For basic auth the `Authorization` header is sent with the request. For digest the
client sends the request, reads the server's `401` challenge, computes the
response, and retries once; you only see the final response. Auth works the same
way on [sessions](#sessions).
## Sessions
A **session** wraps its own cookie jar. Cookies from `Set-Cookie` responses are
stored automatically and sent back on later requests to matching hosts — which is
what makes login-then-fetch flows work. A session also has the same request
methods as the plain module.
### `http.session([path])`
Create a session. With a `path`, the cookie jar is preloaded from that file (see
[Persisting the jar](#persisting-the-jar)).
```lua
local s = http.session() -- fresh, empty jar
local s = http.session("cookies.jsonl") -- jar loaded from disk
```
### Requests
A session has every method the plain module has — `request`, `get`, `post`,
`put`, `patch`, `delete`, `head`, `getJSON`, `postJSON` — called with `:` syntax
and accepting the same `opts`:
```lua
local s = http.session()
-- Log in; the response's Set-Cookie is captured into the jar
s:post("https://example.com/login", { form = { user = "alice", pass = "secret" } })
-- The session cookie is sent automatically
local page = s:get("https://example.com/dashboard")
```
### Cookie behaviour
A stored cookie is sent on a request when the request host **equals** the cookie's
domain or is a subdomain of it. The jar reads the `name=value` pair and the
`Domain` attribute from each `Set-Cookie` header; other attributes (`Path`,
`Expires`, `Secure`, `HttpOnly`) are ignored. When no `Domain` is given, the
request host is used.
`Set-Cookie` headers set on redirect responses are captured too, so a login that
`302`-redirects to a dashboard still records its cookie.
### Inspecting and clearing
#### `s:cookies()`
Return the jar as a nested table, `{domain = {name = value}}`, for inspection.
```lua
local jar = s:cookies()
for domain, names in pairs(jar) do
for name, value in pairs(names) do
print(domain, name, value)
end
end
```
#### `s:clearCookies()`
Empty the in-memory jar.
### Persisting the jar
Cookies live in memory for the session's lifetime. Save them to reuse a logged-in
session across script runs.
#### `s:save(path)`
Write the jar to `path` as JSONL — one JSON object per line, one cookie per line:
```jsonl
{"domain":"example.com","name":"session","value":"abc123"}
{"domain":"api.example.com","name":"token","value":"xyz789"}
```
#### `s:load(path)`
Merge cookies from a JSONL file into the current jar (existing cookies are kept,
matching names overwritten). `http.session(path)` is the same as creating a
session and calling `:load(path)`.
```lua
-- First run: log in and persist
local s = http.session()
s:post("https://example.com/login", { form = { user = "alice", pass = "secret" } })
s:save("session.jsonl")
-- Later run: restore and continue without logging in again
local s = http.session("session.jsonl")
local page = s:get("https://example.com/dashboard")
```
## Redirects
Redirects are followed automatically, up to 10 hops; you receive the final
response. A `303`, and a `301`/`302` in response to a `POST`, are followed as a
bodyless `GET`, matching browser behaviour. For a session, cookies set along the
way are captured at each hop.
## Errors
Failing to obtain a response raises a Lua error: DNS failure, connection refused,
a TLS problem, or a timeout. An HTTP error *status* (4xx/5xx) does not — it is
reported through `resp.status`/`resp.ok`. Parsing a non-JSON body with
`resp.json()` also raises.
Wrap calls in `pcall` or `utils.try` where you want to handle failure
rather than abort:
```lua
local resp, err = utils.try(function()
return http.get("https://does-not-exist.invalid", { timeout = 5 })
end)
if not resp then
log.error("request failed: " .. tostring(err))
end
```
## Notes
- **HTTPS needs no setup.** The TLS stack (rustls + `ring`) is compiled in; trust
roots come from the system certificate store.
- **A default `User-Agent` is sent** (`a/<version>`) because some servers reject
requests without one. Override it with a `User-Agent` entry in `opts.headers`.
- **Requests don't block the event loop.** Network I/O runs on the async core, so
a slow request does not stall other async work (timers, `os.sleep`, SQLite) in
the same script.
- **The cookie jar is per session.** Plain `http.get`/`http.post` calls do not
retain cookies between calls; use a session for that.
## Full example
```lua
-- Talk to a JSON API with a bearer token, then drive a stateful session.
-- One-off authenticated JSON call
local items, resp = http.getJSON("https://api.example.com/items", {
headers = { ["Authorization"] = "Bearer " .. token },
timeout = 15,
})
if not resp.ok then
error("list failed: HTTP " .. resp.status)
end
for _, item in ipairs(items) do
print(item.id, item.name)
end
-- Create one
local created = http.postJSON("https://api.example.com/items", { name = "widget" }, {
headers = { ["Authorization"] = "Bearer " .. token },
})
print("created id:", created.json().id)
-- A login session that persists across runs
local s = http.session("session.jsonl") -- restore if present
local home = s:get("https://example.com/dashboard")
if home.status == 401 then -- session expired; log in again
s:post("https://example.com/login", { form = { user = "alice", pass = "secret" } })
home = s:get("https://example.com/dashboard")
s:save("session.jsonl")
end
print(home.ok and "logged in" or "login failed")
```

@ -0,0 +1,99 @@
-- Proof-of-concept: how Lua coroutines interact with the tokio runtime, and
-- how the async os.sleep behaves in each setting.
--
-- Run with: cargo run -- lua/coroutines-demo.lua
local function banner(s) print("\n=== " .. s .. " ===") end
----------------------------------------------------------------------
banner("1. Pure Lua coroutines (no async involved)")
----------------------------------------------------------------------
-- Plain cooperative coroutines work exactly as in stock Lua: this is pure VM
-- machinery and never touches tokio.
local function counter(n)
for i = 1, n do
coroutine.yield(i * i)
end
return "done"
end
local co = coroutine.wrap(counter)
print("squares:", co(3), co(3), co(3)) -- 1 4 9
local raw = coroutine.create(counter)
print("status fresh:", coroutine.status(raw)) -- suspended
coroutine.resume(raw, 1)
coroutine.resume(raw, 1) -- runs to `return`
print("status after return:", coroutine.status(raw)) -- dead
----------------------------------------------------------------------
banner("2. Async os.sleep at the top level")
----------------------------------------------------------------------
-- The main chunk is itself run by mlua's async executor (exec_async), so an
-- async call here is driven correctly and really suspends on the tokio timer.
local t0 = os.microtime()
os.sleep(0.10)
print(string.format("slept ~0.10s, measured %.3fs", os.microtime() - t0))
----------------------------------------------------------------------
banner("3. GOTCHA: async os.sleep inside a *manually resumed* coroutine")
----------------------------------------------------------------------
-- mlua implements async functions by yielding a private sentinel to whatever is
-- driving the coroutine. mlua's own executor understands it; a plain
-- coroutine.resume / coroutine.wrap does NOT. So driving an async call yourself
-- does not actually wait -- the sleep is not performed by your resume loop.
local sleeper = coroutine.wrap(function()
os.sleep(0.50)
return "awoke"
end)
local t1 = os.microtime()
local first = sleeper() -- returns immediately with the sentinel
print(string.format("first resume returned %s after only %.3fs (did NOT wait 0.5s)",
tostring(first), os.microtime() - t1))
print("--> lesson: don't hand-drive coroutines that call async stdlib functions.")
----------------------------------------------------------------------
banner("4. Real concurrency: task.join drives coroutines on tokio")
----------------------------------------------------------------------
-- task.join runs each function as its own coroutine and lets the tokio reactor
-- interleave them. Three tasks that each sleep concurrently finish in ~the
-- longest single sleep, not the sum -- proof the sleeps overlap on one thread.
local function worker(name, secs)
return function()
print(string.format(" [%s] start", name))
os.sleep(secs)
print(string.format(" [%s] woke after %.2fs", name, secs))
return name .. ":" .. secs
end
end
local t2 = os.microtime()
local a, b, c = task.join(
worker("slow", 0.30),
worker("med", 0.20),
worker("fast", 0.10)
)
local elapsed = os.microtime() - t2
print(string.format("results: %s, %s, %s", a, b, c))
print(string.format("wall time: %.3fs", elapsed))
print(string.format("sequential would have been ~0.60s; concurrent ~0.30s => %s",
elapsed < 0.45 and "CONCURRENT (overlapped on tokio)" or "serialized?!"))
----------------------------------------------------------------------
banner("5. Nested + return values")
----------------------------------------------------------------------
-- task.join itself yields, so it composes: a joined task can join again.
local outer = task.join(
function()
local x, y = task.join(
function() os.sleep(0.05); return 21 end,
function() os.sleep(0.05); return 21 end
)
return x + y
end,
function() os.sleep(0.10); return "sibling" end
)
print("nested join result:", outer) -- 42
print("\nAll demos finished.")

@ -0,0 +1,66 @@
-- Lua-side of the http module.
-- http.request (stateless) and http.session (constructor) are provided by Rust
-- before this runs. This layer adds resp.json(), method shorthands, the
-- JSON/postJSON helpers, and a metatable for session objects.
-- Attach resp.json() to a response table: parses resp.body via utils.fromJSON.
local function wrap_resp(resp)
resp.json = function() return utils.fromJSON(resp.body) end
return resp
end
-- Wrap the stateless http.request so responses carry resp.json().
local _request = http.request
http.request = function(method, url, opts)
return wrap_resp(_request(method, url, opts))
end
-- Method shorthands for the stateless module.
for _, m in ipairs({ "get", "post", "put", "patch", "delete", "head" }) do
http[m] = function(url, opts) return http.request(m:upper(), url, opts) end
end
function http.getJSON(url, opts)
local resp = http.get(url, opts)
return resp.json(), resp
end
function http.postJSON(url, body, opts)
opts = opts or {}
opts.json = body
return http.post(url, opts)
end
-- Session metatable. http.session() returns a raw Rust table whose methods are
-- _request, save, load, clearCookies and cookies. The metatable adds the
-- request wrapper (for resp.json()) and the method shorthands on top.
local session_mt = {}
session_mt.__index = session_mt
function session_mt:request(method, url, opts)
return wrap_resp(self:_request(method, url, opts))
end
function session_mt:get(url, opts) return self:request("GET", url, opts) end
function session_mt:post(url, opts) return self:request("POST", url, opts) end
function session_mt:put(url, opts) return self:request("PUT", url, opts) end
function session_mt:patch(url, opts) return self:request("PATCH", url, opts) end
function session_mt:delete(url, opts) return self:request("DELETE", url, opts) end
function session_mt:head(url, opts) return self:request("HEAD", url, opts) end
function session_mt:getJSON(url, opts)
local resp = self:get(url, opts)
return resp.json(), resp
end
function session_mt:postJSON(url, body, opts)
opts = opts or {}
opts.json = body
return self:post(url, opts)
end
-- Wrap the Rust session constructor to install the metatable.
local _session = http.session
http.session = function(path)
return setmetatable(_session(path), session_mt)
end

@ -0,0 +1,529 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use mlua::prelude::LuaResult;
use mlua::{Lua, Table as LuaTable, Value as LuaValue};
use crate::stdlib::utils::lua_to_json;
const HTTP_LUA: &str = include_str!("../../lua/stdlib/http.lua");
// ---------------------------------------------------------------------------
// Cookie jar
// ---------------------------------------------------------------------------
/// A minimal in-memory cookie jar: bare domain (no leading dot) → name → value.
/// Deliberately simple — it covers the >95% case of `Set-Cookie` flows without
/// pulling in the `cookie_store` crate, and serializes cleanly to JSONL.
#[derive(Default)]
struct CookieJar {
cookies: HashMap<String, HashMap<String, String>>,
}
impl CookieJar {
/// All cookies (name, value) whose stored domain matches `host`: either an
/// exact match or `host` being a subdomain of the stored domain.
fn cookies_for(&self, host: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for (domain, names) in &self.cookies {
let suffix = format!(".{domain}");
if host == domain || host.ends_with(&suffix) {
for (name, value) in names {
out.push((name.clone(), value.clone()));
}
}
}
out
}
/// Parse a single `Set-Cookie` header value and store the cookie. Extracts
/// `name=value` (first segment) and an optional `Domain=` attribute, falling
/// back to the request host. Malformed headers are ignored.
fn set_from_header(&mut self, host: &str, header: &str) {
let mut segments = header.split(';');
let first = match segments.next() {
Some(s) => s.trim(),
None => return,
};
let (name, value) = match first.split_once('=') {
Some((n, v)) => (n.trim(), v.trim()),
None => return,
};
if name.is_empty() {
return;
}
let mut domain = host.to_ascii_lowercase();
for seg in segments {
if let Some((k, v)) = seg.split_once('=')
&& k.trim().eq_ignore_ascii_case("domain")
{
let d = v.trim().trim_start_matches('.').to_ascii_lowercase();
if !d.is_empty() {
domain = d;
}
}
}
self.cookies
.entry(domain)
.or_default()
.insert(name.to_string(), value.to_string());
}
/// Serialize as JSONL — one `{"domain","name","value"}` object per line.
fn to_jsonl(&self) -> String {
let mut out = String::new();
for (domain, names) in &self.cookies {
for (name, value) in names {
let obj = serde_json::json!({
"domain": domain,
"name": name,
"value": value,
});
out.push_str(&obj.to_string());
out.push('\n');
}
}
out
}
/// Merge cookies from JSONL produced by `to_jsonl`. Bad lines are skipped.
fn merge_jsonl(&mut self, src: &str) {
for line in src.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let domain = v.get("domain").and_then(|x| x.as_str());
let name = v.get("name").and_then(|x| x.as_str());
let value = v.get("value").and_then(|x| x.as_str());
if let (Some(d), Some(n), Some(val)) = (domain, name, value) {
self.cookies
.entry(d.to_string())
.or_default()
.insert(n.to_string(), val.to_string());
}
}
}
}
// ---------------------------------------------------------------------------
// Request execution
// ---------------------------------------------------------------------------
/// `application/x-www-form-urlencoded` body from key/value pairs. Implemented
/// inline so the build needs no optional reqwest features.
fn form_urlencode(pairs: &[(String, String)]) -> String {
fn encode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
pairs
.iter()
.map(|(k, v)| format!("{}={}", encode(k), encode(v)))
.collect::<Vec<_>>()
.join("&")
}
const MAX_REDIRECTS: u32 = 10;
/// Shared by the stateless `http.request` and a session's `:request`. When `jar`
/// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses
/// are stored back.
///
/// Redirects are followed manually (the client is built with
/// `redirect::Policy::none`) so that `Set-Cookie` headers on 30x responses —
/// the common login → redirect → dashboard pattern — are captured into the jar,
/// which reqwest's transparent redirect following would otherwise hide.
async fn execute_request(
lua: Lua,
client: reqwest::Client,
jar: Option<Arc<Mutex<CookieJar>>>,
method: String,
url: String,
opts: Option<LuaTable>,
) -> LuaResult<LuaTable> {
let mut method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes())
.map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?;
// Parse opts once into owned pieces so each redirect hop can rebuild the
// request (a reqwest RequestBuilder is single-use).
let mut timeout: Option<Duration> = None;
let mut custom_headers: Vec<(String, String)> = Vec::new();
let mut opts_cookies: Vec<(String, String)> = Vec::new();
// Body, with the Content-Type it implies (None for a raw body).
let mut body: Option<(Vec<u8>, Option<&'static str>)> = None;
// Auth: (is_digest, username, password).
let mut auth: Option<(bool, String, String)> = None;
if let Some(opts) = opts.as_ref() {
if let Some(t) = opts.get::<Option<f64>>("timeout")? {
timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| {
mlua::Error::external(
"http: timeout must be a non-negative finite number of seconds",
)
})?);
}
if let Some(headers) = opts.get::<Option<LuaTable>>("headers")? {
for pair in headers.pairs::<String, String>() {
custom_headers.push(pair?);
}
}
if let Some(cookies) = opts.get::<Option<LuaTable>>("cookies")? {
for pair in cookies.pairs::<String, String>() {
opts_cookies.push(pair?);
}
}
// Body: json > form > raw body (first one present wins).
if let Some(json_val) = opts.get::<Option<LuaValue>>("json")? {
let json = lua_to_json(json_val, 0)?;
let s = serde_json::to_string(&json).map_err(mlua::Error::external)?;
body = Some((s.into_bytes(), Some("application/json")));
} else if let Some(form) = opts.get::<Option<LuaTable>>("form")? {
let mut pairs = Vec::new();
for pair in form.pairs::<String, String>() {
pairs.push(pair?);
}
body = Some((
form_urlencode(&pairs).into_bytes(),
Some("application/x-www-form-urlencoded"),
));
} else if let Some(b) = opts.get::<Option<mlua::String>>("body")? {
body = Some((b.as_bytes().to_vec(), None));
}
// Auth: { username, password, scheme = "basic" (default) | "digest" }.
if let Some(auth_tbl) = opts.get::<Option<LuaTable>>("auth")? {
let username = auth_tbl
.get::<Option<String>>("username")?
.ok_or_else(|| mlua::Error::external("http: auth.username is required"))?;
let password = auth_tbl
.get::<Option<String>>("password")?
.ok_or_else(|| mlua::Error::external("http: auth.password is required"))?;
let is_digest = match auth_tbl.get::<Option<String>>("scheme")?.as_deref() {
None | Some("basic") => false,
Some("digest") => true,
Some(other) => {
return Err(mlua::Error::external(format!(
"http: auth.scheme must be \"basic\" or \"digest\", got \"{other}\""
)));
}
};
auth = Some((is_digest, username, password));
}
}
let is_digest = matches!(auth.as_ref(), Some((true, _, _)));
let mut digest_header: Option<String> = None;
let mut digest_tried = false;
let mut url = url;
let mut redirects_left = MAX_REDIRECTS;
let resp = loop {
// Host used for cookie matching and as the Set-Cookie domain fallback;
// recomputed each hop since a redirect may cross hosts.
let host = reqwest::Url::parse(&url)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase()));
let mut req = client.request(method.clone(), &url);
if let Some(t) = timeout {
req = req.timeout(t);
}
for (k, v) in &custom_headers {
req = req.header(k, v);
}
// Cookie header: jar cookies for this host, then per-request cookies
// which override on name collision.
let mut cookie_map: HashMap<String, String> = HashMap::new();
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) {
for (n, v) in jar.lock().unwrap().cookies_for(host) {
cookie_map.insert(n, v);
}
}
for (k, v) in &opts_cookies {
cookie_map.insert(k.clone(), v.clone());
}
if !cookie_map.is_empty() {
let header = cookie_map
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join("; ");
req = req.header(reqwest::header::COOKIE, header);
}
if let Some((bytes, ct)) = body.as_ref() {
if let Some(ct) = ct {
req = req.header(reqwest::header::CONTENT_TYPE, *ct);
}
req = req.body(bytes.clone());
}
// Auth. Basic goes out on every hop; digest's Authorization is set only
// after the 401 challenge below has been answered (digest_header).
if let Some((digest, username, password)) = auth.as_ref() {
if *digest {
if let Some(h) = digest_header.as_ref() {
req = req.header(reqwest::header::AUTHORIZATION, h);
}
} else {
req = req.basic_auth(username, Some(password));
}
}
let resp = req
.send()
.await
.map_err(|e| mlua::Error::external(format!("http: {e}")))?;
let status = resp.status();
// Store Set-Cookie from this hop into the jar.
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) {
let mut j = jar.lock().unwrap();
for value in resp.headers().get_all(reqwest::header::SET_COOKIE).iter() {
if let Ok(s) = value.to_str() {
j.set_from_header(host, s);
}
}
}
// Digest auth: answer a 401 challenge once, then retry the same request
// with the computed Authorization header.
if is_digest && !digest_tried && status == reqwest::StatusCode::UNAUTHORIZED {
let challenge = resp
.headers()
.get(reqwest::header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
if let Some((_, username, password)) = auth.as_ref()
&& let Some(challenge) = challenge
&& let Ok(mut prompt) = digest_auth::parse(&challenge)
{
let uri = reqwest::Url::parse(&url)
.map(|u| match u.query() {
Some(q) => format!("{}?{}", u.path(), q),
None => u.path().to_string(),
})
.unwrap_or_else(|_| url.clone());
let ctx = digest_auth::AuthContext::new_with_method(
username.as_str(),
password.as_str(),
uri,
body.as_ref().map(|(b, _)| b.as_slice()),
digest_auth::HttpMethod::from(method.as_str()),
);
if let Ok(answer) = prompt.respond(&ctx) {
digest_header = Some(answer.to_header_string());
digest_tried = true;
continue;
}
}
}
// Follow a redirect if there is one to follow.
if status.is_redirection()
&& redirects_left > 0
&& let Some(next) = resp
.headers()
.get(reqwest::header::LOCATION)
.and_then(|l| l.to_str().ok())
.and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok())
{
redirects_left -= 1;
// 303, and 301/302 on a POST, degrade to a bodyless GET — the
// behaviour browsers and reqwest's own redirect policy apply.
let code = status.as_u16();
if code == 303 || ((code == 301 || code == 302) && method == reqwest::Method::POST) {
method = reqwest::Method::GET;
body = None;
}
url = next.to_string();
continue;
}
break resp;
};
let status = resp.status().as_u16();
// Response headers: lowercase names, first value wins.
let headers_tbl = lua.create_table()?;
for (name, value) in resp.headers().iter() {
let lname = name.as_str().to_ascii_lowercase();
if !headers_tbl.contains_key(lname.as_str())? {
headers_tbl.raw_set(lname.as_str(), lua.create_string(value.as_bytes())?)?;
}
}
let bytes = resp
.bytes()
.await
.map_err(|e| mlua::Error::external(format!("http: {e}")))?;
let out = lua.create_table()?;
out.raw_set("status", status)?;
out.raw_set("ok", (200..=299).contains(&status))?;
out.raw_set("headers", headers_tbl)?;
out.raw_set("body", lua.create_string(&bytes)?)?;
Ok(out)
}
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
/// Build the plain Lua table that backs a session. State (the cookie jar) lives
/// in the closures; the Lua layer applies a metatable for the method shorthands.
/// Methods are called as `s:method(...)`, so each closure receives the session
/// table as a leading `_this` argument that it ignores.
fn make_session(
lua: &Lua,
client: reqwest::Client,
jar: Arc<Mutex<CookieJar>>,
) -> LuaResult<LuaTable> {
let tbl = lua.create_table()?;
tbl.raw_set(
"_request",
lua.create_async_function({
let client = client.clone();
let jar = jar.clone();
move |lua, (_this, method, url, opts): (LuaTable, String, String, Option<LuaTable>)| {
let client = client.clone();
let jar = jar.clone();
async move {
execute_request(lua, client, Some(jar), method, url, opts).await
}
}
})?,
)?;
tbl.raw_set("save", {
let jar = jar.clone();
lua.create_function(move |_, (_this, path): (LuaTable, String)| {
let jsonl = jar.lock().unwrap().to_jsonl();
std::fs::write(&path, jsonl)
.map_err(|e| mlua::Error::external(format!("session:save: {e}")))
})?
})?;
tbl.raw_set("load", {
let jar = jar.clone();
lua.create_function(move |_, (_this, path): (LuaTable, String)| {
let src = std::fs::read_to_string(&path)
.map_err(|e| mlua::Error::external(format!("session:load: {e}")))?;
jar.lock().unwrap().merge_jsonl(&src);
Ok(())
})?
})?;
tbl.raw_set("clearCookies", {
let jar = jar.clone();
lua.create_function(move |_, _this: LuaTable| {
jar.lock().unwrap().cookies.clear();
Ok(())
})?
})?;
tbl.raw_set("cookies", {
let jar = jar.clone();
lua.create_function(move |lua, _this: LuaTable| {
let outer = lua.create_table()?;
let j = jar.lock().unwrap();
for (domain, names) in j.cookies.iter() {
let inner = lua.create_table()?;
for (name, value) in names.iter() {
inner.raw_set(name.as_str(), value.as_str())?;
}
outer.raw_set(domain.as_str(), inner)?;
}
Ok(outer)
})?
})?;
Ok(tbl)
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
pub(super) fn install(lua: &Lua) -> LuaResult<()> {
// reqwest is built with `rustls-no-provider`, so it has no crypto provider
// of its own and panics ("No provider set") unless one is installed as the
// process default before the client is built. `ring` is self-contained
// (compiled in, no system OpenSSL). Idempotent across Lua states — only the
// first install in the process wins, and they would all install ring.
let _ = rustls::crypto::ring::default_provider().install_default();
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
// reqwest sends no User-Agent by default; some edges/CDNs reject
// empty-UA requests outright. A per-request `headers` entry overrides it.
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")))
// Redirects are followed manually in execute_request so Set-Cookie
// headers on 30x responses can be captured into the cookie jar.
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(mlua::Error::external)?;
let http = lua.create_table()?;
// Stateless http.request — no cookie jar.
http.raw_set(
"request",
lua.create_async_function({
let client = client.clone();
move |lua, (method, url, opts): (String, String, Option<LuaTable>)| {
let client = client.clone();
async move { execute_request(lua, client, None, method, url, opts).await }
}
})?,
)?;
// http.session(path?) — constructor returning the raw session table.
http.raw_set(
"session",
lua.create_async_function({
let client = client.clone();
move |lua, path: Option<String>| {
let client = client.clone();
async move {
let mut jar = CookieJar::default();
if let Some(p) = path {
let src = tokio::fs::read_to_string(&p)
.await
.map_err(|e| mlua::Error::external(format!("http.session: {e}")))?;
jar.merge_jsonl(&src);
}
make_session(&lua, client, Arc::new(Mutex::new(jar)))
}
}
})?,
)?;
lua.globals().raw_set("http", http)?;
// Lua side adds: resp.json(), method shorthands, getJSON/postJSON, and the
// session metatable wrapping http.session().
lua.load(HTTP_LUA).set_name("@[stdlib/http]").exec()
}

@ -1,8 +1,10 @@
mod http;
mod logging;
mod math;
mod os_ext;
mod sqlite;
mod table;
mod task;
pub(crate) mod utils;
pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
@ -12,5 +14,7 @@ pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
os_ext::install(lua)?;
logging::install(lua)?;
sqlite::install(lua)?;
http::install(lua)?;
task::install(lua)?;
Ok(())
}

@ -0,0 +1,46 @@
use futures::future::join_all;
use mlua::prelude::{LuaResult, LuaValue};
use mlua::{Function, Lua, MultiValue, Variadic};
/// Proof-of-concept concurrency primitive built on Lua coroutines + tokio.
///
/// `task.join(f1, f2, ...)` runs each function as its own Lua coroutine and
/// drives them *concurrently* on the tokio runtime, returning each one's first
/// result positionally once all have finished. Because async stdlib calls like
/// `os.sleep` yield to the tokio reactor rather than blocking the OS thread,
/// sibling coroutines make progress while one is sleeping — so N tasks that each
/// sleep T seconds finish in ~T, not ~N*T.
///
/// Note: the coroutines are NOT `tokio::spawn`ed (the Lua state is `!Send`);
/// they are polled cooperatively on the current thread via `join_all`. The
/// concurrency comes from the reactor, not from extra threads.
pub(super) fn install(lua: &Lua) -> LuaResult<()> {
let task = lua.create_table()?;
task.raw_set(
"join",
lua.create_async_function(|lua, funcs: Variadic<Function>| async move {
// Wrap each function in its own coroutine and turn it into a future.
let mut threads = Vec::with_capacity(funcs.len());
for f in funcs.iter() {
let thread = lua.create_thread(f.clone())?;
threads.push(thread.into_async::<LuaValue>(()));
}
let threads: Vec<_> = threads.into_iter().collect::<LuaResult<_>>()?;
// Poll them all concurrently. The await point is where the tokio
// reactor gets to interleave the sleeping coroutines.
let results = join_all(threads).await;
// Collect first-return-values positionally; propagate the first error.
let mut out = Vec::with_capacity(results.len());
for r in results {
out.push(r?);
}
Ok(MultiValue::from_vec(out))
})?,
)?;
lua.globals().raw_set("task", task)?;
Ok(())
}

@ -21,7 +21,7 @@ pub(crate) fn is_null(val: &LuaValue) -> bool {
// Lua → JSON
// ---------------------------------------------------------------------------
fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult<serde_json::Value> {
pub(crate) fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult<serde_json::Value> {
const MAX_DEPTH: u32 = 64;
if depth > MAX_DEPTH {
return Err(mlua::Error::external("utils.toJSON: nesting too deep (max 64 levels)"));
@ -47,7 +47,7 @@ fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult<serde_json::Value> {
}
}
fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json::Value> {
pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult<serde_json::Value> {
let seq_len = t.raw_len() as usize;
// Try array serialization: every key must be a sequential integer in 1..=seq_len.

Loading…
Cancel
Save