diff --git a/.gitignore b/.gitignore index 86a6564..a898763 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ target/ .vscode/ web/build/ web/.svelte-kit/ + + +# Added by cargo + +/target diff --git a/.hl.config.toml b/.hl.config.toml new file mode 100644 index 0000000..df987b0 --- /dev/null +++ b/.hl.config.toml @@ -0,0 +1,98 @@ +#:schema https://raw.githubusercontent.com/pamburus/hl/v0.34.0/schema/json/config.schema.json + +# Configuration for Railway-compatible JSON logs +# Usage: hl --config .hl.config.toml + +# Time format for display (Railway ignores timestamp but we include it) +time-format = "%b %d %T.%3N" +time-zone = "UTC" + +# Input file display settings +input-info = "auto" + +# ASCII mode detection +ascii = "auto" + +# Theme +theme = "uni" +theme-overlays = ["@accent-italic"] + +[fields] +# Don't ignore any fields by default +ignore = [] +hide = [] + +# Field configuration for Railway format +[fields.predefined] + +# Timestamp field (Railway ignores this but we want consistency) +[fields.predefined.time] +show = "auto" +names = [ + "timestamp", + "ts", + "time", + "@timestamp" +] + +# Logger field (optional, matches Rust 'target' field) +[fields.predefined.logger] +names = ["logger", "target", "span.name"] + +# Level field (Railway uses this) +[fields.predefined.level] +show = "auto" + +[[fields.predefined.level.variants]] +names = ["level"] + +[fields.predefined.level.variants.values] +error = ["error", "err", "fatal", "critical", "panic"] +warning = ["warning", "warn"] +info = ["info", "information"] +debug = ["debug"] +trace = ["trace"] + +# Message field (Railway uses this) +[fields.predefined.message] +names = ["message", "msg"] + +# Caller field +[fields.predefined.caller] +names = ["caller"] + +[fields.predefined.caller-file] +names = [] + +[fields.predefined.caller-line] +names = [] + +# Formatting settings +[formatting] +# Flatten nested fields +flatten = "always" + +# Message format - delimited for better readability +[formatting.message] +format = "delimited" + +# Punctuation +[formatting.punctuation] +logger-name-separator = ":" +field-key-value-separator = "=" +string-opening-quote = "'" +string-closing-quote = "'" +caller-name-file-separator = " @ " +hidden-fields-indicator = " ..." +level-left-separator = "[" +level-right-separator = "]" +input-number-prefix = "#" +input-number-left-separator = "" +input-name-left-separator = "" +array-separator = " " +source-location-separator = { ascii = "-> ", unicode = "→ " } +input-number-right-separator = { ascii = " | ", unicode = " │ " } +input-name-right-separator = { ascii = " | ", unicode = " │ " } +input-name-clipping = { ascii = "..", unicode = "··" } +input-name-common-part = { ascii = "..", unicode = "··" } +message-delimiter = { ascii = "::", unicode = "›" } diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..07ed7fc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1561 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "api" +version = "0.1.0" +dependencies = [ + "axum", + "clap", + "nu-ansi-term", + "reqwest", + "serde", + "serde_json", + "time", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "ulid", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.179" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678faa00651c9eb72dd2020cbdf275d92eccb2400d568e419efdd64838145cb4" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand", + "serde", + "web-time", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +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.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[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", + "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]] +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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..235dd2c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "api" +version = "0.1.0" +edition = "2024" + +[dependencies] +axum = "0.8.8" +clap = { version = "4.5.54", features = ["derive", "env"] } +nu-ansi-term = "0.50.3" +reqwest = { version = "0.13", features = ["charset", "json"], default-features = false } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.148" +time = { version = "0.3.44", features = ["formatting", "macros"] } +tokio = { version = "1.49.0", features = ["full"] } +tokio-util = { version = "0.7.18", features = ["io"] } +tower = "0.5" +tower-http = { version = "0.6.8", features = ["trace", "cors"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] } +ulid = { version = "1", features = ["serde"] } diff --git a/Justfile b/Justfile index 153c1ac..2928653 100644 --- a/Justfile +++ b/Justfile @@ -1,13 +1,23 @@ +default: + just --list + dev: - bun run --cwd web dev + just dev-json | hl --config .hl.config.toml -P + +dev-json: + LOG_JSON=true UPSTREAM_URL=/tmp/xevion-api.sock bunx concurrently --raw --prefix none "bun run --silent --cwd web dev --port 5173" "cargo watch --quiet --exec 'run --quiet -- --listen localhost:8080 --listen /tmp/xevion-api.sock --downstream http://localhost:5173'" setup: bun install --cwd web + cargo build build: bun run --cwd web build + cargo build --release check: bun run --cwd web format bun run --cwd web lint bun run --cwd web check + cargo clippy --all-targets + cargo fmt --check diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..54347ea --- /dev/null +++ b/src/config.rs @@ -0,0 +1,142 @@ +use clap::Parser; +use std::net::{SocketAddr, ToSocketAddrs}; +use std::path::PathBuf; +use std::str::FromStr; + +/// Server configuration parsed from CLI arguments and environment variables +#[derive(Parser, Debug)] +#[command(name = "api")] +#[command(about = "xevion.dev API server with ISR caching", long_about = None)] +pub struct Args { + /// Address(es) to listen on. Can be host:port, :port, or Unix socket path. + /// Can be specified multiple times. + /// Examples: :8080, 0.0.0.0:8080, [::]:8080, /tmp/api.sock + #[arg(long, env = "LISTEN_ADDR", value_delimiter = ',', required = true)] + pub listen: Vec, + + /// Downstream Bun SSR server URL or Unix socket path + /// Examples: http://localhost:5173, /tmp/bun.sock + #[arg(long, env = "DOWNSTREAM_URL", required = true)] + pub downstream: String, + + /// Optional header name to trust for request IDs (e.g., X-Railway-Request-Id) + #[arg(long, env = "TRUST_REQUEST_ID")] + pub trust_request_id: Option, +} + +/// Address to listen on - either TCP or Unix socket +#[derive(Debug, Clone)] +pub enum ListenAddr { + Tcp(SocketAddr), + Unix(PathBuf), +} + +impl FromStr for ListenAddr { + type Err = String; + + fn from_str(s: &str) -> Result { + // Unix socket: starts with / or ./ + if s.starts_with('/') || s.starts_with("./") { + return Ok(ListenAddr::Unix(PathBuf::from(s))); + } + + // Shorthand :port -> 127.0.0.1:port + if let Some(port_str) = s.strip_prefix(':') { + let port: u16 = port_str + .parse() + .map_err(|_| format!("Invalid port number: {}", port_str))?; + return Ok(ListenAddr::Tcp(SocketAddr::from(([127, 0, 0, 1], port)))); + } + + // Try parsing as a socket address (handles both IPv4 and IPv6) + // This supports formats like: 0.0.0.0:8080, [::]:8080, 192.168.1.1:3000 + match s.parse::() { + Ok(addr) => Ok(ListenAddr::Tcp(addr)), + Err(_) => { + // Try resolving as hostname:port + match s.to_socket_addrs() { + Ok(mut addrs) => addrs + .next() + .ok_or_else(|| format!("Could not resolve address: {}", s)) + .map(ListenAddr::Tcp), + Err(_) => Err(format!( + "Invalid address '{}'. Expected host:port, :port, or Unix socket path", + s + )), + } + } + } + } +} + +impl std::fmt::Display for ListenAddr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ListenAddr::Tcp(addr) => write!(f, "{}", addr), + ListenAddr::Unix(path) => write!(f, "{}", path.display()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_shorthand_port() { + let addr: ListenAddr = ":8080".parse().unwrap(); + match addr { + ListenAddr::Tcp(socket) => { + assert_eq!(socket.port(), 8080); + assert_eq!(socket.ip().to_string(), "127.0.0.1"); + } + _ => panic!("Expected TCP address"), + } + } + + #[test] + fn test_parse_ipv4() { + let addr: ListenAddr = "0.0.0.0:8080".parse().unwrap(); + match addr { + ListenAddr::Tcp(socket) => { + assert_eq!(socket.port(), 8080); + assert_eq!(socket.ip().to_string(), "0.0.0.0"); + } + _ => panic!("Expected TCP address"), + } + } + + #[test] + fn test_parse_ipv6() { + let addr: ListenAddr = "[::]:8080".parse().unwrap(); + match addr { + ListenAddr::Tcp(socket) => { + assert_eq!(socket.port(), 8080); + assert_eq!(socket.ip().to_string(), "::"); + } + _ => panic!("Expected TCP address"), + } + } + + #[test] + fn test_parse_unix_socket() { + let addr: ListenAddr = "/tmp/api.sock".parse().unwrap(); + match addr { + ListenAddr::Unix(path) => { + assert_eq!(path, PathBuf::from("/tmp/api.sock")); + } + _ => panic!("Expected Unix socket"), + } + } + + #[test] + fn test_parse_relative_unix_socket() { + let addr: ListenAddr = "./api.sock".parse().unwrap(); + match addr { + ListenAddr::Unix(path) => { + assert_eq!(path, PathBuf::from("./api.sock")); + } + _ => panic!("Expected Unix socket"), + } + } +} diff --git a/src/formatter.rs b/src/formatter.rs new file mode 100644 index 0000000..667e4c4 --- /dev/null +++ b/src/formatter.rs @@ -0,0 +1,280 @@ +//! Custom tracing formatter for Railway-compatible structured logging + +use nu_ansi_term::Color; +use serde::Serialize; +use serde_json::{Map, Value}; +use std::fmt; +use time::macros::format_description; +use time::{format_description::FormatItem, OffsetDateTime}; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::fmt::format::Writer; +use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields, FormattedFields}; +use tracing_subscriber::registry::LookupSpan; + +/// Cached format description for timestamps with 3 subsecond digits (milliseconds) +const TIMESTAMP_FORMAT: &[FormatItem<'static>] = + format_description!("[hour]:[minute]:[second].[subsecond digits:3]"); + +/// A custom formatter with enhanced timestamp formatting and colored output +/// +/// Provides human-readable output for local development with: +/// - Colored log levels +/// - Timestamp with millisecond precision +/// - Span context with hierarchy +/// - Clean field formatting +pub struct CustomPrettyFormatter; + +impl FormatEvent for CustomPrettyFormatter +where + S: Subscriber + for<'a> LookupSpan<'a>, + N: for<'a> FormatFields<'a> + 'static, +{ + fn format_event( + &self, + ctx: &FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &Event<'_>, + ) -> fmt::Result { + let meta = event.metadata(); + + // 1) Timestamp (dimmed when ANSI) + let now = OffsetDateTime::now_utc(); + let formatted_time = now.format(&TIMESTAMP_FORMAT).map_err(|e| { + eprintln!("Failed to format timestamp: {}", e); + fmt::Error + })?; + write_dimmed(&mut writer, formatted_time)?; + writer.write_char(' ')?; + + // 2) Colored 5-char level + write_colored_level(&mut writer, meta.level())?; + writer.write_char(' ')?; + + // 3) Span scope chain (bold names, fields in braces, dimmed ':') + if let Some(scope) = ctx.event_scope() { + let mut saw_any = false; + for span in scope.from_root() { + write_bold(&mut writer, span.metadata().name())?; + saw_any = true; + + write_dimmed(&mut writer, ":")?; + + let ext = span.extensions(); + if let Some(fields) = &ext.get::>() { + if !fields.fields.is_empty() { + write_bold(&mut writer, "{")?; + writer.write_str(fields.fields.as_str())?; + write_bold(&mut writer, "}")?; + } + } + write_dimmed(&mut writer, ":")?; + } + + if saw_any { + writer.write_char(' ')?; + } + } + + // 4) Target (dimmed), then a space + if writer.has_ansi_escapes() { + write!(writer, "{}: ", Color::DarkGray.paint(meta.target()))?; + } else { + write!(writer, "{}: ", meta.target())?; + } + + // 5) Event fields + ctx.format_fields(writer.by_ref(), event)?; + + // 6) Newline + writeln!(writer) + } +} + +/// A custom JSON formatter that flattens fields to root level for Railway +/// +/// Outputs logs in Railway-compatible format: +/// ```json +/// { +/// "message": "...", +/// "level": "...", +/// "target": "...", +/// "customAttribute": "..." +/// } +/// ``` +/// +/// This format allows Railway to: +/// - Parse the `message` field correctly +/// - Filter by `level` and custom attributes using `@attribute:value` +/// - Preserve multi-line logs like stack traces +pub struct CustomJsonFormatter; + +impl FormatEvent for CustomJsonFormatter +where + S: Subscriber + for<'a> LookupSpan<'a>, + N: for<'a> FormatFields<'a> + 'static, +{ + fn format_event( + &self, + ctx: &FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &Event<'_>, + ) -> fmt::Result { + let meta = event.metadata(); + + #[derive(Serialize)] + struct EventFields { + timestamp: String, + message: String, + level: String, + target: String, + #[serde(flatten)] + fields: Map, + } + + let (message, fields) = { + let mut message: Option = None; + let mut fields: Map = Map::new(); + + struct FieldVisitor<'a> { + message: &'a mut Option, + fields: &'a mut Map, + } + + impl<'a> Visit for FieldVisitor<'a> { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + let key = field.name(); + if key == "message" { + *self.message = Some(format!("{:?}", value)); + } else { + self.fields + .insert(key.to_string(), Value::String(format!("{:?}", value))); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + let key = field.name(); + if key == "message" { + *self.message = Some(value.to_string()); + } else { + self.fields + .insert(key.to_string(), Value::String(value.to_string())); + } + } + + fn record_i64(&mut self, field: &Field, value: i64) { + let key = field.name(); + if key != "message" { + self.fields.insert( + key.to_string(), + Value::Number(serde_json::Number::from(value)), + ); + } + } + + fn record_u64(&mut self, field: &Field, value: u64) { + let key = field.name(); + if key != "message" { + self.fields.insert( + key.to_string(), + Value::Number(serde_json::Number::from(value)), + ); + } + } + + fn record_bool(&mut self, field: &Field, value: bool) { + let key = field.name(); + if key != "message" { + self.fields.insert(key.to_string(), Value::Bool(value)); + } + } + } + + let mut visitor = FieldVisitor { + message: &mut message, + fields: &mut fields, + }; + event.record(&mut visitor); + + // Collect span information from the span hierarchy + // Flatten all span fields directly into root level + if let Some(scope) = ctx.event_scope() { + for span in scope.from_root() { + // Extract span fields by parsing the stored extension data + // The fields are stored as a formatted string, so we need to parse them + let ext = span.extensions(); + if let Some(formatted_fields) = ext.get::>() { + let field_str = formatted_fields.fields.as_str(); + + // Parse key=value pairs from the formatted string + // Format is typically: key=value key2=value2 + for pair in field_str.split_whitespace() { + if let Some((key, value)) = pair.split_once('=') { + // Remove quotes if present + let value = value.trim_matches('"').trim_matches('\''); + fields.insert(key.to_string(), Value::String(value.to_string())); + } + } + } + } + } + + (message, fields) + }; + + let json = EventFields { + timestamp: OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| String::from("1970-01-01T00:00:00Z")), + message: message.unwrap_or_default(), + level: meta.level().to_string().to_lowercase(), + target: meta.target().to_string(), + fields, + }; + + writeln!( + writer, + "{}", + serde_json::to_string(&json).unwrap_or_else(|_| "{}".to_string()) + ) + } +} + +/// Write the verbosity level with colored output +fn write_colored_level(writer: &mut Writer<'_>, level: &Level) -> fmt::Result { + if writer.has_ansi_escapes() { + let colored = match *level { + Level::TRACE => Color::Purple.paint("TRACE"), + Level::DEBUG => Color::Blue.paint("DEBUG"), + Level::INFO => Color::Green.paint(" INFO"), + Level::WARN => Color::Yellow.paint(" WARN"), + Level::ERROR => Color::Red.paint("ERROR"), + }; + write!(writer, "{}", colored) + } else { + // Right-pad to width 5 for alignment + match *level { + Level::TRACE => write!(writer, "{:>5}", "TRACE"), + Level::DEBUG => write!(writer, "{:>5}", "DEBUG"), + Level::INFO => write!(writer, "{:>5}", " INFO"), + Level::WARN => write!(writer, "{:>5}", " WARN"), + Level::ERROR => write!(writer, "{:>5}", "ERROR"), + } + } +} + +fn write_dimmed(writer: &mut Writer<'_>, s: impl fmt::Display) -> fmt::Result { + if writer.has_ansi_escapes() { + write!(writer, "{}", Color::DarkGray.paint(s.to_string())) + } else { + write!(writer, "{}", s) + } +} + +fn write_bold(writer: &mut Writer<'_>, s: impl fmt::Display) -> fmt::Result { + if writer.has_ansi_escapes() { + write!(writer, "{}", Color::White.bold().paint(s.to_string())) + } else { + write!(writer, "{}", s) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f62e1c3 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,418 @@ +use axum::{ + Json, Router, + extract::{Request, State}, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, +}; +use clap::Parser; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::Arc; +use tower_http::{cors::CorsLayer, trace::TraceLayer}; +use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; + +mod config; +mod formatter; +mod middleware; +use config::{Args, ListenAddr}; +use formatter::{CustomJsonFormatter, CustomPrettyFormatter}; +use middleware::RequestIdLayer; + +fn init_tracing() { + let use_json = std::env::var("LOG_JSON") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + // Build the EnvFilter + // Priority: RUST_LOG > LOG_LEVEL > default + let filter = if let Ok(rust_log) = std::env::var("RUST_LOG") { + // RUST_LOG overwrites everything + EnvFilter::new(rust_log) + } else { + // Get LOG_LEVEL for our crate, default based on build profile + let our_level = std::env::var("LOG_LEVEL").unwrap_or_else(|_| { + if cfg!(debug_assertions) { + "debug".to_string() + } else { + "info".to_string() + } + }); + + // Default other crates to WARN, our crate to LOG_LEVEL + EnvFilter::new(format!("warn,api={}", our_level)) + }; + + if use_json { + tracing_subscriber::registry() + .with(filter) + .with( + tracing_subscriber::fmt::layer() + .event_format(CustomJsonFormatter) + .fmt_fields(tracing_subscriber::fmt::format::DefaultFields::new()) + .with_ansi(false), // Disable ANSI codes in JSON mode + ) + .init(); + } else { + tracing_subscriber::registry() + .with(filter) + .with(tracing_subscriber::fmt::layer().event_format(CustomPrettyFormatter)) + .init(); + } +} + +#[tokio::main] +async fn main() { + // Initialize tracing with configurable format and levels + init_tracing(); + + // Parse CLI arguments and environment variables + let args = Args::parse(); + + // Validate we have at least one listen address + if args.listen.is_empty() { + eprintln!("Error: At least one --listen address is required"); + std::process::exit(1); + } + + // Create shared application state + let state = Arc::new(AppState { + downstream_url: args.downstream.clone(), + }); + + // Build router with shared state + let app = Router::new() + .nest("/api", api_routes().fallback(api_404_handler)) + .fallback(isr_handler) + .layer(TraceLayer::new_for_http()) + .layer(RequestIdLayer::new(args.trust_request_id.clone())) + .layer(CorsLayer::permissive()) + .with_state(state); + + // Spawn a listener for each address + let mut tasks = Vec::new(); + + for listen_addr in &args.listen { + let app = app.clone(); + let listen_addr = listen_addr.clone(); + + let task = tokio::spawn(async move { + match listen_addr { + ListenAddr::Tcp(addr) => { + let listener = tokio::net::TcpListener::bind(addr) + .await + .expect("Failed to bind TCP listener"); + + // Format as clickable URL + let url = if addr.is_ipv6() { + format!("http://[{}]:{}", addr.ip(), addr.port()) + } else { + format!("http://{}:{}", addr.ip(), addr.port()) + }; + + tracing::info!(url, "Listening on TCP"); + axum::serve(listener, app) + .await + .expect("Server error on TCP listener"); + } + ListenAddr::Unix(path) => { + // Remove existing socket file if it exists + let _ = std::fs::remove_file(&path); + + let listener = tokio::net::UnixListener::bind(&path) + .expect("Failed to bind Unix socket listener"); + + tracing::info!(socket = %path.display(), "Listening on Unix socket"); + axum::serve(listener, app) + .await + .expect("Server error on Unix socket listener"); + } + } + }); + + tasks.push(task); + } + + // Wait for all listeners (this will run forever unless interrupted) + for task in tasks { + task.await.expect("Listener task panicked"); + } +} + +/// Shared application state +#[derive(Clone)] +struct AppState { + downstream_url: String, +} + +/// Custom error type for proxy operations +#[derive(Debug)] +enum ProxyError { + /// Network error (connection failed, timeout, etc.) + Network(reqwest::Error), +} + +impl std::fmt::Display for ProxyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProxyError::Network(e) => write!(f, "Network error: {}", e), + } + } +} + +impl std::error::Error for ProxyError {} + +/// Check if a path represents a static asset that should be logged at TRACE level +fn is_static_asset(path: &str) -> bool { + path.starts_with("/node_modules/") + || path.starts_with("/@") // Vite internals like /@vite/client, /@fs/, /@id/ + || path.starts_with("/.svelte-kit/") + || path.starts_with("/.well-known/") + || path.ends_with(".woff2") + || path.ends_with(".woff") + || path.ends_with(".ttf") + || path.ends_with(".ico") + || path.ends_with(".png") + || path.ends_with(".jpg") + || path.ends_with(".svg") + || path.ends_with(".webp") + || path.ends_with(".css") + || path.ends_with(".js") + || path.ends_with(".map") +} + +/// Check if a path represents a page route (heuristic: no file extension) +fn is_page_route(path: &str) -> bool { + !path.starts_with("/node_modules/") + && !path.starts_with("/@") + && !path.starts_with("/.svelte-kit/") + && !path.contains('.') // Simple heuristic: no extension = likely a page +} + +// API routes for data endpoints +fn api_routes() -> Router> { + Router::new() + .route("/health", get(health_handler)) + .route("/projects", get(projects_handler)) +} + +// Health check endpoint +async fn health_handler() -> impl IntoResponse { + (StatusCode::OK, "OK") +} + +// API 404 fallback handler - catches unmatched /api/* routes +async fn api_404_handler(uri: axum::http::Uri) -> impl IntoResponse { + tracing::warn!(path = %uri.path(), "API route not found"); + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": "Not found", + "path": uri.path() + })), + ) +} + +// Project data structure +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProjectLink { + url: String, + #[serde(skip_serializing_if = "Option::is_none")] + title: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Project { + id: String, + name: String, + #[serde(rename = "shortDescription")] + short_description: String, + #[serde(skip_serializing_if = "Option::is_none")] + icon: Option, + links: Vec, +} + +// Projects endpoint - returns hardcoded project data for now +async fn projects_handler() -> impl IntoResponse { + let projects = vec![ + Project { + id: "1".to_string(), + name: "xevion.dev".to_string(), + short_description: "Personal portfolio with fuzzy tag discovery".to_string(), + icon: None, + links: vec![ProjectLink { + url: "https://github.com/Xevion/xevion.dev".to_string(), + title: Some("GitHub".to_string()), + }], + }, + Project { + id: "2".to_string(), + name: "Contest".to_string(), + short_description: "Competitive programming problem archive".to_string(), + icon: None, + links: vec![ + ProjectLink { + url: "https://github.com/Xevion/contest".to_string(), + title: Some("GitHub".to_string()), + }, + ProjectLink { + url: "https://contest.xevion.dev".to_string(), + title: Some("Demo".to_string()), + }, + ], + }, + ]; + + Json(projects) +} + +// ISR handler - proxies to Bun SSR server +// This is the fallback for all routes not matched by /api/* +#[tracing::instrument(skip(state, req), fields(path = %req.uri().path()))] +async fn isr_handler(State(state): State>, req: Request) -> Response { + let uri = req.uri(); + let path = uri.path(); + let query = uri.query().unwrap_or(""); + + // Check if API route somehow reached ISR handler (shouldn't happen) + if path.starts_with("/api/") { + tracing::error!("API request reached ISR handler - routing bug!"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal routing error", + ) + .into_response(); + } + + // Build URL for Bun server + let bun_url = if query.is_empty() { + format!("{}{}", state.downstream_url, path) + } else { + format!("{}{}?{}", state.downstream_url, path, query) + }; + + // Track request timing + let start = std::time::Instant::now(); + + // TODO: Add ISR caching layer here (moka, singleflight, stale-while-revalidate) + // For now, just proxy directly to Bun + + match proxy_to_bun(&bun_url, &state.downstream_url).await { + Ok((status, headers, body)) => { + let duration_ms = start.elapsed().as_millis() as u64; + let cache = "miss"; // Hardcoded for now, will change when caching is implemented + + // Intelligent logging based on path type and status + let is_static = is_static_asset(path); + let is_page = is_page_route(path); + + match (status.as_u16(), is_static, is_page) { + // Static assets - success at TRACE + (200..=299, true, _) => { + tracing::trace!(status = status.as_u16(), duration_ms, cache, "ISR request"); + } + // Static assets - 404 at WARN + (404, true, _) => { + tracing::warn!( + status = status.as_u16(), + duration_ms, + cache, + "ISR request - missing asset" + ); + } + // Static assets - server error at ERROR + (500..=599, true, _) => { + tracing::error!( + status = status.as_u16(), + duration_ms, + cache, + "ISR request - server error" + ); + } + // Page routes - success at DEBUG + (200..=299, _, true) => { + tracing::debug!(status = status.as_u16(), duration_ms, cache, "ISR request"); + } + // Page routes - 404 silent (normal case for non-existent pages) + (404, _, true) => {} + // Page routes - server error at ERROR + (500..=599, _, _) => { + tracing::error!( + status = status.as_u16(), + duration_ms, + cache, + "ISR request - server error" + ); + } + // Default fallback - DEBUG + _ => { + tracing::debug!(status = status.as_u16(), duration_ms, cache, "ISR request"); + } + } + + // Forward response + (status, headers, body).into_response() + } + Err(err) => { + let duration_ms = start.elapsed().as_millis() as u64; + tracing::error!( + error = %err, + url = %bun_url, + duration_ms, + "Failed to proxy to Bun" + ); + ( + StatusCode::BAD_GATEWAY, + format!("Failed to render page: {}", err), + ) + .into_response() + } + } +} + +// Proxy a request to the Bun SSR server, returning status, headers and body +async fn proxy_to_bun( + url: &str, + downstream_url: &str, +) -> Result<(StatusCode, HeaderMap, String), ProxyError> { + // Check if downstream is a Unix socket path + let client = if downstream_url.starts_with('/') || downstream_url.starts_with("./") { + // Unix socket + let path = PathBuf::from(downstream_url); + reqwest::Client::builder() + .unix_socket(path) + .build() + .map_err(ProxyError::Network)? + } else { + // Regular HTTP + reqwest::Client::new() + }; + + let response = client.get(url).send().await.map_err(ProxyError::Network)?; + + // Extract status code + let status = StatusCode::from_u16(response.status().as_u16()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + + // Convert reqwest headers to axum HeaderMap + let mut headers = HeaderMap::new(); + for (name, value) in response.headers() { + // Skip hop-by-hop headers and content-length (axum will recalculate it) + let name_str = name.as_str(); + if name_str == "transfer-encoding" + || name_str == "connection" + || name_str == "content-length" + { + continue; + } + + if let Ok(header_name) = axum::http::HeaderName::try_from(name.as_str()) { + if let Ok(header_value) = axum::http::HeaderValue::try_from(value.as_bytes()) { + headers.insert(header_name, header_value); + } + } + } + + let body = response.text().await.map_err(ProxyError::Network)?; + Ok((status, headers, body)) +} diff --git a/src/middleware.rs b/src/middleware.rs new file mode 100644 index 0000000..b8e0452 --- /dev/null +++ b/src/middleware.rs @@ -0,0 +1,85 @@ +//! Request ID middleware for distributed tracing and correlation + +use axum::{ + body::Body, + extract::Request, + http::HeaderName, + response::Response, +}; +use std::task::{Context, Poll}; +use tower::{Layer, Service}; + +/// Layer that creates request ID spans for all requests +#[derive(Clone)] +pub struct RequestIdLayer { + /// Optional header name to trust for request IDs + trust_header: Option, +} + +impl RequestIdLayer { + /// Create a new request ID layer + pub fn new(trust_header: Option) -> Self { + Self { + trust_header: trust_header.and_then(|h| h.parse().ok()), + } + } +} + +impl Layer for RequestIdLayer { + type Service = RequestIdService; + + fn layer(&self, inner: S) -> Self::Service { + RequestIdService { + inner, + trust_header: self.trust_header.clone(), + } + } +} + +/// Service that extracts or generates request IDs and creates tracing spans +#[derive(Clone)] +pub struct RequestIdService { + inner: S, + trust_header: Option, +} + +impl Service for RequestIdService +where + S: Service> + Send + 'static, + S::Future: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = std::pin::Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + // Extract or generate request ID + let req_id = self + .trust_header + .as_ref() + .and_then(|header| req.headers().get(header)) + .and_then(|value| value.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| ulid::Ulid::new().to_string()); + + // Create a tracing span for this request + let span = tracing::info_span!("request", req_id = %req_id); + let _enter = span.enter(); + + // Clone span for the future + let span_clone = span.clone(); + + // Call the inner service + let future = self.inner.call(req); + + Box::pin(async move { + // Execute the future within the span + let _enter = span_clone.enter(); + future.await + }) + } +} diff --git a/web/bun.lock b/web/bun.lock index 7903982..017fbf6 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -7,6 +7,7 @@ "@fontsource-variable/inter": "^5.1.0", "@fontsource/hanken-grotesk": "^5.1.0", "@fontsource/schibsted-grotesk": "^5.2.8", + "@logtape/logtape": "^1.3.5", "bits-ui": "^2.8.2", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1", @@ -17,6 +18,8 @@ "@sveltejs/kit": "^2.21.0", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/vite": "^4.1.11", + "@types/node": "^25.0.3", + "concurrently": "^9.2.1", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.13.1", @@ -150,6 +153,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@logtape/logtape": ["@logtape/logtape@1.3.5", "", {}, "sha512-G+MxWB7Tbv/2764519+Cp6rKXUdRbe/GiRwTvlm/Wv/sNsiquRnx9Hzr9eXaIpAYLT4PrBlkthjJ4gmqdSPrFg=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@oxc-project/runtime": ["@oxc-project/runtime@0.71.0", "", {}, "sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw=="], @@ -278,6 +283,8 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.51.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/type-utils": "8.51.0", "@typescript-eslint/utils": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.2.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.51.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.51.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", "@typescript-eslint/typescript-estree": "8.51.0", "@typescript-eslint/visitor-keys": "8.51.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A=="], @@ -304,6 +311,8 @@ "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], @@ -326,6 +335,8 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -334,6 +345,8 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], + "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], "cookie": ["cookie@0.6.0", "", {}, "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw=="], @@ -354,10 +367,14 @@ "devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], @@ -404,6 +421,8 @@ "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@17.0.0", "", {}, "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw=="], @@ -422,6 +441,8 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="], @@ -542,6 +563,8 @@ "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "rolldown": ["rolldown@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@oxc-project/runtime": "0.71.0", "@oxc-project/types": "0.71.0", "@rolldown/pluginutils": "1.0.0-beta.9-commit.d91dfb5", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-darwin-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-freebsd-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.9-commit.d91dfb5" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g=="], @@ -550,6 +573,8 @@ "runed": ["runed@0.35.1", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], @@ -560,15 +585,21 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "svelte": ["svelte@5.46.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA=="], @@ -594,6 +625,8 @@ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -606,6 +639,8 @@ "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], "unplugin-icons": ["unplugin-icons@22.5.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/utils": "^3.0.2", "debug": "^4.4.3", "local-pkg": "^1.1.2", "unplugin": "^2.3.10" }, "peerDependencies": { "@svgr/core": ">=7.0.0", "@svgx/core": "^1.0.1", "@vue/compiler-sfc": "^3.0.2 || ^2.7.0", "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0", "vue-template-compiler": "^2.6.12", "vue-template-es2015-compiler": "^1.9.0" }, "optionalPeers": ["@svgr/core", "@svgx/core", "@vue/compiler-sfc", "svelte", "vue-template-compiler", "vue-template-es2015-compiler"] }, "sha512-MBlMtT5RuMYZy4TZgqUL2OTtOdTUVsS1Mhj6G1pEzMlFJlEnq6mhUfoIt45gBWxHcsOdXJDWLg3pRZ+YmvAVWQ=="], @@ -624,8 +659,16 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="], @@ -650,6 +693,8 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "eslint-plugin-svelte/globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], diff --git a/web/package.json b/web/package.json index 6f7f38d..e3e0c6b 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "@fontsource-variable/inter": "^5.1.0", "@fontsource/hanken-grotesk": "^5.1.0", "@fontsource/schibsted-grotesk": "^5.2.8", + "@logtape/logtape": "^1.3.5", "bits-ui": "^2.8.2", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" @@ -26,6 +27,8 @@ "@sveltejs/kit": "^2.21.0", "@sveltejs/vite-plugin-svelte": "^6.2.1", "@tailwindcss/vite": "^4.1.11", + "@types/node": "^25.0.3", + "concurrently": "^9.2.1", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.13.1", diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts new file mode 100644 index 0000000..634bd76 --- /dev/null +++ b/web/src/hooks.server.ts @@ -0,0 +1,40 @@ +import type { Handle, HandleServerError } from "@sveltejs/kit"; +import { dev } from "$app/environment"; +import { initLogger } from "$lib/logger"; +import { getLogger } from "@logtape/logtape"; + +// Initialize logger on server startup +await initLogger(); + +const logger = getLogger(["ssr", "error"]); + +export const handle: Handle = async ({ event, resolve }) => { + // Handle DevTools request silently to prevent console.log spam + if ( + dev && + event.url.pathname === "/.well-known/appspecific/com.chrome.devtools.json" + ) { + return new Response(undefined, { status: 404 }); + } + + return await resolve(event); +}; + +export const handleError: HandleServerError = async ({ + error, + event, + status, + message, +}) => { + // Use structured logging via LogTape instead of console.error + logger.error(message, { + status, + method: event.request.method, + path: event.url.pathname, + error: error instanceof Error ? error.message : String(error), + }); + + return { + message: status === 404 ? "Not Found" : "Internal Error", + }; +}; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..e752587 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,75 @@ +import { getLogger } from "@logtape/logtape"; +import { env } from "$env/dynamic/private"; + +const logger = getLogger(["ssr", "lib", "api"]); + +// Compute upstream configuration once at module load +const upstreamUrl = env.UPSTREAM_URL; +const isUnixSocket = + upstreamUrl?.startsWith("/") || upstreamUrl?.startsWith("./"); +const baseUrl = isUnixSocket ? "http://localhost" : upstreamUrl; + +/** + * Fetch utility for calling the Rust backend API. + * Automatically prefixes requests with the upstream URL from environment. + * Supports both HTTP URLs and Unix socket paths. + * + * Connection pooling and keep-alive are handled automatically by Bun. + * Default timeout is 30 seconds unless overridden via init.signal. + */ +export async function apiFetch( + path: string, + init?: RequestInit, +): Promise { + if (!upstreamUrl) { + logger.error("UPSTREAM_URL environment variable not set"); + throw new Error("UPSTREAM_URL environment variable not set"); + } + + const url = `${baseUrl}${path}`; + const method = init?.method ?? "GET"; + + // Build fetch options with 30s default timeout and unix socket support + const fetchOptions: RequestInit & { unix?: string } = { + ...init, + // Respect caller-provided signal, otherwise default to 30s timeout + signal: init?.signal ?? AbortSignal.timeout(30_000), + }; + + if (isUnixSocket) { + fetchOptions.unix = upstreamUrl; + } + + logger.debug("API request", { + method, + url, + path, + isUnixSocket, + upstreamUrl, + }); + + try { + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + logger.error("API request failed", { + method, + url, + status: response.status, + statusText: response.statusText, + }); + throw new Error(`API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + logger.debug("API response", { method, url, status: response.status }); + return data; + } catch (error) { + logger.error("API request exception", { + method, + url, + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +} diff --git a/web/src/lib/logger.ts b/web/src/lib/logger.ts new file mode 100644 index 0000000..7e74c6f --- /dev/null +++ b/web/src/lib/logger.ts @@ -0,0 +1,102 @@ +import { configure, getConsoleSink, type LogRecord } from "@logtape/logtape"; + +interface RailwayLogEntry { + timestamp: string; + level: string; + message: string; + target: string; + [key: string]: unknown; +} + +/** + * Custom formatter that outputs Railway-compatible JSON logs. + * Format: { timestamp, level, message, target, ...attributes } + * + * The target field is constructed from the logger category: + * - ["ssr"] -> "ssr" + * - ["ssr", "routes"] -> "ssr:routes" + * - ["ssr", "api", "auth"] -> "ssr:api:auth" + */ +function railwayFormatter(record: LogRecord): string { + const entry: RailwayLogEntry = { + timestamp: new Date().toISOString(), + level: record.level.toLowerCase(), + message: record.message.join(" "), + target: record.category.join(":"), + }; + + // Flatten properties to root level (custom attributes) + if (record.properties && Object.keys(record.properties).length > 0) { + Object.assign(entry, record.properties); + } + + return JSON.stringify(entry) + "\n"; +} + +/** + * Initialize LogTape with Railway-compatible JSON logging. + * Only outputs logs when LOG_JSON=true or LOG_JSON=1 is set. + * Safe to call multiple times (idempotent - will silently skip if already configured). + */ +export async function initLogger() { + const useJsonLogs = + process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1"; + + try { + if (!useJsonLogs) { + // In development, use default console logging with nice formatting + await configure({ + sinks: { + console: getConsoleSink(), + }, + filters: {}, + loggers: [ + { + category: ["logtape", "meta"], + lowestLevel: "warning", + sinks: ["console"], + }, + { + category: [], + lowestLevel: "debug", + sinks: ["console"], + }, + ], + }); + return; + } + + // In production/JSON mode, use Railway-compatible JSON formatter + await configure({ + sinks: { + json: (record: LogRecord) => { + process.stdout.write(railwayFormatter(record)); + }, + }, + filters: {}, + loggers: [ + // Meta logger for LogTape's internal messages + { + category: ["logtape", "meta"], + lowestLevel: "warning", + sinks: ["json"], + }, + // SSR application logs + { + category: ["ssr"], + lowestLevel: "info", + sinks: ["json"], + }, + ], + }); + } catch (error) { + // Already configured (HMR in dev mode), silently ignore + if ( + error instanceof Error && + error.message.includes("Already configured") + ) { + return; + } + throw error; + } +} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index a90bf0c..1b03a9c 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -5,17 +5,12 @@ import IconSimpleIconsDiscord from "~icons/simple-icons/discord"; import MaterialSymbolsMailRounded from "~icons/material-symbols/mail-rounded"; import MaterialSymbolsVpnKey from "~icons/material-symbols/vpn-key"; - // import IconLucideRss from "~icons/lucide/rss";
-
- -
+
diff --git a/web/src/routes/projects/+page.server.ts b/web/src/routes/projects/+page.server.ts index f1587e5..e48dbc3 100644 --- a/web/src/routes/projects/+page.server.ts +++ b/web/src/routes/projects/+page.server.ts @@ -1,4 +1,5 @@ import type { PageServerLoad } from "./$types"; +import { apiFetch } from "$lib/api"; interface ProjectLink { url: string; @@ -14,8 +15,8 @@ export interface Project { } export const load: PageServerLoad = async () => { - // TODO: Fetch from Rust backend API + const projects = await apiFetch("/api/projects"); return { - projects: [] as Project[], + projects, }; }; diff --git a/web/svelte.config.js b/web/svelte.config.js index d148d68..9b8a6d5 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -9,6 +9,7 @@ const config = { adapter: adapter({ out: "build", precompress: false, + serveAssets: false, }), alias: { $components: "src/lib/components", diff --git a/web/vite-plugin-json-logger.ts b/web/vite-plugin-json-logger.ts new file mode 100644 index 0000000..8a54489 --- /dev/null +++ b/web/vite-plugin-json-logger.ts @@ -0,0 +1,170 @@ +import type { Plugin, ViteDevServer } from "vite"; +import { configure, getLogger, type LogRecord } from "@logtape/logtape"; + +interface RailwayLogEntry { + timestamp: string; + level: string; + message: string; + target: string; + [key: string]: unknown; +} + +/** + * Railway-compatible JSON formatter for Vite logs + */ +function railwayFormatter(record: LogRecord): string { + const entry: RailwayLogEntry = { + timestamp: new Date().toISOString(), + level: record.level.toLowerCase(), + message: record.message.join(" "), + target: "vite", + }; + + // Flatten properties to root level + if (record.properties && Object.keys(record.properties).length > 0) { + Object.assign(entry, record.properties); + } + + return JSON.stringify(entry) + "\n"; +} + +// Strip ANSI escape codes from strings +function stripAnsi(str: string): string { + return str.replace(/\u001b\[[0-9;]*m/g, "").trim(); +} + +export function jsonLogger(): Plugin { + const useJsonLogs = + process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1"; + + // If JSON logging is disabled, return a minimal plugin that does nothing + if (!useJsonLogs) { + return { + name: "vite-plugin-json-logger", + }; + } + + // Configure LogTape for Vite plugin logging + let loggerConfigured = false; + const configureLogger = async () => { + if (loggerConfigured) return; + await configure({ + sinks: { + json: (record: LogRecord) => { + process.stdout.write(railwayFormatter(record)); + }, + }, + filters: {}, + loggers: [ + // Suppress LogTape meta logger info messages + { + category: ["logtape", "meta"], + lowestLevel: "warning", + sinks: ["json"], + }, + { + category: ["vite"], + lowestLevel: "debug", + sinks: ["json"], + }, + ], + }); + loggerConfigured = true; + }; + + let server: ViteDevServer; + const ignoredMessages = new Set(["press h + enter to show help", "ready in"]); + + return { + name: "vite-plugin-json-logger", + + async config() { + await configureLogger(); + const logger = getLogger(["vite"]); + + return { + customLogger: { + info(msg: string) { + const cleaned = stripAnsi(msg); + // Filter out noise + if ( + !cleaned || + ignoredMessages.has(cleaned) || + cleaned.includes("VITE v") + ) { + return; + } + logger.info(cleaned); + }, + warn(msg: string) { + const cleaned = stripAnsi(msg); + if (cleaned) { + logger.warn(cleaned); + } + }, + error(msg: string) { + const cleaned = stripAnsi(msg); + if (cleaned) { + logger.error(cleaned); + } + }, + clearScreen() { + // No-op since clearScreen is already false + }, + hasErrorLogged() { + return false; + }, + hasWarned: false, + warnOnce(msg: string) { + this.warn(msg); + }, + }, + }; + }, + + configureServer(s) { + server = s; + const logger = getLogger(["vite"]); + + // Override the default URL printing + const originalPrintUrls = server.printUrls; + server.printUrls = () => { + const urls = server.resolvedUrls; + if (urls) { + logger.info("dev server running", { + local: urls.local, + network: urls.network, + }); + } + }; + + // Listen to server events + server.httpServer?.once("listening", () => { + logger.info("server listening"); + }); + + server.ws.on("connection", () => { + logger.info("client connected"); + }); + }, + + handleHotUpdate({ file, modules }) { + const logger = getLogger(["vite"]); + logger.info("hmr update", { + file: file.replace(process.cwd(), ""), + modules: modules.length, + }); + return modules; + }, + + buildStart() { + const logger = getLogger(["vite"]); + logger.info("build started"); + }, + + buildEnd() { + const logger = getLogger(["vite"]); + logger.info("build ended"); + }, + }; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 5c8a230..8622375 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,7 +2,14 @@ import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; import Icons from "unplugin-icons/vite"; +import { jsonLogger } from "./vite-plugin-json-logger"; export default defineConfig({ - plugins: [tailwindcss(), sveltekit(), Icons({ compiler: "svelte" })], + plugins: [ + jsonLogger(), + tailwindcss(), + sveltekit(), + Icons({ compiler: "svelte" }), + ], + clearScreen: false, });