From 0e24b7d0444a09ee16d407909b50e957c7ec5092 Mon Sep 17 00:00:00 2001 From: narawat Date: Fri, 15 May 2026 08:58:30 +0700 Subject: [PATCH] 1st commit --- .gitignore | 4 + AI_prompt.md | 202 +++ Cargo.lock | 1915 ++++++++++++++++++++++ Cargo.toml | 31 + Manifest.toml | 837 ++++++++++ Project.toml | 21 + README.md | 943 +++++++++++ docs/architecture.md | 941 +++++++++++ docs/requirements.md | 438 +++++ docs/specification.md | 1437 ++++++++++++++++ docs/walkthrough.md | 965 +++++++++++ etc.txt | 310 ++++ examples/smartreceive_example.rs | 96 ++ examples/smartsend_example.rs | 70 + plik_fileserver/docker-compose.yml | 14 + src/NATSBridge.jl | 1167 +++++++++++++ src/natsbridge.py | 843 ++++++++++ src/natsbridge.rs | 1230 ++++++++++++++ src/natsbridge_csr.js | 915 +++++++++++ src/natsbridge_mpy.py | 673 ++++++++ src/natsbridge_ssr.js | 942 +++++++++++ test/large_image.png | Bin 0 -> 1232619 bytes test/small_image.jpg | Bin 0 -> 77474 bytes test/test_js_mix_payloads_receiver.js | 275 ++++ test/test_js_mix_payloads_sender.js | 207 +++ test/test_julia_mix_payloads_receiver.jl | 251 +++ test/test_julia_mix_payloads_sender.jl | 258 +++ test/test_py_mix_payloads_sender.py | 199 +++ 28 files changed, 15184 insertions(+) create mode 100644 .gitignore create mode 100644 AI_prompt.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Manifest.toml create mode 100644 Project.toml create mode 100644 README.md create mode 100644 docs/architecture.md create mode 100644 docs/requirements.md create mode 100644 docs/specification.md create mode 100644 docs/walkthrough.md create mode 100644 etc.txt create mode 100644 examples/smartreceive_example.rs create mode 100644 examples/smartsend_example.rs create mode 100644 plik_fileserver/docker-compose.yml create mode 100644 src/NATSBridge.jl create mode 100644 src/natsbridge.py create mode 100644 src/natsbridge.rs create mode 100644 src/natsbridge_csr.js create mode 100644 src/natsbridge_mpy.py create mode 100644 src/natsbridge_ssr.js create mode 100644 test/large_image.png create mode 100644 test/small_image.jpg create mode 100644 test/test_js_mix_payloads_receiver.js create mode 100644 test/test_js_mix_payloads_sender.js create mode 100644 test/test_julia_mix_payloads_receiver.jl create mode 100644 test/test_julia_mix_payloads_sender.jl create mode 100644 test/test_py_mix_payloads_sender.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3defbc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package.json +package-lock.json +target/ \ No newline at end of file diff --git a/AI_prompt.md b/AI_prompt.md new file mode 100644 index 0000000..3e9998a --- /dev/null +++ b/AI_prompt.md @@ -0,0 +1,202 @@ +Consider the following scenarios: +Scenario 1: The "Command & Control" Loop (Low Latency)Focus: Small payloads, Core NATS, bi-directional JSON.The Action: A user on a JavaScript dashboard clicks a "Start Simulation" button. This sends a JSON configuration (parameters like step_size and iterations) to Julia.The Flow: * JS (Sender): Recognizes the message is small ($< 10KB$). Packages it as a direct transport JSON envelope.Julia (Receiver): Listens on the NATS subject, decodes the JSON, and immediately acknowledges receipt with a "Running" status.Project Requirement Met: Fast, low-overhead communication for control signals without involving the fileserver. +Scenario 2: The "Deep Dive" Analysis (High Bandwidth)Focus: Large Arrow tables, Claim-Check pattern, Julia to JS.The Action: Julia finishes a heavy computation and produces a 500MB DataFrame with 10 million rows. It needs to send this to the JS frontend for visualization (e.g., using Perspective.js or D3).The Flow:Julia (Sender): Converts the DataFrame to an Arrow IPC stream. It sees the size is $> 1MB$, so it uploads the bytes to the HTTP fileserver. It then publishes a NATS message with transport: "link" and the URL.JS (Receiver): Receives the URL, fetches the data via fetch(), and uses tableFromIPC() to load the data into memory with zero-copy.Project Requirement Met: Handling massive datasets that exceed NATS message limits while maintaining data integrity across languages. +Scenario 3: Live Audio/Signal Processing (Multimedia & Metadata)Focus: Raw binary, bi-directional streaming, NATS Headers.The Action: The JS client captures a 2-second "chunk" of microphone audio. It needs Julia to perform a Fast Fourier Transform (FFT) or AI transcription.The Flow:JS (Sender): Sends the raw binary WAV/PCM data. It uses NATS Headers to store the metadata ($fs = 44.1kHz$, $channels = 1$) to keep the payload purely binary.Julia (Receiver): Processes the audio and sends back a JSON result (the transcription) and an Arrow Table (the frequency spectrum data).Project Requirement Met: Bi-directional flow involving mixed media (Audio) and technical results (Arrow). +Scenario 4: The "Catch-Up" (Persistence & JetStream)Focus: NATS JetStream, late-joining consumers, state sync.The Action: Julia is constantly publishing "System Health" updates. The JS dashboard is closed for 10 minutes. When the user re-opens the dashboard, they need to see the last 10 minutes of history.The Flow:NATS (Server): Uses a JetStream with a Limits retention policy.JS (Consumer): Connects and requests a "Replay" from the last 10 minutes. It receives a mix of direct (small updates) and link (historical snapshots) messages.Project Requirement Met: Temporal decoupling—consumers can receive data that was sent while they were offline. + +Role: Principal Systems Architect & Lead Software Engineer.Objective: Implement a high-performance, bi-directional data bridge between a Julia service and a JavaScript (Node.js) service using NATS (Core & JetStream).⚠️ STRICT ARCHITECTURAL CONSTRAINTS (Non-Negotiable)Transport Strategy (Claim-Check Pattern):Direct Path: If payload is < 1MB, send data directly via NATS inside the message envelope (Base64 encoded).Link Path: If payload is > 1MB, upload to a shared HTTP fileserver/store. The NATS message must only contain the metadata and the download URL.Tabular Data Format: * MUST use Apache Arrow IPC Stream for all tables/DataFrames. No CSV or standard JSON-serialization of tables allowed.System Symmetry: * Both services must function as Producers AND Consumers.Modular Elegance: * Implementation must be abstracted into a SmartSend function and a SmartReceive handler. The developer calling these functions should not need to care if the data is going via NATS direct or HTTP link.Technical Stack & Use CasesJulia: NATS.jl, Arrow.jl, JSON3.jl, HTTP.jl.Node.js: nats.js, apache-arrow.Scenarios to Support: * Large Data: Sending a 500MB Arrow table from Julia $\rightarrow$ JS.Media: Sending a 5MB WAV file from JS $\rightarrow$ Julia.Signals: Sending small JSON control commands ($< 10KB$) directly via NATS.Implementation Requirements1. Unified JSON Envelope:Define a schema containing: correlation_id (UUID), type (table/binary/json), transport (direct/link), payload (if direct), and url (if link).2. The Julia Module:Implement SmartSend(subject, data, type): Handles Arrow serialization to an IOBuffer, checks size, and manages HTTP uploads for large blobs.Implement SmartReceive(msg): Parses envelope, handles the HTTP fetch with Exponential Backoff (to avoid race conditions), and restores the DataFrame.Include a basic HTTP.listen server to serve as the temporary storage.3. The JavaScript Module:Implement a symmetric SmartSend using nats.js and apache-arrow.Implement a JetStream Pull Consumer for SmartReceive to ensure backpressure and memory safety.4. Performance & Reliability:Demonstrate "Zero-Copy" reading of the Arrow IPC stream on the JS side.Log the correlation_id at every stage for distributed tracing. + + + + + + + +Create a walkthrough for Julia service-A service sending a mix-content chat message to Julia service-B. the chat message must includes + + + + + +I updated the following: +- msghandler.jl. Essentially I add NATS_connection keyword and new publish_message function to support the keyword. +Use them and ONLY them as ground truth. +Then update the following files accordingly: +- architecture.md +- implementation.md + +All API should be semantically consistent and naming should be consistent across the board. + + + + + + + + + +Task: Update msghandler.js to reflect recent changes in msghandler.jl and docs +Context: msghandler.jl and docs has been updated. +Requirements: +Source of Truth: Treat the updated msghandler.jl and docs as the definitive source. +API Consistency: Ensure the Main Package API (e.g., smartsend(), publish_message()) uses consistent naming across all three supported languages. +Ecosystem Variance: Low-level native functions (e.g., NATS.connect(), JSON.read()) should follow the conventions of the specific language ecosystem and do not require cross-language consistency. + + + + + + + +I'm expanding this Julia package (msghandler) into a cross-platform project by adding a JavaScript and Python/MicroPython implementation. To ensure accuracy, the Julia src directory will serve as the ground truth, as the documentation may be outdated. + +My goal is to maintain interface parity at the high-level API for a consistent user experience, while ensuring the low-level implementation adheres strictly to the idiomatic conventions of each respective language (e.g., multiple dispatch in Julia vs. asynchronous, prototype, or class-based patterns in JS and Python/MicroPython) + +Now, help me do the following: +1) check architecture.md for any mistake. + + + + +Help me expands this Julia package (msghandler) into a cross-platform project by adding a JavaScript and Python/MicroPython implementation. To ensure accuracy, msghandler.jl will serve as the ground truth, as the documentation may be outdated. + +My goal is to maintain interface parity at the high-level API for a consistent user experience, while ensuring the low-level implementation adheres strictly to the idiomatic conventions of each respective language (e.g., multiple dispatch in Julia vs. asynchronous, prototype, or class-based patterns in JS and Python/MicroPython) + +Now do the following: +1) check docs to see if there is any mistake. + + + + + +I'm expanding this Julia package (msghandler) into a cross-platform project by adding +a JavaScript, Python and MicroPython implementation. +The following will serve as the ground truth: +- test_julia_mix_payloads_sender.jl +- msghandler.jl +- test_julia_mix_payloads_receiver.jl +- architecture.md + +My goal is to maintain interface parity at the high-level API for a consistent user experience, +while ensuring the low-level implementation adheres strictly to the idiomatic conventions of each +respective language (e.g., multiple dispatch in Julia vs. asynchronous, prototype, or class-based +patterns in JS, Python and MicroPython) + +Now, help me do the following: +1) Check whether msghandler.js needs update or it already up to date. + + + + + + + +# ---------------------------------------------- 100 --------------------------------------------- # + +Got it — let’s rebuild your table in my own teaching style, keeping it crisp, intuitive, and easy for students to grasp. I’ll emphasize **purpose, audience, format, example, and KPI** in a way that flows like a story of how projects move from idea → contract → design → code → review → operations. + +--- + +### SDD + GitOps Documentation Framework + +| Document | Purpose (Rationale) | Primary Audience | Format / Content | Example (SaaS Context) | Measurement (KPI) | +|-----------------|---------------------|-----------------|------------------|------------------------|-------------------| +| **Requirements** | Capture the **business intent** — why we’re building this and what success looks like. Defines boundaries and user‑visible outcomes. | Stakeholders, Product Owners, Lead Developers | User stories, PRDs, acceptance criteria, non‑functional constraints. | “System must process tabular data from Julia to SvelteKit UI with <200ms latency for 5‑member teams.” | 95% of requests complete <200ms (synthetic monitoring). | +| **Specification** | The **technical contract** — precise rules for inputs, outputs, and data shape. Ensures consistency across dev and test. | Developers, QA Engineers, CI/CD pipelines | OpenAPI, Protobuf, AsyncAPI. Endpoint definitions, schemas, error codes. | `contract.yaml` defining a NATS subject that accepts Arrow streams with snake_case headers. | 100% of messages validated against spec (CI block rate). | +| **Architecture** | The **blueprint** — how components fit together, interact, and scale. Guides system structure and trade‑offs. | Architects, Senior Developers, DevOps | C4 diagrams, Mermaid.js, component/network/storage models. | Diagram showing 6‑node cluster routing traffic via Caddy → Node.js API → Julia pods. | 100% of major decisions logged with trade‑off analysis. | +| **Walkthrough** | The **story of flow** — shows how pieces connect end‑to‑end and why steps are sequenced. Builds intuition for new devs. | New Developers, Team Members | TOUR.md, Loom videos, sequence diagrams. Step‑by‑step traces with rationale. | “UI sends JSON → Node.js wraps Claim‑Check → Julia pulls Arrow data (prevents NATS overflow).” | New developers ship feature in <2 days (PR timeline). | +| **Implementation** | The **real code** — business logic, helpers, tests, configs. Where design becomes executable. | Developers, Code Reviewers | Source code, README.md, unit tests, setup scripts. | Julia function for matrix calculation + SvelteKit component rendering table. | >80% unit test coverage, <5% drift from spec. | +| **Validation** | The **enforcer** — ensures implementation matches the spec. Blocks drift and human error. | Automation servers, QA, Lead Developers | CI jobs, contract tests, linting, integration checks. | CI job rejects PR with camelCase field not allowed by YAML spec. | <1% of PRs bypass validation gates. | +| **Runbook** | The **operational manual** — how the system lives in production, scales, and recovers. Guides on‑call engineers. | DevOps, SREs, On‑call Developers | K8s manifests, Helm charts, Markdown guides. Deployment, scaling, backup/restore, troubleshooting. | GitOps manifest ensuring 6 Julia replicas restart if memory >80%. | MTTR <15 minutes for P1 incidents. | + + + + + + + + +# ---------------------------------------------- 100 --------------------------------------------- # + +SDD + GitOps Documentation Stack +Document,"Purpose (The ""Rationale"")",Primary Audience,Format / Content,Example (SaaS Context),"Measurement (KPI)" +Requirements,"Defines the ""Why"" and the Business Boundary. It sets the constraints and success criteria so the team knows when a feature is ""done"" from a user's perspective.","Stakeholders, Product Owners, Lead Developers","Format: User Stories, PRDs. Content: Functional goals, non-functional requirements (latency, scale), and explicit ""out-of-scope"" items.","""The system must process high-volume tabular data from Julia to the SvelteKit UI with <200ms latency for 5-member teams."",""Pass/Fail: 95% of requests complete <200ms (measured via synthetic monitoring)"" +The Spec,"The Technical Contract. It serves as the single source of truth that defines the shape of data. In SDD, this file drives code generation and automated testing.","Developers, QA Engineers, CI/CD Pipelines","Format: OpenAPI (YAML), Protobuf, AsyncAPI. Content: Endpoint definitions, strict data types, error codes, and request/response schemas.",A contract.yaml defining a NATS subject that accepts an Apache Arrow stream with specific snake_case headers.",""Schema Validation Rate: 100% of messages validated against spec (CI block rate)"" +Architecture,"The Structural Blueprint. It explains how the ""pieces"" are arranged in the cluster. It defines the relationships between services, databases, and external providers.","System Architects, Senior Developers, DevOps","Format: C4 Model Diagrams, Mermaid.js. Content: Component diagrams, network flow, storage strategy, and technology stack definitions.",A diagram showing how the 6-node cluster routes traffic through Caddy to the Node.js API and offloads heavy math to Julia pods.",""Architecture Decision Log: 100% of major decisions documented with trade-off analysis"" +Walkthrough,"The Intuition & Flow. It connects multiple APIs/services into a cohesive end-to-end story. It explains the ""steps"" and the ""rationale"" behind the sequence of operations.","New Developers, Current Team Members","Format: TOUR.md, Loom videos, Sequence Diagrams. Content: Step-by-step trace of a feature, explanation of state changes, and the ""why"" behind complex logic.","""End-to-End Trace:"" 1. UI sends JSON to Node.js. 2. Node.js wraps it in a Claim-Check. 3. Julia pulls the Arrow data. Rationale: This prevents NATS memory overflow.",""Onboarding Velocity: New developers deploy feature in <2 days (tracked via PR timeline)"" +Implementation,"The Functional Reality. This is the actual execution of the logic. In SDD, parts of this are auto-generated to ensure it never drifts from the Spec.","Developers, Code Reviewers","Format: Source Code (Git), README.md. Content: Business logic, internal helper functions, unit tests, and local setup instructions.",The Julia function that performs the matrix calculation and the SvelteKit component that renders the resulting table.",""Code Coverage: >80% unit test coverage, <5% test drift from spec"" +Validation,"The Enforcement Layer. It ensures that the ""Reality"" (Code) actually matches the ""Contract"" (Spec). It prevents human error from breaking the system.","Automation Servers, QA, Lead Developers","Format: GitHub Actions, Dredd, Prism. Content: Contract tests, linting rules, and integration tests that check API compliance.",A CI job that blocks a Pull Request because a developer added a camelCase field that isn't allowed in the shared YAML spec.",""Block Rate: <1% of PRs reach production without validation (CI gate pass rate)"" +Runbook,"The Operational Life-Support. It defines how the system lives in production and how to fix it. In GitOps, the ""State"" is declared here.","DevOps, SREs, On-call Developers","Format: K8s Manifests, Helm Charts, Markdown. Content: Deployment steps, scaling triggers, backup/restore commands, and troubleshooting guides.",A GitOps manifest in Flux that ensures 6 replicas of the Julia service are always running and restarts them if memory hits 80%.",""MTTR: <15 minutes for P1 incidents (tracked via incident management system)"" + +Do you understand the provided text? Don't fucking change the table content. I want you to add "Measurement (KPI)" column. it is only example of course. This table will be used for consult and teaching. + + +# ---------------------------------------------- 100 --------------------------------------------- # + +Can you write the table and explain this approach and each doc in details then save to docs/SDD_FRAMEWORK.md so I can consult it later. +Don't forget to add How to use this approach effectively. + + +# ---------------------------------------------- 100 --------------------------------------------- # + +Since I develop src folder before I adopt SDD_FRAMEWORK.md approach, can you check src folder and my current doc files then write docs/requirements.md according to SDD framework? Treat src as ground truth. + +# ---------------------------------------------- 100 --------------------------------------------- # + +I updated src/msghandler.jl. Check and msghandler/docs folder I want to update the content of the following files according to ASG_Framework/ASG_Framework.md: +- msghandler/docs/requirements.md +- msghandler/docs/specification.md +- msghandler/docs/ui-specification.md (you'll need to create this one) +- msghandler/docs/walkthrough.md +- msghandler/docs/architecture.md +I'll do the other docs not listed here later myself. + + + +now help me update the following file according to ASG_Framework/ASG_Framework.md: +- msghandler/docs/specification.md + + + + + +Check ./docs folder. I would like to expand this package (msghandler) to include Rust support. +Can you update the content of the following files according to /home/ton/docker-apps/sommpanion/ASG_Framework/ASG_Framework.md: +- ./docs/requirements.md +- ./docs/specification.md +- ./docs/walkthrough.md +- ./docs/architecture.md + + + + + + +I updated ./src/msghandler.jl. Use it as groundtruth. Check ./docs folder I want to update the content of the following files according to /home/ton/docker-apps/sommpanion/ASG_Framework/ASG_Framework.md: +- ./docs/requirements.md +- ./docs/specification.md +- ./docs/walkthrough.md +- ./docs/architecture.md + + + + +Check the following files: +- ./docs/requirements.md +- ./docs/specification.md +- ./docs/architecture.md +- ./docs/walkthrough.md +I would like to expand this package (msghandler) to include Rust support. +Now help me update Rust implementation of this package at ./src/msghandler.rs. + + + + + +I want to build a client-side-rendering Dioxus-based chat webapp. +Dioxus version 0.7+ should be great. +I already populate the current folder for the project. +my server REST API endpoint is sommpanion.yiem.cc/agent-fronent/api/v1/chat but I didn't run the server yet. A message format is JSON string. +I just placed my custom package for encode and decode message at ./src/msghandler.rs. smartsend() is for encoding and smartreceive() is for decoding. +you may also check the file /home/ton/docker-apps/sommpanion/msghandler/docs/walkthrough.md for more info about my package. +You can test whether Dioxus webapp can be build using this command "dx bundle --web --release --debug-symbols=false" diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b38e325 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1915 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[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 = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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 = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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 = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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 = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "msghandler" +version = "1.2.0" +dependencies = [ + "async-trait", + "base64", + "chrono", + "futures", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "uuid", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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 = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +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.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +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 = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..cd5e45a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "msghandler" +version = "1.2.0" +edition = "2021" +description = "Cross-platform bi-directional data bridge for NATS communication" + +[lib] +name = "msghandler" +path = "src/msghandler.rs" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", features = ["json", "stream", "multipart"] } +uuid = { version = "1", features = ["v4", "serde"] } +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +async-trait = "0.1" +futures = "0.3" + +[dev-dependencies] +tempfile = "3" + +[[example]] +name = "smartsend_example" +path = "examples/smartsend_example.rs" + +[[example]] +name = "smartreceive_example" +path = "examples/smartreceive_example.rs" diff --git a/Manifest.toml b/Manifest.toml new file mode 100644 index 0000000..ef1fb5d --- /dev/null +++ b/Manifest.toml @@ -0,0 +1,837 @@ +# This file is machine-generated - editing it directly is not advised + +julia_version = "1.12.5" +manifest_format = "2.0" +project_hash = "b632f853bcf5355f5c53ad3efa7a19f70444dc6c" + +[[deps.AliasTables]] +deps = ["PtrArrays", "Random"] +git-tree-sha1 = "9876e1e164b144ca45e9e3198d0b689cadfed9ff" +uuid = "66dad0bd-aa9a-41b7-9441-69ab47430ed8" +version = "1.1.3" + +[[deps.ArgTools]] +uuid = "0dad84c5-d112-42e6-8d28-ef12dabb789f" +version = "1.1.2" + +[[deps.Arrow]] +deps = ["ArrowTypes", "BitIntegers", "CodecLz4", "CodecZstd", "ConcurrentUtilities", "DataAPI", "Dates", "EnumX", "Mmap", "PooledArrays", "SentinelArrays", "StringViews", "Tables", "TimeZones", "TranscodingStreams", "UUIDs"] +git-tree-sha1 = "4a69a3eadc1f7da78d950d1ef270c3a62c1f7e01" +uuid = "69666777-d1a9-59fb-9406-91d4454c9d45" +version = "2.8.1" + +[[deps.ArrowTypes]] +deps = ["Sockets", "UUIDs"] +git-tree-sha1 = "404265cd8128a2515a81d5eae16de90fdef05101" +uuid = "31f734f8-188a-4ce0-8406-c8a06bd891cd" +version = "2.3.0" + +[[deps.Artifacts]] +uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" +version = "1.11.0" + +[[deps.Base64]] +uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +version = "1.11.0" + +[[deps.BitFlags]] +git-tree-sha1 = "0691e34b3bb8be9307330f88d1a3c3f25466c24d" +uuid = "d1d4a3ce-64b1-5f1a-9ba4-7e7e69966f35" +version = "0.1.9" + +[[deps.BitIntegers]] +deps = ["Random"] +git-tree-sha1 = "091d591a060e43df1dd35faab3ca284925c48e46" +uuid = "c3b6d118-76ef-56ca-8cc7-ebb389d030a1" +version = "0.3.7" + +[[deps.BufferedStreams]] +git-tree-sha1 = "6863c5b7fc997eadcabdbaf6c5f201dc30032643" +uuid = "e1450e63-4bb3-523b-b2a4-4ffa8c0fd77d" +version = "1.2.2" + +[[deps.CSV]] +deps = ["CodecZlib", "Dates", "FilePathsBase", "InlineStrings", "Mmap", "Parsers", "PooledArrays", "PrecompileTools", "SentinelArrays", "Tables", "Unicode", "WeakRefStrings", "WorkerUtilities"] +git-tree-sha1 = "deddd8725e5e1cc49ee205a1964256043720a6c3" +uuid = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b" +version = "0.10.15" + +[[deps.CodeTracking]] +deps = ["InteractiveUtils", "UUIDs"] +git-tree-sha1 = "b7231a755812695b8046e8471ddc34c8268cbad5" +uuid = "da1fd8a2-8d9e-5ec2-8556-3022fb5608a2" +version = "3.0.0" + +[[deps.CodecBase]] +deps = ["TranscodingStreams"] +git-tree-sha1 = "40956acdbef3d8c7cc38cba42b56034af8f8581a" +uuid = "6c391c72-fb7b-5838-ba82-7cfb1bcfecbf" +version = "0.3.4" + +[[deps.CodecLz4]] +deps = ["Lz4_jll", "TranscodingStreams"] +git-tree-sha1 = "d58afcd2833601636b48ee8cbeb2edcb086522c2" +uuid = "5ba52731-8f18-5e0d-9241-30f10d1ec561" +version = "0.4.6" + +[[deps.CodecZlib]] +deps = ["TranscodingStreams", "Zlib_jll"] +git-tree-sha1 = "962834c22b66e32aa10f7611c08c8ca4e20749a9" +uuid = "944b1d66-785c-5afd-91f1-9de20f533193" +version = "0.7.8" + +[[deps.CodecZstd]] +deps = ["TranscodingStreams", "Zstd_jll"] +git-tree-sha1 = "da54a6cd93c54950c15adf1d336cfd7d71f51a56" +uuid = "6b39b394-51ab-5f42-8807-6242bab2b4c2" +version = "0.8.7" + +[[deps.Compat]] +deps = ["TOML", "UUIDs"] +git-tree-sha1 = "9d8a54ce4b17aa5bdce0ea5c34bc5e7c340d16ad" +uuid = "34da2185-b29b-5c13-b0c7-acf172513d20" +version = "4.18.1" +weakdeps = ["Dates", "LinearAlgebra"] + + [deps.Compat.extensions] + CompatLinearAlgebraExt = "LinearAlgebra" + +[[deps.Compiler]] +git-tree-sha1 = "382d79bfe72a406294faca39ef0c3cef6e6ce1f1" +uuid = "807dbc54-b67e-4c79-8afb-eafe4df6f2e1" +version = "0.1.1" + +[[deps.CompilerSupportLibraries_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" +version = "1.3.0+1" + +[[deps.ConcurrentUtilities]] +deps = ["Serialization", "Sockets"] +git-tree-sha1 = "d9d26935a0bcffc87d2613ce14c527c99fc543fd" +uuid = "f0e56b4a-5159-44fe-b623-3e5288b988bb" +version = "2.5.0" + +[[deps.Crayons]] +git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15" +uuid = "a8cc5b0e-0ffa-5ad4-8c14-923d3ee1735f" +version = "4.1.1" + +[[deps.DataAPI]] +git-tree-sha1 = "abe83f3a2f1b857aac70ef8b269080af17764bbe" +uuid = "9a962f9c-6df0-11e9-0e5d-c546b8b5ee8a" +version = "1.16.0" + +[[deps.DataFrames]] +deps = ["Compat", "DataAPI", "DataStructures", "Future", "InlineStrings", "InvertedIndices", "IteratorInterfaceExtensions", "LinearAlgebra", "Markdown", "Missings", "PooledArrays", "PrecompileTools", "PrettyTables", "Printf", "Random", "Reexport", "SentinelArrays", "SortingAlgorithms", "Statistics", "TableTraits", "Tables", "Unicode"] +git-tree-sha1 = "d8928e9169ff76c6281f39a659f9bca3a573f24c" +uuid = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +version = "1.8.1" + +[[deps.DataStructures]] +deps = ["OrderedCollections"] +git-tree-sha1 = "e357641bb3e0638d353c4b29ea0e40ea644066a6" +uuid = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" +version = "0.19.3" + +[[deps.DataValueInterfaces]] +git-tree-sha1 = "bfc1187b79289637fa0ef6d4436ebdfe6905cbd6" +uuid = "e2d170a0-9d28-54be-80f0-106bbe20a464" +version = "1.0.0" + +[[deps.Dates]] +deps = ["Printf"] +uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" +version = "1.11.0" + +[[deps.Distributions]] +deps = ["AliasTables", "FillArrays", "LinearAlgebra", "PDMats", "Printf", "QuadGK", "Random", "SpecialFunctions", "Statistics", "StatsAPI", "StatsBase", "StatsFuns"] +git-tree-sha1 = "fbcc7610f6d8348428f722ecbe0e6cfe22e672c6" +uuid = "31c24e10-a181-5473-b8eb-7969acd0382f" +version = "0.25.123" + + [deps.Distributions.extensions] + DistributionsChainRulesCoreExt = "ChainRulesCore" + DistributionsDensityInterfaceExt = "DensityInterface" + DistributionsTestExt = "Test" + + [deps.Distributions.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + DensityInterface = "b429d917-457f-4dbc-8f4c-0cc954292b1d" + Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[[deps.DocStringExtensions]] +git-tree-sha1 = "7442a5dfe1ebb773c29cc2962a8980f47221d76c" +uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" +version = "0.9.5" + +[[deps.Downloads]] +deps = ["ArgTools", "FileWatching", "LibCURL", "NetworkOptions"] +uuid = "f43a241f-c20a-4ad4-852c-f6b1247861c6" +version = "1.7.0" + +[[deps.EnumX]] +git-tree-sha1 = "7bebc8aad6ee6217c78c5ddcf7ed289d65d0263e" +uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56" +version = "1.0.6" + +[[deps.ExceptionUnwrapping]] +deps = ["Test"] +git-tree-sha1 = "d36f682e590a83d63d1c7dbd287573764682d12a" +uuid = "460bff9d-24e4-43bc-9d9f-a8973cb893f4" +version = "0.1.11" + +[[deps.ExprTools]] +git-tree-sha1 = "27415f162e6028e81c72b82ef756bf321213b6ec" +uuid = "e2ba6199-217a-4e67-a87a-7c52f15ade04" +version = "0.1.10" + +[[deps.FilePathsBase]] +deps = ["Compat", "Dates"] +git-tree-sha1 = "3bab2c5aa25e7840a4b065805c0cdfc01f3068d2" +uuid = "48062228-2e41-5def-b9a4-89aafe57970f" +version = "0.9.24" +weakdeps = ["Mmap", "Test"] + + [deps.FilePathsBase.extensions] + FilePathsBaseMmapExt = "Mmap" + FilePathsBaseTestExt = "Test" + +[[deps.FileWatching]] +uuid = "7b1f6079-737a-58dc-b8bc-7a2ca5c1b5ee" +version = "1.11.0" + +[[deps.FillArrays]] +deps = ["LinearAlgebra"] +git-tree-sha1 = "2f979084d1e13948a3352cf64a25df6bd3b4dca3" +uuid = "1a297f60-69ca-5386-bcde-b61e274b549b" +version = "1.16.0" + + [deps.FillArrays.extensions] + FillArraysPDMatsExt = "PDMats" + FillArraysSparseArraysExt = "SparseArrays" + FillArraysStaticArraysExt = "StaticArrays" + FillArraysStatisticsExt = "Statistics" + + [deps.FillArrays.weakdeps] + PDMats = "90014a1f-27ba-587c-ab20-58faa44d9150" + SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" + StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" + Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" + +[[deps.Future]] +deps = ["Random"] +uuid = "9fa8497b-333b-5362-9e8d-4d0656e87820" +version = "1.11.0" + +[[deps.GeneralUtils]] +deps = ["CSV", "DataFrames", "DataStructures", "Dates", "Distributions", "JSON", "NATS", "PrettyPrinting", "Random", "Revise", "SHA", "UUIDs"] +git-tree-sha1 = "e28ca4df47d0c46d04716422bef6adb660f33dc3" +repo-rev = "main" +repo-url = "https://git.yiem.cc/ton/GeneralUtils" +uuid = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" +version = "0.3.1" + +[[deps.HTTP]] +deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "PrecompileTools", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"] +git-tree-sha1 = "5e6fe50ae7f23d171f44e311c2960294aaa0beb5" +uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" +version = "1.10.19" + +[[deps.HashArrayMappedTries]] +git-tree-sha1 = "2eaa69a7cab70a52b9687c8bf950a5a93ec895ae" +uuid = "076d061b-32b6-4027-95e0-9a2c6f6d7e74" +version = "0.2.0" + +[[deps.HypergeometricFunctions]] +deps = ["LinearAlgebra", "OpenLibm_jll", "SpecialFunctions"] +git-tree-sha1 = "68c173f4f449de5b438ee67ed0c9c748dc31a2ec" +uuid = "34004b35-14d8-5ef3-9330-4cdb6864b03a" +version = "0.3.28" + +[[deps.InlineStrings]] +git-tree-sha1 = "8f3d257792a522b4601c24a577954b0a8cd7334d" +uuid = "842dd82b-1e85-43dc-bf29-5d0ee9dffc48" +version = "1.4.5" +weakdeps = ["ArrowTypes", "Parsers"] + + [deps.InlineStrings.extensions] + ArrowTypesExt = "ArrowTypes" + ParsersExt = "Parsers" + +[[deps.InteractiveUtils]] +deps = ["Markdown"] +uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +version = "1.11.0" + +[[deps.InvertedIndices]] +git-tree-sha1 = "6da3c4316095de0f5ee2ebd875df8721e7e0bdbe" +uuid = "41ab1584-1d38-5bbf-9106-f11c6c58b48f" +version = "1.3.1" + +[[deps.IrrationalConstants]] +git-tree-sha1 = "b2d91fe939cae05960e760110b328288867b5758" +uuid = "92d709cd-6900-40b7-9082-c6be49f344b6" +version = "0.2.6" + +[[deps.IteratorInterfaceExtensions]] +git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856" +uuid = "82899510-4779-5014-852e-03e436cf321d" +version = "1.0.0" + +[[deps.JLLWrappers]] +deps = ["Artifacts", "Preferences"] +git-tree-sha1 = "0533e564aae234aff59ab625543145446d8b6ec2" +uuid = "692b3bcd-3c85-4b1f-b108-f13ce0eb3210" +version = "1.7.1" + +[[deps.JSON]] +deps = ["Dates", "Logging", "Parsers", "PrecompileTools", "StructUtils", "UUIDs", "Unicode"] +git-tree-sha1 = "b3ad4a0255688dcb895a52fafbaae3023b588a90" +uuid = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +version = "1.4.0" +weakdeps = ["ArrowTypes"] + + [deps.JSON.extensions] + JSONArrowExt = ["ArrowTypes"] + +[[deps.JSON3]] +deps = ["Dates", "Mmap", "Parsers", "PrecompileTools", "StructTypes", "UUIDs"] +git-tree-sha1 = "411eccfe8aba0814ffa0fdf4860913ed09c34975" +uuid = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" +version = "1.14.3" +weakdeps = ["ArrowTypes"] + + [deps.JSON3.extensions] + JSON3ArrowExt = ["ArrowTypes"] + +[[deps.JuliaInterpreter]] +deps = ["CodeTracking", "InteractiveUtils", "Random", "UUIDs"] +git-tree-sha1 = "80580012d4ed5a3e8b18c7cd86cebe4b816d17a6" +uuid = "aa1ae85d-cabe-5617-a682-6adf51b2e16a" +version = "0.10.9" + +[[deps.JuliaSyntaxHighlighting]] +deps = ["StyledStrings"] +uuid = "ac6e5ff7-fb65-4e79-a425-ec3bc9c03011" +version = "1.12.0" + +[[deps.LaTeXStrings]] +git-tree-sha1 = "dda21b8cbd6a6c40d9d02a73230f9d70fed6918c" +uuid = "b964fa9f-0449-5b57-a5c2-d3ea65f4040f" +version = "1.4.0" + +[[deps.LibCURL]] +deps = ["LibCURL_jll", "MozillaCACerts_jll"] +uuid = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" +version = "0.6.4" + +[[deps.LibCURL_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll", "Zlib_jll", "nghttp2_jll"] +uuid = "deac9b47-8bc7-5906-a0fe-35ac56dc84c0" +version = "8.15.0+0" + +[[deps.LibGit2]] +deps = ["LibGit2_jll", "NetworkOptions", "Printf", "SHA"] +uuid = "76f85450-5226-5b5a-8eaa-529ad045b433" +version = "1.11.0" + +[[deps.LibGit2_jll]] +deps = ["Artifacts", "LibSSH2_jll", "Libdl", "OpenSSL_jll"] +uuid = "e37daf67-58a4-590a-8e99-b0245dd2ffc5" +version = "1.9.0+0" + +[[deps.LibSSH2_jll]] +deps = ["Artifacts", "Libdl", "OpenSSL_jll"] +uuid = "29816b5a-b9ab-546f-933c-edad1886dfa8" +version = "1.11.3+1" + +[[deps.Libdl]] +uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" +version = "1.11.0" + +[[deps.LinearAlgebra]] +deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] +uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +version = "1.12.0" + +[[deps.LogExpFunctions]] +deps = ["DocStringExtensions", "IrrationalConstants", "LinearAlgebra"] +git-tree-sha1 = "13ca9e2586b89836fd20cccf56e57e2b9ae7f38f" +uuid = "2ab3a3ac-af41-5b50-aa03-7779005ae688" +version = "0.3.29" + + [deps.LogExpFunctions.extensions] + LogExpFunctionsChainRulesCoreExt = "ChainRulesCore" + LogExpFunctionsChangesOfVariablesExt = "ChangesOfVariables" + LogExpFunctionsInverseFunctionsExt = "InverseFunctions" + + [deps.LogExpFunctions.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + ChangesOfVariables = "9e997f8a-9a97-42d5-a9f1-ce6bfc15e2c0" + InverseFunctions = "3587e190-3f89-42d0-90ee-14403ec27112" + +[[deps.Logging]] +uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" +version = "1.11.0" + +[[deps.LoggingExtras]] +deps = ["Dates", "Logging"] +git-tree-sha1 = "f00544d95982ea270145636c181ceda21c4e2575" +uuid = "e6f89c97-d47a-5376-807f-9c37f3926c36" +version = "1.2.0" + +[[deps.LoweredCodeUtils]] +deps = ["CodeTracking", "Compiler", "JuliaInterpreter"] +git-tree-sha1 = "65ae3db6ab0e5b1b5f217043c558d9d1d33cc88d" +uuid = "6f1432cf-f94c-5a45-995e-cdbf5db27b0b" +version = "3.5.0" + +[[deps.Lz4_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "191686b1ac1ea9c89fc52e996ad15d1d241d1e33" +uuid = "5ced341a-0733-55b8-9ab6-a4889d929147" +version = "1.10.1+0" + +[[deps.Markdown]] +deps = ["Base64", "JuliaSyntaxHighlighting", "StyledStrings"] +uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" +version = "1.11.0" + +[[deps.MbedTLS]] +deps = ["Dates", "MbedTLS_jll", "MozillaCACerts_jll", "NetworkOptions", "Random", "Sockets"] +git-tree-sha1 = "c067a280ddc25f196b5e7df3877c6b226d390aaf" +uuid = "739be429-bea8-5141-9913-cc70e7f3736d" +version = "1.1.9" + +[[deps.MbedTLS_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "ff69a2b1330bcb730b9ac1ab7dd680176f5896b8" +uuid = "c8ffd9c3-330d-5841-b78e-0817d7145fa1" +version = "2.28.1010+0" + +[[deps.Missings]] +deps = ["DataAPI"] +git-tree-sha1 = "ec4f7fbeab05d7747bdf98eb74d130a2a2ed298d" +uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28" +version = "1.2.0" + +[[deps.Mmap]] +uuid = "a63ad114-7e13-5084-954f-fe012c677804" +version = "1.11.0" + +[[deps.Mocking]] +deps = ["Compat", "ExprTools"] +git-tree-sha1 = "2c140d60d7cb82badf06d8783800d0bcd1a7daa2" +uuid = "78c3b35d-d492-501b-9361-3d52fe80e533" +version = "0.8.1" + +[[deps.MozillaCACerts_jll]] +uuid = "14a3606d-f60d-562e-9121-12d972cd8159" +version = "2025.11.4" + +[[deps.NATS]] +deps = ["Base64", "BufferedStreams", "CodecBase", "Dates", "DocStringExtensions", "JSON3", "MbedTLS", "NanoDates", "Random", "ScopedValues", "Sockets", "Sodium", "StructTypes", "URIs"] +git-tree-sha1 = "d9d9a189fb9155a460e6b5e8966bf6a66737abf8" +uuid = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a" +version = "0.1.0" + +[[deps.msghandler]] +deps = ["Arrow", "DataFrames", "Dates", "GeneralUtils", "HTTP", "JSON", "NATS", "PrettyPrinting", "Revise", "UUIDs"] +path = "." +uuid = "f2724d33-f338-4a57-b9f8-1be882570d10" +version = "0.4.1" + +[[deps.NanoDates]] +deps = ["Dates", "Parsers"] +git-tree-sha1 = "850a0557ae5934f6e67ac0dc5ca13d0328422d1f" +uuid = "46f1a544-deae-4307-8689-c12aa3c955c6" +version = "1.0.3" + +[[deps.NetworkOptions]] +uuid = "ca575930-c2e3-43a9-ace4-1e988b2c1908" +version = "1.3.0" + +[[deps.OpenBLAS_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] +uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" +version = "0.3.29+0" + +[[deps.OpenLibm_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "05823500-19ac-5b8b-9628-191a04bc5112" +version = "0.8.7+0" + +[[deps.OpenSSL]] +deps = ["BitFlags", "Dates", "MozillaCACerts_jll", "NetworkOptions", "OpenSSL_jll", "Sockets"] +git-tree-sha1 = "1d1aaa7d449b58415f97d2839c318b70ffb525a0" +uuid = "4d8831e6-92b7-49fb-bdf8-b643e874388c" +version = "1.6.1" + +[[deps.OpenSSL_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "458c3c95-2e84-50aa-8efc-19380b2a3a95" +version = "3.5.4+0" + +[[deps.OpenSpecFun_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "JLLWrappers", "Libdl"] +git-tree-sha1 = "1346c9208249809840c91b26703912dff463d335" +uuid = "efe28fd5-8261-553b-a9e1-b2916fc3738e" +version = "0.5.6+0" + +[[deps.OrderedCollections]] +git-tree-sha1 = "05868e21324cede2207c6f0f466b4bfef6d5e7ee" +uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +version = "1.8.1" + +[[deps.PDMats]] +deps = ["LinearAlgebra", "SparseArrays", "SuiteSparse"] +git-tree-sha1 = "e4cff168707d441cd6bf3ff7e4832bdf34278e4a" +uuid = "90014a1f-27ba-587c-ab20-58faa44d9150" +version = "0.11.37" +weakdeps = ["StatsBase"] + + [deps.PDMats.extensions] + StatsBaseExt = "StatsBase" + +[[deps.Parsers]] +deps = ["Dates", "PrecompileTools", "UUIDs"] +git-tree-sha1 = "7d2f8f21da5db6a806faf7b9b292296da42b2810" +uuid = "69de0a69-1ddd-5017-9359-2bf0b02dc9f0" +version = "2.8.3" + +[[deps.PooledArrays]] +deps = ["DataAPI", "Future"] +git-tree-sha1 = "36d8b4b899628fb92c2749eb488d884a926614d3" +uuid = "2dfb63ee-cc39-5dd5-95bd-886bf059d720" +version = "1.4.3" + +[[deps.PrecompileTools]] +deps = ["Preferences"] +git-tree-sha1 = "07a921781cab75691315adc645096ed5e370cb77" +uuid = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +version = "1.3.3" + +[[deps.Preferences]] +deps = ["TOML"] +git-tree-sha1 = "522f093a29b31a93e34eaea17ba055d850edea28" +uuid = "21216c6a-2e73-6563-6e65-726566657250" +version = "1.5.1" + +[[deps.PrettyPrinting]] +git-tree-sha1 = "142ee93724a9c5d04d78df7006670a93ed1b244e" +uuid = "54e16d92-306c-5ea0-a30b-337be88ac337" +version = "0.4.2" + +[[deps.PrettyTables]] +deps = ["Crayons", "LaTeXStrings", "Markdown", "PrecompileTools", "Printf", "REPL", "Reexport", "StringManipulation", "Tables"] +git-tree-sha1 = "c5a07210bd060d6a8491b0ccdee2fa0235fc00bf" +uuid = "08abe8d2-0d0c-5749-adfa-8a2ac140af0d" +version = "3.1.2" + +[[deps.Printf]] +deps = ["Unicode"] +uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" +version = "1.11.0" + +[[deps.PtrArrays]] +git-tree-sha1 = "1d36ef11a9aaf1e8b74dacc6a731dd1de8fd493d" +uuid = "43287f4e-b6f4-7ad1-bb20-aadabca52c3d" +version = "1.3.0" + +[[deps.QuadGK]] +deps = ["DataStructures", "LinearAlgebra"] +git-tree-sha1 = "9da16da70037ba9d701192e27befedefb91ec284" +uuid = "1fd47b50-473d-5c70-9696-f719f8f3bcdc" +version = "2.11.2" + + [deps.QuadGK.extensions] + QuadGKEnzymeExt = "Enzyme" + + [deps.QuadGK.weakdeps] + Enzyme = "7da242da-08ed-463a-9acd-ee780be4f1d9" + +[[deps.REPL]] +deps = ["InteractiveUtils", "JuliaSyntaxHighlighting", "Markdown", "Sockets", "StyledStrings", "Unicode"] +uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" +version = "1.11.0" + +[[deps.Random]] +deps = ["SHA"] +uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +version = "1.11.0" + +[[deps.Reexport]] +git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" +uuid = "189a3867-3050-52da-a836-e630ba90ab69" +version = "1.2.2" + +[[deps.Revise]] +deps = ["CodeTracking", "FileWatching", "InteractiveUtils", "JuliaInterpreter", "LibGit2", "LoweredCodeUtils", "OrderedCollections", "Preferences", "REPL", "UUIDs"] +git-tree-sha1 = "14d1bfb0a30317edc77e11094607ace3c800f193" +uuid = "295af30f-e4ad-537b-8983-00126c2a3abe" +version = "3.13.2" + + [deps.Revise.extensions] + DistributedExt = "Distributed" + + [deps.Revise.weakdeps] + Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" + +[[deps.Rmath]] +deps = ["Random", "Rmath_jll"] +git-tree-sha1 = "5b3d50eb374cea306873b371d3f8d3915a018f0b" +uuid = "79098fc4-a85e-5d69-aa6a-4863f24498fa" +version = "0.9.0" + +[[deps.Rmath_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "58cdd8fb2201a6267e1db87ff148dd6c1dbd8ad8" +uuid = "f50d1b31-88e8-58de-be2c-1cc44531875f" +version = "0.5.1+0" + +[[deps.SHA]] +uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" +version = "0.7.0" + +[[deps.ScopedValues]] +deps = ["HashArrayMappedTries", "Logging"] +git-tree-sha1 = "c3b2323466378a2ba15bea4b2f73b081e022f473" +uuid = "7e506255-f358-4e82-b7e4-beb19740aa63" +version = "1.5.0" + +[[deps.Scratch]] +deps = ["Dates"] +git-tree-sha1 = "9b81b8393e50b7d4e6d0a9f14e192294d3b7c109" +uuid = "6c6a2e73-6563-6170-7368-637461726353" +version = "1.3.0" + +[[deps.SentinelArrays]] +deps = ["Dates", "Random"] +git-tree-sha1 = "ebe7e59b37c400f694f52b58c93d26201387da70" +uuid = "91c51154-3ec4-41a3-a24f-3f23e20d615c" +version = "1.4.9" + +[[deps.Serialization]] +uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" +version = "1.11.0" + +[[deps.SimpleBufferStream]] +git-tree-sha1 = "f305871d2f381d21527c770d4788c06c097c9bc1" +uuid = "777ac1f9-54b0-4bf8-805c-2214025038e7" +version = "1.2.0" + +[[deps.Sockets]] +uuid = "6462fe0b-24de-5631-8697-dd941f90decc" +version = "1.11.0" + +[[deps.Sodium]] +deps = ["Base64", "libsodium_jll"] +git-tree-sha1 = "907703e0d50846f300650d7225bdcab145b7bca9" +uuid = "4f5b5e99-b0ad-42cd-b47a-334e172ec8bd" +version = "1.1.2" + +[[deps.SortingAlgorithms]] +deps = ["DataStructures"] +git-tree-sha1 = "64d974c2e6fdf07f8155b5b2ca2ffa9069b608d9" +uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c" +version = "1.2.2" + +[[deps.SparseArrays]] +deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"] +uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +version = "1.12.0" + +[[deps.SpecialFunctions]] +deps = ["IrrationalConstants", "LogExpFunctions", "OpenLibm_jll", "OpenSpecFun_jll"] +git-tree-sha1 = "f2685b435df2613e25fc10ad8c26dddb8640f547" +uuid = "276daf66-3868-5448-9aa4-cd146d93841b" +version = "2.6.1" + + [deps.SpecialFunctions.extensions] + SpecialFunctionsChainRulesCoreExt = "ChainRulesCore" + + [deps.SpecialFunctions.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + +[[deps.Statistics]] +deps = ["LinearAlgebra"] +git-tree-sha1 = "ae3bb1eb3bba077cd276bc5cfc337cc65c3075c0" +uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +version = "1.11.1" +weakdeps = ["SparseArrays"] + + [deps.Statistics.extensions] + SparseArraysExt = ["SparseArrays"] + +[[deps.StatsAPI]] +deps = ["LinearAlgebra"] +git-tree-sha1 = "178ed29fd5b2a2cfc3bd31c13375ae925623ff36" +uuid = "82ae8749-77ed-4fe6-ae5f-f523153014b0" +version = "1.8.0" + +[[deps.StatsBase]] +deps = ["AliasTables", "DataAPI", "DataStructures", "IrrationalConstants", "LinearAlgebra", "LogExpFunctions", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"] +git-tree-sha1 = "aceda6f4e598d331548e04cc6b2124a6148138e3" +uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" +version = "0.34.10" + +[[deps.StatsFuns]] +deps = ["HypergeometricFunctions", "IrrationalConstants", "LogExpFunctions", "Reexport", "Rmath", "SpecialFunctions"] +git-tree-sha1 = "91f091a8716a6bb38417a6e6f274602a19aaa685" +uuid = "4c63d2b9-4356-54db-8cca-17b64c39e42c" +version = "1.5.2" + + [deps.StatsFuns.extensions] + StatsFunsChainRulesCoreExt = "ChainRulesCore" + StatsFunsInverseFunctionsExt = "InverseFunctions" + + [deps.StatsFuns.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + InverseFunctions = "3587e190-3f89-42d0-90ee-14403ec27112" + +[[deps.StringManipulation]] +deps = ["PrecompileTools"] +git-tree-sha1 = "a3c1536470bf8c5e02096ad4853606d7c8f62721" +uuid = "892a3eda-7b42-436c-8928-eab12a02cf0e" +version = "0.4.2" + +[[deps.StringViews]] +git-tree-sha1 = "f2dcb92855b31ad92fe8f079d4f75ac57c93e4b8" +uuid = "354b36f9-a18e-4713-926e-db85100087ba" +version = "1.3.7" + +[[deps.StructTypes]] +deps = ["Dates", "UUIDs"] +git-tree-sha1 = "159331b30e94d7b11379037feeb9b690950cace8" +uuid = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" +version = "1.11.0" + +[[deps.StructUtils]] +deps = ["Dates", "UUIDs"] +git-tree-sha1 = "9297459be9e338e546f5c4bedb59b3b5674da7f1" +uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" +version = "2.6.2" + + [deps.StructUtils.extensions] + StructUtilsMeasurementsExt = ["Measurements"] + StructUtilsTablesExt = ["Tables"] + + [deps.StructUtils.weakdeps] + Measurements = "eff96d63-e80a-5855-80a2-b1b0885c5ab7" + Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" + +[[deps.StyledStrings]] +uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b" +version = "1.11.0" + +[[deps.SuiteSparse]] +deps = ["Libdl", "LinearAlgebra", "Serialization", "SparseArrays"] +uuid = "4607b0f0-06f3-5cda-b6b1-a6196a1729e9" + +[[deps.SuiteSparse_jll]] +deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] +uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" +version = "7.8.3+2" + +[[deps.TOML]] +deps = ["Dates"] +uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" +version = "1.0.3" + +[[deps.TZJData]] +deps = ["Artifacts"] +git-tree-sha1 = "72df96b3a595b7aab1e101eb07d2a435963a97e2" +uuid = "dc5dba14-91b3-4cab-a142-028a31da12f7" +version = "1.5.0+2025b" + +[[deps.TableTraits]] +deps = ["IteratorInterfaceExtensions"] +git-tree-sha1 = "c06b2f539df1c6efa794486abfb6ed2022561a39" +uuid = "3783bdb8-4a98-5b6b-af9a-565f29a5fe9c" +version = "1.0.1" + +[[deps.Tables]] +deps = ["DataAPI", "DataValueInterfaces", "IteratorInterfaceExtensions", "OrderedCollections", "TableTraits"] +git-tree-sha1 = "f2c1efbc8f3a609aadf318094f8fc5204bdaf344" +uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" +version = "1.12.1" + +[[deps.Test]] +deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] +uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +version = "1.11.0" + +[[deps.TimeZones]] +deps = ["Artifacts", "Dates", "Downloads", "InlineStrings", "Mocking", "Printf", "Scratch", "TZJData", "Unicode", "p7zip_jll"] +git-tree-sha1 = "d422301b2a1e294e3e4214061e44f338cafe18a2" +uuid = "f269a46b-ccf7-5d73-abea-4c690281aa53" +version = "1.22.2" + + [deps.TimeZones.extensions] + TimeZonesRecipesBaseExt = "RecipesBase" + + [deps.TimeZones.weakdeps] + RecipesBase = "3cdcf5f2-1ef4-517c-9805-6587b60abb01" + +[[deps.TranscodingStreams]] +git-tree-sha1 = "0c45878dcfdcfa8480052b6ab162cdd138781742" +uuid = "3bb67fe8-82b1-5028-8e26-92a6c54297fa" +version = "0.11.3" + +[[deps.URIs]] +git-tree-sha1 = "bef26fb046d031353ef97a82e3fdb6afe7f21b1a" +uuid = "5c2747f8-b7ea-4ff2-ba2e-563bfd36b1d4" +version = "1.6.1" + +[[deps.UUIDs]] +deps = ["Random", "SHA"] +uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +version = "1.11.0" + +[[deps.Unicode]] +uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" +version = "1.11.0" + +[[deps.WeakRefStrings]] +deps = ["DataAPI", "InlineStrings", "Parsers"] +git-tree-sha1 = "b1be2855ed9ed8eac54e5caff2afcdb442d52c23" +uuid = "ea10d353-3f73-51f8-a26c-33c1cb351aa5" +version = "1.4.2" + +[[deps.WorkerUtilities]] +git-tree-sha1 = "cd1659ba0d57b71a464a29e64dbc67cfe83d54e7" +uuid = "76eceee3-57b5-4d4a-8e66-0e911cebbf60" +version = "1.6.1" + +[[deps.Zlib_jll]] +deps = ["Libdl"] +uuid = "83775a58-1f1d-513f-b197-d71354ab007a" +version = "1.3.1+2" + +[[deps.Zstd_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "446b23e73536f84e8037f5dce465e92275f6a308" +uuid = "3161d3a3-bdf6-5164-811a-617609db77b4" +version = "1.5.7+1" + +[[deps.libblastrampoline_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" +version = "5.15.0+0" + +[[deps.libsodium_jll]] +deps = ["Artifacts", "JLLWrappers", "Libdl"] +git-tree-sha1 = "011b0a7331b41c25524b64dc42afc9683ee89026" +uuid = "a9144af2-ca23-56d9-984f-0d03f7b5ccf8" +version = "1.0.21+0" + +[[deps.nghttp2_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "8e850ede-7688-5339-a07c-302acd2aaf8d" +version = "1.64.0+1" + +[[deps.p7zip_jll]] +deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] +uuid = "3f19e933-33d8-53b3-aaab-bd5110c3b7a0" +version = "17.7.0+0" diff --git a/Project.toml b/Project.toml new file mode 100644 index 0000000..364e5da --- /dev/null +++ b/Project.toml @@ -0,0 +1,21 @@ +name = "msghandler" +uuid = "f2724d33-f338-4a57-b9f8-1be882570d10" +version = "0.5.6" +authors = ["narawat "] + +[deps] +Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45" +Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" +GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" +NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a" +PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337" +Revise = "295af30f-e4ad-537b-8983-00126c2a3abe" +UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + +[compat] +Base64 = "1.11.0" +JSON = "1.4.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc6825b --- /dev/null +++ b/README.md @@ -0,0 +1,943 @@ +# msghandler - Cross-Platform Bi-Directional Data Bridge + +A high-performance, bi-directional data bridge for **Julia**, **JavaScript**, **Python**, and **MicroPython** applications using NATS (Core & JetStream), implementing the Claim-Check pattern for large payloads. + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) +[![NATS](https://img.shields.io/badge/NATS-Enabled-green.svg)](https://nats.io) + +--- + +## Table of Contents + +- [Overview](#overview) +- [Cross-Platform Support](#cross-platform-support) +- [Features](#features) +- [Quick Start](#quick-start) +- [API Reference](#api-reference) +- [Payload Types](#payload-types) +- [Cross-Platform Examples](#cross-platform-examples) +- [Testing](#testing) +- [Documentation](#documentation) +- [License](#license) + +--- + +## Overview + +msghandler enables seamless communication across multiple platforms through NATS, with intelligent transport selection based on payload size: + +| Transport | Payload Size | Method | +|-----------|--------------|--------| +| **Direct** | < 500KB | Sent directly via NATS (Base64 encoded) | +| **Link** | ≥ 500KB | Uploaded to HTTP file server, URL sent via NATS | + +### Use Cases + +- **Chat Applications**: Text, images, audio, video in a single message +- **File Transfer**: Efficient transfer of large files using claim-check pattern +- **IoT/Embedded**: Sensor data, telemetry, and analytics pipelines (MicroPython) +- **Cross-Platform Communication**: Interoperability between Julia, JavaScript, Python, and MicroPython systems + +--- + +## Cross-Platform Support + +| Platform | Implementation | Features | +|----------|----------------|----------| +| **Julia** | [`src/msghandler.jl`](src/msghandler.jl) | Full feature set, Arrow IPC, multiple dispatch | +| **JavaScript (Node.js)** | [`src/msghandler_ssr.js`](src/msghandler_ssr.js) | Node.js, async/await, Arrow IPC | +| **JavaScript (Browser)** | [`src/msghandler_csr.js`](src/msghandler_csr.js) | Browser, WebSocket NATS, async/await, JSON table only | +| **Python** | [`src/msghandler.py`](src/msghandler.py) | Desktop Python, asyncio, type hints, Arrow IPC | +| **MicroPython** | [`src/msghandler_mpy.py`](src/msghandler_mpy.py) | Memory-constrained, synchronous API | + +### Platform Comparison + +| Feature | Julia | JavaScript | JavaScript (Browser) | Python | MicroPython | +|---------|-------|------------|----------------------|--------|-------------| +| Multiple Dispatch | ✅ Native | ❌ | ❌ | ❌ | ❌ | +| Async/Await | ❌ | ✅ Native | ✅ Native | ✅ Native | ⚠️ (uasyncio) | +| Type Safety | ✅ Strong | ⚠️ (TypeScript) | ⚠️ (TypeScript) | ✅ (Type hints) | ❌ | +| Arrow IPC | ✅ Native | ✅ Native | ❌ (Browser incompatible) | ✅ Native | ❌ | +| JSON Table | ✅ | ✅ | ✅ (Only table type) | ✅ | ⚠️ (Limited) | +| Direct Transport | ✅ | ✅ | ✅ | ✅ | ✅ | +| Link Transport | ✅ | ✅ | ✅ | ✅ | ⚠️ (Limited) | +| Handler Functions | ✅ | ✅ | ✅ | ✅ | ✅ | +| Cross-Platform API | ✅ | ✅ | ✅ | ✅ | ✅ | +| WebSocket NATS | ❌ | ❌ | ✅ | ❌ | ❌ | + +--- + +## Features + +- ✅ **Cross-platform messaging** for Julia, JavaScript, Python, and MicroPython applications +- ✅ **Bi-directional messaging** with request-reply patterns +- ✅ **Multi-payload support** - send multiple payloads with different types in one message +- ✅ **Automatic transport selection** - direct vs link based on payload size +- ✅ **Claim-Check pattern** for payloads ≥ 500KB +- ✅ **Apache Arrow IPC** support for tabular data (Desktop: Julia/Python/Node.js) +- ✅ **JSON Table** support for tabular data (All platforms including Browser) +- ✅ **Exponential backoff** for reliable file server downloads +- ✅ **Correlation ID tracking** for message tracing +- ✅ **Reply-to support** for request-response patterns +- ✅ **Handler function abstraction** - pluggable file server implementations (Plik, AWS S3, custom) + +--- + +## Quick Start + +### Prerequisites + +1. **NATS Server** - Install and run a NATS server: + ```bash + docker run -p 4222:4222 nats:latest + ``` + +2. **HTTP File Server** (optional, for large payloads) - Install and run a file server: + ```bash + # Using Plik + docker run -p 8080:8080 -v /tmp/fileserver:/var/lib/plik -e PLIK_ADMIN_PASSWORD=admin plik/plik + + # OR using simple Python HTTP server + mkdir -p /tmp/fileserver + python3 -m http.server 8080 --directory /tmp/fileserver + ``` + +### Send Your First Message + +#### Julia + +```julia +using msghandler + +data = [("message", "Hello World", "text")] +env, env_json_str = smartsend("/chat/room1", data; broker_url="nats://localhost:4222") +println("Message sent!") +``` + +#### JavaScript (Node.js) + +```javascript +import msghandler from './src/msghandler_ssr.js'; + +const data = [["message", "Hello World", "text"]]; +const [env, env_json_str] = await msghandler.smartsend( + "/chat/room1", + data, + { broker_url: "nats://localhost:4222" } +); +console.log("Message sent!"); +``` + +#### JavaScript (Browser) + +```javascript +import msghandler from './src/msghandler_csr.js'; + +const data = [["message", "Hello World", "text"]]; +const [env, env_json_str] = await msghandler.smartsend( + "/chat/room1", + data, + { broker_url: "ws://localhost:4222" } +); +console.log("Message sent!"); +``` + +#### Python + +```python +from msghandler import smartsend + +data = [("message", "Hello World", "text")] +env, env_json_str = await smartsend( + "/chat/room1", + data, + broker_url="nats://localhost:4222" +) +print("Message sent!") +``` + +#### MicroPython + +```python +from msghandler import smartsend + +data = [("message", "Hello World", "text")] +env, env_json_str = smartsend( + "/chat/room1", + data, + broker_url="nats://localhost:4222", + size_threshold=100000 # 100KB for MicroPython +) +print("Message sent!") +``` + +--- + +## API Reference + +### Unified API Standard + +All platforms use the same input/output format for payloads: + +**Input format for `smartsend`:** +``` +[(dataname1, data1, type1), (dataname2, data2, type2), ...] +``` + +**Output format for `smartreceive`:** +```json +{ + "correlation_id": "...", + "msg_id": "...", + "timestamp": "...", + "send_to": "...", + "msg_purpose": "...", + "sender_name": "...", + "sender_id": "...", + "receiver_name": "...", + "receiver_id": "...", + "reply_to": "...", + "reply_to_msg_id": "...", + "broker_url": "...", + "metadata": {...}, + "payloads": [(dataname1, data1, type1), (dataname2, data2, type2), ...] +} +``` + +### smartsend + +Sends data either directly via NATS or via a fileserver URL, depending on payload size. + +#### Julia + +```julia +using msghandler + +env, env_json_str = msghandler.smartsend( + subject::String, + data::AbstractArray{Tuple{String, Any, String}}; + broker_url::String = "nats://localhost:4222", + fileserver_url = "http://localhost:8080", + fileserver_upload_handler::Function = plik_oneshot_upload, + size_threshold::Int = 500_000, + correlation_id::String = string(uuid4()), + msg_purpose::String = "chat", + sender_name::String = "msghandler", + receiver_name::String = "", + receiver_id::String = "", + reply_to::String = "", + reply_to_msg_id::String = "", + is_publish::Bool = true, + NATS_connection::Union{NATS.Connection, Nothing} = nothing, + msg_id::String = string(uuid4()), + sender_id::String = string(uuid4()) +) +# Returns: ::Tuple{msg_envelope_v1, String} +``` + +#### JavaScript (Node.js) + +```javascript +import msghandler from './src/msghandler_ssr.js'; + +const [env, env_json_str] = await msghandler.smartsend( + subject, + data, // Array of [dataname, data, type] tuples + { + broker_url: 'nats://localhost:4222', + fileserver_url: 'http://localhost:8080', + fileserver_upload_handler: msghandler.plikOneshotUpload, + size_threshold: 500_000, + correlation_id: uuidv4(), + msg_purpose: 'chat', + sender_name: 'msghandler', + receiver_name: '', + receiver_id: '', + reply_to: '', + reply_to_msg_id: '', + is_publish: true, + nats_connection: null, + msg_id: uuidv4(), + sender_id: uuidv4() + } +); +// Returns: Promise<[env, env_json_str]> +``` + +#### JavaScript (Browser) + +```javascript +import msghandler from './src/msghandler_csr.js'; + +const [env, env_json_str] = await msghandler.smartsend( + subject, + data, + { + broker_url: 'ws://localhost:4222', + fileserver_url: 'http://localhost:8080', + fileserver_upload_handler: msghandler.plikOneshotUpload, + size_threshold: 500_000, + correlation_id: uuidv4(), + msg_purpose: 'chat', + sender_name: 'msghandler', + receiver_name: '', + receiver_id: '', + reply_to: '', + reply_to_msg_id: '', + is_publish: true, + nats_connection: null, + msg_id: uuidv4(), + sender_id: uuidv4() + } +); +// Returns: Promise<[env, env_json_str]> +``` + +#### Python + +```python +from msghandler import msghandler + +env, env_json_str = await msghandler.smartsend( + subject: str, + data: List[Tuple[str, Any, str]], + broker_url: str = "nats://localhost:4222", + fileserver_url: str = "http://localhost:8080", + fileserver_upload_handler: Callable = plik_oneshot_upload, + size_threshold: int = 500_000, + correlation_id: str = None, + msg_purpose: str = "chat", + sender_name: str = "msghandler", + receiver_name: str = "", + receiver_id: str = "", + reply_to: str = "", + reply_to_msg_id: str = "", + is_publish: bool = True, + nats_connection: Any = None, + msg_id: str = None, + sender_id: str = None +) +# Returns: Tuple[Dict, str] +``` + +#### MicroPython + +```python +from msghandler import msghandler + +# Limited to direct transport (< 100KB threshold) +env, env_json_str = msghandler.smartsend( + subject, + data, # List of (dataname, data, type) tuples + broker_url="nats://localhost:4222", + size_threshold=100000 # Lower threshold for memory constraints +) +# Returns: Tuple[Dict, str] +``` + +### smartreceive + +Receives and processes messages from NATS, handling both direct and link transport. + +#### Julia + +```julia +using msghandler + +env = msghandler.smartreceive( + msg::NATS.Msg; + fileserver_download_handler::Function = _fetch_with_backoff, + max_retries::Int = 5, + base_delay::Int = 100, + max_delay::Int = 5000 +) +# Returns: ::JSON.Object{String, Any} +``` + +#### JavaScript (Node.js) + +```javascript +import msghandler from './src/msghandler_ssr.js'; + +const env = await msghandler.smartreceive( + msg, + { + fileserver_download_handler: msghandler.fetchWithBackoff, + max_retries: 5, + base_delay: 100, + max_delay: 5000 + } +); +// Returns: Promise +``` + +#### JavaScript (Browser) + +```javascript +import msghandler from './src/msghandler_csr.js'; + +const env = await msghandler.smartreceive( + msg, + { + fileserver_download_handler: msghandler.fetchWithBackoff, + max_retries: 5, + base_delay: 100, + max_delay: 5000 + } +); +// Returns: Promise +``` + +#### Python + +```python +from msghandler import msghandler + +env = await msghandler.smartreceive( + msg, + fileserver_download_handler=fetch_with_backoff, + max_retries=5, + base_delay=100, + max_delay=5000 +) +# Returns: Dict with "payloads" key +``` + +#### MicroPython + +```python +from msghandler import msghandler + +env = msghandler.smartreceive( + msg, + fileserver_download_handler=_sync_fileserver_download, + max_retries=3, + base_delay=100, + max_delay=1000 +) +# Returns: Dict with "payloads" key +``` + +--- + +## Payload Types + +| Type | Julia | JavaScript | Python | MicroPython | Description | +|------|-------|------------|--------|-------------|-------------| +| `text` | `String` | `string` | `str` | `str` | Plain text strings | +| `dictionary` | `Dict`, `NamedTuple` | `Object`, `Array` | `dict`, `list` | `dict` | JSON-serializable dictionaries | +| `arrowtable` | `DataFrame`, `Arrow.Table` | ❌ (Browser), ✅ (Node.js) | `pandas.DataFrame` | ❌ | Tabular data (Arrow IPC) | +| `jsontable` | `DataFrame`, `Vector{NamedTuple}` | `Array` | `list[dict]` | ⚠️ | Tabular data (JSON) - **Only table type in Browser** | +| `image` | `Vector{UInt8}` | `Uint8Array`, `Buffer` | `bytes` | `bytearray` | Image data (PNG, JPG) | +| `audio` | `Vector{UInt8}` | `Uint8Array`, `Buffer` | `bytes` | `bytearray` | Audio data (WAV, MP3) | +| `video` | `Vector{UInt8}` | `Uint8Array`, `Buffer` | `bytes` | `bytearray` | Video data (MP4, AVI) | +| `binary` | `Vector{UInt8}`, `IOBuffer` | `Uint8Array`, `Buffer` | `bytes`, `bytearray` | `bytearray` | Generic binary data | + +--- + +## Cross-Platform Examples + +### Example 1: Chat with Mixed Content + +Send text, image, and large file in one message. + +#### Julia + +```julia +using msghandler + +data = [ + ("message_text", "Hello!", "text"), + ("user_avatar", image_data, "image"), + ("large_document", large_file_data, "binary") +] + +env, env_json_str = smartsend("/chat/room1", data; fileserver_url="http://localhost:8080") +``` + +#### JavaScript (Node.js) + +```javascript +import msghandler from './src/msghandler_ssr.js'; + +const data = [ + ["message_text", "Hello!", "text"], + ["user_avatar", imageData, "image"], + ["large_document", largeFileData, "binary"] +]; + +const [env, env_json_str] = await msghandler.smartsend( + "/chat/room1", + data, + { fileserver_url: 'http://localhost:8080' } +); +``` + +#### JavaScript (Browser) + +```javascript +import msghandler from './src/msghandler_csr.js'; + +const data = [ + ["message_text", "Hello!", "text"], + ["user_avatar", imageData, "image"], + ["large_document", largeFileData, "binary"] +]; + +const [env, env_json_str] = await msghandler.smartsend( + "/chat/room1", + data, + { broker_url: 'ws://localhost:4222', fileserver_url: 'http://localhost:8080' } +); +``` + +#### Python + +```python +from msghandler import msghandler + +data = [ + ("message_text", "Hello!", "text"), + ("user_avatar", image_data, "image"), + ("large_document", large_file_data, "binary") +] + +env, env_json_str = await msghandler.smartsend( + "/chat/room1", + data, + fileserver_url="http://localhost:8080" +) +``` + +### Example 2: Dictionary Exchange + +Send configuration data between platforms. + +#### Julia + +```julia +using msghandler + +config = Dict( + "wifi_ssid" => "MyNetwork", + "wifi_password" => "password123", + "update_interval" => 60 +) + +data = [("config", config, "dictionary")] +env, env_json_str = smartsend("/device/config", data) +``` + +#### JavaScript (Node.js) + +```javascript +import msghandler from './src/msghandler_ssr.js'; + +const config = { + wifi_ssid: "MyNetwork", + wifi_password: "password123", + update_interval: 60 +}; + +const [env, env_json_str] = await msghandler.smartsend( + "/device/config", + [["config", config, "dictionary"]] +); +``` + +#### Python + +```python +from msghandler import msghandler + +config = { + "wifi_ssid": "MyNetwork", + "wifi_password": "password123", + "update_interval": 60 +} + +data = [("config", config, "dictionary")] +env, env_json_str = await msghandler.smartsend("/device/config", data) +``` + +### Example 3: Table Data (Arrow IPC) + +Send tabular data using Apache Arrow IPC format. + +#### Julia + +```julia +using msghandler +using DataFrames + +df = DataFrame( + id = [1, 2, 3], + name = ["Alice", "Bob", "Charlie"], + score = [95, 88, 92] +) + +data = [("students", df, "arrowtable")] +env, env_json_str = smartsend("/data/analysis", data) +``` + +#### JavaScript (Node.js) + +```javascript +import msghandler from './src/msghandler_ssr.js'; + +const df = [ + { id: 1, name: "Alice", score: 95 }, + { id: 2, name: "Bob", score: 88 }, + { id: 3, name: "Charlie", score: 92 } +]; + +const [env, env_json_str] = await msghandler.smartsend( + "/data/analysis", + [["students", df, "arrowtable"]] +); +``` + +#### Python + +```python +from msghandler import msghandler +import pandas as pd + +df = pd.DataFrame({ + "id": [1, 2, 3], + "name": ["Alice", "Bob", "Charlie"], + "score": [95, 88, 92] +}) + +data = [("students", df, "arrowtable")] +env, env_json_str = await msghandler.smartsend("/data/analysis", data) +``` + +#### JavaScript (Browser) + +```javascript +import msghandler from './src/msghandler_csr.js'; + +// Browser uses jsontable (JSON array of objects) instead of arrowtable +// Apache Arrow is not compatible with browsers +const df = [ + { id: 1, name: "Alice", score: 95 }, + { id: 2, name: "Bob", score: 88 }, + { id: 3, name: "Charlie", score: 92 } +]; + +const [env, env_json_str] = await msghandler.smartsend( + "/data/analysis", + [["students", df, "jsontable"]], // Use jsontable for browser + { broker_url: 'ws://localhost:4222' } +); +``` + +### Example 4: Request-Response Pattern + +Bi-directional communication with reply-to support. + +#### Julia + +```julia +using msghandler + +# Requester +env, env_json_str = smartsend( + "/device/command", + [("command", Dict("action" => "read_sensor"), "dictionary")]; + broker_url="nats://localhost:4222", + reply_to="/device/response" +) + +# Receiver (in separate application) +msg = NATS.subscription.next() +env = smartreceive(msg) +# Process request and send response +response_env, response_json = smartsend( + "/device/response", + [("result", Dict("value" => 42), "dictionary")], + reply_to="/device/command", + reply_to_msg_id=env["msg_id"] +) +``` + +#### JavaScript (Node.js) + +```javascript +import msghandler from './src/msghandler_ssr.js'; + +// Requester +const [env, env_json_str] = await msghandler.smartsend( + "/device/command", + [["command", { action: "read_sensor" }, "dictionary"]], + { broker_url: 'nats://localhost:4222', reply_to: '/device/response' } +); + +// Receiver (in separate application) +// const msg = await natsConsumer.next(); +// const env = await msghandler.smartreceive(msg); +// Process request and send response +// const response_env, response_json = await msghandler.smartsend( +// "/device/response", +// [["result", { value: 42 }, "dictionary"]], +// { reply_to: '/device/command', reply_to_msg_id: env.msg_id } +// ); +``` + +#### Python + +```python +from msghandler import msghandler + +# Requester +env, env_json_str = await msghandler.smartsend( + "/device/command", + [("command", {"action": "read_sensor"}, "dictionary")], + broker_url="nats://localhost:4222", + reply_to="/device/response" +) + +# Receiver (in separate application) +# msg = await nats_consumer.next() +# env = await msghandler.smartreceive(msg) +# Process request and send response +# response_env, response_json = await msghandler.smartsend( +# "/device/response", +# [("result", {"value": 42}, "dictionary")], +# reply_to="/device/command", +# reply_to_msg_id=env["msg_id"] +# ) +``` + +--- + +## Testing + +### Test File Organization + +| Platform | Sender Tests | Receiver Tests | +|----------|--------------|----------------| +| **Julia** | `test/test_julia_*_sender.jl` | `test/test_julia_*_receiver.jl` | +| **JavaScript** | `test/test_js_*_sender.js` | `test/test_js_*_receiver.js` | +| **Python** | `test/test_py_*_sender.py` | `test/test_py_*_receiver.py` | + +### Run Tests + +#### Julia + +```bash +# Text message exchange +julia test/test_julia_text_sender.jl +julia test/test_julia_text_receiver.jl + +# Dictionary exchange +julia test/test_julia_dict_sender.jl +julia test/test_julia_dict_receiver.jl + +# File transfer +julia test/test_julia_file_sender.jl +julia test/test_julia_file_receiver.jl + +# Mixed payload types +julia test/test_julia_mix_payloads_sender.jl +julia test/test_julia_mix_payloads_receiver.jl + +# Table exchange +julia test/test_julia_table_sender.jl +julia test/test_julia_table_receiver.jl +``` + +#### JavaScript (Node.js) + +```bash +# Text message exchange +node test/test_js_text_sender.js +node test/test_js_text_receiver.js + +# Dictionary exchange +node test/test_js_dictionary_sender.js +node test/test_js_dictionary_receiver.js + +# Binary transfer +node test/test_js_binary_sender.js +node test/test_js_binary_receiver.js + +# Table exchange +node test/test_js_table_sender.js +node test/test_js_table_receiver.js +``` + +#### Python + +```bash +# Text message exchange +python3 test/test_py_text_sender.py +python3 test/test_py_text_receiver.py + +# Dictionary exchange +python3 test/test_py_dictionary_sender.py +python3 test/test_py_dictionary_receiver.py + +# Binary transfer +python3 test/test_py_binary_sender.py +python3 test/test_py_binary_receiver.py + +# Table exchange +python3 test/test_py_table_sender.py +python3 test/test_py_table_receiver.py +``` + +--- + +## Browser Deployment + +### Using with Node.js Build Tools + +The browser implementation (`src/msghandler_csr.js`) can be bundled for production deployment using modern JavaScript build tools. + +#### Prerequisites + +```bash +# Install the browser-compatible NATS client +npm install nats.ws +``` + +#### Vite (Recommended) + +```bash +npm create vite@latest my-app -- --template vanilla +cd my-app +npm install nats.ws +``` + +In `vite.config.js`: +```javascript +import { defineConfig } from 'vite'; +export default defineConfig({ + resolve: { + alias: { + 'nats.ws': 'nats.ws/dist/esm/browser.js' + } + } +}); +``` + +Build command: +```bash +npm run build # Outputs to dist/ folder +``` + +#### Webpack + +```bash +npm install webpack webpack-cli --save-dev +npm install nats.ws +``` + +In `webpack.config.js`: +```javascript +module.exports = { + entry: './src/index.js', + output: { + filename: 'bundle.js', + path: __dirname + '/dist' + }, + resolve: { + alias: { + 'nats.ws': 'nats.ws/dist/esm/browser.js' + } + } +}; +``` + +Build command: +```bash +npx webpack +``` + +#### esbuild (Simple & Fast) + +```bash +npm install esbuild nats.ws --save-dev +``` + +Create `build.js`: +```javascript +import esbuild from 'esbuild'; + +esbuild.buildSync({ + entryPoints: ['src/msghandler_csr.js'], + bundle: true, + outfile: 'dist/msghandler-csr-bundle.js', + format: 'esm', + platform: 'browser', + target: 'es2020' +}); +``` + +Build command: +```bash +node build.js +``` + +### Using in Your HTML + +```html + + + + My App + + + + + + +``` + +--- + +## Documentation + +For detailed architecture and implementation information, see: + +- [`docs/architecture.md`](docs/architecture.md) - Cross-platform architecture, API parity, platform-specific patterns +- [`docs/requirements.md`](docs/requirements.md) - Business requirements and user stories +- [`docs/spec.md`](docs/spec.md) - Technical specification and contracts +- [`docs/walkthrough.md`](docs/walkthrough.md) - Real-world application building guides + +--- + +## License + +MIT License + +Copyright (c) 2026 msghandler Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..470c2a6 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,941 @@ +# Architecture Documentation: msghandler + +**Version**: 1.4.0 +**Date**: 2026-05-14 +**Status**: Active +**Ground Truth**: [`src/msghandler.jl`](../src/msghandler.jl) +**Architecture Level**: C4 Container Level + +--- + +## 1. Executive Summary + +This document defines the **blueprint** for msghandler - the cross-platform bi-directional data bridge that enables seamless communication between **Julia**, **JavaScript**, **Python**, **Dart**, **Rust**, and **MicroPython** applications using NATS as the message bus. + +This architecture document serves as the single source of truth for: +- **System Structure**: How components fit together and interact +- **Scaling Considerations**: How the system scales horizontally and vertically +- **Failure Modes**: How the system handles failures and recovers +- **Trade-off Decisions**: The rationale behind architectural decisions + +### 1.1 Specification Traceability + +| Architecture Section | Specification Reference | UI Specification Reference | Requirement ID(s) | +|---------------------|-------------------------|---------------------------|-------------------| +| Section 2 (Context Diagram) | specification.md:2 | - | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-012, FR-013, FR-014 | +| Section 3 (Container Diagram) | specification.md:2, specification.md:3, specification.md:11 | - | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-012, FR-013, FR-014 | +| Section 4 (Component Diagram) | specification.md:2, specification.md:3, specification.md:5, specification.md:11 | - | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-012, FR-013, FR-014 | +| Section 5 (High-Level) | specification.md:2, specification.md:3, specification.md:5, specification.md:11 | - | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-012, FR-013, FR-014 | +| Section 6 (Message Envelope) | specification.md:2, specification.md:3, specification.md:8 | - | FR-011, FR-012, FR-013, FR-014, NFR-401, NFR-403 | +| Section 7 (Payload Type) | specification.md:3, specification.md:5, specification.md:6 | - | FR-001, FR-002, FR-003, FR-006, FR-012, NFR-101, NFR-102 | +| Section 8 (Transport Strategy) | specification.md:6, specification.md:7 | - | FR-003, FR-004, FR-005, FR-010, NFR-104, NFR-105, NFR-106 | +| Section 9 (Platform-Specific) | specification.md:13, specification.md:14 | - | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-012, FR-013, FR-014 | +| Section 10 (Scaling) | specification.md:7, specification.md:13 | - | NFR-101, NFR-102, NFR-103, NFR-104, NFR-105, NFR-106, NFR-107 | +| Section 11 (Failure Modes) | specification.md:9, specification.md:11 | - | FR-008, FR-009, FR-010, FR-011, NFR-201, NFR-202, NFR-203 | +| Section 12 (Trade-offs) | specification.md:2, specification.md:3, specification.md:6, specification.md:7 | - | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-008, FR-009, FR-010, FR-011, FR-012, FR-013, FR-014 | +| Section 13 (Deployment) | specification.md:12, specification.md:18 | - | FR-013, FR-014, NFR-201, NFR-203 | +| Section 14 (Security) | specification.md:4, specification.md:9, specification.md:12 | - | NFR-301, NFR-302, NFR-303, NFR-401, NFR-402, NFR-403, NFR-404, NFR-405 | +| Section 15 (Testing) | specification.md:17 | - | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-012, FR-013, FR-014 | + +--- + +## 2. Architecture Overview + +## Architecture Overview + +### C4 Context Diagram + +```mermaid +flowchart TD + subgraph "External Systems" + NATS_Server[NATS Server] + File_Server[HTTP File Server
Plik/AWS S3/Custom] + end + + subgraph "Client Applications" + Julia_App[Julia Application] + JS_App[JavaScript Application
Node.js/Browser] + Python_App[Python Application
Desktop] + Dart_App[Dart Application
Desktop/Flutter/Web] + Rust_App[Rust Application
Server/Desktop] + MicroPython_App[MicroPython Device] + end + + Julia_App -->|NATS| NATS_Server + JS_App -->|NATS| NATS_Server + Python_App -->|NATS| NATS_Server + Dart_App -->|NATS| NATS_Server + Rust_App -->|NATS| NATS_Server + MicroPython_App -->|NATS| NATS_Server + + Julia_App -->|HTTP| File_Server + JS_App -->|HTTP| File_Server + Python_App -->|HTTP| File_Server + Dart_App -->|HTTP| File_Server + Rust_App -->|HTTP| File_Server + MicroPython_App -->|HTTP| File_Server + + style NATS_Server fill:#fff3e0,stroke:#f57c00 + style File_Server fill:#f3e5f5,stroke:#9c27b4 + style Julia_App fill:#e8f5e9,stroke:#4caf50 + style JS_App fill:#e3f2fd,stroke:#2196f3 + style Python_App fill:#e3f2fd,stroke:#2196f3 + style Dart_App fill:#fff0f6,stroke:#e91e63 + style Rust_App fill:#dea584,stroke:#e65100 + style MicroPython_App fill:#fce4ec,stroke:#e91e63 +``` + +### C4 Container Diagram + +```mermaid +flowchart TD + subgraph "Client Container" + Julia_Module[Julia msghandler Module] + JS_Module[JavaScript msghandler Module] + Python_Module[Python msghandler Module] + Dart_Module[Dart msghandler Module] + Rust_Module[Rust msghandler Module] + MicroPython_Module[MicroPython msghandler Module] + end + + Julia_Module --> NATS_Client + JS_Module --> NATS_Client + Python_Module --> NATS_Client + Dart_Module --> NATS_Client + Rust_Module --> NATS_Client + MicroPython_Module --> NATS_Client + + NATS_Client --> NATS_Broker + + Julia_Module --> File_Client + JS_Module --> File_Client + Python_Module --> File_Client + Dart_Module --> File_Client + Rust_Module --> File_Client + MicroPython_Module --> File_Client + + File_Client --> File_Server + + style Julia_Module fill:#e8f5e9,stroke:#4caf50 + style JS_Module fill:#e3f2fd,stroke:#2196f3 + style Python_Module fill:#e3f2fd,stroke:#2196f3 + style Dart_Module fill:#fff0f6,stroke:#e91e63 + style Rust_Module fill:#dea584,stroke:#e65100 + style MicroPython_Module fill:#fce4ec,stroke:#e91e63 + style NATS_Broker fill:#fff3e0,stroke:#f57c00 + style File_Server fill:#f3e5f5,stroke:#9c27b4 +``` + +### C4 Component Diagram (Julia Implementation) + +```mermaid +flowchart TD + subgraph "msghandler Module" + SmartSend[smartsend Function] + SmartReceive[smartreceive Function] + + Serialize[_serialize_data] + Deserialize[_deserialize_data] + + EnvelopeToJson[envelope_to_json] + + FileServerUpload[fileserver_upload_handler] + FileServerDownload[fileserver_download_handler] + + LogTrace[log_trace] + end + + subgraph "Data Models" + Payload[msg_payload_v1 Struct] + Envelope[msg_envelope_v1 Struct] + end + + SmartSend --> Serialize + SmartSend --> EnvelopeToJson + SmartSend --> FileServerUpload + + SmartReceive --> Deserialize + SmartReceive --> FileServerDownload + + EnvelopeToJson --> Envelope + Serialize --> Payload + + style SmartSend fill:#d1fae5,stroke:#10b981 + style SmartReceive fill:#d1fae5,stroke:#10b981 + style FileServerUpload fill:#fef3c7,stroke:#f59e0b + style FileServerDownload fill:#fef3c7,stroke:#f59e0b +``` + +--- + +## High-Level Architecture + +### System Components + +| Component | Purpose | Platform Support | +|-----------|---------|------------------| +| **smartsend** | Send data via NATS with automatic transport selection, returns (envelope, json_string) for caller to publish | All | +| **smartreceive** | Receive and process NATS messages from JSON string | All | +| **_serialize_data** | Serialize data according to payload type | All | +| **_deserialize_data** | Deserialize bytes to native data types | All | +| **envelope_to_json** | Convert msg_envelope_v1 struct to JSON string | All | +| **log_trace** | Log trace messages with correlation ID | All | +| **fileserver_upload_handler** | Upload large payloads to HTTP server | Desktop (Julia/JS/Python/Dart/Rust) | +| **fileserver_download_handler** | Download payloads from HTTP server with exponential backoff | Desktop (Julia/JS/Python/Dart/Rust) | +| **plik_upload_file** | Upload a local file to Plik server from disk | Rust | + +### Data Flow + +```mermaid +flowchart TD + A[User calls smartsend subject data] --> B[Process each payload] + B --> C{Calculate serialized size} + C -->|Size < Threshold| D[Direct Transport] + C -->|Size >= Threshold| E[Link Transport] + + D --> F[Serialize data] + F --> G[Base64 encode] + G --> H[Build payload object] + + E --> I[Serialize data] + I --> J[Upload to file server] + J --> K[Get download URL] + K --> H + + H --> L[Build envelope] + L --> M[Convert to JSON] + M --> N[Return envelope + JSON to caller] + + style A fill:#f9f9f9,stroke:#333 + style N fill:#e0e7ff,stroke:#3b82f6 + style D fill:#d1fae5,stroke:#10b981 + style E fill:#fef3c7,stroke:#f59e0b +``` + +--- + +## Message Envelope Architecture + +### msg_envelope_v1 Structure (Julia) + +```julia +struct msg_envelope_v1 + correlation_id::String # UUID v4 for distributed tracing + msg_id::String # UUID v4 for this message + timestamp::String # ISO 8601 UTC timestamp + + send_to::String # NATS subject to publish to + msg_purpose::String # ACK, NACK, updateStatus, shutdown, chat + sender_name::String # Sender application name + sender_id::String # UUID v4 of sender + receiver_name::String # Receiver application name (empty = broadcast) + receiver_id::String # UUID v4 of receiver (empty = broadcast) + + reply_to::String # Topic for reply messages + reply_to_msg_id::String # Message ID being replied to + broker_url::String # NATS broker URL + + metadata::Dict{String, Any} # Message-level metadata + payloads::Vector{msg_payload_v1} # List of payloads +end +``` + +### msg_payload_v1 Structure (Julia) + +```julia +struct msg_payload_v1 + id::String # UUID v4 for this payload + dataname::String # Name of the payload + payload_type::String # text, dictionary, arrowtable, etc. + transport::String # direct or link + encoding::String # none, json, base64, arrow-ipc + size::Integer # Size in bytes + data::Any # Base64 string or URL + metadata::Dict{String, Any} # Payload-level metadata +end +``` + +### JSON Schema (Cross-Platform) + +```json +{ + "correlation_id": "string (UUID v4)", + "msg_id": "string (UUID v4)", + "timestamp": "string (ISO 8601 UTC)", + "send_to": "string", + "msg_purpose": "string", + "sender_name": "string", + "sender_id": "string (UUID v4)", + "receiver_name": "string", + "receiver_id": "string (UUID v4)", + "reply_to": "string", + "reply_to_msg_id": "string", + "broker_url": "string", + "metadata": "object", + "payloads": [ + { + "id": "string (UUID v4)", + "dataname": "string", + "payload_type": "string", + "transport": "string", + "encoding": "string", + "size": "integer", + "data": "string or URL", + "metadata": "object" + } + ] +} +``` + +--- + +## Payload Type Architecture + +### Supported Payload Types + +| Type | Description | Serialization | Encoding | Platforms | +|------|-------------|---------------|----------|-----------| +| `text` | Plain text string | UTF-8 bytes | Base64 | All | +| `dictionary` | JSON object | JSON string | Base64/JSON | All | +| `arrowtable` | Apache Arrow IPC | Arrow IPC stream | Base64/arrow-ipc | Desktop (Julia/Python/Node.js/Dart/Rust) | +| `jsontable` | JSON array of objects | JSON string | Base64/json | All (including Browser/Dart Web) | +| `image` | Binary image data | Raw bytes | Base64 | All | +| `audio` | Binary audio data | Raw bytes | Base64 | All | +| `video` | Binary video data | Raw bytes | Base64 | All | +| `binary` | Generic binary data | Raw bytes | Base64 | All | + +### Serialization Logic + +```mermaid +flowchart TD + A[Input data + payload_type] --> B{Payload Type} + + B -->|"text"| C[UTF-8 encode] + B -->|"dictionary"| D[JSON serialize] + B -->|"arrowtable"| E[Arrow IPC serialize] + B -->|"jsontable"| F[JSON serialize] + B -->|"image"| G[Raw bytes] + B -->|"audio"| H[Raw bytes] + B -->|"video"| I[Raw bytes] + B -->|"binary"| J[Raw bytes] + + C --> K[Return bytes] + D --> K + E --> K + F --> K + G --> K + H --> K + I --> K + J --> K + + style A fill:#f9f9f9,stroke:#333 + style K fill:#e0e7ff,stroke:#3b82f6 +``` + +--- + +## Transport Strategy Architecture + +### Size Threshold Decision Logic + +| Platform | Size Threshold | Notes | +|----------|----------------|-------| +| Desktop (Julia/JS/Python/Dart) | 500,000 bytes (0.5MB) | Default threshold | +| Dart Desktop | 500,000 bytes (0.5MB) | Default threshold | +| Dart Flutter | 500,000 bytes (0.5MB) | Default threshold | +| Dart Web | 500,000 bytes (0.5MB) | Default threshold | +| MicroPython | 100,000 bytes (100KB) | Lower threshold for memory constraints | + +### Transport Selection Flow + +```mermaid +flowchart TD + A[smartsend called] --> B[Serialize payload] + B --> C[Calculate size] + C --> D{Size < Threshold?} + + D -->|Yes| E[Direct Transport] + D -->|No| F[Link Transport] + + E --> G[Base64 encode] + G --> H[Build payload with direct transport] + + F --> I[Upload to file server] + I --> J[Get download URL] + J --> K[Build payload with link transport] + + H --> L[Build envelope] + K --> L + + style A fill:#f9f9f9,stroke:#333 + style L fill:#e0e7ff,stroke:#3b82f6 + style E fill:#d1fae5,stroke:#10b981 + style F fill:#fef3c7,stroke:#f59e0b +``` + +### Direct Transport Protocol + +When `transport = "direct"`, the `data` field contains a Base64-encoded string of the serialized payload. + +**Encoding Rules**: +- `text`: UTF-8 → Base64 +- `dictionary`: JSON → Base64 (or direct JSON) +- `arrowtable`: Arrow IPC → Base64 (or arrow-ipc) +- `jsontable`: JSON → Base64 (or direct JSON) +- `image`/`audio`/`video`/`binary`: Raw bytes → Base64 + +### Link Transport Protocol + +When `transport = "link"`, the `data` field contains a URL pointing to the uploaded payload. + +**Upload Flow**: +1. Serialize payload according to `payload_type` +2. Upload to HTTP file server (e.g., Plik) +3. Include returned URL in `data` field + +**Download Flow**: +1. Extract URL from payload +2. Fetch with exponential backoff (max 5 retries) +3. Deserialize based on `payload_type` + +--- + +## Platform-Specific Architecture + +### Julia Architecture + +Julia leverages multiple dispatch for type-specific implementations: + +- **Multiple Dispatch**: Function overloading based on argument types +- **Struct-based Data Models**: Explicit type definitions with `struct` +- **Native Arrow IPC**: Support via `Arrow.jl` +- **Async/Await**: Tasks for non-blocking I/O + +```julia +# Multiple dispatch for serialization +function _serialize_data(data::String, payload_type::String) + # Text serialization +end + +function _serialize_data(data::Dict, payload_type::String) + # Dictionary serialization +end + +function _serialize_data(data::DataFrame, payload_type::String) + # Arrow table serialization +end +``` + +### JavaScript Architecture + +JavaScript uses async/await for non-blocking I/O: + +- **Module-level Utilities**: Serialization functions +- **Native ArrayBuffer**: Binary data handling (Browser) / Buffer (Node.js) +- **Fetch API**: HTTP file server communication + +#### Node.js Implementation (msghandler_ssr.js) + +- **TCP NATS connections**: Uses `nats://` or `tls://` URLs +- **Apache Arrow IPC**: Full support via `apache-arrow` +- **Buffer for binary data**: Native Node.js Buffer handling + +#### Browser Implementation (msghandler_csr.js) + +- **WebSocket NATS connections**: Uses `ws://` or `wss://` URLs via `nats.ws` +- **No Apache Arrow**: Uses `jsontable` for tabular data only +- **Uint8Array for binary data**: Browser-compatible binary handling +- **Web Crypto API**: UUID generation via `crypto.getRandomValues()` + +### Python Architecture + +Python uses classes for stateful operations: + +- **Class-based msghandler**: Encapsulated API +- **Dataclasses**: Structured data (MsgPayloadV1, MsgEnvelopeV1) +- **Async/await**: I/O operations +- **pyarrow**: Arrow IPC support + +```python +class msghandler: + DEFAULT_SIZE_THRESHOLD = 500_000 + + def __init__(self, broker_url=None, fileserver_url=None): + self.broker_url = broker_url or self.DEFAULT_BROKER_URL + self.fileserver_url = fileserver_url or self.DEFAULT_FILESERVER_URL +``` + +### Dart Architecture + +Dart uses classes for stateful operations with async/await: + +- **Class-based msghandler**: Encapsulated API +- **Data classes**: Structured data (MsgPayloadV1, MsgEnvelopeV1) +- **Async/await**: I/O operations +- **dart-arrow**: Arrow IPC support (Desktop/Flutter only) +- **HTTP package**: HTTP file server communication +- **nats package**: NATS client with WebSocket support (Dart Web) + +```dart +class msghandler { + static const DEFAULT_SIZE_THRESHOLD = 500000; + + final String brokerUrl; + final String fileserverUrl; + + msghandler({ + this.brokerUrl = 'nats://localhost:4222', + this.fileserverUrl = 'http://localhost:8080', + }); +} +``` + +#### Dart Desktop (Dart SDK) + +- **TCP NATS connections**: Uses `nats://` or `tls://` URLs +- **Apache Arrow IPC**: Full support via `dart-arrow` +- **Uint8List for binary data**: Native Dart binary handling + +#### Dart Flutter (Dart SDK) + +- **TCP NATS connections**: Uses `nats://` or `tls://` URLs +- **Apache Arrow IPC**: Full support via `dart-arrow` +- **Uint8List for binary data**: Native Dart binary handling + +#### Dart Web (Dart SDK) + +- **WebSocket NATS connections**: Uses `ws://` or `wss://` URLs via `nats` package +- **No Apache Arrow**: Uses `jsontable` for tabular data only +- **Uint8List for binary data**: Browser-compatible binary handling +- **Fetch API**: HTTP file server communication via `http` package + +### Browser Architecture + +Browser JavaScript has specific constraints due to security and compatibility: + +- **Async/await**: Native async/await support +- **No Apache Arrow**: Arrow IPC not available in browsers +- **JSON table only**: Use "jsontable" for tabular data +- **WebSocket NATS**: Uses nats.ws for browser-compatible NATS connections +- **Fetch API**: HTTP file server communication via fetch + +### MicroPython Architecture + +MicroPython has significant constraints: + +- **Synchronous API**: No async/await +- **Memory-constrained**: 256KB - 1MB +- **Limited payload support**: No tables, max 50KB +- **Simplified UUID generation**: Custom implementation + +```python +# MicroPython constraints +DEFAULT_SIZE_THRESHOLD = 100_000 # 100KB +MAX_PAYLOAD_SIZE = 50_000 # 50KB hard limit +``` + +### Rust Architecture + +Rust leverages compile-time type safety and async runtimes: + +- **Type-safe payloads**: Rust enum discriminates between `Text`, `Dictionary`, `ArrowTable`, `Binary`, etc. +- **serde serialization**: Automatic JSON deserialization via `#[derive(Serialize, Deserialize)]` +- **tokio runtime**: Efficient async I/O for NATS connections and HTTP file server operations +- **arrow2 integration**: Native Arrow IPC deserialization without intermediate format conversion +- **reqwest**: High-performance HTTP client with built-in TLS and connection pooling +- **Zero-copy patterns**: `Vec` passed directly to avoid unnecessary memory copies +- **Result**: Idiomatic error handling with typed error types + +```rust +// Type-safe payload enum (compile-time discrimination) +#[derive(Serialize, Deserialize, Clone)] +pub enum Payload { + Text(String), + Dictionary(serde_json::Value), + ArrowTable(Vec), + JsonTable(serde_json::Value), + Image(Vec), + Audio(Vec), + Video(Vec), + Binary(Vec), +} + +// Configuration via builder pattern +pub struct SmartsendOptions { + pub broker_url: String, + pub fileserver_url: String, + pub fileserver_upload_handler: Option>, + pub size_threshold: usize, + pub correlation_id: String, + pub msg_purpose: String, + pub sender_name: String, + // ... other fields +} + +// NATS client with tokio integration +let conn = nats::connect("nats://localhost:4222").await?; + +// Subscribe and process messages +let mut sub = conn.subscribe("/agent/wine/api/v1/analyze")?; +for msg in sub.messages() { + let envelope = smartreceive(&String::from_utf8_lossy(&msg.payload), &Default::default()).await?; + // Access deserialized payloads by type + for payload in &envelope.payloads { + match payload.payload_type.as_str() { + "arrowtable" => { /* payload.data is base64-encoded Arrow IPC */ }, + "text" => { /* payload.data is decoded text string */ }, + "binary" | "image" | "audio" | "video" => { /* payload.data is base64-encoded binary */ }, + _ => { /* other types */ } + } + } +} +``` + +--- + +## Scaling Architecture + +### Horizontal Scaling + +| Component | Scaling Strategy | +|-----------|------------------| +| **NATS Server** | Cluster deployment with multiple nodes | +| **File Server** | Load balancer + multiple instances | +| **Client Applications** | Deploy multiple instances behind load balancer | + +### Vertical Scaling + +| Component | Scaling Strategy | +|-----------|------------------| +| **NATS Server** | Increase memory, CPU, disk I/O | +| **File Server** | Increase memory, CPU, disk capacity | +| **Client Applications** | Increase heap size (Python/JS) | + +### Performance Considerations + +| Metric | Target | Notes | +|--------|--------|-------| +| Message serialization overhead | <50ms | For 10KB payload | +| Message deserialization overhead | <50ms | For 10KB payload | +| NATS connection establishment | <100ms | Connection pool recommended | +| File upload latency | <1s | For 0.5MB file | +| File download latency | <1s | For 0.5MB file | + +--- + +## Failure Modes and Recovery + +### NATS Connection Failure + +**Scenario**: NATS server unavailable + +**Handler**: +- Connection auto-reconnect via TCP-level reconnection +- Retry with exponential backoff for publish operations + +**Recovery**: +- NATS client automatically attempts reconnection +- Application can check connection status before publishing + +### File Server Unavailable + +**Scenario**: HTTP file server unavailable during upload/download + +**Handler**: +- Retry up to 5 times with exponential backoff (100ms → 5000ms) +- Fallback to direct transport for upload (MicroPython) + +**Recovery**: +- Exponential backoff: `delay = min(delay * 2, max_delay)` +- After max retries, throw error with correlation ID + +### Deserialization Error + +**Scenario**: Payload type mismatch or corrupted data + +**Handler**: +- Log correlation ID and throw error +- No retry (data corruption) + +**Recovery**: +- Application must validate payload_type matches data type +- Use proper serialization before sending + +### Memory Overflow (MicroPython) + +**Scenario**: Payload exceeds maximum size (50KB) + +**Handler**: +- Reject payloads >50KB with MemoryError +- No retry (client-side check) + +**Recovery**: +- Application must split large payloads +- Use direct transport only for small payloads + +--- + +## Trade-off Decisions + +### Decision 1: Direct vs Link Transport Threshold + +**Trade-off**: Memory vs Network I/O + +**Decision**: Use 0.5MB threshold for desktop, 100KB for MicroPython + +**Rationale**: +- Direct transport uses more memory (Base64 encoding adds ~33% overhead) +- Link transport requires network I/O for upload/download +- 0.5MB is reasonable for desktop memory constraints +- 100KB is necessary for MicroPython memory constraints + +### Decision 2: Base64 Encoding for Direct Transport + +**Trade-off**: Bandwidth vs Simplicity + +**Decision**: Use Base64 encoding for all direct transport payloads + +**Rationale**: +- Simplifies JSON serialization (all data is string-compatible) +- Increases payload size by ~33%, but NATS can handle this +- Alternative would be binary payload support (more complex) + +### Decision 3: Multiple Platform Implementations + +**Trade-off**: Development effort vs Cross-platform support + +**Decision**: Maintain separate implementations for each platform + +**Rationale**: +- Each platform has idiomatic patterns (multiple dispatch, async/await, etc.) +- Maintains developer productivity and code quality +- API parity ensures cross-platform compatibility + +### Decision 4: Handler Function Abstraction + +**Trade-off**: Flexibility vs Simplicity + +**Decision**: Abstract file server operations through handler functions + +**Rationale**: +- Allows support for different file server implementations (Plik, AWS S3, custom) +- Maintains simplicity for common use cases +- Enables plug-in architecture for custom backends + +--- + +## Deployment Architecture + +### Minimum Infrastructure + +| Component | Minimum | Notes | +|-----------|---------|-------| +| NATS Server | 1 instance | Single node for development | +| File Server | 1 instance | HTTP server for large payloads | +| Client Memory | 50MB | Desktop platforms (Julia/JS/Python/Dart) | +| Client Memory | 256KB | MicroPython devices | + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NATS_URL` | `nats://localhost:4222` | NATS server URL | +| `FILESERVER_URL` | `http://localhost:8080` | HTTP file server URL | +| `SIZE_THRESHOLD` | `500000` | Size threshold in bytes (0.5MB) | + +### Container Deployment + +```mermaid +flowchart TD + subgraph "Docker Network" + NATS_Container[NATS Server] + FileServer_Container[Plik File Server] + App_Container[Application Container] + end + + App_Container -->|NATS| NATS_Container + App_Container -->|HTTP| FileServer_Container + + style NATS_Container fill:#fff3e0,stroke:#f57c00 + style FileServer_Container fill:#f3e5f5,stroke:#9c27b4 + style App_Container fill:#e3f2fd,stroke:#2196f3 +``` + +--- + +## Security Considerations + +### Payload Integrity + +**Mechanism**: SHA-256 checksum via metadata + +**Implementation**: +- Sender calculates checksum and stores in payload metadata +- Receiver validates checksum on receipt + +### Transport Security + +**Mechanism**: TLS support for NATS connections + +**Implementation**: +- Use `nats://` URL for plain text +- Use `tls://` URL for TLS-encrypted connections + +### File Server Security + +**Mechanism**: Authentication token for file uploads + +**Implementation**: +- Plik uses upload token in `X-UploadToken` header +- Application can implement custom authentication + +--- + +## Testing Architecture + +### Unit Test Coverage + +| Test Category | Coverage | Files | +|---------------|----------|-------| +| Serialization | All payload types | `test/test_*_sender.*` | +| Deserialization | All payload types | `test/test_*_receiver.*` | +| Transport selection | Direct vs link | `test/test_*_mix_payloads.*` | +| File server upload | Plik integration | Platform-specific | +| File server download | Exponential backoff | Platform-specific | + +### Integration Test Scenarios + +| Scenario | Platforms | Payloads | Transport | Expected Result | +|----------|-----------|----------|-----------|-----------------| +| Cross-platform text | Julia ↔ JS ↔ Python | text | direct | Round-trip successful | +| Arrow IPC round-trip | Julia ↔ JS ↔ Python | arrowtable | direct | Arrow IPC preserved | +| Large file transfer | All | image/audio/video | link | File server upload/download | +| Multi-payload mixed | All | text + image + file | direct/link | All payloads preserved | + +--- + +## Versioning + +### Architecture Versioning + +| Component | Version | Notes | +|-----------|---------|-------| +| Architecture | 1.0.0 | Initial release | +| Protocol | v1 | Message envelope protocol version | + +### Backward Compatibility + +| Version | Supported Platforms | +|---------|---------------------| +| v1.0.x | Julia 1.7+, Node.js 16+, Python 3.8+, Dart 2.17+, Rust 1.70+, MicroPython 1.19+ | + +--- + +## Change Log + +| Date | Version | Changes | +|------|---------|---------| +| 2026-05-14 | 1.4.0 | Updated Rust API to reflect `smartreceive` deserialization changes | All sections | +| - | - | `smartreceive` now stores deserialized data in `MsgPayloadV1.data` | specification.md:8 | +| - | - | Added `plik_upload_file` convenience function to component table | specification.md:13 | +| - | - | Fixed Rust payload access pattern (data is String, not Payload enum) | All sections | +| - | - | Fixed `SmartsendOptions.fileserver_upload_handler` type to `Arc` | specification.md:13 | +| - | - | Removed `metadata` from link transport examples (now `None`/omitted) | specification.md:3 | +| - | - | Removed duplicate footer text | All sections | +| 2026-05-13 | 1.3.0 | Added Rust support with tokio, serde, and arrow2 | All sections | +| - | - | Added Rust to C4 diagrams (context, container) | All sections | +| - | - | Added Rust platform-specific architecture section | specification.md:13 | +| - | - | Updated component table with Rust support | All sections | +| 2026-05-13 | 1.2.0 | Aligned with ground truth implementation (src/msghandler.jl) | +| - | - | Removed publish_message component (commented out in source) | +| - | - | Removed NATSClient and NATSConnectionPool classes (not in ground truth) | +| - | - | Updated component diagram to match actual module structure | +| - | - | Updated data flow to show smartsend returns JSON for caller to publish | +| - | - | Fixed SIZE_THRESHOLD default to 500,000 bytes | +| 2026-03-15 | 1.1.0 | JavaScript connection management | +| - | - | Added NATSClient with keepAlive support | +| - | - | Added NATSConnectionPool for connection reuse | +| - | - | Added publishMessage function with closeConnection option | +| 2026-03-13 | 1.0.0 | Initial architecture documentation | + +--- + +## 16. References + +### 16.1 Documentation Artifacts + +| Document | Purpose | Specification Traceability | UI Specification Traceability | Requirement ID(s) | +|----------|---------|---------------------------|------------------------------|-------------------| +| [`docs/requirements.md`](./requirements.md) | Business requirements and user stories | FR-001 through FR-014, NFR-101 through NFR-405 | - | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`docs/specification.md`](./specification.md) | Technical contract for msghandler | specification.md:2-19 (all sections) | - | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`docs/ui-specification.md`](./ui-specification.md) | UI specification for client applications | - | All UI components and interactions | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`docs/walkthrough.md`](./walkthrough.md) | End-to-end system flow | specification.md:2-19 (all sections) | - | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`docs/architecture.md`](./architecture.md) | System architecture diagrams | specification.md:2-19 (all sections) | - | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`docs/validation.md`](./validation.md) | CI/CD validation rules | specification.md:2-19 (all sections) | - | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`docs/runbook.md`](./runbook.md) | Operational runbook | specification.md:2-19 (all sections) | - | FR-001 through FR-014, NFR-101 through NFR-405 | + +### 16.2 Implementation Files + +| File | Platform | Features | Specification Traceability | Requirement ID(s) | +|------|----------|----------|---------------------------|-------------------| +| [`src/msghandler.jl`](../src/msghandler.jl) | Julia | Full feature set, Arrow IPC, multiple dispatch | specification.md:2-19 (all sections) | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler_ssr.js`](../src/msghandler_ssr.js) | Node.js | Arrow IPC, async/await | specification.md:2-19 (all sections) | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler_csr.js`](../src/msghandler_csr.js) | Browser | JSON table only, WebSocket NATS | specification.md:2-19 (all sections) | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler.py`](../src/msghandler.py) | Python | Arrow IPC, async/await | specification.md:2-19 (all sections) | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler.dart`](../src/msghandler.dart) | Dart | Full feature set, Arrow IPC, async/await | specification.md:2-19 (all sections) | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler.rs`](../src/msghandler.rs) | Rust | Full feature set, Arrow IPC, async/await, type-safe, file upload helpers | specification.md:2-19 (all sections) | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler_mpy.py`](../src/msghandler_mpy.py) | MicroPython | Limited to direct transport | specification.md:2-19 (all sections) | FR-005, FR-006, FR-012 | + +### 16.3 External Dependencies + +| Platform | Package | Version | Purpose | Specification Traceability | Requirement ID(s) | +|----------|---------|---------|---------|---------------------------|-------------------| +| Julia | NATS.jl | Latest | NATS client | specification.md:11 | FR-013, FR-014, NFR-201 | +| Julia | JSON.jl | Latest | JSON serialization | specification.md:11 | FR-012, NFR-101, NFR-102 | +| Julia | Arrow.jl | Latest | Arrow IPC support | specification.md:11 | FR-002, FR-012 | +| Julia | HTTP.jl | Latest | HTTP file server | specification.md:11 | FR-008, FR-009 | +| Julia | UUIDs.jl | Latest | UUID generation | specification.md:11 | FR-011, NFR-401 | +| Node.js | nats | Latest | NATS client (TCP) | specification.md:11 | FR-013, FR-014 | +| Node.js | node-fetch | Latest | HTTP file server | specification.md:11 | FR-008, FR-009 | +| Browser | nats.ws | Latest | NATS client (WebSocket) | specification.md:11 | FR-013, FR-014 | +| Browser | nats | Latest | NATS client (for bundling) | specification.md:11 | FR-013, FR-014 | +| Python | nats-py | Latest | NATS client | specification.md:11 | FR-013, FR-014 | +| Python | aiohttp | Latest | HTTP file server | specification.md:11 | FR-008, FR-009 | +| Python | pyarrow | Latest | Arrow IPC support | specification.md:11 | FR-002, FR-012 | +| Dart | nats | Latest | NATS client | specification.md:11 | FR-013, FR-014 | +| Dart | http | Latest | HTTP file server | specification.md:11 | FR-008, FR-009 | +| Dart | uuid | Latest | UUID generation | specification.md:11 | FR-011, NFR-401 | +| Dart | dart-arrow | Latest | Arrow IPC support | specification.md:11 | FR-002, FR-012 | +| Rust | nats | Latest | NATS client | specification.md:11 | FR-013, FR-014 | +| Rust | serde | Latest | JSON serialization | specification.md:11 | FR-012, NFR-101, NFR-102 | +| Rust | serde_json | Latest | JSON handling | specification.md:11 | FR-012, NFR-101, NFR-102 | +| Rust | tokio | Latest | Async runtime | specification.md:11 | FR-013, FR-014 | +| Rust | reqwest | Latest | HTTP file server | specification.md:11 | FR-008, FR-009 | +| Rust | uuid | Latest | UUID generation | specification.md:11 | FR-011, NFR-401 | +| Rust | arrow2 | Latest | Arrow IPC support | specification.md:11 | FR-002, FR-012 | +| MicroPython | builtin | N/A | Limited implementation | specification.md:11 | FR-005, FR-006, FR-012 | + +--- + +## 17. Change Log + +| Date | Version | Changes | Specification Reference | +|------|---------|---------|------------------------| +| 2026-03-23 | 1.1.0 | Updated to ASG Framework architecture guidelines | specification.md:2-19 (all sections) | +| 2026-03-15 | 1.1.0 | JavaScript connection management | specification.md:2-19 (all sections) | +| 2026-03-13 | 1.0.0 | Initial architecture documentation | specification.md:2-19 (all sections) | + +--- + +## 18. Gap-Check Validation + +| Stage Transition | Gap-Check Question | Status | +|------------------|-------------------|--------| +| Requirements → Specification | Does the Specification define all edge cases and conflict scenarios from the Requirements? | ✅ Verified - All FR-XXX requirements have corresponding spec rules | +| Specification → UI Specification | Does the UI Specification expose all the data and states defined in the Specification? | ⏳ Pending - UI spec not yet created | +| UI Specification → Walkthrough | Does the Walkthrough reflect the complete flow including error states and timing? | ⏳ Pending - UI spec not yet created | +| Walkthrough → Architecture | Does the Architecture support the performance and integration requirements defined in the Walkthrough? | ✅ Verified - Architecture supports all walkthrough flows | + +--- + +*This architecture document is versioned and maintained in git alongside the codebase. All implementations must adhere to this architecture.* diff --git a/docs/requirements.md b/docs/requirements.md new file mode 100644 index 0000000..f6b9198 --- /dev/null +++ b/docs/requirements.md @@ -0,0 +1,438 @@ +# Requirements Document: msghandler + +**Version**: 1.2.0 +**Date**: 2026-05-13 +**Status**: Active +**Ground Truth**: [`src/msghandler.jl`](../src/msghandler.jl) + +--- + +## 1. Business Context & Success Metrics + +### 1.1 Business Goal + +msghandler is a cross-platform, bi-directional data bridge that enables seamless communication between **Julia**, **JavaScript**, **Python**, **Dart**, **Rust**, and **MicroPython** applications using NATS as the message bus. The system implements the **Claim-Check pattern** for efficient handling of large payloads (>0.5MB) by uploading them to an HTTP file server instead of sending raw binary data over NATS. + +### 1.2 User Stories (with acceptance criteria) + +| Story | Priority | Acceptance Criteria | +|-------|----------|---------------------| +| **As a Julia developer**, I want to send text messages to JavaScript/Dart applications that lives on a server and also on a browser | P1 | Text messages are serialized, encoded, and received correctly across platforms | +| **As a Python developer**, I want to send tabular data to Julia/Dart applications | P1 | DataFrame exchange works with both Arrow IPC and JSON formats | +| **As a JavaScript developer**, I want to send large files (>0.5MB) from JavaScript applications that lives on a server and also on a browser to other applications | P1 | Large files are automatically uploaded to file server and URLs are sent via NATS | +| **As a Dart developer**, I want to send text messages to other platforms | P1 | Text messages are serialized, encoded, and received correctly across platforms | +| **As a Dart developer**, I want to send dictionary data to other platforms | P1 | JSON-serializable data is exchanged correctly | +| **As a Dart developer**, I want to send tabular data (List) to other platforms | P1 | JSON table format exchange works with Arrow IPC on desktop | +| **As a Dart developer**, I want to send large files (>0.5MB) | P1 | Large files are automatically uploaded to file server and URLs are sent via NATS | +| **As a MicroPython developer**, I want to send sensor data with minimal memory usage | P1 | Direct transport works for payloads <100KB on memory-constrained devices | +| **As a Rust developer**, I want to send and receive messages with type-safe APIs | P1 | Rust implementation uses serde for serialization, tokio for async, and nats-io for NATS connectivity | +| **As a developer**, I want to send mixed-content messages (text + image + file) | P1 | msghandler accepts list of (dataname, data, type) tuples and handles each payload appropriately | +| **As a developer**, I want to receive multi-payload messages | P1 | msghandler returns payloads as list of tuples with correct types preserved | +| **As a developer**, I want to use Plik as the file server | P2 | Plik one-shot upload mode is supported with upload ID and token handling | +| **As a developer**, I want to use custom HTTP file servers | P2 | Handler function abstraction allows plugging in AWS S3 or custom implementations | +| **As a developer**, I want automatic retry on file server download failures | P1 | Exponential backoff with configurable retries (default: 5, base_delay: 100ms, max_delay: 5000ms) | +| **As a developer**, I want message tracing across distributed systems | P1 | Correlation ID is propagated through all message processing steps | + +### 1.3 KPIs & Targets + +| Metric | Target | Measurement Method | +|--------|--------|-------------------| +| 95% of messages complete within 200ms | 95% | Synthetic monitoring | +| <2 days from onboarding to first PR | 2 days | PR timeline tracking | +| 100% of messages validate against spec | 100% | CI block rate | +| >80% unit test coverage | 80% | Test coverage tools | +| <1% of PRs bypass validation gates | 1% | CI gate analysis | +| MTTR <15 minutes for P1 incidents | 15 minutes | Incident tracking | + +--- + +## 2. Technical Boundaries + +### 2.1 In Scope + +| Feature | Description | +|---------|-------------| +| Cross-platform interoperability | Seamless data exchange between Julia, JavaScript, Python, Dart, Rust, and MicroPython | +| Intelligent transport selection | Direct transport (<0.5MB) vs Link transport (≥0.5MB) based on payload size | +| Unified API | Consistent `smartsend()` and `smartreceive()` functions across all platforms | +| Multi-payload support | List of (dataname, data, type) tuples with appropriate handling | +| File server integration | Plik one-shot upload and custom HTTP server support | +| Reliability features | Exponential backoff retry and correlation ID propagation | +| Message serialization | Converts data types to binary format (Base64, JSON, Arrow IPC) | +| NATS communication | Publishing and subscription via NATS subjects | + +### 2.2 Out of Scope + +| Feature | Reason | +|---------|--------| +| NATS JetStream support | Core NATS sufficient for current use cases | +| Message compression | Compression adds complexity without clear benefit | +| Message encryption | Payload encryption is application-layer concern | +| Persistent message queues | NATS request-reply pattern sufficient | +| Advanced routing rules | Simple NATS subject matching sufficient | + +### 2.3 Dependencies + +| Platform | Package | Version | +|----------|---------|---------| +| Julia | NATS.jl | Latest stable | +| Julia | JSON.jl | Latest stable | +| Julia | Arrow.jl | Latest stable | +| Julia | HTTP.jl | Latest stable | +| Julia | UUIDs.jl | Latest stable | +| Node.js | nats | Latest stable | +| Node.js | node-fetch | Latest stable | +| Python | nats-py | Latest stable | +| Python | aiohttp | Latest stable | +| Python | pyarrow | Latest stable | +| Browser | nats.ws | Latest stable | +| Dart | nats | Latest stable | +| Dart | http | Latest stable | +| Dart | uuid | Latest stable | +| Rust | nats | Latest stable | +| Rust | serde | Latest stable | +| Rust | serde_json | Latest stable | +| Rust | tokio | Latest stable | +| Rust | uuid | Latest stable | + +### 2.4 Platform Compatibility + +| Platform | Minimum Version | Notes | +|----------|-----------------|-------| +| Julia | 1.7+ | Arrow.jl required for arrowtable support | +| Node.js | 16+ | nats.js required, Arrow IPC supported | +| Python | 3.8+ | pyarrow required for arrowtable support | +| Browser | Latest | No Arrow IPC (uses jsontable only) | +| Dart | 2.17+ | Supports Desktop (Dart SDK), Flutter (Dart SDK), and Web (Dart SDK) | +| Rust | 1.70+ | Full support with async/await, Arrow IPC on desktop | +| MicroPython | 1.19+ | Limited to direct transport | + +--- + +## 3. Functional Requirements (FR) + +| ID | Requirement | Description | +|----|-------------|-------------| +| **FR-001** | Cross-platform text messaging | System shall allow users to send text messages between Julia, JavaScript, Python, and MicroPython applications | +| **FR-002** | Cross-platform tabular data | System shall support DataFrame exchange between Julia and Python applications using Arrow IPC format | +| **FR-003** | Large file handling | System shall automatically detect payloads ≥0.5MB and upload them to HTTP file server instead of sending via NATS | +| **FR-004** | Direct transport for small payloads | System shall send payloads <0.5MB directly via NATS without file server upload | +| **FR-005** | MicroPython support | System shall support payloads <100KB on MicroPython devices using direct transport | +| **FR-006** | Multi-payload messages | System shall accept and process lists of (dataname, data, type) tuples | +| **FR-007** | Payload type preservation | System shall preserve payload types when returning multi-payload messages | +| **FR-008** | Plik file server integration | System shall support Plik one-shot upload mode with upload ID and token handling | +| **FR-009** | Custom file server support | System shall provide handler function abstraction for custom HTTP file server implementations | +| **FR-010** | Exponential backoff retry | System shall implement exponential backoff with configurable retries (default: 5, base_delay: 100ms, max_delay: 5000ms) for file server download failures | +| **FR-011** | Correlation ID propagation | System shall propagate correlation IDs through all message processing steps | +| **FR-012** | Message serialization | System shall serialize data types using Base64, JSON, or Arrow IPC encoding | +| **FR-013** | NATS publishing | System shall return JSON string representation for caller to publish to NATS subjects (caller is responsible for actual NATS publish) | +| **FR-014** | NATS subscription | System shall receive and process NATS messages by accepting JSON string from NATS payload | + +--- + +## 4. Non-Functional Requirements (NFRs) + +### 4.1 Performance & Scalability + +| ID | Requirement | Specification | Test Method | +|----|-------------|---------------|-------------| +| **NFR-101** | Message serialization overhead | <50ms for 10KB payload | Benchmark tests | +| **NFR-102** | Message deserialization overhead | <50ms for 10KB payload | Benchmark tests | +| **NFR-103** | NATS connection establishment | <100ms | Connection pool benchmarks | +| **NFR-104** | File upload latency | <1s for 0.5MB file | Integration tests | +| **NFR-105** | File download latency | <1s for 0.5MB file | Integration tests | +| **NFR-106** | Concurrent connections | Support 100+ simultaneous NATS connections | Scale testing | +| **NFR-107** | Message throughput | Handle 1000+ messages/second per instance | Load testing | +| **NFR-108** | File server scalability | Support horizontal scaling of file server backend | Architecture review | + +### 4.2 Availability & Reliability + +| ID | Requirement | Specification | +|----|-------------|---------------| +| **NFR-201** | Message delivery | At-least-once delivery semantics via NATS | +| **NFR-202** | File server availability | Graceful degradation when file server is unavailable | +| **NFR-203** | Connection recovery | Auto-reconnect on NATS connection failure | + +### 4.3 Privacy & Security + +| ID | Requirement | Specification | +|----|-------------|---------------| +| **NFR-301** | Payload integrity | SHA-256 checksum support via metadata | +| **NFR-302** | Transport security | TLS support for NATS connections | +| **NFR-303** | File server security | Authentication token for file uploads | + +### 4.4 Observability & Telemetry + +| ID | Requirement | Specification | +|----|-------------|---------------| +| **NFR-401** | Required logs | `correlation_id`, `msg_id`, `timestamp`, `sender_name`, `receiver_name`, `payload_type`, `transport` | +| **NFR-402** | Critical metrics | `messages_sent_total`, `messages_received_total`, `file_upload_duration_seconds`, `file_download_duration_seconds`, `retry_attempts_total` | +| **NFR-403** | Tracing | Correlation ID propagation for request tracing | +| **NFR-404** | Alerting | `download_retry_exceeded` triggers alert when max retries exceeded | +| **NFR-405** | Retention | Logs: 30 days, Metrics: 1 year | + +--- + +## 5. Acceptance Conditions + +| Condition | Description | +|-----------|-------------| +| **AC-001** | All functional requirements FR-001 through FR-014 are implemented and tested | +| **AC-002** | All non-functional requirements NFR-101 through NFR-405 meet specified targets | +| **AC-003** | Cross-platform text message test passes (Julia ↔ JavaScript ↔ Python) | +| **AC-004** | Cross-platform tabular data test passes with Arrow IPC round-trip (Desktop) | +| **AC-005** | Cross-platform tabular data test passes with JSON table round-trip (Browser) | +| **AC-006** | Large file transfer test passes with file server upload/download | +| **AC-007** | Multi-payload mixed content test passes with all payload types in one message | +| **AC-008** | CI validation gates block PRs on specification violations | +| **AC-009** | Unit test coverage exceeds 80% | +| **AC-010** | Documentation is complete and includes walkthroughs, architecture, and runbook | + +--- + +## 6. Payload Type Requirements + +### 6.1 Supported Payload Types + +| Type | Julia | JavaScript | Python | Dart | MicroPython | Description | +|------|-------|------------|--------|------|-------------|-------------| +| `text` | `String` | `string` | `str` | `String` | `String` | `str` | Plain text strings | +| `dictionary` | `Dict`, `NamedTuple` | `Object`, `Array` | `dict`, `list` | `Map`, `serde_json::Value` | `String` | `dict` | JSON-serializable data | +| `arrowtable` | `DataFrame`, `Arrow.Table` | ❌ (Browser), ✅ (Node.js) | `pandas.DataFrame` | `List` (Desktop), `List` (Flutter) | `arrow2::Table` | ❌ | Tabular data (Arrow IPC) | +| `jsontable` | `Vector{NamedTuple}` | `Array` | `list[dict]` | `Vec` | ⚠️ | Tabular data (JSON) - **Only table type in Browser** | +| `image` | `Vector{UInt8}` | `Uint8Array`, `Buffer` | `bytes` | `Uint8List` | `Vec` | `bytearray` | Image binary data | +| `audio` | `Vector{UInt8}` | `Uint8Array`, `Buffer` | `bytes` | `Uint8List` | `Vec` | `bytearray` | Audio binary data | +| `video` | `Vector{UInt8}` | `Uint8Array`, `Buffer` | `bytes` | `Uint8List` | `Vec` | `bytearray` | Video binary data | +| `binary` | `Vector{UInt8}`, `IOBuffer` | `Uint8Array`, `Buffer` | `bytes`, `bytearray` | `Uint8List` | `Vec` | `bytearray` | Generic binary data | + +### 6.2 Encoding Requirements + +| Payload Type | Encoding Method | Notes | +|--------------|-----------------|-------| +| `text` | UTF-8 → Base64 | Text must be String type | +| `dictionary` | JSON → Base64 | JSON.jl for Julia | +| `arrowtable` | Arrow IPC → Base64 | Requires Arrow.jl/pyarrow (Desktop only) | +| `jsontable` | JSON → Base64 | Human-readable format - **Browser uses this only** | +| `image`/`audio`/`video`/`binary` | Direct → Base64 | Binary data preserved | + +--- + +## 7. Size Threshold Requirements + +### 7.1 Direct Transport Threshold + +| Platform | Threshold | Notes | +|----------|-----------|-------| +| Desktop (Julia/JS/Python/Dart) | 0.5MB | Default size threshold | +| Dart Desktop | 0.5MB | Default size threshold | +| Dart Flutter | 0.5MB | Default size threshold | +| Dart Web | 0.5MB | Default size threshold | +| Rust | 0.5MB | Default size threshold | +| MicroPython | 100KB | Lower threshold for memory constraints | + +### 7.2 Maximum Payload Size + +| Platform | Maximum | Notes | +|----------|---------|-------| +| Desktop | Unlimited | Limited by NATS server configuration | +| Dart Desktop | Unlimited | Limited by NATS server configuration | +| Dart Flutter | Unlimited | Limited by NATS server configuration | +| Dart Web | Unlimited | Limited by NATS server configuration | +| Rust | Unlimited | Limited by NATS server configuration | +| MicroPython | 50KB | Hard limit due to 256KB-1MB memory | + +--- + +## 8. Message Envelope Requirements + +### 8.1 Required Fields + +| Field | Type | Purpose | +|-------|------|---------| +| `correlation_id` | String (UUID) | Track message flow across systems | +| `msg_id` | String (UUID) | Unique message identifier | +| `timestamp` | String (ISO 8601) | Message publication timestamp | +| `send_to` | String | NATS subject to publish to | +| `msg_purpose` | String | ACK, NACK, updateStatus, shutdown, chat | +| `sender_name` | String | Sender application name | +| `sender_id` | String (UUID) | Sender unique identifier | +| `receiver_name` | String | Receiver application name (empty = broadcast) | +| `receiver_id` | String (UUID) | Receiver unique identifier (empty = broadcast) | +| `reply_to` | String | Topic for reply messages | +| `reply_to_msg_id` | String | Message ID being replied to | +| `broker_url` | String | NATS server URL | +| `metadata` | Dict | Message-level metadata | +| `payloads` | Array | List of payload objects | + +### 8.2 Payload Fields + +| Field | Type | Purpose | +|-------|------|---------| +| `id` | String (UUID) | Unique payload identifier | +| `dataname` | String | Name of the payload | +| `payload_type` | String | Type: text, dictionary, arrowtable, etc. | +| `transport` | String | direct or link | +| `encoding` | String | none, json, base64, arrow-ipc | +| `size` | Integer | Payload size in bytes | +| `data` | Any | Base64 string or URL | +| `metadata` | Dict | Payload-level metadata | + +--- + +## 9. Error Handling Requirements + +### 9.1 Error Codes + +| Error | Condition | Response | +|-------|-----------|----------| +| `Unknown payload_type` | Unsupported type | Throw error | +| `Failed to upload` | File server error | Throw error | +| `Failed to fetch` | File server unavailable | Retry with exponential backoff | +| `Unknown transport` | Invalid transport type | Throw error | +| `NATS connection failed` | NATS unavailable | Throw error | + +### 9.2 Exception Handling + +| Scenario | Handler | +|----------|---------| +| File server unavailable | Retry up to 5 times with exponential backoff | +| NATS publish failure | Connection auto-reconnect | +| Deserialization error | Log correlation ID and throw error | +| Memory overflow (MicroPython) | Reject payloads >50KB | + +--- + +## 10. Testing Requirements + +### 10.1 Unit Tests + +| Test Category | Coverage | Files | +|---------------|----------|-------| +| Serialization | All payload types | `test/test_*_sender.*` | +| Deserialization | All payload types | `test/test_*_receiver.*` | +| Transport selection | Direct vs link | `test/test_*_mix_payloads.*` | +| File server upload | Plik integration | Platform-specific | +| File server download | Exponential backoff | Platform-specific | + +### 10.2 Integration Tests + +| Test Scenario | Success Criteria | +|-------------|-----------------| +| Cross-platform text message | Julia ↔ JavaScript ↔ Python | +| Cross-platform tabular data (Desktop) | Arrow IPC round-trip | +| Cross-platform tabular data (Browser) | JSON table round-trip | +| Large file transfer | File server upload/download | +| Multi-payload mixed content | All payload types in one message | + +--- + +## 11. API Contract + +### 11.1 smartsend Signature + +```julia +function smartsend( + subject::String, + data::AbstractArray{Tuple{String, T1, String}, 1}; + broker_url::String = DEFAULT_BROKER_URL, + fileserver_url::String = DEFAULT_FILESERVER_URL, + fileserver_upload_handler::Function = plik_oneshot_upload, + size_threshold::Int = DEFAULT_SIZE_THRESHOLD, + correlation_id::String = string(uuid4()), + msg_purpose::String = "chat", + sender_name::String = "msghandler", + receiver_name::String = "", + receiver_id::String = "", + reply_to::String = "", + reply_to_msg_id::String = "", + msg_id::String = string(uuid4()), + sender_id::String = string(uuid4()) +)::Tuple{msg_envelope_v1, String} where {T1<:Any} +``` + +**Note**: NATS publishing is the caller's responsibility. `smartsend` returns `(env::msg_envelope_v1, env_json_str::String)`. + +### 11.2 smartreceive Signature + +```julia +function smartreceive( + msg_json_str::String; + fileserver_download_handler::Function = _fetch_with_backoff, + max_retries::Int = 5, + base_delay::Int = 100, + max_delay::Int = 5000 +)::JSON.Object{String, Any} +``` + +**Note**: Pass `String(nats_msg.payload)` from NATS subscription to `smartreceive`. + +--- + +## 12. Deployment Requirements + +### 12.1 Minimum Infrastructure + +| Component | Minimum | Notes | +|-----------|---------|-------| +| NATS Server | 1 instance | Single node for development | +| File Server | 1 instance | HTTP server for large payloads | +| Client Memory | 50MB | Desktop platforms (Julia/JS/Python/Dart) | +| Client Memory | 256KB | MicroPython devices | + +### 12.2 Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NATS_URL` | `nats://localhost:4222` | NATS server URL | +| `FILESERVER_URL` | `http://localhost:8080` | HTTP file server URL | +| `SIZE_THRESHOLD` | `500000` | Size threshold in bytes (0.5MB) | + +--- + +## 13. Versioning + +### 13.1 Current Version + +- **Major**: 1 (Breaking changes require major version bump) +- **Minor**: 0 (Feature additions) +- **Patch**: 0 (Bug fixes) + +### 13.2 Version Compatibility + +| Version | Supported Platforms | +|---------|---------------------| +| v1.0.x | Julia 1.7+, Node.js 16+, Python 3.8+, Dart 2.17+, Rust 1.70+, Browser (latest), MicroPython 1.19+ | + +--- + +## 14. Change Log + +| Date | Version | Changes | +|------|---------|---------| +| 2026-05-13 | 1.2.0 | Aligned with ground truth implementation (src/msghandler.jl) | +| - | - | Fixed smartsend signature: removed is_publish, NATS_connection; added sender_name | +| - | - | Fixed smartreceive signature: takes msg_json_str::String instead of msg::NATS.Msg | +| - | - | Fixed size_threshold default from 1,000,000 to 500,000 | +| - | - | Updated FR-013/FR-014 to reflect caller responsibility for NATS publishing | +| - | - | Updated FR-008/FR-009 to include file path upload overload | +| - | - | Updated SIZE_THRESHOLD env var default to 500000 | +| 2026-03-23 | 1.0.0 | Updated to ASG Framework requirements structure | + +--- + +## 15. References + +- [`src/msghandler.jl`](../src/msghandler.jl) - Ground truth implementation (Julia) +- [`src/msghandler_ssr.js`](../src/msghandler_ssr.js) - Server-side JavaScript implementation +- [`src/msghandler_csr.js`](../src/msghandler_csr.js) - Client-side JavaScript implementation +- [`src/msghandler.py`](../src/msghandler.py) - Python implementation +- [`src/msghandler.dart`](../src/msghandler.dart) - Dart implementation +- [`src/msghandler_mpy.py`](../src/msghandler_mpy.py) - MicroPython implementation +- [`src/msghandler.rs`](../src/msghandler.rs) - Rust implementation +- [`README.md`](../README.md) - Project overview +- [`docs/specification.md`](./specification.md) - Technical specification +- [`docs/ui-specification.md`](./ui-specification.md) - UI specification +- [`docs/walkthrough.md`](./walkthrough.md) - End-to-end walkthrough +- [`docs/architecture.md`](./architecture.md) - Architecture documentation +- [`docs/validation.md`](./validation.md) - Validation and CI/CD +- [`docs/runbook.md`](./runbook.md) - Operational runbook \ No newline at end of file diff --git a/docs/specification.md b/docs/specification.md new file mode 100644 index 0000000..f3b2231 --- /dev/null +++ b/docs/specification.md @@ -0,0 +1,1437 @@ +# Specification: msghandler + +**Version**: 1.2.0 +**Date**: 2026-05-13 +**Status**: Active +**Ground Truth**: [`src/msghandler.jl`](../src/msghandler.jl) +**Specification Format**: JSON Schema + AsyncAPI + +--- + +## 1. Technical Contract Overview + +This document defines the **technical contract** for msghandler - the cross-platform bi-directional data bridge that enables seamless communication between **Julia**, **JavaScript**, **Python**, **Dart**, **Rust**, and **MicroPython** applications using NATS as the message bus. + +This specification serves as the single source of truth for: +- **Inputs**: What data structures are accepted by `smartsend()` +- **Outputs**: What data structures are returned by `smartreceive()` +- **Data Shapes**: Exact field names, types, and constraints +- **Error Codes**: Standardized error responses for failure scenarios + +### 1.1 Requirements Traceability + +| Specification Section | Requirement ID(s) | Description | +|----------------------|-------------------|-------------| +| Section 2 (Message Envelope) | FR-012, FR-013, NFR-101, NFR-102 | Message envelope structure and validation | +| Section 3 (Payload Schema) | FR-001, FR-002, FR-003, FR-004, NFR-101, NFR-102 | Payload structure and field definitions | +| Section 4 (Payload Format) | FR-006, FR-007 | Tuple format for smartsend() | +| Section 5 (Enumerations) | FR-003, FR-004, FR-006, NFR-101 | Enumerations for transport and encoding | +| Section 6 (Transport Protocols) | FR-003, FR-004, NFR-104, NFR-105 | Direct and link transport protocols | +| Section 7 (Size Thresholds) | FR-004, FR-005, NFR-104, NFR-105 | Size thresholds for transport selection | +| Section 8 (NATS Subject Convention) | FR-013, FR-014 | NATS subject naming patterns | +| Section 9 (Error Handling) | FR-010, FR-011, NFR-201, NFR-202, NFR-203 | Error codes and exception handling | +| Section 10 (Serialization Rules) | FR-001, FR-002, FR-003, FR-012, NFR-101, NFR-102 | Serialization and encoding rules | +| Section 11 (API Contract) | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-008, FR-009, FR-010, FR-011, FR-012, FR-013, FR-014 | Function signatures for all platforms | +| Section 12 (File Server Interface) | FR-008, FR-009, FR-010 | Upload and download handler contracts | +| Section 13 (Platform-Specific Constraints) | FR-005, FR-006, NFR-106, NFR-107 | Platform-specific feature support | +| Section 14 (Implementation Files) | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007 | Implementation file mapping | +| Section 15 (Message Flow) | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-008, FR-009, FR-010, FR-011, FR-012, FR-013, FR-014 | Mermaid diagrams for send/receive flows | +| Section 16 (Validation Rules) | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-008, FR-009, FR-010, FR-011, FR-012, FR-013, FR-014 | Envelope and payload validation rules | +| Section 17 (Test Contracts) | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-008, FR-009, FR-010, FR-011, FR-012, FR-013, FR-014 | Unit and integration test scenarios | +| Section 18 (Dependencies) | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-008, FR-009, FR-010, FR-011, FR-012, FR-013, FR-014 | Platform-specific dependencies | +| Section 19 (Change Log) | N/A | Version history and changes | + +--- + +## 2. Message Envelope Schema + +## Specification Versioning + +| Component | Version | Notes | +|-----------|---------|-------| +| Specification | 1.0.0 | Initial release | +| Protocol | v1 | Message envelope protocol version | + +--- + +## Message Envelope Schema + +### Envelope Structure (JSON) + +```json +{ + "correlation_id": "string (UUID)", + "msg_id": "string (UUID)", + "timestamp": "string (ISO 8601 UTC)", + "send_to": "string", + "msg_purpose": "string", + "sender_name": "string", + "sender_id": "string (UUID)", + "receiver_name": "string", + "receiver_id": "string (UUID)", + "reply_to": "string", + "reply_to_msg_id": "string", + "broker_url": "string", + "metadata": "object", + "payloads": [ + { + "id": "string (UUID)", + "dataname": "string", + "payload_type": "string", + "transport": "string", + "encoding": "string", + "size": "integer", + "data": "string or URL", + "metadata": "object" + } + ] +} +``` + +### Field Definitions + +| Field | Type | Required | Validation | Description | Requirement ID | +|-------|------|----------|------------|-------------|----------------| +| `correlation_id` | `string` | Yes | UUID v4 format | Track message flow across distributed systems | FR-011, NFR-401 | +| `msg_id` | `string` | Yes | UUID v4 format | Unique identifier for this specific message | FR-012, NFR-401 | +| `timestamp` | `string` | Yes | ISO 8601 UTC | Message publication timestamp | FR-012, NFR-401 | +| `send_to` | `string` | Yes | Non-empty string | NATS subject/topic to publish the message to | FR-013 | +| `msg_purpose` | `string` | Yes | Enum | Purpose of the message | FR-013 | +| `sender_name` | `string` | Yes | Non-empty string | Name of the sender application | FR-013 | +| `sender_id` | `string` | Yes | UUID v4 format | Unique identifier for the sender | FR-013 | +| `receiver_name` | `string` | Yes | Any string | Name of the receiver (empty = broadcast) | FR-013 | +| `receiver_id` | `string` | Yes | Any string | UUID of the receiver (empty = broadcast) | FR-013 | +| `reply_to` | `string` | Yes | Any string | Topic where receiver should reply | FR-013 | +| `reply_to_msg_id` | `string` | Yes | Any string | Message ID this message is replying to | FR-013 | +| `broker_url` | `string` | Yes | Valid URL | NATS broker URL | FR-013 | +| `metadata` | `object` | No | Any JSON object | Message-level metadata | NFR-401 | +| `payloads` | `array` | Yes | Non-empty array | List of payload objects | FR-012, FR-013 | + +--- + +## Payload Schema + +### Payload Structure (JSON) + +```json +{ + "id": "string (UUID)", + "dataname": "string", + "payload_type": "string", + "transport": "string", + "encoding": "string", + "size": "integer", + "data": "string or URL", + "metadata": "object" +} +``` + +### Payload Field Definitions + +| Field | Type | Required | Validation | Description | Requirement ID | +|-------|------|----------|------------|-------------|----------------| +| `id` | `string` | Yes | UUID v4 format | Unique identifier for this payload | FR-012 | +| `dataname` | `string` | Yes | Non-empty string | Name of the payload (e.g., `login_image`, `user_data`) | FR-001, FR-002, FR-003 | +| `payload_type` | `string` | Yes | Enum | Type of payload (see `payload_type` enum) | FR-001, FR-002, FR-003, FR-006 | +| `transport` | `string` | Yes | Enum | Transport method: `direct` or `link` | FR-003, FR-004, NFR-104, NFR-105 | +| `encoding` | `string` | Yes | Enum | Encoding method (see `encoding` enum) | FR-001, FR-002, FR-003, FR-012 | +| `size` | `integer` | Yes | Positive integer | Size of the payload in bytes | FR-003, FR-004, NFR-104, NFR-105 | +| `data` | `string` or `URL` | Yes | Base64 string or URL | Payload data (base64 for direct, URL for link) | FR-003, FR-004, FR-008, FR-009, FR-010 | +| `metadata` | `object` | No | Any JSON object | Payload-level metadata | NFR-401 | + +--- + +## Payload Format + +### Tuple Format for `smartsend()` + +The `smartsend()` function accepts data as an array of tuples with the format: + +``` +("data_name", data, "data_type") +``` + +| Position | Type | Description | Example | +|----------|------|-------------|---------| +| 1 | `string` | Data name - identifier for the payload | `"msg"`, `"login_image"`, `"user_data"` | +| 2 | `any` | Actual data - content to be serialized | `"Hello"`, `{"key": "value"}`, `DataFrame(...)` | +| 3 | `string` | Data type - must be in `payload_type` enum | `"text"`, `"dictionary"`, `"arrowtable"` | + +### Single Payload Example + +```julia +# Julia +smartsend("/chat/user/v1/message", [("msg", "Hello World", "text")]) +``` + +```python +# Python +await smartsend("/chat/user/v1/message", [("msg", "Hello World", "text")]) +``` + +```typescript +// JavaScript +await smartsend("/chat/user/v1/message", [["msg", "Hello World", "text"]]); +``` + +### Multiple Payloads Example + +```julia +# Julia - Mixed text and binary data +data = [ + ("msg", "Hello", "text"), + ("img", binary_data, "image") +] +smartsend("/agent/v1/process", data) +``` + +```python +# Python - Mixed types +data = [ + ("msg", "Hello", "text"), + ("img", binary_data, "image") +] +await smartsend("/agent/v1/process", data) +``` + +### Data Type Mapping + +| Platform | Input Type | Data Type String | +|----------|------------|------------------| +| All | `String` | `"text"` | +| All | `Dict`/`Object` | `"dictionary"` | +| Desktop | `DataFrame` | `"arrowtable"` or `"jsontable"` | +| Browser | `Array` of objects | `"jsontable"` (only table type) | +| All | `Array` of objects | `"jsontable"` | +| All | `Uint8Array`/`Buffer`/`bytes` | `"binary"` | +| Desktop | `Arrow.Table` | `"arrowtable"` | +| All | Image/Audio/Video binary | `"image"`, `"audio"`, `"video"` | + +--- + +## Enumerations + +### `msg_purpose` Enum + +| Value | Description | +|-------|-------------| +| `ACK` | Acknowledgment of successful message processing | +| `NACK` | Negative acknowledgment of message processing failure | +| `updateStatus` | Status update message | +| `shutdown` | Graceful shutdown request | +| `chat` | Chat/message payload | +| `command` | Command payload | +| `event` | Event payload | + +### `payload_type` Enum + +| Value | Description | Supported Platforms | Encoding Options | +|-------|-------------|---------------------|------------------| +| `text` | Plain text string | All | `base64` | +| `dictionary` | JSON object/dictionary | All | `base64`, `json` | +| `arrowtable` | Apache Arrow IPC table | Desktop (Julia/Python/Node.js/Dart) | `base64`, `arrow-ipc` | +| `jsontable` | JSON array of objects | All (including Browser/Dart Web) | `base64`, `json` | +| `image` | Binary image data | All | `base64` | +| `audio` | Binary audio data | All | `base64` | +| `video` | Binary video data | All | `base64` | +| `binary` | Generic binary data | All | `base64` | + +### `transport` Enum + +| Value | Description | Data Format | Use Case | +|-------|-------------|-------------|----------| +| `direct` | Payload sent directly via NATS | Base64-encoded string | Payloads < size_threshold | +| `link` | Payload uploaded to file server | HTTP URL | Payloads ≥ size_threshold | + +### `encoding` Enum + +| Value | Description | Payload Types | +|-------|-------------|---------------| +| `none` | No additional encoding | Link transport URLs | +| `base64` | Base64 encoding | Text, binary, image, audio, video | +| `json` | JSON encoding | Dictionary, jsontable | +| `arrow-ipc` | Apache Arrow IPC format | Arrowtable | + +--- + +## Transport Protocols + +### Direct Transport Protocol + +When `transport = "direct"`, the `data` field contains a Base64-encoded string of the serialized payload. + +**Flow**: +1. Serialize payload according to `payload_type` +2. Encode serialized bytes as Base64 +3. Include Base64 string in `data` field + +**Example**: +```json +{ + "transport": "direct", + "encoding": "base64", + "size": 11, + "data": "SGVsbG8gV29ybGQ=" +} +``` + +### Link Transport Protocol + +When `transport = "link"`, the `data` field contains a URL pointing to the uploaded payload. + +**Flow**: +1. Serialize payload according to `payload_type` +2. Upload to HTTP file server (e.g., Plik) +3. Include returned URL in `data` field + +**Example**: +```json +{ + "transport": "link", + "encoding": "none", + "size": 1000000, + "data": "http://localhost:8080/file/3F62E/4AgGT/data.zip" +} +``` + +--- + +## Size Thresholds + +### Desktop Platforms (Julia/JS/Python) + +| Platform | Size Threshold | Notes | +|----------|----------------|-------| +| Desktop | 500,000 bytes (0.5MB) | Default threshold | + +### MicroPython Platform + +| Platform | Size Threshold | Maximum Payload | Notes | +|----------|----------------|-----------------|-------| +| MicroPython | 100,000 bytes (100KB) | 50,000 bytes | Hard limit due to memory constraints | + +--- + +## NATS Subject Convention + +### Subject Naming Pattern + +``` +/// +``` + +**Examples**: +- `/agent/wine/api/v1/prompt` - AI agent prompt endpoint +- `/chat/user/v1/message` - User chat message +- `/system/worker/v1/status` - Worker status update + +### Subject Wildcards + +| Wildcard | Description | Example | +|----------|-------------|---------| +| `*` | Single-level wildcard | `/chat/user/v1/*` matches `/chat/user/v1/message` | +| `>` | Multi-level wildcard | `/chat/user/v1/>` matches all `/chat/user/v1/*` subjects | + +--- + +## Error Handling + +### Error Response Format + +```json +{ + "error": { + "code": "string", + "message": "string", + "details": "object" + } +} +``` + +### Error Codes + +| Code | HTTP Status | Description | Recovery | Requirement ID | +|------|-------------|-------------|----------|----------------| +| `INVALID_ENVELOPE` | 400 | Message envelope validation failed | Fix envelope structure | FR-012, FR-013, FR-014 | +| `INVALID_PAYLOAD_TYPE` | 400 | Unsupported payload type | Use supported payload_type | FR-001, FR-002, FR-003, FR-006 | +| `INVALID_TRANSPORT` | 400 | Unsupported transport type | Use `direct` or `link` | FR-003, FR-004, FR-006 | +| `UPLOAD_FAILED` | 500 | File server upload failed | Retry or use direct transport | FR-008, FR-009 | +| `DOWNLOAD_FAILED` | 503 | File server download failed | Retry with exponential backoff | FR-010, FR-011, NFR-201, NFR-202 | +| `NATS_CONNECTION_FAILED` | 503 | NATS connection failed | Check NATS server availability | FR-013, FR-014, NFR-201, NFR-203 | +| `DESERIALIZATION_ERROR` | 500 | Payload deserialization failed | Check payload_type matches data | FR-001, FR-002, FR-003, FR-012 | +| `SIZE_EXCEEDED` | 413 | Payload exceeds maximum size | Split payload or use link transport | FR-003, FR-004, FR-005, NFR-104, NFR-105 | + +### Exception Handling + +| Scenario | Handler | Retry Policy | Requirement ID | +|----------|---------|--------------|----------------| +| File server unavailable | Retry up to 5 times | Exponential backoff (100ms → 5000ms) | FR-010, NFR-202 | +| NATS publish failure | Connection auto-reconnect | TCP-level reconnection | FR-013, FR-014, NFR-201, NFR-203 | +| Deserialization error | Log correlation ID and throw | No retry (data corruption) | FR-001, FR-002, FR-003, FR-012, NFR-401 | +| Memory overflow (MicroPython) | Reject payloads >50KB | No retry (client-side check) | FR-005, NFR-106 | + +--- + +## Serialization Rules + +### Text Serialization + +| Platform | Input Type | Serialization | Encoding | +|----------|------------|---------------|----------| +| All | `String` | UTF-8 bytes | Base64 | + +### Dictionary Serialization + +| Platform | Input Type | Serialization | Encoding | +|----------|------------|---------------|----------| +| All | `Object`/`Dict` | JSON string | Base64 or direct JSON | + +### Arrow Table Serialization + +| Platform | Input Type | Serialization | Encoding | +|----------|------------|---------------|----------| +| Desktop | `DataFrame` | Arrow IPC stream | Base64 or arrow-ipc | +| Desktop | `Arrow.Table` | Arrow IPC stream | Base64 or arrow-ipc | +| MicroPython | ❌ | Not supported | N/A | + +### JSON Table Serialization + +| Platform | Input Type | Serialization | Encoding | +|----------|------------|---------------|----------| +| All | `Vector{Dict}`/`Array` | JSON array | Base64 or direct JSON | +| Desktop | `pandas.DataFrame` | JSON array | Base64 or direct JSON | + +### Binary Serialization + +| Platform | Input Type | Serialization | Encoding | +|----------|------------|---------------|----------| +| All | `Uint8Array`/`Buffer`/`bytes` | Raw bytes | Base64 | + +--- + +## API Contract + +### `smartsend` Function Signature + +#### Julia + +```julia +function smartsend( + subject::String, + data::AbstractArray{Tuple{String, T1, String}, 1}; + broker_url::String = DEFAULT_BROKER_URL, + fileserver_url::String = DEFAULT_FILESERVER_URL, + fileserver_upload_handler::Function = plik_oneshot_upload, + size_threshold::Int = DEFAULT_SIZE_THRESHOLD, + correlation_id::String = string(uuid4()), + msg_purpose::String = "chat", + sender_name::String = "msghandler", + receiver_name::String = "", + receiver_id::String = "", + reply_to::String = "", + reply_to_msg_id::String = "", + msg_id::String = string(uuid4()), + sender_id::String = string(uuid4()) +)::Tuple{msg_envelope_v1, String} where {T1<:Any} +``` + +**Note**: NATS publishing is the caller's responsibility. Returns `(env::msg_envelope_v1, env_json_str::String)`. + +#### Python + +```python +async def smartsend( + subject: str, + data: List[Tuple[str, Any, str]], + broker_url: str = "nats://localhost:4222", + fileserver_url: str = "http://localhost:8080", + fileserver_upload_handler: Callable = plik_oneshot_upload, + size_threshold: int = 500_000, + correlation_id: str = None, + msg_purpose: str = "chat", + sender_name: str = "msghandler", + receiver_name: str = "", + receiver_id: str = "", + reply_to: str = "", + reply_to_msg_id: str = "", + msg_id: str = None, + sender_id: str = None +) -> Tuple[Dict, str]: +``` + +**Note**: NATS publishing is the caller's responsibility. + +#### JavaScript (Node.js) + +```typescript +async function smartsend( + subject: string, + data: Array<[string, any, string]>, + options?: { + broker_url?: string; + fileserver_url?: string; + fileserver_upload_handler?: Function; + size_threshold?: number; + correlation_id?: string; + msg_purpose?: string; + sender_name?: string; + receiver_name?: string; + receiver_id?: string; + reply_to?: string; + reply_to_msg_id?: string; + msg_id?: string; + sender_id?: string; + } +): Promise<[Object, string]>; +``` + +**Note**: NATS publishing is the caller's responsibility. + +#### JavaScript (Browser) + +```typescript +async function smartsend( + subject: string, + data: Array<[string, any, string]>, + options?: { + broker_url?: string; + fileserver_url?: string; + fileserver_upload_handler?: Function; + size_threshold?: number; + correlation_id?: string; + msg_purpose?: string; + sender_name?: string; + receiver_name?: string; + receiver_id?: string; + reply_to?: string; + reply_to_msg_id?: string; + msg_id?: string; + sender_id?: string; + } +): Promise<[Object, string]>; +``` + +**Note**: NATS publishing is the caller's responsibility. + +#### MicroPython + +```python +def smartsend( + subject: str, + data: List[Tuple[str, Any, str]], + size_threshold: int = 100_000, # Lower threshold for memory constraints + **kwargs +) -> Tuple[Dict, str]: +``` + +**Note**: NATS publishing is the caller's responsibility. + +#### Dart (Desktop/Flutter) + +```dart +Future<[Map, String]> smartsend( + String subject, + List> data, { + String brokerUrl = 'nats://localhost:4222', + String fileserverUrl = 'http://localhost:8080', + Function? fileserverUploadHandler, + int sizeThreshold = 500000, + String? correlationId, + String msgPurpose = 'chat', + String senderName = 'msghandler', + String receiverName = '', + String receiverId = '', + String replyTo = '', + String replyToMsgId = '', + String? msgId, + String? senderId, +}) async { + // Returns [envelope, jsonString] + // NATS publishing is caller's responsibility +} +``` + +#### Dart Web + +```dart +Future<[Map, String]> smartsend( + String subject, + List> data, { + String brokerUrl = 'nats://localhost:4222', + String fileserverUrl = 'http://localhost:8080', + Function? fileserverUploadHandler, + int sizeThreshold = 500000, + String? correlationId, + String msgPurpose = 'chat', + String senderName = 'msghandler', + String receiverName = '', + String receiverId = '', + String replyTo = '', + String replyToMsgId = '', + String? msgId, + String? senderId, +}) async { + // Returns [envelope, jsonString] + // NATS publishing is caller's responsibility +} +``` + +#### Rust + +```rust +pub async fn smartsend( + subject: &str, + data: &[(String, Payload, String)], + options: &SmartsendOptions, +) -> Result<(MsgEnvelopeV1, String), msghandlerError> + +// SmartsendOptions struct +pub struct SmartsendOptions { + pub broker_url: String, + pub fileserver_url: String, + pub fileserver_upload_handler: Option, + pub size_threshold: usize, + pub correlation_id: String, + pub msg_purpose: String, + pub sender_name: String, + pub receiver_name: String, + pub receiver_id: String, + pub reply_to: String, + pub reply_to_msg_id: String, + pub msg_id: String, + pub sender_id: String, +} + +// Payload enum for type-safe data handling +#[derive(Serialize, Deserialize, Clone)] +pub enum Payload { + Text(String), + Dictionary(serde_json::Value), + ArrowTable(Vec), + JsonTable(serde_json::Value), + Image(Vec), + Audio(Vec), + Video(Vec), + Binary(Vec), +} + +// MsgEnvelopeV1 struct (serde-serializable) +#[derive(Serialize, Deserialize, Clone)] +pub struct MsgEnvelopeV1 { + pub correlation_id: String, + pub msg_id: String, + pub timestamp: String, + pub send_to: String, + pub msg_purpose: String, + pub sender_name: String, + pub sender_id: String, + pub receiver_name: String, + pub receiver_id: String, + pub reply_to: String, + pub reply_to_msg_id: String, + pub broker_url: String, + pub metadata: serde_json::Value, + pub payloads: Vec, +} +``` + +**Note**: NATS publishing is the caller's responsibility. Returns `Result<(MsgEnvelopeV1, String), msghandlerError>`. Uses `serde` for JSON serialization. + +### `smartreceive` Function Signature + +#### Julia + +```julia +function smartreceive( + msg_json_str::String; # Pass String(nats_msg.payload) from NATS subscription + fileserver_download_handler::Function = _fetch_with_backoff, + max_retries::Int = 5, + base_delay::Int = 100, + max_delay::Int = 5000 +)::JSON.Object{String, Any} +``` + +**Note**: Input is JSON string from NATS message payload, not NATS.Msg directly. + +#### Python + +```python +async def smartreceive( + msg_json_str: str, # JSON string from NATS message payload + fileserver_download_handler: Callable = fetch_with_backoff, + max_retries: int = 5, + base_delay: int = 100, + max_delay: int = 5000 +) -> Dict[str, Any]: +``` + +**Note**: Input is JSON string from NATS message payload. + +#### JavaScript (Node.js) + +```typescript +async function smartreceive( + msg_json_str: string, // JSON string from NATS message payload + options?: { + fileserver_download_handler?: Function; + max_retries?: number; + base_delay?: number; + max_delay?: number; + } +): Promise; +``` + +#### JavaScript (Browser) + +```typescript +async function smartreceive( + msg_json_str: string, // JSON string from NATS message payload + options?: { + fileserver_download_handler?: Function; + max_retries?: number; + base_delay?: number; + max_delay?: number; + } +): Promise; +``` + +**Note**: Input is JSON string from NATS message payload. + +#### MicroPython + +```python +def smartreceive(msg_json_str: str, **kwargs) -> Dict[str, Any]: +``` + +**Note**: Input is JSON string from NATS message payload. + +#### Dart (Desktop/Flutter) + +```dart +Future> smartreceive( + Map msg_json_str, // JSON object from NATS message payload + { + Function? fileserverDownloadHandler, + int maxRetries = 5, + int baseDelay = 100, + int maxDelay = 5000, +}) async { + // Returns envelope with processed payloads +} +``` + +#### Dart Web + +```dart +Future> smartreceive( + Map msg_json_str, // JSON object from NATS message payload + { + Function? fileserverDownloadHandler, + int maxRetries = 5, + int baseDelay = 100, + int maxDelay = 5000, +}) async { + // Returns envelope with processed payloads +} +``` + +#### Rust + +```rust +pub async fn smartreceive( + msg_json_str: &str, // JSON string from NATS message payload + options: &SmartreceiveOptions, +) -> Result + +// SmartreceiveOptions struct +pub struct SmartreceiveOptions { + pub fileserver_download_handler: Option, + pub max_retries: u32, + pub base_delay: u64, + pub max_delay: u64, +} +``` + +**Note**: Input is JSON string from NATS message payload. Returns `Result`. + +--- + +## File Server Interface + +### Upload Handler Contract + +**Function Signature**: +```julia +function fileserver_upload_handler( + file_server_url::String, + dataname::String, + data::Vector{UInt8} +)::Dict{String, Any} + +# Overload: Upload file from disk +function fileserver_upload_handler( + file_server_url::String, + filepath::String +)::Dict{String, Any} +``` + +**Return Format**: +```json +{ + "status": 200, + "uploadid": "string", + "fileid": "string", + "url": "string" +} +``` + +**Required Keys**: +| Key | Type | Description | +|-----|------|-------------| +| `status` | `integer` | HTTP response status code | +| `uploadid` | `string` | Upload session identifier | +| `fileid` | `string` | File identifier within session | +| `url` | `string` | Full download URL | + +### Download Handler Contract + +**Function Signature**: +```julia +function fileserver_download_handler( + url::String, + max_retries::Int, + base_delay::Int, + max_delay::Int, + correlation_id::String +)::Vector{UInt8} +``` + +**Retry Policy**: +- Initial delay: `base_delay` milliseconds +- Maximum delay: `max_delay` milliseconds +- Multiplier: 2x per retry +- Maximum retries: `max_retries` + +--- + +## Platform-Specific Constraints + +### Desktop (Julia/Python/Node.js/Dart) + +| Feature | Status | Notes | +|---------|--------|-------| +| Arrow IPC | ✅ Supported | Requires Arrow.jl/pyarrow/dart-arrow | +| JSON table | ✅ Supported | Human-readable format | +| File server upload | ✅ Supported | HTTP/HTTPS | +| File server download | ✅ Supported | HTTP/HTTPS | +| Size threshold | 500KB | Configurable | + +### Browser (JavaScript) + +| Feature | Status | Notes | +|---------|--------|-------| +| Arrow IPC | ❌ Not supported | Apache Arrow not browser-compatible | +| JSON table | ✅ Supported | Only table type available in browser | +| File server upload | ✅ Supported | HTTP/HTTPS | +| File server download | ✅ Supported | HTTP/HTTPS | +| Size threshold | 500KB | Configurable | + +### Dart Desktop (Dart SDK) + +| Feature | Status | Notes | +|---------|--------|-------| +| Arrow IPC | ✅ Supported | Requires dart-arrow package | +| JSON table | ✅ Supported | Human-readable format | +| File server upload | ✅ Supported | HTTP/HTTPS | +| File server download | ✅ Supported | HTTP/HTTPS | +| Size threshold | 500KB | Configurable | + +### Dart Flutter (Dart SDK) + +| Feature | Status | Notes | +|---------|--------|-------| +| Arrow IPC | ✅ Supported | Requires dart-arrow package | +| JSON table | ✅ Supported | Human-readable format | +| File server upload | ✅ Supported | HTTP/HTTPS | +| File server download | ✅ Supported | HTTP/HTTPS | +| Size threshold | 500KB | Configurable | + +### Dart Web (Dart SDK) + +| Feature | Status | Notes | +|---------|--------|-------| +| Arrow IPC | ❌ Not supported | Apache Arrow not browser-compatible | +| JSON table | ✅ Supported | Only table type available in browser | +| File server upload | ✅ Supported | HTTP/HTTPS | +| File server download | ✅ Supported | HTTP/HTTPS | +| Size threshold | 500KB | Configurable | + +### Rust + +| Feature | Status | Notes | +|---------|--------|-------| +| Arrow IPC | ✅ Supported | Requires `arrow2` crate | +| JSON table | ✅ Supported | Uses `serde_json` | +| File server upload | ✅ Supported | HTTP/HTTPS via `reqwest` | +| File server download | ✅ Supported | HTTP/HTTPS via `reqwest` with retry | +| Size threshold | 500KB | Configurable | +| Async runtime | ✅ Supported | Uses `tokio` for async I/O | +| Type safety | ✅ Supported | Compile-time type checking via Rust enums | + +### MicroPython + +| Feature | Status | Notes | +|---------|--------|-------| +| Arrow IPC | ❌ Not supported | Memory constraints | +| JSON table | ⚠️ Limited | Only direct transport | +| File server upload | ❌ Not implemented | Placeholder only | +| File server download | ❌ Not implemented | Placeholder only | +| Size threshold | 100KB | Hard limit enforced | +| Max payload | 50KB | Hard limit enforced | + +--- + +## Implementation Files + +| File | Platform | Features | Notes | +|------|----------|----------|-------| +| [`src/msghandler.jl`](../src/msghandler.jl) | Julia | Full feature set, Arrow IPC, multiple dispatch | Ground truth implementation | +| [`src/msghandler_ssr.js`](../src/msghandler_ssr.js) | Node.js | Arrow IPC, async/await | Server-side JavaScript | +| [`src/msghandler_csr.js`](../src/msghandler_csr.js) | Browser | JSON table only, WebSocket NATS | Client-side rendering | +| [`src/msghandler.py`](../src/msghandler.py) | Python | Arrow IPC, async/await | Desktop Python | +| [`src/msghandler.dart`](../src/msghandler.dart) | Dart | Full feature set, Arrow IPC, async/await | Desktop/Flutter/Web | +| [`src/msghandler.rs`](../src/msghandler.rs) | Rust | Full feature set, Arrow IPC, async/await, type-safe | Uses tokio + serde + arrow2 | +| [`src/msghandler_mpy.py`](../src/msghandler_mpy.py) | MicroPython | Limited to direct transport | Memory-constrained | + +### Browser Implementation Notes + +The browser implementation ([`src/msghandler_csr.js`](../src/msghandler_csr.js)) has the following constraints: + +| Constraint | Reason | Workaround | +|------------|--------|------------| +| No Apache Arrow IPC | Browser-incompatible dependency | Use `jsontable` for tabular data | +| WebSocket NATS only | Browser cannot use TCP directly | Use `ws://` or `wss://` broker URLs | +| Fetch API for HTTP | Browser fetch() API only | Compatible with Plik and other HTTP servers | + +### Payload Type Availability by Platform + +| Payload Type | Julia | Node.js | Browser | Python | Dart | Rust | MicroPython | +|--------------|-------|---------|---------|--------|------|------|-------------| +| `text` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `dictionary` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `arrowtable` | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ | ❌ | +| `jsontable` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | +| `image` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `audio` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `video` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| `binary` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | + +--- + +## Message Flow + +### Sending Flow + +```mermaid +flowchart TD + A[User calls smartsend subject data] --> B[Serialize payload according to payload_type] + B --> C{Calculate serialized size} + C -->|Size < Threshold| D[Direct Transport: Encode as Base64] + C -->|Size >= Threshold| E[Link Transport: Upload to file server] + D --> F[Build envelope with metadata] + E --> F + F --> G[Convert envelope to JSON string] + G --> H[Publish to NATS subject] + H --> I[Return envelope and JSON string to caller] + + style A fill:#f9f9f9,stroke:#333 + style I fill:#e0e7ff,stroke:#3b82f6 + style D fill:#d1fae5,stroke:#10b981 + style E fill:#fef3c7,stroke:#f59e0b +``` + +### Receiving Flow + +```mermaid +flowchart TD + A[NATS message arrives] --> B[Parse JSON envelope] + B --> C[For each payload: Check transport type] + C -->|transport == direct| D[Direct Transport: Extract Base64] + C -->|transport == link| E[Link Transport: Fetch from URL] + D --> F[Decode Base64] + E --> G[Fetch with exponential backoff] + F --> H[Deserialize based on payload_type] + G --> H + H --> I[Build payloads array] + I --> J[Replace payloads array with deserialized tuples] + J --> K[Return envelope with processed payloads] + + style A fill:#f9f9f9,stroke:#333 + style K fill:#e0e7ff,stroke:#3b82f6 + style D fill:#d1fae5,stroke:#10b981 + style E fill:#fef3c7,stroke:#f59e0b +``` + +--- + +## Validation Rules + +### Envelope Validation + +| Rule | Condition | Error Code | Requirement ID | +|------|-----------|------------|----------------| +| Required fields present | `correlation_id`, `msg_id`, `timestamp`, `send_to`, `payloads` | `INVALID_ENVELOPE` | FR-012, FR-013 | +| Valid UUID format | `correlation_id`, `msg_id`, `sender_id`, `receiver_id` | `INVALID_ENVELOPE` | FR-011, FR-012, NFR-401 | +| Valid timestamp format | ISO 8601 UTC | `INVALID_ENVELOPE` | FR-012, NFR-401 | +| Non-empty payloads array | `length(payloads) > 0` | `INVALID_ENVELOPE` | FR-012, FR-013 | + +### Payload Validation + +| Rule | Condition | Error Code | Requirement ID | +|------|-----------|------------|----------------| +| Valid payload_type | Must be in `payload_type` enum | `INVALID_PAYLOAD_TYPE` | FR-001, FR-002, FR-003, FR-006 | +| Valid transport | Must be `direct` or `link` | `INVALID_TRANSPORT` | FR-003, FR-004, FR-006 | +| Valid encoding | Must match payload_type and transport | `INVALID_TRANSPORT` | FR-001, FR-002, FR-003, FR-012 | +| Positive size | `size > 0` | `INVALID_PAYLOAD` | FR-003, FR-004, NFR-104, NFR-105 | +| Valid Base64 for direct | `data` matches Base64 pattern | `DESERIALIZATION_ERROR` | FR-001, FR-002, FR-003, FR-012 | +| Valid URL for link | `data` matches HTTP(S) URL pattern | `DOWNLOAD_FAILED` | FR-008, FR-009, FR-010 | + +--- + +## Test Contracts + +### Unit Test Validation + +| Test | Input | Expected Output | Notes | Requirement ID | +|------|-------|-----------------|-------|----------------| +| Text round-trip | `("msg", "Hello", "text")` | `("msg", "Hello", "text")` | String serialization | FR-001, FR-012, NFR-101, NFR-102 | +| Dictionary round-trip | `("data", {"key": "value"}, "dictionary")` | `("data", {"key": "value"}, "dictionary")` | JSON object round-trip | FR-002, FR-012, NFR-101, NFR-102 | +| Arrow table round-trip | `("table", arrow_table_data, "arrowtable")` | `("table", arrow_table_data, "arrowtable")` | Arrow IPC round-trip | FR-002, FR-012, NFR-101, NFR-102 | +| JSON table round-trip | `("table", [{"a":1},{"b":2}], "jsontable")` | `("table", [{"a":1},{"b":2}], "jsontable")` | JSON array of objects | FR-001, FR-002, FR-006, FR-012 | +| Mixed payloads | `[("msg", "Hello", "text"), ("imgname", bytes, "binary")]` | `[("msg", "Hello", "text"), ("imgname", bytes, "binary")]` | Multiple payload types | FR-006, FR-007 | +| Large payload | `("data", rand(10_000_000), "arrowtable")` | `("data", URL, "arrowtable")` with link transport | File server upload | FR-003, FR-004, FR-008, FR-009, NFR-104, NFR-105 | + +**Platform-Specific Notes:** +- **Julia**: Use `Dict`, `Vector{Dict}`, or convert `DataFrame` to dictionary for testing +- **Python**: Use `dict`, `list[dict]`, or convert `pandas.DataFrame` to dictionary for testing +- **JavaScript**: Use plain objects `{}` and arrays `[]` +- **MicroPython**: Use plain `dict` and `list` (limited to JSON table and text types) + +### Integration Test Scenarios + +| Scenario | Platforms | Payloads | Size Mix | Transport | Expected Result | Requirement ID | +|----------|-----------|----------|----------|-----------|-----------------|----------------| +| Single text (small) | All | `text` | Small | direct | Round-trip successful | FR-001, FR-012, NFR-101, NFR-102 | +| Single dictionary (small) | All | `dictionary` | Small | direct | Round-trip successful | FR-002, FR-012, NFR-101, NFR-102 | +| Single arrow table (small) | Julia/JS/Python | `arrowtable` | Small | direct | Arrow IPC round-trip | FR-002, FR-012, NFR-101, NFR-102 | +| Single JSON table (small) | All | `jsontable` | Small | direct | Dictionary array round-trip | FR-001, FR-002, FR-006, FR-012 | +| Single image (small) | All | `image` | Small | direct | Binary round-trip | FR-001, FR-006, FR-012 | +| Single audio (small) | All | `audio` | Small | direct | Binary round-trip | FR-001, FR-006, FR-012 | +| Single video (small) | All | `video` | Small | direct | Binary round-trip | FR-001, FR-006, FR-012 | +| Single binary (small) | All | `binary` | Small | direct | Binary round-trip | FR-001, FR-006, FR-012 | +| Single text (large) | All | `text` | Large | link | File server upload/download | FR-003, FR-004, FR-008, FR-009, NFR-104, NFR-105 | +| Single JSON table (large) | All | `jsontable` | Large | link | File server upload/download | FR-003, FR-004, FR-008, FR-009, NFR-104, NFR-105 | +| Single image (large) | All | `image` | Large | link | File server upload/download | FR-003, FR-004, FR-008, FR-009, NFR-104, NFR-105 | +| **Ultimate Test** | Julia/JS/Python | `text` (small) + `dictionary` (small) + `arrowtable` (small) + `jsontable` (small) + `image` (small) + `audio` (small) + `video` (small) + `binary` (small) + `text` (large) + `dictionary` (large) + `arrowtable` (large) + `jsontable` (large) + `image` (large) | Mixed | direct/link | All payloads preserved with correct transport | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-008, FR-009, FR-010, FR-011, FR-012, FR-013, FR-014 | +| **Ultimate Test** | MicroPython | `text` (small) + `dictionary` (small) + `text` (large) + `dictionary` (large) | Mixed | direct | Limited to text/dictionary with direct transport only | FR-005, FR-006, FR-012 | +| Cross-platform JSON table | All | `jsontable` | Small | direct | Dictionary array round-trip | FR-001, FR-002, FR-006, FR-012 | +| MicroPython ↔ Desktop | MicroPython ↔ Desktop | `text`/`dictionary` | Small | direct | Limited payload types | FR-005, FR-006, FR-012 | +| Desktop ↔ Desktop (all combos) | Julia↔JS↔Python | All types | Small/Large | direct/link | Full compatibility | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-012, FR-013, FR-014 | + +--- + +## Dependencies + +### Required Dependencies by Platform + +| Platform | Package | Version | Purpose | +|----------|---------|---------|---------| +| Julia | NATS.jl | Latest | NATS client | +| Julia | JSON.jl | Latest | JSON serialization | +| Julia | Arrow.jl | Latest | Arrow IPC support | +| Julia | HTTP.jl | Latest | HTTP file server | +| Julia | UUIDs.jl | Latest | UUID generation | +| Node.js | nats | Latest | NATS client (TCP) | +| Node.js | node-fetch | Latest | HTTP file server | +| Browser | nats.ws | Latest | NATS client (WebSocket) | +| Browser | nats | Latest | NATS client (for bundling) | +| Python | nats-py | Latest | NATS client | +| Python | aiohttp | Latest | HTTP file server | +| Python | pyarrow | Latest | Arrow IPC support | +| Dart | nats | Latest | NATS client | +| Dart | http | Latest | HTTP file server | +| Dart | uuid | Latest | UUID generation | +| Dart | dart-arrow | Latest | Arrow IPC support (Desktop/Flutter) | +| Rust | nats | Latest | NATS client | +| Rust | serde | Latest | JSON serialization | +| Rust | serde_json | Latest | JSON handling | +| Rust | tokio | Latest | Async runtime | +| Rust | reqwest | Latest | HTTP file server | +| Rust | uuid | Latest | UUID generation | +| Rust | arrow2 | Latest | Arrow IPC support | +| MicroPython | builtin | N/A | Limited implementation | + +### Optional Dependencies + +| Platform | Package | Purpose | +|----------|---------|---------| +| Julia | DataFrames.jl | DataFrame support | +| Python | pandas | DataFrame support | + +--- + +## Change Log + +| Date | Version | Changes | +|------|---------|---------| +| 2026-03-15 | 1.1.0 | Browser connection management | +| - | - | Added NATSClient class with keepAlive support | +| - | - | Added NATSConnectionPool for connection reuse | +| - | - | Added publishMessage function with closeConnection option | +| - | - | Added nats.ws to browser dependencies | +| 2026-03-13 | 1.0.0 | Initial specification | +| - | - | Message envelope schema defined | +| - | - | Payload schema with transport modes | +| - | - | Enumerations for payload_type, transport, encoding | +| - | - | Size thresholds for desktop/MicroPython | +| - | - | Error codes and validation rules | +| - | - | API contracts for all platforms | + +--- + +## References + +### 20.1 Documentation Artifacts + +| Document | Purpose | Requirements Traceability | +|----------|---------|--------------------------| +| [`docs/requirements.md`](./requirements.md) | Business requirements and user stories | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`docs/specification.md`](./specification.md) | Technical contract for msghandler | This document | +| [`docs/ui-specification.md`](./ui-specification.md) | UI specification for client applications | UI components for data entry and display | +| [`docs/walkthrough.md`](./walkthrough.md) | End-to-end system flow | Traceability from user journey to technical implementation | +| [`docs/architecture.md`](./architecture.md) | System architecture diagrams | Component interaction and data flow | +| [`docs/validation.md`](./validation.md) | CI/CD validation rules | Contract testing and spec compliance | +| [`docs/runbook.md`](./runbook.md) | Operational runbook | Deployment, scaling, and troubleshooting | + +### 20.2 Implementation Files + +| File | Platform | Features | Requirements Traceability | +|------|----------|----------|--------------------------| +| [`src/msghandler.jl`](../src/msghandler.jl) | Julia | Full feature set, Arrow IPC, multiple dispatch | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler_ssr.js`](../src/msghandler_ssr.js) | Node.js | Arrow IPC, async/await | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler_csr.js`](../src/msghandler_csr.js) | Browser | JSON table only, WebSocket NATS | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler.py`](../src/msghandler.py) | Python | Arrow IPC, async/await | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler.dart`](../src/msghandler.dart) | Dart | Full feature set, Arrow IPC, async/await | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler.rs`](../src/msghandler.rs) | Rust | Full feature set, Arrow IPC, async/await, type-safe | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`src/msghandler_mpy.py`](../src/msghandler_mpy.py) | MicroPython | Limited to direct transport | FR-005, FR-006, FR-012 | + +### 20.3 External Dependencies + +| Platform | Package | Version | Purpose | Requirements Traceability | +|----------|---------|---------|---------|--------------------------| +| Julia | NATS.jl | Latest | NATS client | FR-013, FR-014, NFR-201 | +| Julia | JSON.jl | Latest | JSON serialization | FR-012, NFR-101, NFR-102 | +| Julia | Arrow.jl | Latest | Arrow IPC support | FR-002, FR-012 | +| Julia | HTTP.jl | Latest | HTTP file server | FR-008, FR-009 | +| Julia | UUIDs.jl | Latest | UUID generation | FR-011, NFR-401 | +| Node.js | nats | Latest | NATS client (TCP) | FR-013, FR-014 | +| Node.js | node-fetch | Latest | HTTP file server | FR-008, FR-009 | +| Browser | nats.ws | Latest | NATS client (WebSocket) | FR-013, FR-014 | +| Browser | nats | Latest | NATS client (for bundling) | FR-013, FR-014 | +| Python | nats-py | Latest | NATS client | FR-013, FR-014 | +| Python | aiohttp | Latest | HTTP file server | FR-008, FR-009 | +| Python | pyarrow | Latest | Arrow IPC support | FR-002, FR-012 | +| Dart | nats | Latest | NATS client | FR-013, FR-014 | +| Dart | http | Latest | HTTP file server | FR-008, FR-009 | +| Dart | uuid | Latest | UUID generation | FR-011, NFR-401 | +| Dart | dart-arrow | Latest | Arrow IPC support | FR-002, FR-012 | +| Rust | nats | Latest | NATS client | FR-013, FR-014 | +| Rust | serde | Latest | JSON serialization | FR-012, NFR-101, NFR-102 | +| Rust | serde_json | Latest | JSON handling | FR-012, NFR-101, NFR-102 | +| Rust | tokio | Latest | Async runtime | FR-013, FR-014 | +| Rust | reqwest | Latest | HTTP file server | FR-008, FR-009 | +| Rust | uuid | Latest | UUID generation | FR-011, NFR-401 | +| Rust | arrow2 | Latest | Arrow IPC support | FR-002, FR-012 | +| MicroPython | builtin | N/A | Limited implementation | FR-005, FR-006 | + +--- + +## 21. Change Log + +| Date | Version | Changes | Requirement ID(s) | +|------|---------|---------|-------------------| +| 2026-05-13 | 1.2.0 | Aligned with ground truth implementation (src/msghandler.jl) | All | +| - | - | Updated smartsend signatures: removed is_publish, nats_connection; added sender_name | FR-001 through FR-014 | +| - | - | Updated smartreceive signatures: takes msg_json_str::String instead of msg | FR-001 through FR-014 | +| - | - | Removed publishMessage function and NATSClient/NATSConnectionPool classes from browser section | FR-013, FR-014 | +| - | - | Added plik_oneshot_upload(filepath) overload to file server interface | FR-008, FR-009 | +| - | - | Fixed SIZE_THRESHOLD default to 500,000 bytes | FR-003, FR-004 | +| 2026-03-23 | 1.1.0 | Updated to ASG Framework specification guidelines | All | +| 2026-03-15 | 1.1.0 | Browser connection management | FR-001 through FR-014 | +| 2026-03-13 | 1.0.0 | Initial specification | FR-001 through FR-014, NFR-101 through NFR-405 | + +--- + +## Appendix + +### A. Complete JSON Schema + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "msghandler Envelope", + "type": "object", + "properties": { + "correlation_id": { + "type": "string", + "pattern": "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", + "description": "UUID v4 format for tracking message flow" + }, + "msg_id": { + "type": "string", + "pattern": "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", + "description": "Unique message identifier" + }, + "timestamp": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$", + "description": "ISO 8601 UTC timestamp" + }, + "send_to": { + "type": "string", + "minLength": 1, + "description": "NATS subject to publish to" + }, + "msg_purpose": { + "type": "string", + "enum": ["ACK", "NACK", "updateStatus", "shutdown", "chat", "command", "event"], + "description": "Purpose of the message" + }, + "sender_name": { + "type": "string", + "minLength": 1, + "description": "Sender application name" + }, + "sender_id": { + "type": "string", + "pattern": "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$", + "description": "Sender UUID" + }, + "receiver_name": { + "type": "string", + "description": "Receiver name (empty = broadcast)" + }, + "receiver_id": { + "type": "string", + "pattern": "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$|^$", + "description": "Receiver UUID (empty = broadcast)" + }, + "reply_to": { + "type": "string", + "description": "Topic for reply messages" + }, + "reply_to_msg_id": { + "type": "string", + "description": "Message ID being replied to" + }, + "broker_url": { + "type": "string", + "pattern": "^nats://[^\\s]+$", + "description": "NATS broker URL" + }, + "metadata": { + "type": "object", + "description": "Message-level metadata" + }, + "payloads": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/Payload" + } + } + }, + "required": ["correlation_id", "msg_id", "timestamp", "send_to", "msg_purpose", "sender_name", "sender_id", "receiver_name", "receiver_id", "reply_to", "reply_to_msg_id", "broker_url", "payloads"], + "definitions": { + "Payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$" + }, + "dataname": { + "type": "string", + "minLength": 1 + }, + "payload_type": { + "type": "string", + "enum": ["text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary"] + }, + "transport": { + "type": "string", + "enum": ["direct", "link"] + }, + "encoding": { + "type": "string", + "enum": ["none", "base64", "json", "arrow-ipc"] + }, + "size": { + "type": "integer", + "minimum": 1 + }, + "data": { + "anyOf": [ + { + "type": "string", + "pattern": "^(https?://[^\\s]+)$" + }, + { + "type": "string", + "pattern": "^[A-Za-z0-9+/]+=*$" + } + ] + }, + "metadata": { + "type": "object" + } + }, + "required": ["id", "dataname", "payload_type", "transport", "encoding", "size", "data"] + } + } +} +``` + +### B. AsyncAPI Specification (NATS) + +```yaml +asyncapi: '2.6.0' +info: + title: msghandler API + version: '1.0.0' + description: Cross-platform bi-directional data bridge using NATS + contact: + name: msghandler Team + url: https://github.com/your-org/msghandler + license: + name: MIT + url: https://opensource.org/licenses/MIT +channels: + /agent/{service}/api/v{version}/{operation}: + address: /agent/{service}/api/v{version}/{operation} + parameters: + service: + schema: + type: string + version: + schema: + type: string + enum: ['v1'] + operation: + schema: + type: string + publish: + summary: Publish message to NATS + operationId: publishMessage + message: + $ref: '#/components/message' + subscribe: + summary: Subscribe to NATS messages + operationId: subscribeMessage + message: + $ref: '#/components/message' +components: + message: + payload: + $ref: '#/components/schemas/Envelope' + schemas: + Envelope: + type: object + properties: + correlation_id: + type: string + format: uuid + msg_id: + type: string + format: uuid + timestamp: + type: string + format: date-time + send_to: + type: string + msg_purpose: + type: string + enum: [ACK, NACK, updateStatus, shutdown, chat, command, event] + sender_name: + type: string + sender_id: + type: string + format: uuid + receiver_name: + type: string + receiver_id: + type: string + format: uuid + reply_to: + type: string + reply_to_msg_id: + type: string + broker_url: + type: string + metadata: + type: object + payloads: + type: array + items: + $ref: '#/components/schemas/Payload' + required: + - correlation_id + - msg_id + - timestamp + - send_to + - msg_purpose + - sender_name + - sender_id + - receiver_name + - receiver_id + - reply_to + - reply_to_msg_id + - broker_url + - payloads + Payload: + type: object + properties: + id: + type: string + format: uuid + dataname: + type: string + payload_type: + type: string + enum: [text, dictionary, arrowtable, jsontable, image, audio, video, binary] + transport: + type: string + enum: [direct, link] + encoding: + type: string + enum: [none, base64, json, arrow-ipc] + size: + type: integer + minimum: 1 + data: + type: string + metadata: + type: object + required: + - id + - dataname + - payload_type + - transport + - encoding + - size + - data +``` + +--- + +*This specification is versioned and maintained in git alongside the codebase. All implementations must adhere to this specification.* diff --git a/docs/walkthrough.md b/docs/walkthrough.md new file mode 100644 index 0000000..335fad8 --- /dev/null +++ b/docs/walkthrough.md @@ -0,0 +1,965 @@ +# Walkthrough: msghandler + +**Version**: 1.4.0 +**Date**: 2026-05-14 +**Status**: Active +**Ground Truth**: [`src/msghandler.jl`](../src/msghandler.jl) + +--- + +## 1. Executive Summary + +This document provides the **end-to-end trace** for msghandler - the cross-platform bi-directional data bridge that enables seamless communication between **Julia**, **JavaScript**, **Python**, **Dart**, **Rust**, and **MicroPython** applications using NATS as the message bus. + +This walkthrough serves as the primary onboarding guide for new developers and explains: +- **User scenarios** - Real-world use cases from developer perspective +- **Why steps are sequenced** - The rationale behind architectural decisions +- **What could go wrong** - Common failure scenarios and recovery strategies + +### 1.1 Specification Traceability + +| Walkthrough Section | Specification Reference | Requirement ID(s) | Description | +|---------------------|-------------------------|-------------------|-------------| +| Section 2 (Big Picture) | specification.md:2, specification.md:15 | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-012, FR-013, FR-014 | End-to-end system flow diagrams | +| Section 3 (Chat Scenario) | specification.md:2, specification.md:3, specification.md:5, specification.md:11 | FR-001, FR-006, FR-007, FR-012, FR-013, FR-014 | Chat webapp ↔ Julia backend with mixed payloads | +| Section 4 (Large File) | specification.md:6, specification.md:7 | FR-003, FR-004, FR-008, FR-009, FR-010, NFR-104, NFR-105 | Large file transfer with link transport | +| Section 5 (Tabular Data) | specification.md:5, specification.md:10 | FR-002, FR-012, NFR-101, NFR-102 | Arrow IPC tabular data exchange | +| Section 6 (MicroPython) | specification.md:13, specification.md:17 | FR-005, FR-006, FR-012, NFR-106 | Memory-constrained device communication | +| Section 7 (Cross-Platform) | specification.md:3, specification.md:4, specification.md:5, specification.md:11 | FR-001, FR-002, FR-003, FR-004, FR-005, FR-006, FR-007, FR-012, FR-013, FR-014 | Multi-platform chat application | +| Section 8 (Error Handling) | specification.md:9 | FR-008, FR-009, FR-010, NFR-201, NFR-202, NFR-203 | Common error scenarios and recovery | +| Section 9 (Debugging) | specification.md:4, specification.md:11 | FR-011, NFR-401, NFR-403 | Correlation ID tracking | +| Section 10 (Performance) | specification.md:7, specification.md:13 | NFR-101, NFR-102, NFR-103, NFR-104, NFR-105, NFR-106, NFR-107 | Optimization strategies | +| Section 11 (Deployment) | specification.md:12, specification.md:18 | FR-013, FR-014, NFR-201, NFR-203 | Infrastructure requirements | + +--- + +## 2. Overview: The Big Picture + +## Overview: The Big Picture + +msghandler implements the **Claim-Check pattern** for efficient handling of large payloads (>0.5MB): + +```mermaid +flowchart TB + subgraph msghandler["msghandler Module"] + direction TB + + subgraph Sender["Sender (smartsend)"] + direction LR + S1["Data Tuples
[(dataname, data, type)]"] + S2["Serialize Data"] + S3["Size Check"] + S4["Transport Selection"] + S5["Build Envelope"] + S6["Publish to NATS"] + + S1 --> S2 + S2 --> S3 + S3 --> S4 + S4 --> S5 + S5 --> S6 + end + + subgraph Receiver["Receiver (smartreceive)"] + direction LR + R1["Subscribe to NATS"] + R2["Parse Envelope"] + R3["Check Transport"] + R4["Deserialize Data"] + R5["Return Payloads"] + + R1 --> R2 + R2 --> R3 + R3 --> R4 + R4 --> R5 + end + + S6 -.->|Message| R1 + end + + subgraph FileServer["HTTP File Server (Plik)"] + direction TB + FS1["Upload URL"] + FS2["Download URL"] + + S4 -.->|Large Payload| FS1 + FS1 -.->|URL| S5 + R3 -.->|Fetch URL| FS2 + end + + style msghandler fill:#e1f5fe,stroke:#0288d1,stroke-width:2px + style Sender fill:#b3e5fc,stroke:#0288d1 + style Receiver fill:#b3e5fc,stroke:#0288d1 + style FileServer fill:#ffe0b2,stroke:#f57c00 +``` + +### Key Design Principles + +### Key Design Principles + +| Principle | Description | Rationale | +|-----------|-------------|-----------| +| **Claim-Check Pattern** | Large payloads uploaded to HTTP server, URL sent via NATS | NATS has message size limits; avoids NATS overflow | +| **Automatic Transport Selection** | Direct (< threshold) vs Link (≥ threshold) based on size | Optimizes memory vs network I/O trade-off | +| **Cross-Platform API** | Consistent `smartsend()`/`smartreceive()` across all platforms | Simplifies developer experience | +| **Exponential Backoff** | Retry downloads with increasing delays | Handles transient failures gracefully | + +--- + +## User Scenario 1: Chat Webapp ↔ Julia Backend + +### Scenario Description + +A JavaScript chat webapp wants to send mixed payloads (text message + user avatar image) to a Julia backend, and receive mixed payloads (text response + AI-generated image) back. + +### Step-by-Step Flow + +#### Step 1: JavaScript Webapp Sends Mixed Payloads + +```javascript +// JavaScript (Browser or Node.js) +const [env, msgJson] = await msghandler.smartsend( + "/agent/wine/api/v1/prompt", + [ + ["msg", "Hello! I'm Ton.", "text"], + ["avatar", avatarImageData, "image"] + ], + { + broker_url: "ws://localhost:4222", + receiver_name: "agent-backend", + msg_purpose: "chat" + } +); +``` + +**Rationale**: +- **Why mixed payloads?** Real chat apps often send both text and images together +- **Why text first?** Text is smaller, sent via direct transport (fast, no file server needed) +- **Why image second?** Images may trigger link transport if >0.5MB + +#### Step 2: Transport Selection + +For each payload, msghandler determines transport: + +| Payload | Size | Transport | Reason | +|---------|------|-----------|--------| +| `"msg"` (text) | ~20 bytes | direct | < 0.5MB threshold | +| `"avatar"` (image) | ~150KB | direct | < 0.5MB threshold | + +**Rationale**: +- Direct transport is faster for small payloads (no file server round-trip) +- Link transport is used when payload ≥ 0.5MB (avoids NATS size limits) + +#### Step 3: Serialization and Encoding + +Each payload is serialized: + +| Payload | Type | Serialization | Encoding | +|---------|------|---------------|----------| +| `"msg"` | `text` | UTF-8 bytes | Base64 | +| `"avatar"` | `image` | Raw bytes | Base64 | + +**Rationale**: +- Text uses UTF-8 encoding for human-readable data +- Images use raw bytes to preserve binary data integrity +- All payloads encoded as Base64 for JSON compatibility + +#### Step 4: Envelope Building + +msghandler builds the message envelope: + +```json +{ + "correlation_id": "a1b2c3d4...", + "msg_id": "e5f6g7h8...", + "timestamp": "2026-03-13T16:30:00.000Z", + "send_to": "/agent/wine/api/v1/prompt", + "msg_purpose": "chat", + "sender_name": "chat-webapp", + "sender_id": "sender-uuid...", + "receiver_name": "agent-backend", + "receiver_id": "", + "reply_to": "/agent/wine/api/v1/response", + "reply_to_msg_id": "", + "broker_url": "ws://localhost:4222", + "metadata": {}, + "payloads": [ + { + "id": "payload-uuid...", + "dataname": "msg", + "payload_type": "text", + "transport": "direct", + "encoding": "base64", + "size": 20, + "data": "SGVsbG8hIEknIHRlbCB5b3UgSW4gZW5nbGlzaC4=", + "metadata": {"payload_bytes": 20} + }, + { + "id": "payload-uuid...", + "dataname": "avatar", + "payload_type": "image", + "transport": "direct", + "encoding": "base64", + "size": 150000, + "data": "iVBORw0KGgoAAAANSUhEUgAA...", + "metadata": {"payload_bytes": 150000} + } + ] +} +``` + +**Rationale**: +- **correlation_id**: Tracks this chat session across all systems +- **reply_to**: Tells backend where to send response +- **payloads array**: Contains all data with metadata for proper handling + +#### Step 5: Publish to NATS (Caller's Responsibility) + +```javascript +// NATS publishing is the caller's responsibility +const conn = await NATS.connect({ servers: "ws://localhost:4222" }); +await conn.publish("/agent/wine/api/v1/prompt", msgJson); +``` + +**Rationale**: +- NATS provides low-latency message delivery +- JSON format ensures cross-platform compatibility +- `smartsend()` returns `(env, msgJson)` - caller handles publishing + +#### Step 6: Julia Backend Receives Message + +```julia +# Julia backend +nats_msg = NATS.subscription.next() # Get message from NATS +env = smartreceive(String(nats_msg.payload)) + +# env["payloads"] is now: +# [ +# ("msg", "Hello! I'm Ton.", "text"), +# ("avatar", binary_data, "image") +# ] +``` + +**Rationale**: +- `smartreceive()` handles both transport types automatically +- Deserialization is type-aware based on `payload_type` +- Returns consistent tuple format regardless of transport + +#### Step 7: Julia Backend Sends Response + +```julia +# Julia backend processes the message +response_text = "Hello Ton! I'm the AI assistant." +generated_image = generate_ai_image(response_text) + +env, msg_json = smartsend( + "/agent/wine/api/v1/response", + [ + ("response", response_text, "text"), + ("generated_image", generated_image, "image") + ], + reply_to = "/chat/user/v1/message", + reply_to_msg_id = msg["msg_id"] +) +``` + +**Rationale**: +- **Mixed response**: Text explanation + AI-generated image +- **reply_to**: Ensures response goes to correct topic +- **reply_to_msg_id**: Links response to original message for tracing + +--- + +## User Scenario 2: Large File Transfer + +### Scenario Description + +A JavaScript webapp wants to upload a large file (10MB) to a Julia backend for processing. + +### Step-by-Step Flow + +#### Step 1: JavaScript Webapp Sends Large File + +```javascript +const [env, msgJson] = await msghandler.smartsend( + "/agent/wine/api/v1/process", + [ + ["file", largeFileData, "binary"] + ], + { + broker_url: "ws://localhost:4222", + receiver_name: "agent-backend" + } +); +``` + +#### Step 2: Transport Selection (Link) + +| Payload | Size | Transport | Reason | +|---------|------|-----------|--------| +| `"file"` | 10MB | link | ≥ 0.5MB threshold | + +**Rationale**: +- Link transport used for large payloads +- File server handles large file upload +- NATS only sends URL (small message) + +#### Step 3: File Server Upload + +```javascript +// msghandler internally calls: +const response = await plikOneshotUpload( + "http://localhost:8080", + "file", + largeFileData +); + +// Response: +// { +// status: 200, +// uploadid: "UPLOAD_ID", +// fileid: "FILE_ID", +// url: "http://localhost:8080/file/UPLOAD_ID/FILE_ID/file" +// } +``` + +**Rationale**: +- Plik handles multipart upload +- One-shot mode simplifies API +- Returns URL for download + +#### Step 4: Envelope with Link Transport + +```json +{ + "correlation_id": "a1b2c3d4...", + "payloads": [ + { + "id": "payload-uuid...", + "dataname": "file", + "payload_type": "binary", + "transport": "link", + "encoding": "none", + "size": 10000000, + "data": "http://localhost:8080/file/UPLOAD_ID/FILE_ID/file" + } + ] +} +``` + +**Rationale**: +- `data` field contains URL instead of Base64 +- `transport: "link"` signals URL-based download +- `encoding: "none"` indicates no additional encoding + +#### Step 5: Julia Backend Receives and Downloads + +```julia +# Julia backend +nats_msg = NATS.subscription.next() +env = smartreceive(String(nats_msg.payload)) + +# msghandler automatically: +# 1. Extracts URL from payload +# 2. Downloads with exponential backoff +# 3. Deserializes to binary data +``` + +**Rationale**: +- Exponential backoff handles transient failures +- Automatic download simplifies receiver code +- Binary data returned directly + +--- + +## User Scenario 3: Tabular Data Exchange + +### Scenario Description + +A Python application sends tabular data (pandas DataFrame) to a Julia backend for analysis, and receives processed results back. + +### Step-by-Step Flow + +#### Step 1: Python Sends Tabular Data + +```python +# Python +import pandas as pd +from msghandler import smartsend + +df = pd.DataFrame({ + "id": [1, 2, 3], + "name": ["Alice", "Bob", "Charlie"], + "score": [95, 88, 92] +}) + +env, msg_json = await smartsend( + "/agent/wine/api/v1/analyze", + [("data", df, "arrowtable")], + broker_url="nats://localhost:4222", + receiver_name="agent-backend" +) +``` + +**Rationale**: +- `arrowtable` type for efficient tabular data transfer +- Arrow IPC format preserves data types +- Much faster than JSON serialization + +#### Step 2: Serialization to Arrow IPC + +```python +# msghandler internally: +import pyarrow as pa +import pyarrow.ipc as ipc + +table = pa.Table.from_pandas(df) +buf = io.BytesIO() +sink = ipc.new_file(buf, table.schema) +ipc.write_table(table, sink) +arrow_bytes = buf.getvalue() +``` + +**Rationale**: +- Arrow IPC preserves column types +- Binary format is compact +- No schema information loss + +#### Step 3: Julia Receives and Deserializes + +```julia +# Julia backend +nats_msg = NATS.subscription.next() +env = smartreceive(String(nats_msg.payload)) + +# env["payloads"][1] is now: +# ("data", DataFrame with id, name, score columns, "arrowtable") +``` + +**Rationale**: +- Arrow.jl reads IPC format directly +- DataFrame returned with correct types +- No manual parsing needed + +#### Step 4: Julia Sends Results + +```julia +# Julia backend +results = analyze_data(env["payloads"][1][2]) + +# Send results back +env, msg_json = smartsend( + "/agent/wine/api/v1/results", + [("results", results, "arrowtable")], + reply_to = "/python/worker/v1/results" +) +``` + +**Rationale**: +- Arrow IPC format for efficient round-trip +- Results preserve DataFrame structure +- Python can deserialize to pandas DataFrame + +--- + +## User Scenario 4: Rust Service with Type-Safe API + +### Scenario Description + +A Rust service needs to process messages from a Julia analytics pipeline and send typed results back. The Rust implementation leverages compile-time type safety via Rust enums and serde for serialization. + +### Step-by-Step Flow + +#### Step 1: Rust Service Receives Message + +```rust +// Rust service - using tokio async runtime +use msghandler::{smartreceive, MsgEnvelopeV1}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + +#[tokio::main] +async fn main() { + let conn = nats::connect("nats://localhost:4222").unwrap(); + + // Subscribe and receive messages + let mut sub = conn.subscribe("/agent/wine/api/v1/analyze").unwrap(); + + for msg in sub.messages() { + let envelope = smartreceive( + &String::from_utf8_lossy(&msg.payload), + &Default::default(), + ).await.unwrap(); + + // Access deserialized payloads by type + for payload in &envelope.payloads { + match payload.payload_type.as_str() { + "arrowtable" => { + // Data is base64-encoded Arrow IPC bytes after smartreceive() + let arrow_bytes = BASE64.decode(&payload.data).unwrap(); + println!("Received arrowtable payload ({} bytes)", arrow_bytes.len()); + }, + "text" => { + // Data is the decoded text string + println!("Message: {}", payload.data); + }, + "image" | "audio" | "video" | "binary" => { + // Data is base64-encoded binary content + let bytes = BASE64.decode(&payload.data).unwrap(); + println!("Received {} bytes of {} data", bytes.len(), payload.payload_type); + }, + "dictionary" | "jsontable" => { + // Data is a JSON string + println!("Data: {}", payload.data); + }, + _ => println!("Unknown payload type: {}", payload.payload_type), + } + } + } +} +``` + +**Rationale**: +- **serde serialization**: Automatic JSON deserialization to `MsgEnvelopeV1` +- **tokio runtime**: Efficient async I/O for NATS and HTTP operations +- **smartreceive deserialization**: Payload data is deserialized and stored as strings in `payload.data` +- **Type dispatch**: `payload_type` field determines how to interpret the `data` string + +#### Step 2: Rust Service Sends Processed Results + +```rust +// Rust service sends results back with mixed payload types +use msghandler::{smartsend, Payload, SmartsendOptions}; + +let results_df = /* processed Arrow table */; +let result_bytes = /* serialize to Arrow IPC */; + +let (envelope, json_str) = smartsend( + "/agent/wine/api/v1/results", + &[ + ( + "results".to_string(), + Payload::ArrowTable(result_bytes), + "arrowtable".to_string(), + ), + ( + "summary".to_string(), + Payload::Text("Analysis complete: 1500 rows processed".to_string()), + "text".to_string(), + ), + ], + &SmartsendOptions { + broker_url: "nats://localhost:4222".to_string(), + reply_to: "/python/worker/v1/results".to_string(), + msg_purpose: "chat".to_string(), + ..Default::default() + }, +).await?; + +// Caller publishes to NATS +conn.publish("/agent/wine/api/v1/results", &json_str)?; +``` + +**Rationale**: +- **Builder pattern**: `SmartsendOptions` provides clean configuration +- **Enum-based payloads**: Type safety prevents sending incorrect data types +- **Default options**: sensible defaults reduce boilerplate +- **Result**: idiomatic Rust error handling + +#### Step 3: Python/Julia Receives Rust Response + +```python +# Python backend receives Rust response +env = await smartreceive(str(nats_msg.payload)) + +# env["payloads"][0] is now: +# ("results", arrow_table_data, "arrowtable") +# env["payloads"][1] is now: +# ("summary", "Analysis complete: 1500 rows processed", "text") +``` + +**Rationale**: +- **Cross-platform parity**: Rust envelope matches other platform envelopes exactly +- **Same JSON wire format**: No protocol translation needed +- **Type preservation**: Arrow IPC and text types preserved across all platforms + +#### Step 4: Large File Transfer from Rust + +```rust +// Rust service sends large binary file via link transport +let large_file_data: Vec = std::fs::read("/data/large_dataset.parquet")?; + +let (envelope, json_str) = smartsend( + "/agent/wine/api/v1/upload", + &[ + ( + "dataset".to_string(), + Payload::Binary(large_file_data), + "binary".to_string(), + ), + ], + &SmartsendOptions { + broker_url: "nats://localhost:4222".to_string(), + fileserver_url: "http://localhost:8080".to_string(), + size_threshold: 500_000, // 0.5MB triggers link transport + ..Default::default() + }, +).await?; +``` + +**Rationale**: +- **Automatic transport selection**: Same 0.5MB threshold as other desktop platforms +- **reqwest integration**: Efficient HTTP client for file server upload/download +- **Exponential backoff**: Built-in retry with configurable parameters +- **Zero-copy where possible**: `Vec` passed directly without intermediate copies + +--- + +## User Scenario 5: MicroPython Device + +### Scenario Description + +A MicroPython sensor device sends sensor readings to a Python backend. + +### Step-by-Step Flow + +#### Step 1: MicroPython Sends Sensor Data + +```python +# MicroPython +from msghandler import smartsend + +sensor_data = { + "temperature": 25.5, + "humidity": 60.0, + "pressure": 1013.25 +} + +env, msg_json = smartsend( + "/sensor/device/v1/readings", + [("data", sensor_data, "dictionary")], + broker_url="nats://localhost:4222", + size_threshold=100000 # 100KB for MicroPython +) +``` + +**Rationale**: +- `dictionary` type for JSON-serializable sensor data +- Smaller threshold (100KB) for memory constraints +- Direct transport only (no file server support) + +#### Step 2: Serialization + +```python +# msghandler internally: +json_str = json.dumps(sensor_data) +json_bytes = json_str.encode('utf-8') +payload_b64 = base64.b64encode(json_bytes).decode('ascii') +``` + +**Rationale**: +- JSON format for human-readable data +- Base64 for NATS compatibility +- UTF-8 for text encoding + +#### Step 3: Python Backend Receives + +```python +# Python backend +nats_msg = await nats_consumer.next() +env = await smartreceive(str(nats_msg.payload)) + +# env["payloads"][0] is now: +# ("data", {"temperature": 25.5, "humidity": 60.0, ...}, "dictionary") +``` + +**Rationale**: +- JSON deserialization +- Dictionary returned directly +- No Arrow support (memory constraints) + +--- + +## User Scenario 6: Cross-Platform Chat with Mixed Payloads + +### Scenario Description + +Multiple platforms (JavaScript, Python, Julia) communicate in a chat application with mixed payload types. + +### Step-by-Step Flow + +#### Step 1: JavaScript Sends Chat Message + +```javascript +// JavaScript (Frontend) +const [env, msgJson] = await msghandler.smartsend( + "/chat/user/v1/message", + [ + ["text", "Check this out!", "text"], + ["image", imageData, "image"] + ], + { + broker_url: "ws://localhost:4222", + receiver_name: "", + msg_purpose: "chat" + } +); +``` + +**Rationale**: +- Empty `receiver_name` = broadcast to all subscribers +- Chat messages often include text + images +- NATS wildcard subscriptions route to correct recipients + +#### Step 2: Python Backend Receives + +```python +# Python (Backend) +nats_msg = await nats_consumer.next() +env = await smartreceive(str(nats_msg.payload)) + +# env["payloads"] is now: +# [ +# ("text", "Check this out!", "text"), +# ("image", binary_data, "image") +# ] +``` + +**Rationale**: +- Consistent API across platforms +- Same payload structure regardless of sender +- Type information preserved + +#### Step 3: Julia Backend Receives + +```julia +# Julia (Backend) +nats_msg = NATS.subscription.next() +env = smartreceive(String(nats_msg.payload)) + +# env["payloads"] is now: +# [ +# ("text", "Check this out!", "text"), +# ("image", binary_data, "image") +# ] +``` + +**Rationale**: +- Cross-platform API parity +- Same function signature across platforms +- Type information enables proper deserialization + +#### Step 4: All Platforms Reply + +Each platform can reply using the same API: + +```python +# Python reply +await smartsend( + "/chat/user/v1/reply", + [("response", "Nice!", "text")], + reply_to="/chat/user/v1/message" +) +``` + +```julia +# Julia reply +smartsend( + "/chat/user/v1/reply", + [("response", "Nice!", "text")], + reply_to="/chat/user/v1/message" +) +``` + +```javascript +// JavaScript reply +await msghandler.smartsend( + "/chat/user/v1/reply", + [["response", "Nice!", "text"]], + { reply_to: "/chat/user/v1/message" } +); +``` + +**Rationale**: +- Same API across platforms +- Consistent behavior +- Easy to maintain parity + +--- + +## Error Handling + +### Common Error Scenarios + +| Scenario | Error | Recovery | +|----------|-------|----------| +| File server unavailable | `UPLOAD_FAILED` | Fall back to direct transport or smaller payloads | +| File server download fails | `DOWNLOAD_FAILED` | Retry with exponential backoff | +| Payload type mismatch | `DESERIALIZATION_ERROR` | Validate payload_type matches data | +| NATS connection lost | `NATS_CONNECTION_FAILED` | NATS client auto-reconnects | + +### Error Response Format + +```json +{ + "correlation_id": "abc123...", + "error": { + "code": "DOWNLOAD_FAILED", + "message": "Failed to fetch data after 5 attempts", + "details": { + "url": "http://localhost:8080/file/...", + "correlation_id": "abc123..." + } + } +} +``` + +--- + +## Debugging and Tracing + +### Correlation ID Tracking + +Every message includes a `correlation_id`: + +```julia +# At start of request +correlation_id = string(uuid4()) + +# Use throughout the flow +log_trace(correlation_id, "Starting smartsend") +log_trace(correlation_id, "Serialized payload size: 100 bytes") +log_trace(correlation_id, "Published to NATS") +``` + +**Log Format**: +``` +[2026-03-13T16:30:00.000Z] [Correlation: abc123...] Starting smartsend +[2026-03-13T16:30:00.001Z] [Correlation: abc123...] Serialized payload size: 100 bytes +[2026-03-13T16:30:00.002Z] [Correlation: abc123...] Published to NATS +``` + +--- + +## Performance Considerations + +### Optimization Strategies + +| Strategy | Description | When to Use | +|----------|-------------|-------------| +| Pre-create NATS connection | Reuse connection for multiple sends | High-throughput scenarios | +| Adjust size threshold | Increase threshold if file server slow | File server bottleneck | +| Use direct transport | Avoid file server for small payloads | Low latency requirements | + +### Size Threshold by Platform + +| Platform | Threshold | Notes | +|----------|-----------|-------| +| Desktop (Julia/JS/Python/Dart) | 500,000 bytes (0.5MB) | Default threshold | +| Dart Desktop | 500,000 bytes (0.5MB) | Default threshold | +| Dart Flutter | 500,000 bytes (0.5MB) | Default threshold | +| Dart Web | 500,000 bytes (0.5MB) | Default threshold | +| MicroPython | 100,000 bytes (100KB) | Lower threshold for memory constraints | + +--- + +## Deployment Considerations + +### Minimum Infrastructure + +| Component | Minimum | Notes | +|-----------|---------|-------| +| NATS Server | 1 instance | Single node for development | +| File Server | 1 instance | HTTP server for large payloads | +| Client Memory | 50MB | Desktop platforms (Julia/JS/Python/Dart) | +| Client Memory | 256KB | MicroPython devices | + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NATS_URL` | `nats://localhost:4222` | NATS server URL | +| `FILESERVER_URL` | `http://localhost:8080` | HTTP file server URL | +| `SIZE_THRESHOLD` | `500000` | Size threshold in bytes (0.5MB) | + +--- + +## Change Log + +| Date | Version | Changes | +|------|---------|---------| +| 2026-03-13 | 1.0.0 | Initial walkthrough documentation | + +--- + +## 12. References + +### 12.1 Documentation Artifacts + +| Document | Purpose | Specification Traceability | +|----------|---------|---------------------------| +| [`docs/requirements.md`](./requirements.md) | Business requirements and user stories | FR-001 through FR-014, NFR-101 through NFR-405 | +| [`docs/specification.md`](./specification.md) | Technical contract for msghandler | specification.md:2-19 (all sections) | +| [`docs/ui-specification.md`](./ui-specification.md) | UI specification for client applications | UI components for data entry and display | +| [`docs/walkthrough.md`](./walkthrough.md) | End-to-end system flow | This document | +| [`docs/architecture.md`](./architecture.md) | System architecture diagrams | Component interaction and data flow | +| [`docs/validation.md`](./validation.md) | CI/CD validation rules | Contract testing and spec compliance | +| [`docs/runbook.md`](./runbook.md) | Operational runbook | Deployment, scaling, and troubleshooting | + +### 12.2 Implementation Files + +| File | Platform | Features | Specification Traceability | +|------|----------|----------|---------------------------| +| [`src/msghandler.jl`](../src/msghandler.jl) | Julia | Full feature set, Arrow IPC, multiple dispatch | specification.md:2-19 (all sections) | +| [`src/msghandler_ssr.js`](../src/msghandler_ssr.js) | Node.js | Arrow IPC, async/await | specification.md:2-19 (all sections) | +| [`src/msghandler_csr.js`](../src/msghandler_csr.js) | Browser | JSON table only, WebSocket NATS | specification.md:2-19 (all sections) | +| [`src/msghandler.py`](../src/msghandler.py) | Python | Arrow IPC, async/await | specification.md:2-19 (all sections) | +| [`src/msghandler.dart`](../src/msghandler.dart) | Dart | Full feature set, Arrow IPC, async/await | specification.md:2-19 (all sections) | +| [`src/msghandler.rs`](../src/msghandler.rs) | Rust | Full feature set, Arrow IPC, async/await, type-safe, file upload helpers | specification.md:2-19 (all sections) | +| [`src/msghandler_mpy.py`](../src/msghandler_mpy.py) | MicroPython | Limited to direct transport | specification.md:2-19 (all sections) | + +--- + +## 13. Change Log + +| Date | Version | Changes | Specification Reference | +|------|---------|---------|------------------------| +| 2026-05-14 | 1.4.0 | Updated Rust API to reflect `smartreceive` deserialization changes | All sections | +| - | - | `smartreceive` now stores deserialized data in `MsgPayloadV1.data` | specification.md:8 | +| - | - | Added `plik_upload_file` convenience function documentation | specification.md:13 | +| - | - | Fixed Rust scenario payload access (data is String, not Payload enum) | All sections | +| - | - | Removed `metadata` from link transport examples | specification.md:3 | +| 2026-05-13 | 1.3.0 | Added Rust support with tokio, serde, and arrow2 | All sections | +| - | - | Added Rust user scenario (User Scenario 4) | specification.md:11 (Rust API) | +| - | - | Updated scenario numbering (MicroPython → Scenario 5, Cross-Platform → Scenario 6) | All sections | +| 2026-05-13 | 1.2.0 | Aligned with ground truth implementation (src/msghandler.jl) | All sections | +| - | - | Updated smartreceive calls to use String(nats_msg.payload) pattern | All sections | +| - | - | Removed NATSClient.publish() calls (caller responsible for NATS publishing) | All sections | +| - | - | Removed is_publish and nats_connection parameter references | All sections | +| 2026-03-23 | 1.0.0 | Updated to ASG Framework walkthrough guidelines | All sections | +| 2026-03-13 | 1.0.0 | Initial walkthrough documentation | specification.md:2-19 (all sections) | + +--- + +## 14. Gap-Check Validation + +| Stage Transition | Gap-Check Question | Status | +|------------------|-------------------|--------| +| Requirements → Specification | Does the Specification define all edge cases and conflict scenarios from the Requirements? | ✅ Verified - All FR-XXX requirements have corresponding spec rules | +| Specification → UI Specification | Does the UI Specification expose all the data and states defined in the Specification? | ⏳ Pending - UI spec not yet created | +| UI Specification → Walkthrough | Does the Walkthrough reflect the complete flow including error states and timing? | ⏳ Pending - UI spec not yet created | +| Walkthrough → Architecture | Does the Architecture support the performance and integration requirements defined in the Walkthrough? | ⏳ Pending - Architecture not yet created | + +--- + +*This walkthrough document is versioned and maintained in git alongside the codebase. All implementations must adhere to this documentation.* + +--- + +*This walkthrough document is versioned and maintained in git alongside the codebase. All implementations must adhere to this documentation.* + + + + +[x] Analyze existing documentation (requirements.md, spec.md, architecture.md) +[x] Read all source files in src/ folder +[x] Write docs/walkthrough.md according to SDD framework with user scenarios \ No newline at end of file diff --git a/etc.txt b/etc.txt new file mode 100644 index 0000000..4de8893 --- /dev/null +++ b/etc.txt @@ -0,0 +1,310 @@ +#!/usr/bin/env julia +# Test script for mixed-content message testing +# Tests receiving a mix of text, json, table, image, audio, video, and binary data +# from Julia serviceA to Julia serviceB using msghandler.jl smartreceive +# +# This test demonstrates that any combination and any number of mixed content +# can be sent and received correctly. + +using NATS, JSON, UUIDs, Dates, PrettyPrinting, DataFrames, Arrow, HTTP, Base64 + +# Include the bridge module +include("./src/msghandler.jl") +using .msghandler + +# Configuration +const SUBJECT = "/test/mix" +const NATS_URL = "nats.yiem.cc" +const FILESERVER_URL = "http://192.168.88.104:8080" + + +# ------------------------------------------------------------------------------------------------ # +# test mixed content transfer # +# ------------------------------------------------------------------------------------------------ # + + +# Helper: Log with correlation ID +function log_trace(message) + timestamp = Dates.now() + println("[$timestamp] $message") +end + + +# Receiver: Listen for messages and verify mixed content handling +function test_mix_receive() + conn = NATS.connect(NATS_URL) + incoming_msg = nothing + NATS.subscribe(conn, SUBJECT) do msg + log_trace("Received message on $(msg.subject)") + incoming_msg = msg + + # # Use msghandler.smartreceive to handle the data + # # API: smartreceive(msg, download_handler; max_retries, base_delay, max_delay) + # result = msghandler.smartreceive( + # msg; + # max_retries = 5, + # base_delay = 100, + # max_delay = 5000 + # ) + + # log_trace("Received $(length(result["payloads"])) payloads") + + + # # Result is an envelope dictionary with payloads field containing list of (dataname, data, data_type) tuples + # for (dataname, data, data_type) in result["payloads"] + # log_trace("\n=== Payload: $dataname (type: $data_type) ===") + + # # Handle different data types + # if data_type == "text" + # # Text data - should be a String + # if isa(data, String) + # log_trace(" Type: String") + # log_trace(" Length: $(length(data)) characters") + + # # Display first 200 characters + # if length(data) > 200 + # log_trace(" First 200 chars: $(data[1:200])...") + # else + # log_trace(" Content: $data") + # end + + # # Save to file + # output_path = "./received_$dataname.txt" + # write(output_path, data) + # log_trace(" Saved to: $output_path") + # else + # log_trace(" ERROR: Expected String, got $(typeof(data))") + # end + + # elseif data_type == "dictionary" + # # Dictionary data - should be JSON object + # if isa(data, JSON.Object{String, Any}) + # log_trace(" Type: Dict") + # log_trace(" Keys: $(keys(data))") + + # # Display nested content + # for (key, value) in data + # log_trace(" $key => $value") + # end + + # # Save to JSON file + # output_path = "./received_$dataname.json" + # json_str = JSON.json(data, 2) + # write(output_path, json_str) + # log_trace(" Saved to: $output_path") + # else + # log_trace(" ERROR: Expected Dict, got $(typeof(data))") + # end + + # elseif data_type == "table" + # # Table data - should be a DataFrame + # tabledata = deepcopy(data) + # println("found table data") + # break + # # return data + # # if isa(data, DataFrame) + # # log_trace(" Type: DataFrame") + # # log_trace(" Dimensions: $(size(data, 1)) rows x $(size(data, 2)) columns") + # # log_trace(" Columns: $(names(data))") + + # # # Display first few rows + # # log_trace(" First 5 rows:") + # # display(data[1:min(5, size(data, 1)), :]) + + # # # Save to Arrow file + # # output_path = "./received_$dataname.arrow" + # # io = IOBuffer() + # # Arrow.write(io, data) + # # write(output_path, take!(io)) + # # log_trace(" Saved to: $output_path") + # # else + # # log_trace(" ERROR: Expected DataFrame, got $(typeof(data))") + # # end + + # elseif data_type == "image" + # # Image data - should be Vector{UInt8} + # if isa(data, Vector{UInt8}) + # log_trace(" Type: Vector{UInt8} (binary)") + # log_trace(" Size: $(length(data)) bytes") + + # # Save to file + # output_path = "./received_$dataname.bin" + # write(output_path, data) + # log_trace(" Saved to: $output_path") + # else + # log_trace(" ERROR: Expected Vector{UInt8}, got $(typeof(data))") + # end + + # elseif data_type == "audio" + # # Audio data - should be Vector{UInt8} + # if isa(data, Vector{UInt8}) + # log_trace(" Type: Vector{UInt8} (binary)") + # log_trace(" Size: $(length(data)) bytes") + + # # Save to file + # output_path = "./received_$dataname.bin" + # write(output_path, data) + # log_trace(" Saved to: $output_path") + # else + # log_trace(" ERROR: Expected Vector{UInt8}, got $(typeof(data))") + # end + + # elseif data_type == "video" + # # Video data - should be Vector{UInt8} + # if isa(data, Vector{UInt8}) + # log_trace(" Type: Vector{UInt8} (binary)") + # log_trace(" Size: $(length(data)) bytes") + + # # Save to file + # output_path = "./received_$dataname.bin" + # write(output_path, data) + # log_trace(" Saved to: $output_path") + # else + # log_trace(" ERROR: Expected Vector{UInt8}, got $(typeof(data))") + # end + + # elseif data_type == "binary" + # # Binary data - should be Vector{UInt8} + # if isa(data, Vector{UInt8}) + # log_trace(" Type: Vector{UInt8} (binary)") + # log_trace(" Size: $(length(data)) bytes") + + # # Save to file + # output_path = "./received_$dataname.bin" + # write(output_path, data) + # log_trace(" Saved to: $output_path") + # else + # log_trace(" ERROR: Expected Vector{UInt8}, got $(typeof(data))") + # end + + # else + # log_trace(" ERROR: Unknown data type '$data_type'") + # end + # end + + # Summary + # println("\n=== Verification Summary ===") + # text_count = count(x -> x[3] == "text", result["payloads"]) + # dict_count = count(x -> x[3] == "dictionary", result["payloads"]) + # table_count = count(x -> x[3] == "table", result["payloads"]) + # image_count = count(x -> x[3] == "image", result["payloads"]) + # audio_count = count(x -> x[3] == "audio", result["payloads"]) + # video_count = count(x -> x[3] == "video", result["payloads"]) + # binary_count = count(x -> x[3] == "binary", result["payloads"]) + + # log_trace("Text payloads: $text_count") + # log_trace("Dictionary payloads: $dict_count") + # log_trace("Table payloads: $table_count") + # log_trace("Image payloads: $image_count") + # log_trace("Audio payloads: $audio_count") + # log_trace("Video payloads: $video_count") + # log_trace("Binary payloads: $binary_count") + + # # Print transport type info for each payload if available + # println("\n=== Payload Details ===") + # for (dataname, data, data_type) in result["payloads"] + # if data_type in ["image", "audio", "video", "binary"] + # log_trace("$dataname: $(length(data)) bytes (binary)") + # elseif data_type == "table" + # data = DataFrame(data) + # log_trace("$dataname: $(size(data, 1)) rows x $(size(data, 2)) columns (DataFrame)") + # elseif data_type == "dictionary" + # log_trace("$dataname: $(length(JSON.json(data))) bytes (Dict)") + # elseif data_type == "text" + # log_trace("$dataname: $(length(data)) characters (String)") + # end + # end + end + + # Keep listening for 2 minutes + sleep(20) + NATS.drain(conn) + return incoming_msg +end + + +# Run the test +println("Starting mixed-content transport test...") +println("Note: This receiver will wait for messages from the sender.") +println("Run test_julia_to_julia_mix_sender.jl first to send test data.") + +# Run receiver +println("\ntesting smartreceive for mixed content") +incoming_msg = test_mix_receive() + +println("\nTest completed.") + + + + + + + + + + + + + + +Check architecture.md. For sending table I want to add JSON in addition to Apache Arrow. +Currently I use "table" datatype when sending table data using Arrow. Now table that I want to send using JSON +I will use "jsontable" as datatype while sending table using Arrow I will use "arrowtable" as datatype. +This will select how smartsend and smartreceive serialize/deserialize the table. + +Can you help me do this? Save the updated architecture.md into updated_architecture.md file. I will deal with source code later. + + + + +Now update implementation.md and save into updated_implementation.md +Keep in mind that Julia DataFrame and Python Pandas rely on columnar-oriented dictionary to create as the following example: +julia> dict = Dict("customer age" => [15, 20, 25], + "first name" => ["Rohit", "Rahul", "Akshat"]) +julia> DataFrame(dict) + +python> data = { + "Name": ["Alice", "Bob", "Charlie"], + "Age": [25, 30, 35], + "Score": [88.5, 92.0, 79.5] +} + +python> df = pd.DataFrame(data) + + +But JS use Array of Objects while MicroPython use list of lists. Both are row-oriented structure. +So use row-oriented JSON to send across these languages. For Julia and Python, only convert +row-oriented JSON to columnar-oriented dictionary for "going-into" and vise versa for "coming-out" +a dataframe while JS and MicroPython won't require such process. +You may add these info into architecture.md if you see fit. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/smartreceive_example.rs b/examples/smartreceive_example.rs new file mode 100644 index 0000000..36ec424 --- /dev/null +++ b/examples/smartreceive_example.rs @@ -0,0 +1,96 @@ +use msghandler::{smartreceive, SmartreceiveOptions}; + +#[tokio::main] +async fn main() { + // Simulated NATS message JSON (received from NATS subscription) + let msg_json_str = r#"{ + "correlation_id": "abc123-def456-ghi789", + "msg_id": "msg-uuid-001", + "timestamp": "2026-05-13T12:00:00.000Z", + "send_to": "/agent/wine/api/v1/prompt", + "msg_purpose": "chat", + "sender_name": "js-webapp", + "sender_id": "sender-uuid-001", + "receiver_name": "rust-backend", + "receiver_id": "", + "reply_to": "/agent/wine/api/v1/response", + "reply_to_msg_id": "", + "broker_url": "nats://localhost:4222", + "metadata": {}, + "payloads": [ + { + "id": "payload-uuid-001", + "dataname": "message", + "payload_type": "text", + "transport": "direct", + "encoding": "base64", + "size": 29, + "data": "SGVsbG8gZnJvbSBKYXZhU2NyaXB0ISE=", + "metadata": {"payload_bytes": 29} + }, + { + "id": "payload-uuid-002", + "dataname": "user_data", + "payload_type": "dictionary", + "transport": "direct", + "encoding": "json", + "size": 58, + "data": "eyJ0eXBlIjoiY2hhdCIsInNlbmRlciI6InNlcnZpY2VBIiwicmVjZWl2ZXIiOiJzZXJ2aWNlQiJ9", + "metadata": {"payload_bytes": 58} + } + ] + }"#; + + let options = SmartreceiveOptions::default(); + + match smartreceive(msg_json_str, &options).await { + Ok(envelope) => { + println!("=== Envelope Received ==="); + println!("Correlation ID: {}", envelope.correlation_id); + println!("Message ID: {}", envelope.msg_id); + println!("Subject: {}", envelope.send_to); + println!("Purpose: {}", envelope.msg_purpose); + println!("Sender: {}", envelope.sender_name); + println!("Receiver: {}", envelope.receiver_name); + println!("Payloads: {}", envelope.payloads.len()); + println!(); + + for payload in &envelope.payloads { + println!("--- Payload: {} ---", payload.dataname); + println!(" Type: {}", payload.payload_type); + println!(" Transport: {}", payload.transport); + println!(" Encoding: {}", payload.encoding); + println!(" Size: {} bytes", payload.size); + + // In a real scenario, you would deserialize payload.data here + // based on payload_type to get the actual data + match payload.payload_type.as_str() { + "text" => { + // For demonstration, decode the base64 + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + if payload.transport == "direct" { + let decoded = BASE64.decode(&payload.data).unwrap(); + println!(" Data: {}", String::from_utf8_lossy(&decoded)); + } else { + println!(" URL: {}", payload.data); + } + } + "dictionary" => { + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; + if payload.transport == "direct" { + let decoded = BASE64.decode(&payload.data).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&decoded).unwrap(); + println!(" Data: {}", serde_json::to_string_pretty(&json).unwrap()); + } + } + other => { + println!(" Data type: {}", other); + } + } + } + } + Err(e) => { + eprintln!("Error: {}", e); + } + } +} diff --git a/examples/smartsend_example.rs b/examples/smartsend_example.rs new file mode 100644 index 0000000..ced4d1e --- /dev/null +++ b/examples/smartsend_example.rs @@ -0,0 +1,70 @@ +use msghandler::{smartsend, Payload, SmartsendOptions}; + +#[tokio::main] +async fn main() { + // Create mixed payload data + let payloads = vec![ + ( + "message".to_string(), + Payload::Text("Hello from Rust!".to_string()), + "text".to_string(), + ), + ( + "user_data".to_string(), + Payload::Dictionary(serde_json::json!({ + "name": "Alice", + "role": "admin", + "scores": [95, 88, 92] + })), + "dictionary".to_string(), + ), + ( + "avatar".to_string(), + Payload::Binary(vec![0x89, 0x50, 0x4E, 0x47]), // PNG header + "image".to_string(), + ), + ]; + + let options = SmartsendOptions { + broker_url: "nats://localhost:4222".to_string(), + fileserver_url: "http://localhost:8080".to_string(), + msg_purpose: "chat".to_string(), + sender_name: "rust-example".to_string(), + ..Default::default() + }; + + match smartsend("/agent/wine/api/v1/prompt", &payloads, &options).await { + Ok((envelope, json_str)) => { + println!("=== Envelope Created ==="); + println!("Correlation ID: {}", envelope.correlation_id); + println!("Message ID: {}", envelope.msg_id); + println!("Timestamp: {}", envelope.timestamp); + println!("Subject: {}", envelope.send_to); + println!("Purpose: {}", envelope.msg_purpose); + println!("Sender: {}", envelope.sender_name); + println!("Payloads: {}", envelope.payloads.len()); + println!(); + + for payload in &envelope.payloads { + println!("Payload: {} (type: {}, transport: {}, encoding: {})", + payload.dataname, + payload.payload_type, + payload.transport, + payload.encoding); + println!(" Size: {} bytes", payload.size); + println!(" Data: {}", if payload.transport == "direct" { + &payload.data[..payload.data.len().min(40)] + } else { + &payload.data[..payload.data.len().min(60)] + }); + } + + println!(); + println!("=== JSON String for NATS Publishing ==="); + println!("{}", json_str); + } + Err(e) => { + eprintln!("Error: {}", e); + } + } +} diff --git a/plik_fileserver/docker-compose.yml b/plik_fileserver/docker-compose.yml new file mode 100644 index 0000000..bfdef58 --- /dev/null +++ b/plik_fileserver/docker-compose.yml @@ -0,0 +1,14 @@ +services: + plik: + image: rootgg/plik:latest + container_name: plik-server + restart: unless-stopped + ports: + - "8080:8080" + volumes: + # # Mount the config file (created below) + # - ./plikd.cfg:/home/plik/server/plikd.cfg + # Mount local folder for uploads and database + - ./plik-data:/data + # Set user to match your host UID to avoid permission issues + user: "1000:1000" \ No newline at end of file diff --git a/src/NATSBridge.jl b/src/NATSBridge.jl new file mode 100644 index 0000000..3c6372c --- /dev/null +++ b/src/NATSBridge.jl @@ -0,0 +1,1167 @@ +# Bi-Directional Data Bridge - Julia Module +# Implements smartsend and smartreceive for NATS communication +# This module provides functionality for sending and receiving data across network boundaries +# using NATS as the message bus, with support for both direct payload transport and +# URL-based transport for larger payloads. +# +# File Server Handler Architecture: +# The system uses handler functions to abstract file server operations, allowing support +# for different file server implementations (e.g., Plik, AWS S3, custom HTTP server). +# +# Handler Function Signatures: +# +# ```jldoctest +# # Upload handler - uploads data to file server and returns URL +# fileserver_upload_handler(fileserver_url::String, dataname::String, data::Vector{UInt8})::Dict{String, Any} +# +# # Download handler - fetches data from file server URL with exponential backoff +# fileserver_download_handler(url::String, max_retries::Int, base_delay::Int, max_delay::Int, correlation_id::String)::Vector{UInt8} +# ``` +# +# Multi-Payload Support (Standard API): +# The system uses a standardized list-of-tuples format for all payload operations. +# Even when sending a single payload, the user must wrap it in a list. +# +# API Standard: +# ```jldoctest +# # Input format for smartsend (always a list of tuples with type info) +# [(dataname1, data1, type1), (dataname2, data2, type2), ...] +# +# # Output format for smartreceive (always returns a list of tuples) +# [(dataname1, data1, type1), (dataname2, data2, type2), ...] +# ``` +# +# Supported types: "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary" +# +# Table Datatypes: +# - `arrowtable`: Apache Arrow IPC format for efficient binary serialization +# - Input: DataFrame, Arrow.Table +# - Encoding: arrow-ipc +# - `jsontable`: JSON format for human-readable tabular data +# - Input: Vector{NamedTuple}, Vector{Dict} +# - Encoding: json + +module msghandler + +using JSON, Arrow, HTTP, UUIDs, Dates, Base64, PrettyPrinting, DataFrames +# ---------------------------------------------- 100 --------------------------------------------- # + +# Constants +const DEFAULT_SIZE_THRESHOLD = 500_000 # 0.5MB - threshold for switching from direct to link transport +const DEFAULT_BROKER_URL = "nats://localhost:4222" # Default NATS server URL +const DEFAULT_FILESERVER_URL = "http://localhost:8080" # Default HTTP file server URL for link transport + + +""" msg_payload_v1 - Internal message payload structure +This structure represents a single payload within a NATS message envelope. +It supports both direct transport (base64-encoded data) and link transport (URL-based). + +# Arguments: + - `id::String` - Unique identifier for this payload (e.g., "uuid4") + - `dataname::String` - Name of the payload (e.g., "login_image") + - `payload_type::String` - Payload type: "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary" + - `transport::String` - Transport method: "direct" or "link" + - `encoding::String` - Encoding method: "none", "json", "base64", "arrow-ipc" + - `size::Integer` - Size of the payload in bytes (e.g., 15433) + - `data::Any` - Payload data (bytes for direct, URL for link) + - `metadata::Dict{String, Any}` - Optional metadata dictionary + +# Keyword Arguments: + - `id::String = ""` - Payload ID, auto-generated if empty + - `dataname::String = string(uuid4())` - Payload name, auto-generated UUID if empty + - `transport::String = "direct"` - Transport method + - `encoding::String = "none"` - Encoding method + - `size::Integer = 0` - Payload size + - `metadata::Dict{String, T} = Dict{String, Any}()` - Metadata dictionary + +# Return: + - A msg_payload_v1 struct instance + +# Example +```jldoctest +using UUIDs + +# Create a direct transport payload +payload = msg_payload_v1( + "Hello World", + "text"; + id = string(uuid4()), + dataname = "message", + transport = "direct", + encoding = "base64", + size = 11, + metadata = Dict{String, Any}() +) + +# Create a link transport payload +payload = msg_payload_v1( + "http://example.com/file.zip", + "binary"; + id = string(uuid4()), + dataname = "file", + transport = "link", + encoding = "none", + size = 1000000 +) +``` +""" +struct msg_payload_v1 + id::String # id of this payload e.g. "uuid4" + dataname::String # name of this payload e.g. "login_image" + payload_type::String # this payload type. Can be "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary" + transport::String # transport method: "direct" or "link" + encoding::String # encoding method: "none", "json", "base64", "arrow-ipc" + size::Integer # data size in bytes e.g. 15433 + data::Any # payload data in case of direct transport or a URL in case of link + metadata::Dict{String, Any} # Dict("checksum" => "sha256_hash", ...) This metadata is for this payload +end + +# constructor +function msg_payload_v1( + data::Any, + payload_type::String; + id::String = "", + dataname::String = string(uuid4()), + transport::String = "direct", + encoding::String = "none", + size::Integer = 0, + metadata::Dict{String, T} = Dict{String, Any}() +) where {T<:Any} + return msg_payload_v1( + id, + dataname, + payload_type, + transport, + encoding, + size, + data, + metadata + ) +end + + +""" msg_envelope_v1 - Internal message envelope structure +This structure represents a complete NATS message envelope containing multiple payloads +with metadata for routing, tracing, and message context. + +# Arguments: + - `send_to::String` - NATS subject/topic to publish the message to (e.g., "/agent/wine/api/v1/prompt") + - `payloads::Vector{msg_payload_v1}` - List of payloads to include in the message + +# Keyword Arguments: + - `correlation_id::String = ""` - Unique identifier to track messages across systems; auto-generated if empty + - `msg_id::String = ""` - Unique message identifier; auto-generated if empty + - `timestamp::String = string(Dates.now())` - Message publication timestamp + - `msg_purpose::String = ""` - Purpose of the message: "ACK", "NACK", "updateStatus", "shutdown", "chat", etc. + - `sender_name::String = ""` - Name of the sender (e.g., "agent-wine-web-frontend") + - `sender_id::String = ""` - UUID of the sender; auto-generated if empty + - `receiver_name::String = ""` - Name of the receiver (empty string means broadcast) + - `receiver_id::String = ""` - UUID of the receiver (empty string means broadcast) + - `reply_to::String = ""` - Topic where receiver should reply (empty string if no reply expected) + - `reply_to_msg_id::String = ""` - Message ID this message is replying to + - `broker_url::String = DEFAULT_BROKER_URL` - NATS broker URL + - `metadata::Dict{String, Any} = Dict{String, Any}()` - Optional message-level metadata + +# Return: + - A msg_envelope_v1 struct instance + +# Example +```jldoctest +using UUIDs, msghandler + +# Create payloads for the message +payload1 = msg_payload_v1("Hello", "text"; dataname="message", transport="direct", encoding="base64") +payload2 = msg_payload_v1("http://example.com/file.zip", "binary"; dataname="file", transport="link") + +# Create message envelope +env = msg_envelope_v1( + "my.subject", + [payload1, payload2]; + correlation_id = string(uuid4()), + msg_purpose = "chat", + sender_name = "my-app", + receiver_name = "receiver-app", + reply_to = "reply.subject" +) +``` +""" +struct msg_envelope_v1 + correlation_id::String # Unique identifier to track messages across systems. Many senders can talk about the same topic. + msg_id::String # this message id + timestamp::String # message published timestamp (string(Dates.now())) + + send_to::String # topic/subject the sender sends this msg to e.g. "/agent/wine/api/v1/prompt" + msg_purpose::String # purpose of this message e.g. "ACK", "NACK", "updateStatus", "shutdown", ... + sender_name::String # sender name (String) e.g. "agent-wine-web-frontend" + sender_id::String # sender id e.g. uuid4() + receiver_name::String # msg receiver name (String) e.g. "agent-backend" + receiver_id::String # msg receiver id, nothing means everyone in the topic e.g. uuid4() + + reply_to::String # sender ask receiver to reply to this topic + reply_to_msg_id::String # the message id this message is replying to + broker_url::String # NATS server address + + metadata::Dict{String, Any} + payloads::Vector{msg_payload_v1} # multiple payload store here +end + +# constructor +function msg_envelope_v1( + send_to::String, + payloads::Vector{msg_payload_v1}; + correlation_id::String = "", + msg_id::String = "", + timestamp::String = string(Dates.now()), + msg_purpose::String = "", + sender_name::String = "", + sender_id::String = "", + receiver_name::String = "", + receiver_id::String = "", + reply_to::String = "", + reply_to_msg_id::String = "", + broker_url::String = DEFAULT_BROKER_URL, + metadata::Dict{String, Any} = Dict{String, Any}() +) + return msg_envelope_v1( + correlation_id, + msg_id, + timestamp, + send_to, + msg_purpose, + sender_name, + sender_id, + receiver_name, + receiver_id, + reply_to, + reply_to_msg_id, + broker_url, + metadata, + payloads + ) +end + + + +""" envelope_to_json - Convert msg_envelope_v1 to JSON string +This function converts the msg_envelope_v1 struct to a JSON string representation, +preserving all metadata and payload information for NATS message publishing. + +# Function Workflow: + 1. Creates a dictionary with envelope metadata (correlation_id, msg_id, timestamp, etc.) +2. Conditionally includes metadata dictionary if not empty +3. Iterates through payloads and converts each to JSON-compatible dictionary +4. Handles direct transport payloads (Base64 encoding) and link transport payloads (URL) +5. Returns final JSON string representation + +# Arguments: + - `env::msg_envelope_v1` - The msg_envelope_v1 struct to convert to JSON + +# Return: + - `String` - JSON string representation of the envelope + +# Example +```jldoctest +using UUIDs + +# Create an envelope with payloads +payload = msg_payload_v1("Hello", "text"; dataname="msg", transport="direct", encoding="base64") +env = msg_envelope_v1("my.subject", [payload]) + +# Convert to JSON for publishing +json_msg = envelope_to_json(env) +``` +""" +function envelope_to_json(env::msg_envelope_v1) + obj = Dict{String, Any}( + "correlation_id" => env.correlation_id, + "msg_id" => env.msg_id, + "timestamp" => env.timestamp, + "send_to" => env.send_to, + "msg_purpose" => env.msg_purpose, + "sender_name" => env.sender_name, + "sender_id" => env.sender_id, + "receiver_name" => env.receiver_name, + "receiver_id" => env.receiver_id, + "reply_to" => env.reply_to, + "reply_to_msg_id" => env.reply_to_msg_id, + "broker_url" => env.broker_url + ) + + obj["metadata"] = Dict(String(k) => v for (k, v) in env.metadata) + + # Convert payloads to JSON array + payloads_json = [] + for payload in env.payloads + payload_obj = Dict{String, Any}( + "id" => payload.id, + "dataname" => payload.dataname, + "payload_type" => payload.payload_type, + "transport" => payload.transport, + "encoding" => payload.encoding, + "size" => payload.size, + ) + payload_obj["data"] = payload.data + if !isempty(payload.metadata) + payload_obj["metadata"] = Dict(String(k) => v for (k, v) in payload.metadata) + end + push!(payloads_json, payload_obj) + end + obj["payloads"] = payloads_json + + JSON.json(obj) +end + + +""" log_trace - Log a trace message with correlation ID and timestamp +This function logs information messages with a correlation ID for tracing purposes, +making it easier to track message flow across distributed systems. + +# Arguments: + - `correlation_id::String` - Correlation ID to identify the message flow + - `message::String` - The message content to log + +# Return: + - `nothing` - This function performs logging but returns nothing + +# Example +```jldoctest +using Dates + +log_trace("abc123", "Starting message processing") +# Logs: [2026-02-21T05:39:00] [Correlation: abc123] Starting message processing +``` +""" +function log_trace(correlation_id::String, message::String) + timestamp = Dates.now() # Get current timestamp + @info "[$timestamp] [Correlation: $correlation_id] $message" # Log formatted message +end + + +""" smartsend - Send data either directly via NATS or via a fileserver URL, depending on payload size + +This function intelligently routes data delivery based on payload size relative to a threshold. +If the serialized payload is smaller than `size_threshold`, it encodes the data as Base64 and constructs a "direct" msg_payload_v1. +Otherwise, it uploads the data to a fileserver (by default using `plik_oneshot_upload`) and constructs a "link" msg_payload_v1 with the download URL. + +The function accepts a list of (dataname, data, type) tuples as input and processes each payload individually. +Each payload can have a different type, enabling mixed-content messages (e.g., chat with text, images, audio). + +This function creates and returns the msg_envelope_v1 and its JSON string representation only. +NATS publishing must be performed by the caller. + +# Function Workflow: +1. Iterates through the list of (dataname, data, type) tuples +2. For each payload: extracts the type from the tuple and serializes accordingly +3. Compares the serialized size against `size_threshold` +4. For small payloads: encodes as Base64, constructs a "direct" msg_payload_v1 +5. For large payloads: uploads to the fileserver, constructs a "link" msg_payload_v1 with the URL +6. Constructs msg_envelope_v1 with all payloads and metadata +7. Converts envelope to JSON string and returns (NATS publishing is handled by the caller) + +# Arguments: + - `subject::String` - NATS subject to publish the message to + - `data::AbstractArray{Tuple{String, T1, String}, 1}` - List of (dataname, data, type) tuples to send + - `dataname::String` - Name of the payload + - `data::T1` - The actual data to send (any type supported by `_serialize_data`) + - `payload_type::String` - Payload type: "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary" + - No standalone `type` parameter - type is specified per payload + +# Keyword Arguments: + - `broker_url::String = DEFAULT_BROKER_URL` - URL of the NATS server + - `fileserver_url = DEFAULT_FILESERVER_URL` - URL of the HTTP file server for large payloads + - `fileserver_upload_handler::Function = plik_oneshot_upload` - Function to handle fileserver uploads (must return Dict with "status", "uploadid", "fileid", "url" keys) + - `size_threshold::Int = DEFAULT_SIZE_THRESHOLD` - Threshold in bytes separating direct vs link transport + - `correlation_id::String = string(uuid4())` - Correlation ID for tracing (auto-generated UUID) + - `msg_purpose::String = "chat"` - Purpose of the message: "ACK", "NACK", "updateStatus", "shutdown", "chat", etc. + - `sender_name::String = "msghandler"` - Name of the sender + - `receiver_name::String = ""` - Name of the receiver (empty string means broadcast) + - `receiver_id::String = ""` - UUID of the receiver (empty string means broadcast) + - `reply_to::String = ""` - Topic to reply to (empty string if no reply expected) + - `reply_to_msg_id::String = ""` - Message ID this message is replying to + - `msg_id::String = string(uuid4())` - Message ID (auto-generated UUID if not provided) + - `sender_id::String = string(uuid4())` - Sender ID (auto-generated UUID if not provided) + +# Return: + - `::Tuple{msg_envelope_v1, String}` - A tuple containing: + - `env::msg_envelope_v1` - The envelope object containing all metadata and payloads + - `env_json_str::String` - JSON string representation of the envelope for publishing + +# Example +```jldoctest +using UUIDs + +# Send a single payload (still wrapped in a list) +data = Dict("key" => "value") +env, msg_json = smartsend("my.subject", [("dataname1", data, "dictionary")]) + +# Send multiple payloads in one message with different types +data1 = Dict("key1" => "value1") +data2 = rand(10_000) # Small array +env, msg_json = smartsend("my.subject", [("dataname1", data1, "dictionary"), ("dataname2", data2, "arrowtable")]) + +# Send a large array using fileserver upload +data = rand(10_000_000) # ~80 MB +env, msg_json = smartsend("large.data", [("large_arrow_table", data, "arrowtable")]) + +# Send jsontable (JSON format) +rows = [Dict("id" => 1, "name" => "Alice"), Dict("id" => 2, "name" => "Bob")] +env, msg_json = smartsend("json.data", [("users", rows, "jsontable")]) + +# Mixed content (e.g., chat with text and image) +env, msg_json = smartsend("chat.subject", [ + ("message_text", "Hello!", "text"), + ("user_image", image_data, "image"), + ("audio_clip", audio_data, "audio") +]) + +# Publish the JSON string directly using NATS (manual publish) +# conn = NATS.connect(broker_url) +# NATS.publish(conn, subject, env_json_str) +``` +""" +function smartsend( + subject::String, # smartreceive's subject + data::AbstractArray{Tuple{String, T1, String}, 1}; # List of (dataname, data, type) tuples. Use Tuple{String, Any, String}[] for empty payloads + broker_url::String = DEFAULT_BROKER_URL, # NATS server URL + fileserver_url = DEFAULT_FILESERVER_URL, + fileserver_upload_handler::Function = plik_oneshot_upload, # a function to handle uploading data to specific HTTP fileserver + size_threshold::Int = DEFAULT_SIZE_THRESHOLD, + + # Generate a globally unique identifier (UUID) at the start of the request. + # This ID must remain constant and immutable as it propagates through every + # stage of the execution pipeline. It serves as the end-to-end ID for + # distributed tracing, enabling the correlation of all logs, metrics, and + # errors across the system back to this specific request instance. + + correlation_id::String = string(uuid4()), + + msg_purpose::String = "chat", + sender_name::String = "msghandler", + receiver_name::String = "", + receiver_id::String = "", + reply_to::String = "", + reply_to_msg_id::String = "", + msg_id::String = string(uuid4()), # Message ID + sender_id::String = string(uuid4()) # Sender ID +)::Tuple{msg_envelope_v1, String} where {T1<:Any} + + # Log start of send operation + log_trace(correlation_id, "Starting smartsend for subject: $subject") + + # Process each payload in the list + payloads = msg_payload_v1[] + for (dataname, payload_data, payload_type) in data + @show dataname typeof(payload_data) + + # Serialize data based on type + payload_bytes = _serialize_data(payload_data, payload_type) + + payload_size = length(payload_bytes) # Calculate payload size in bytes + log_trace(correlation_id, "Serialized payload '$dataname' (payload_type: $payload_type) size: $payload_size bytes") # Log payload size + + # Decision: Direct vs Link + if payload_size < size_threshold # Check if payload is small enough for direct transport + # Direct path - Base64 encode and send via NATS + payload_b64 = Base64.base64encode(payload_bytes) # Encode bytes as base64 string + log_trace(correlation_id, "Using direct transport for $payload_size bytes") # Log transport choice + + # Determine encoding based on payload_type + encoding = "base64" + if payload_type == "jsontable" + encoding = "json" + elseif payload_type == "arrowtable" + encoding = "arrow-ipc" + end + + # Create msg_payload_v1 for direct transport + payload = msg_payload_v1( + payload_b64, + payload_type; + id = string(uuid4()), + dataname = dataname, + transport = "direct", + encoding = encoding, + size = payload_size, + metadata = Dict{String, Any}("payload_bytes" => payload_size) + ) + push!(payloads, payload) + else + # Link path - Upload to HTTP server, send URL via NATS + log_trace(correlation_id, "Using link transport, uploading to fileserver") # Log link transport choice + + # Upload to HTTP server + response = fileserver_upload_handler(fileserver_url, dataname, payload_bytes) + + if response["status"] != 200 # Check if upload was successful + error("Failed to upload data to fileserver: $(response["status"])") # Throw error if upload failed + end + + url = response["url"] # URL for the uploaded data + log_trace(correlation_id, "Uploaded to URL: $url") # Log successful upload + + # Determine encoding based on payload_type + encoding = "none" + if payload_type == "jsontable" + encoding = "json" + elseif payload_type == "arrowtable" + encoding = "arrow-ipc" + end + + # Create msg_payload_v1 for link transport + payload = msg_payload_v1( + url, + payload_type; + id = string(uuid4()), + dataname = dataname, + transport = "link", + encoding = encoding, + size = payload_size, + metadata = Dict{String, Any}() + ) + push!(payloads, payload) + end + end + + # Create msg_envelope_v1 with all payloads + env = msg_envelope_v1( + subject, + payloads; + correlation_id = correlation_id, + msg_id = msg_id, + msg_purpose = msg_purpose, + sender_name = sender_name, + sender_id = sender_id, + receiver_name = receiver_name, + receiver_id = receiver_id, + reply_to = reply_to, + reply_to_msg_id = reply_to_msg_id, + broker_url = broker_url, + metadata = Dict{String, Any}(), + ) + + env_json_str = envelope_to_json(env) # Convert envelope to JSON + # if is_publish == false + # # skip publish a message + # elseif is_publish == true && NATS_connection === nothing + # # Publish message to NATS using new connection + # publish_message(broker_url, subject, env_json_str, correlation_id) + # elseif is_publish == true && NATS_connection !== nothing + # # Publish message to NATS using existing connection + # publish_message(NATS_connection, subject, env_json_str, correlation_id) + # end + + return (env, env_json_str) +end + + +""" _serialize_data - Serialize data according to specified format +This function serializes arbitrary Julia data into a binary representation based on the specified format. +It supports multiple serialization formats for different data types. + +# Function Workflow: +1. Validates the data type against the specified format +2. Converts data to binary representation according to format rules +3. For text: converts string to UTF-8 bytes +4. For dictionary: serializes as JSON then converts to bytes +5. For arrowtable: uses Arrow.jl to write as IPC stream +6. For jsontable: converts to JSON then to bytes +7. For image/audio/video/binary: returns binary data directly + +# Arguments: + - `data::Any` - Data to serialize (string for `"text"`, JSON-serializable for `"dictionary"`, table-like for `"arrowtable"`, Vector{NamedTuple}/Vector{Dict} for `"jsontable"`, binary for `"image"`, `"audio"`, `"video"`, `"binary"`) + - `payload_type::String` - Target format: "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary" + +# Return: + - `Vector{UInt8}` - Binary representation of the serialized data + +# Throws: + - `Error` if `payload_type` is not one of the supported types + - `Error` if `payload_type` is `"image"`, `"audio"`, or `"video"` but `data` is not `Vector{UInt8}` + +# Example +```jldoctest +using JSON, Arrow, DataFrames + +# Text serialization +text_data = "Hello, World!" +text_bytes = _serialize_data(text_data, "text") + +# JSON serialization +json_data = Dict("name" => "Alice", "age" => 30) +json_bytes = _serialize_data(json_data, "dictionary") + +# Arrow table serialization with a DataFrame (recommended for tabular data) +df = DataFrame(id = 1:3, name = ["Alice", "Bob", "Charlie"], score = [95, 88, 92]) +arrow_bytes = _serialize_data(df, "arrowtable") + +# JSON table serialization - Vector{NamedTuple} or Vector{Dict} +rows = [Dict("id" => 1, "name" => "Alice"), Dict("id" => 2, "name" => "Bob")] +json_bytes = _serialize_data(rows, "jsontable") + +# Image data (Vector{UInt8}) +image_bytes = UInt8[1, 2, 3] # Image bytes +image_serialized = _serialize_data(image_bytes, "image") + +# Audio data (Vector{UInt8}) +audio_bytes = UInt8[1, 2, 3] # Audio bytes +audio_serialized = _serialize_data(audio_bytes, "audio") + +# Video data (Vector{UInt8}) +video_bytes = UInt8[1, 2, 3] # Video bytes +video_serialized = _serialize_data(video_bytes, "video") + +# Binary data (IOBuffer) +buf = IOBuffer() +write(buf, "hello") +binary_bytes = _serialize_data(buf, "binary") + +# Binary data (already bytes) +binary_bytes_direct = _serialize_data(UInt8[1, 2, 3], "binary") +``` +""" +function _serialize_data(data::Any, payload_type::String) + """ Example on how JSON.jl convert: dictionary -> json string -> json string bytes -> json string -> json object + d = Dict( + "name"=>"ton", + "age"=> 20, + "metadata" => Dict( + "height"=> 155, + "wife"=> "jane" + ) + ) + + json_str = JSON.json(d) + json_str_bytes = Vector{UInt8}(json_str) + json_str_2 = String(json_str_bytes) + json_obj = JSON.parse(json_str_2) + """ + + if payload_type == "text" # Text data - convert to UTF-8 bytes + if isa(data, String) + data_bytes = Vector{UInt8}(data) # Convert string to UTF-8 bytes + return data_bytes + else + error("Text data must be a String") + end + elseif payload_type == "dictionary" # JSON data - serialize directly + json_str = JSON.json(data) # Convert Julia data to JSON string + json_str_bytes = Vector{UInt8}(json_str) # Convert JSON string to bytes + return json_str_bytes + elseif payload_type == "arrowtable" # Arrow table data - convert to Arrow IPC stream + io = IOBuffer() # Create in-memory buffer + Arrow.write(io, data) # Write data as Arrow IPC stream to buffer + return take!(io) # Return the buffer contents as bytes + elseif payload_type == "jsontable" # JSON table data - convert to JSON + # data can be Vector{NamedTuple}, Vector{Dict}, or DataFrame + # If DataFrame, convert to Vector{Dict} first + if isa(data, DataFrame) + # Convert DataFrame to Vector{Dict} (row-oriented) + rows = [] + for i in 1:nrow(data) + row_dict = Dict() + for col in names(data) + row_dict[String(col)] = data[i, col] + end + push!(rows, row_dict) + end + json_str = JSON.json(rows) + return Vector{UInt8}(json_str) + else + # Already Vector{NamedTuple} or Vector{Dict} + json_str = JSON.json(data) + return Vector{UInt8}(json_str) + end + elseif payload_type == "image" # Image data - treat as binary + if isa(data, Vector{UInt8}) + return data # Return binary data directly + else + error("Image data must be Vector{UInt8}") + end + elseif payload_type == "audio" # Audio data - treat as binary + if isa(data, Vector{UInt8}) + return data # Return binary data directly + else + error("Audio data must be Vector{UInt8}") + end + elseif payload_type == "video" # Video data - treat as binary + if isa(data, Vector{UInt8}) + return data # Return binary data directly + else + error("Video data must be Vector{UInt8}") + end + elseif payload_type == "binary" # Binary data - treat as binary + if isa(data, IOBuffer) # Check if data is an IOBuffer + return take!(data) # Return buffer contents as bytes + elseif isa(data, Vector{UInt8}) # Check if data is already binary + return data # Return binary data directly + else # Unsupported binary data type + error("Binary data must be binary (Vector{UInt8} or IOBuffer)") + end + else # Unknown type + error("Unknown payload_type: $payload_type") + end +end + + +# """ publish_message - Publish message to NATS +# This function publishes a message to a NATS subject with proper +# connection management and logging. + +# # Arguments: +# - `broker_url::String` - NATS server URL (e.g., "nats://localhost:4222") +# - `subject::String` - NATS subject to publish to (e.g., "/agent/wine/api/v1/prompt") +# - `message::String` - JSON message to publish +# - `correlation_id::String` - Correlation ID for tracing and logging + +# # Return: +# - `nothing` - This function performs publishing but returns nothing + +# # Example +# ```jldoctest +# using NATS + +# # Prepare JSON message +# message = "{\"correlation_id\":\"abc123\",\"payload\":\"test\"}" + +# # Publish to NATS +# publish_message("nats://localhost:4222", "my.subject", message, "abc123") +# ``` +# """ +# function publish_message(broker_url::String, subject::String, message::String, correlation_id::String) +# conn = NATS.connect(broker_url) # Create NATS connection +# publish_message(conn, subject, message, correlation_id) +# end + +# """ publish_message - Publish message to NATS using pre-existing connection +# This function publishes a message to a NATS subject using a pre-existing NATS connection, +# avoiding the overhead of connection establishment. + +# # Arguments: +# - `conn::NATS.Connection` - Pre-existing NATS connection +# - `subject::String` - NATS subject to publish to (e.g., "/agent/wine/api/v1/prompt") +# - `message::String` - JSON message to publish +# - `correlation_id::String` - Correlation ID for tracing and logging + +# # Return: +# - `nothing` - This function performs publishing but returns nothing + +# # Example +# ```jldoctest +# using NATS + +# # Prepare JSON message +# message = "{\"correlation_id\":\"abc123\",\"payload\":\"test\"}" + +# # Create connection once and reuse for multiple publishes +# conn = NATS.connect("nats://localhost:4222") +# publish_message(conn, "my.subject", message, "abc123") +# # Connection is automatically drained after publish +# ``` + +# # Use Case: +# Use this version when you already have an established NATS connection and want to publish +# multiple messages without the overhead of creating a new connection for each publish. +# """ +# function publish_message(conn::NATS.Connection, subject::String, message::String, correlation_id::String) +# try +# NATS.publish(conn, subject, message) # Publish message to NATS +# log_trace(correlation_id, "Message published to $subject") # Log successful publish +# finally +# NATS.drain(conn) # Ensure connection is closed properly +# end +# end + + +""" smartreceive - Receive and process messages from NATS +This function processes incoming NATS messages, handling both direct transport +(base64 decoded payloads) and link transport (URL-based payloads). +It deserializes the data based on the transport type and returns the result. +A HTTP file server is required along with its download function. + +# Function Workflow: +1. Parses the JSON envelope from the NATS message +2. Iterates through each payload in the envelope +3. For each payload: determines the transport type (direct or link) +4. For direct transport: decodes Base64 payload and deserializes based on type +5. For link transport: fetches data from URL with exponential backoff, then deserializes + +# Arguments: + - `msg_json_str::String` - JSON string from NATS message payload (e.g., `String(nats_msg.payload)`) + +# Keyword Arguments: + - `fileserver_download_handler::Function = _fetch_with_backoff` - Function to handle downloading data from file server URLs + - `max_retries::Int = 5` - Maximum retry attempts for fetching URL + - `base_delay::Int = 100` - Initial delay for exponential backoff in ms + - `max_delay::Int = 5000` - Maximum delay for exponential backoff in ms + +# Return: + - `::JSON.Object{String, Any}` - key-value structure resemble msg_envelope_v1 + +# Example +```jldoctest +# Receive and process message +msg = nats_message # NATS message +msg_json_str = String(msg.payload) +env = smartreceive(msg_json_str; fileserver_download_handler=_fetch_with_backoff, max_retries=5, base_delay=100, max_delay=5000) +# env["payloads"] = [("dataname1", data1, "type1"), ("dataname2", data2, "type2"), ...] +``` +""" +function smartreceive( + msg_json_str::String; # get it from String(nats_msg.payload) + fileserver_download_handler::Function = _fetch_with_backoff, + max_retries::Int = 5, + base_delay::Int = 100, + max_delay::Int = 5000 +)::JSON.Object{String, Any} + + # Parse the JSON envelope + env_json_obj = JSON.parse(msg_json_str) + log_trace(env_json_obj["correlation_id"], "Processing received message") # Log message processing start + + # Process all payloads in the envelope + payloads_list = Tuple{String, Any, String}[] + + # Get number of payloads + num_payloads = length(env_json_obj["payloads"]) + + for i in 1:num_payloads + payload = env_json_obj["payloads"][i] + transport = String(payload["transport"]) + dataname = String(payload["dataname"]) + + if transport == "direct" # Direct transport - payload is in the message + log_trace(env_json_obj["correlation_id"], "Direct transport - decoding payload '$dataname'") # Log direct transport handling + + # Extract base64 payload from the payload + payload_b64 = String(payload["data"]) + + # Decode Base64 payload + payload_bytes = Base64.base64decode(payload_b64) # Decode base64 payload to bytes + + # Deserialize based on type + data_type = String(payload["payload_type"]) + data = _deserialize_data(payload_bytes, data_type, env_json_obj["correlation_id"]) + + push!(payloads_list, (dataname, data, data_type)) + elseif transport == "link" # Link transport - payload is at URL + # Extract download URL from the payload + url = String(payload["data"]) + log_trace(env_json_obj["correlation_id"], "Link transport - fetching '$dataname' from URL: $url") # Log link transport handling + + # Fetch with exponential backoff using the download handler + downloaded_data = fileserver_download_handler(url, max_retries, base_delay, max_delay, env_json_obj["correlation_id"]) + + # Deserialize based on type + data_type = String(payload["payload_type"]) + data = _deserialize_data(downloaded_data, data_type, env_json_obj["correlation_id"]) + + push!(payloads_list, (dataname, data, data_type)) + else # Unknown transport type + error("Unknown transport type for payload '$dataname': $(transport)") # Throw error for unknown transport + end + end + env_json_obj["payloads"] = payloads_list + return env_json_obj # key-value structure resemble msg_envelope_v1 +end + + +""" _fetch_with_backoff - Fetch data from URL with exponential backoff +This internal function retrieves data from a URL with retry logic using +exponential backoff to handle transient failures. + +# Function Workflow: +1. Initializes delay with base_delay value +2. Attempts to fetch data from URL in a retry loop +3. On success: logs success and returns response body as bytes +4. On failure: sleeps using exponential backoff and retries +5. After max_retries: throws error indicating failure + +# Arguments: + - `url::String` - URL to fetch from + - `max_retries::Int` - Maximum number of retry attempts + - `base_delay::Int` - Initial delay in milliseconds + - `max_delay::Int` - Maximum delay in milliseconds + - `correlation_id::String` - Correlation ID for logging + +# Return: + - `Vector{UInt8}` - Fetched data as bytes + +# Throws: + - `Error` if all retry attempts fail + +# Example +```jldoctest +# Fetch data with exponential backoff +data = _fetch_with_backoff("http://example.com/file.zip", 5, 100, 5000, "correlation123") +``` +""" +function _fetch_with_backoff( + url::String, + max_retries::Int, + base_delay::Int, + max_delay::Int, + correlation_id::String +) + delay = base_delay # Initialize delay with base delay value + for attempt in 1:max_retries # Attempt to fetch data up to max_retries times + try + response = HTTP.request("GET", url) # Make HTTP GET request to URL + if response.status == 200 # Check if request was successful + log_trace(correlation_id, "Successfully fetched data from $url on attempt $attempt") # Log success + return response.body # Return response body as bytes + else # Request failed + error("Failed to fetch: $(response.status)") # Throw error for non-200 status + end + catch e # Handle exceptions during fetch + log_trace(correlation_id, "Attempt $attempt failed: $(typeof(e))") # Log failure + + if attempt < max_retries # Only sleep if not the last attempt + sleep(delay / 1000.0) # Sleep for delay seconds (convert from ms) + delay = min(delay * 2, max_delay) # Double delay for next attempt, capped at max_delay + end + end + end + + error("Failed to fetch data after $max_retries attempts") # Throw error if all attempts failed +end + + +""" _deserialize_data - Deserialize bytes to data based on type +This internal function converts serialized bytes back to Julia data based on type. +It handles "text" (string), "dictionary" (JSON deserialization), "arrowtable" (Arrow IPC deserialization), +"jsontable" (JSON deserialization), "image" (binary data), "audio" (binary data), "video" (binary data), and "binary" (binary data). + +# Function Workflow: +1. Validates the data type against supported formats +2. Converts bytes to appropriate Julia data type based on format +3. For text: converts bytes to string +4. For dictionary: converts bytes to JSON string then parses to Julia object +5. For arrowtable: reads Arrow IPC format and returns a DataFrame +6. For jsontable: converts bytes to JSON string then parses to Vector{Dict} and return a DataFrame +7. For image/audio/video/binary: returns bytes directly + +# Arguments: + - `data::Vector{UInt8}` - Serialized data as bytes + - `payload_type::String` - Data type ("text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary") + - `correlation_id::String` - Correlation ID for logging + +# Return: + - Deserialized data (String for "text", Arrow.Table for "arrowtable", Vector{Dict} for "jsontable", JSON data for "dictionary", bytes for "image", "audio", "video", "binary") + +# Throws: + - `Error` if `payload_type` is not one of the supported types + +# Example +```jldoctest +# Text data +text_bytes = Vector{UInt8}("Hello World") +text_data = _deserialize_data(text_bytes, "text", "correlation123") + +# JSON data +json_bytes = UInt8[123, 34, 110, 97, 109, 101, 34, 58, 34, 65, 108, 105, 99, 101, 125] # {"name":"Alice"} +json_data = _deserialize_data(json_bytes, "dictionary", "correlation123") + +# Arrow IPC data (arrowtable) +arrow_bytes = Vector{UInt8}([1, 2, 3]) # Arrow IPC bytes +df = _deserialize_data(arrow_bytes, "arrowtable", "correlation123") + +# JSON table data (jsontable) +json_table_bytes = UInt8[91, 123, 34, 105, 100, 34, 58, 49, 44, 34, 110, 97, 109, 101, 34, 58, 34, 65, 108, 105, 99, 101, 34, 125] # [{"id":1,"name":"Alice"}] +df = _deserialize_data(json_table_bytes, "jsontable", "correlation123") +``` +""" +function _deserialize_data( + data::Vector{UInt8}, + payload_type::String, + correlation_id::String +) + if payload_type == "text" # Text data - convert to string + return String(data) # Convert bytes to string + elseif payload_type == "dictionary" # JSON data - deserialize + json_str = String(data) # Convert bytes to string + return JSON.parse(json_str) # Parse JSON string to JSON object + elseif payload_type == "arrowtable" # Arrow table data - deserialize Arrow IPC stream + io = IOBuffer(data) # Create buffer from bytes + arrowtable = Arrow.Table(io) # Read Arrow IPC format from buffer + df = DataFrame(arrowtable) + return df + elseif payload_type == "jsontable" # JSON table data - deserialize JSON + json_str = String(data) # Convert bytes to string + jsontable = JSON.parse(json_str) # Parse JSON string to jsontable i.e. Vector{Dict} + df = DataFrame(jsontable) + return df + elseif payload_type == "image" # Image data - return binary + return data # Return bytes directly + elseif payload_type == "audio" # Audio data - return binary + return data # Return bytes directly + elseif payload_type == "video" # Video data - return binary + return data # Return bytes directly + elseif payload_type == "binary" # Binary data - return binary + return data # Return bytes directly + else # Unknown type + error("Unknown payload_type: $payload_type") # Throw error for unknown type + end +end + + +""" plik_oneshot_upload - Upload a single file to a plik server using one-shot mode +This function uploads a raw byte array to a plik server in one-shot mode (no upload session). +It first creates a one-shot upload session by sending a POST request with `{"OneShot": true}`, +retrieves an upload ID and token, then uploads the file data as multipart form data using the token. + +# Function Workflow: +1. Creates a one-shot upload session by sending POST request with `{"OneShot": true}` +2. Retrieves upload ID and token from server response +3. Uploads binary data as multipart form data using the token +4. Returns identifiers and download URL for the uploaded file + +# Arguments: + - `file_server_url::String` - Base URL of the plik server (e.g., `"http://localhost:8080"`) + - `dataname::String` - Name of the file being uploaded + - `data::Vector{UInt8}` - Raw byte data of the file content + +# Return: + - `Dict{String, Any}` - Dictionary with keys: + - `"status"` - HTTP server response status + - `"uploadid"` - ID of the one-shot upload session + - `"fileid"` - ID of the uploaded file within the session + - `"url"` - Full URL to download the uploaded file + +# Example + ```jldoctest + using HTTP, JSON + + fileserver_url = "http://localhost:8080" + dataname = "test.txt" + data = Vector{UInt8}("hello world") + + # Upload to local plik server + result = plik_oneshot_upload(file_server_url, dataname, data) + + # Access the result as a Dict + # result["status"], result["uploadid"], result["fileid"], result["url"] + ``` +""" +function plik_oneshot_upload(file_server_url::String, dataname::String, data::Vector{UInt8}) + + # ----------------------------------------- get upload id ---------------------------------------- # + # Equivalent curl command: curl -X POST -d '{ "OneShot" : true }' http://localhost:8080/upload + url_getUploadID = "$file_server_url/upload" # URL to get upload ID + headers = ["Content-Type" => "application/json"] + body = """{ "OneShot" : true }""" + http_response = HTTP.request("POST", url_getUploadID, headers, body; body_is_form=false) + response_json = JSON.parse(http_response.body) + uploadid = response_json["id"] + uploadtoken = response_json["uploadToken"] + + # ------------------------------------------ upload file ----------------------------------------- # + # Equivalent curl command: curl -X POST --header "X-UploadToken: UPLOAD_TOKEN" -F "file=@PATH_TO_FILE" http://localhost:8080/file/UPLOAD_ID + file_multipart = HTTP.Multipart(dataname, IOBuffer(data), "application/octet-stream") # Plik won't accept raw bytes upload + url_upload = "$file_server_url/file/$uploadid" + headers = ["X-UploadToken" => uploadtoken] + + # Create the multipart form data + form = HTTP.Form(Dict( + "file" => file_multipart + )) + + # Execute the POST request + http_response = nothing + try + http_response = HTTP.post(url_upload, headers, form) + catch e + @error "Request failed" exception=e + end + response_json = JSON.parse(http_response.body) + fileid = response_json["id"] + + # url of the uploaded data e.g. "http://192.168.1.20:8080/file/3F62E/4AgGT/test.zip" + url = "$file_server_url/file/$uploadid/$fileid/$dataname" + + return Dict("status" => http_response.status, "uploadid" => uploadid, "fileid" => fileid, "url" => url) +end + + +""" plik_oneshot_upload(file_server_url::String, filepath::String) +This function uploads a file from disk to a plik server in one-shot mode (no upload session). +It first creates a one-shot upload session by sending a POST request with `{"OneShot": true}`, +retrieves an upload ID and token, then uploads the file data as multipart form data using the token. + +# Function Workflow: +1. Creates a one-shot upload session by sending POST request with `{"OneShot": true}` +2. Retrieves upload ID and token from server response +3. Uploads the file at `filepath` using multipart form data and the `X-UploadToken` header +4. Returns identifiers and download URL for the uploaded file + +# Arguments: + - `file_server_url::String` - Base URL of the plik server (e.g., `"http://localhost:8080"`) + - `filepath::String` - Full path to the local file to upload + +# Return: + - `Dict{String, Any}` - Dictionary with keys: + - `"status"` - HTTP server response status + - `"uploadid"` - ID of the one-shot upload session + - `"fileid"` - ID of the uploaded file within the session + - `"url"` - Full URL to download the uploaded file + +# Example +```jldoctest +using HTTP, JSON + +fileserver_url = "http://localhost:8080" +filepath = "./test.zip" + +# Upload to local plik server +result = plik_oneshot_upload(file_server_url, filepath) + +# Access the result as a Dict +# result["status"], result["uploadid"], result["fileid"], result["url"] +``` +""" +function plik_oneshot_upload(file_server_url::String, filepath::String) + + # ----------------------------------------- get upload id ---------------------------------------- # + # Equivalent curl command: curl -X POST -d '{ "OneShot" : true }' http://localhost:8080/upload + filename = basename(filepath) + url_getUploadID = "$file_server_url/upload" # URL to get upload ID + headers = ["Content-Type" => "application/json"] + body = """{ "OneShot" : true }""" + http_response = HTTP.request("POST", url_getUploadID, headers, body; body_is_form=false) + response_json = JSON.parse(http_response.body) + + uploadid = response_json["id"] + uploadtoken = response_json["uploadToken"] + + # ------------------------------------------ upload file ----------------------------------------- # + # Equivalent curl command: curl -X POST --header "X-UploadToken: UPLOAD_TOKEN" -F "file=@PATH_TO_FILE" http://localhost:8080/file/UPLOAD_ID + url_upload = "$file_server_url/file/$uploadid" + headers = ["X-UploadToken" => uploadtoken] + http_response = open(filepath, "r") do file_stream + form = HTTP.Form(Dict("file" => file_stream)) + + # Adding status_exception=false prevents 4xx/5xx from triggering 'catch' + HTTP.post(url_upload, headers, form; status_exception = false) + end + + if !isnothing(http_response) && http_response.status == 200 + # Success - response already logged by caller + else + error("Failed to upload file: server returned status $(http_response.status)") + end + response_json = JSON.parse(http_response.body) + fileid = response_json["id"] + + # url of the uploaded data e.g. "http://192.168.1.20:8080/file/3F62E/4AgGT/test.zip" + url = "$file_server_url/file/$uploadid/$fileid/$filename" + + return Dict("status" => http_response.status, "uploadid" => uploadid, "fileid" => fileid, "url" => url) +end + +function _get_payload_bytes(data) + @error "Didn't implement yet. The developer will implement this function later." +end + + + + +end # module diff --git a/src/natsbridge.py b/src/natsbridge.py new file mode 100644 index 0000000..692a49f --- /dev/null +++ b/src/natsbridge.py @@ -0,0 +1,843 @@ +""" +msghandler - Cross-Platform Bi-Directional Data Bridge +Python Desktop Implementation + +This module provides functionality for sending and receiving data across network boundaries +using NATS as the message bus, with support for both direct payload transport and +URL-based transport for larger payloads. + +@package msghandler +""" + +import asyncio +import base64 +import json +import uuid +from datetime import datetime +from typing import Any, Callable, Dict, List, Tuple, Union +import aiohttp + +try: + import pyarrow as arrow + import pyarrow.ipc as ipc + ARROW_AVAILABLE = True +except ImportError: + ARROW_AVAILABLE = False + +try: + import nats + from nats.aio.client import Client as NATSClient + NATS_AVAILABLE = True +except ImportError: + NATS_AVAILABLE = False + +# ---------------------------------------------- Constants ---------------------------------------------- # + +""" +Default size threshold for switching from direct to link transport (0.5MB) +""" +DEFAULT_SIZE_THRESHOLD = 500_000 + +""" +Default NATS server URL +""" +DEFAULT_BROKER_URL = "nats://localhost:4222" + +""" +Default HTTP file server URL for link transport +""" +DEFAULT_FILESERVER_URL = "http://localhost:8080" + + +# ---------------------------------------------- Utility Functions ---------------------------------------------- # + +def log_trace(correlation_id: str, message: str) -> None: + """ + Log a trace message with correlation ID and timestamp. + + Args: + correlation_id: Correlation ID for tracing + message: Message content to log + """ + timestamp = datetime.utcnow().isoformat() + 'Z' + print(f"[{timestamp}] [Correlation: {correlation_id}] {message}") + + +# ---------------------------------------------- Serialization Functions ---------------------------------------------- # + +def _serialize_data(data: Any, payload_type: str) -> bytes: + """ + Serialize data according to specified format. + + Args: + data: Data to serialize (string for "text", JSON-serializable for "dictionary", + table-like for "arrowtable"/"jsontable", binary for "image", "audio", "video", "binary") + payload_type: Target format: "text", "dictionary", "arrowtable", "jsontable", + "image", "audio", "video", "binary" + + Returns: + Binary representation of the serialized data + + Raises: + Error: If payload_type is not one of the supported types + Error: If payload_type is "image", "audio", or "video" but data is not bytes + Error: If payload_type is "arrowtable" but data is not a pandas DataFrame or pyarrow Table + Error: If payload_type is "jsontable" but data is not a list of dicts + """ + if payload_type == 'text': + if isinstance(data, str): + return data.encode('utf-8') + else: + raise ValueError('Text data must be a string') + elif payload_type == 'dictionary': + json_str = json.dumps(data) + return json_str.encode('utf-8') + elif payload_type == 'arrowtable': + if not ARROW_AVAILABLE: + raise RuntimeError('pyarrow not available for arrowtable serialization') + + import io + buf = io.BytesIO() + + import pandas as pd + if isinstance(data, pd.DataFrame): + table = arrow.Table.from_pandas(data) + sink = ipc.new_file(buf, table.schema) + ipc.write_table(table, sink) + sink.close() + return buf.getvalue() + elif isinstance(data, arrow.Table): + sink = ipc.new_file(buf, data.schema) + ipc.write_table(data, sink) + sink.close() + return buf.getvalue() + else: + raise ValueError('Arrow table data must be a pandas DataFrame or pyarrow Table') + elif payload_type == 'jsontable': + # Serialize list of dicts to JSON format + if isinstance(data, list) and all(isinstance(row, dict) for row in data): + json_str = json.dumps(data) + return json_str.encode('utf-8') + else: + raise ValueError('JSON table data must be a list of dicts') + elif payload_type == 'image': + if isinstance(data, (bytes, bytearray)): + return bytes(data) + else: + raise ValueError('Image data must be bytes') + elif payload_type == 'audio': + if isinstance(data, (bytes, bytearray)): + return bytes(data) + else: + raise ValueError('Audio data must be bytes') + elif payload_type == 'video': + if isinstance(data, (bytes, bytearray)): + return bytes(data) + else: + raise ValueError('Video data must be bytes') + elif payload_type == 'binary': + if isinstance(data, (bytes, bytearray)): + return bytes(data) + else: + raise ValueError('Binary data must be bytes') + else: + raise ValueError(f'Unknown payload_type: {payload_type}') + + +def _deserialize_data(data: bytes, payload_type: str, correlation_id: str) -> Any: + """ + Deserialize bytes to data based on type. + + Args: + data: Serialized data as bytes + payload_type: Data type ("text", "dictionary", "arrowtable", "jsontable", + "image", "audio", "video", "binary") + correlation_id: Correlation ID for logging + + Returns: + Deserialized data (String for "text", DataFrame for "arrowtable", + Vector{Dict} for "jsontable"/"dictionary", bytes for "image", "audio", "video", "binary") + + Raises: + Error: If payload_type is not one of the supported types + """ + if payload_type == 'text': + return data.decode('utf-8') + elif payload_type == 'dictionary': + json_str = data.decode('utf-8') + return json.loads(json_str) + elif payload_type == 'arrowtable': + if not ARROW_AVAILABLE: + raise RuntimeError('pyarrow not available for arrowtable deserialization') + + import io + buf = io.BytesIO(data) + reader = ipc.open_file(buf) + return reader.read_all().to_pandas() + elif payload_type == 'jsontable': + # Deserialize JSON to list of dicts + json_str = data.decode('utf-8') + return json.loads(json_str) + elif payload_type == 'image': + return data + elif payload_type == 'audio': + return data + elif payload_type == 'video': + return data + elif payload_type == 'binary': + return data + else: + raise ValueError(f'Unknown payload_type: {payload_type}') + + +# ---------------------------------------------- File Server Handlers ---------------------------------------------- # + +async def plik_oneshot_upload( + file_server_url: str, + dataname: str, + data: bytes +) -> Dict[str, Any]: + """ + Upload data to plik server in one-shot mode. + + This function uploads a raw byte array to a plik server in one-shot mode (no upload session). + It first creates a one-shot upload session by sending a POST request with {"OneShot": true}, + retrieves an upload ID and token, then uploads the file data as multipart form data using the token. + + Args: + file_server_url: Base URL of the plik server (e.g., "http://localhost:8080") + dataname: Name of the file being uploaded + data: Raw byte data of the file content + + Returns: + Dict with keys: + - "status": HTTP server response status + - "uploadid": ID of the one-shot upload session + - "fileid": ID of the uploaded file within the session + - "url": Full URL to download the uploaded file + + Example: + >>> fileserver_url = "http://localhost:8080" + >>> dataname = "test.txt" + >>> data = b"hello world" + >>> result = await plik_oneshot_upload(file_server_url, dataname, data) + >>> result["status"], result["uploadid"], result["fileid"], result["url"] + """ + async with aiohttp.ClientSession() as session: + # Get upload id + url_getUploadID = f"{file_server_url}/upload" + headers = {'Content-Type': 'application/json'} + body = json.dumps({"OneShot": True}) + + async with session.post(url_getUploadID, headers=headers, data=body) as response: + response_json = await response.json() + uploadid = response_json['id'] + uploadtoken = response_json['uploadToken'] + + # Upload file + url_upload = f"{file_server_url}/file/{uploadid}" + headers = {'X-UploadToken': uploadtoken} + + form = aiohttp.FormData() + form.add_field('file', data, filename=dataname, content_type='application/octet-stream') + + async with session.post(url_upload, headers=headers, data=form) as upload_response: + upload_json = await upload_response.json() + fileid = upload_json['id'] + + url = f"{file_server_url}/file/{uploadid}/{fileid}/{dataname}" + + return { + 'status': upload_response.status, + 'uploadid': uploadid, + 'fileid': fileid, + 'url': url + } + + +async def fetch_with_backoff( + url: str, + max_retries: int, + base_delay: int, + max_delay: int, + correlation_id: str +) -> bytes: + """ + Fetch data from URL with exponential backoff. + + This internal function retrieves data from a URL with retry logic using + exponential backoff to handle transient failures. + + Args: + url: URL to fetch from + max_retries: Maximum number of retry attempts + base_delay: Initial delay in milliseconds + max_delay: Maximum delay in milliseconds + correlation_id: Correlation ID for logging + + Returns: + Fetched data as bytes + + Raises: + Error: If all retry attempts fail + + Example: + >>> data = await fetch_with_backoff("http://example.com/file.zip", 5, 100, 5000, "correlation123") + """ + delay = base_delay + + for attempt in range(1, max_retries + 1): + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + if response.status == 200: + log_trace(correlation_id, f"Successfully fetched data from {url} on attempt {attempt}") + return await response.read() + else: + raise Exception(f"Failed to fetch: {response.status}") + except Exception as e: + log_trace(correlation_id, f"Attempt {attempt} failed: {type(e).__name__}") + + if attempt < max_retries: + await asyncio.sleep(delay / 1000.0) + delay = min(delay * 2, max_delay) + + raise Exception(f"Failed to fetch data after {max_retries} attempts") + + +# ---------------------------------------------- NATS Client ---------------------------------------------- # + +class NATSClient: + """NATS client wrapper for connection management.""" + + def __init__(self, url: str = DEFAULT_BROKER_URL): + """ + Create a new NATS client. + + Args: + url: NATS server URL + """ + self.url = url + self._client: NATSClient = None + + async def connect(self) -> NATSClient: + """ + Connect to NATS server. + + Returns: + NATS client instance + """ + if NATS_AVAILABLE: + self._client = nats.connect(self.url) + await self._client + else: + raise RuntimeError('nats-py not available') + return self._client + + async def publish(self, subject: str, message: str, correlation_id: str = "") -> None: + """ + Publish message to NATS subject. + + Args: + subject: NATS subject to publish to + message: Message to publish + correlation_id: Correlation ID for logging + """ + if self._client: + await self._client.publish(subject, message) + if correlation_id: + log_trace(correlation_id, f"Message published to {subject}") + + async def close(self) -> None: + """Close the NATS connection.""" + if self._client: + await self._client.drain() + await self._client.close() + + +# ---------------------------------------------- Core Functions ---------------------------------------------- # + +def _build_envelope( + subject: str, + payloads: List[Dict[str, Any]], + options: Dict[str, Any] +) -> Dict[str, Any]: + """ + Build message envelope from payloads and metadata. + + Args: + subject: NATS subject + payloads: Array of payload objects + options: Envelope metadata options + + Returns: + Envelope object + """ + return { + 'correlation_id': options['correlation_id'], + 'msg_id': options['msg_id'], + 'timestamp': datetime.utcnow().isoformat() + 'Z', + 'send_to': subject, + 'msg_purpose': options['msg_purpose'], + 'sender_name': options['sender_name'], + 'sender_id': options['sender_id'], + 'receiver_name': options['receiver_name'], + 'receiver_id': options['receiver_id'], + 'reply_to': options['reply_to'], + 'reply_to_msg_id': options['reply_to_msg_id'], + 'broker_url': options['broker_url'], + 'metadata': options.get('metadata', {}), + 'payloads': payloads + } + + +def _build_payload( + dataname: str, + payload_type: str, + payload_bytes: bytes, + transport: str, + data: Union[str, bytes] +) -> Dict[str, Any]: + """ + Build payload object from serialized data. + + Args: + dataname: Name of the payload + payload_type: Type of the payload + payload_bytes: Serialized payload bytes + transport: Transport type ("direct" or "link") + data: Data (base64 for direct, URL for link) + + Returns: + Payload object + """ + # Determine encoding based on payload type (matching Julia/JS implementation) + encoding = 'base64' + if payload_type == 'jsontable': + encoding = 'json' + elif payload_type == 'arrowtable': + encoding = 'arrow-ipc' + + return { + 'id': str(uuid.uuid4()), + 'dataname': dataname, + 'payload_type': payload_type, + 'transport': transport, + 'encoding': encoding, + 'size': len(payload_bytes), + 'data': data, + 'metadata': {'payload_bytes': len(payload_bytes)} if transport == 'direct' else {} + } + + +async def publish_message( + broker_url_or_client: Union[str, NATSClient, Any], + subject: str, + message: str, + correlation_id: str +) -> None: + """ + Publish message to NATS. + + Args: + broker_url_or_client: NATS URL, client, or connection + subject: NATS subject to publish to + message: JSON message to publish + correlation_id: Correlation ID for tracing + """ + if isinstance(broker_url_or_client, NATSClient): + client = broker_url_or_client + elif NATS_AVAILABLE and hasattr(broker_url_or_client, 'publish'): + # Direct NATS client connection + await broker_url_or_client.publish(subject, message) + log_trace(correlation_id, f"Message published to {subject}") + return + else: + # String URL - create new client + client = NATSClient(broker_url_or_client) + await client.connect() + + await client.publish(subject, message, correlation_id) + + if isinstance(broker_url_or_client, NATSClient): + await broker_url_or_client.close() + elif not (NATS_AVAILABLE and hasattr(broker_url_or_client, 'publish')): + await client.close() + + +async def smartsend( + subject: str, + data: List[Tuple[str, Any, str]], + broker_url: str = DEFAULT_BROKER_URL, + fileserver_url: str = DEFAULT_FILESERVER_URL, + fileserver_upload_handler: Callable = plik_oneshot_upload, + size_threshold: int = DEFAULT_SIZE_THRESHOLD, + correlation_id: str = None, + msg_purpose: str = "chat", + sender_name: str = "msghandler", + receiver_name: str = "", + receiver_id: str = "", + reply_to: str = "", + reply_to_msg_id: str = "", + is_publish: bool = True, + nats_connection: Any = None, + msg_id: str = None, + sender_id: str = None +) -> Tuple[Dict, str]: + """ + Send data via NATS with automatic transport selection. + + This function intelligently routes data delivery based on payload size. + If the serialized payload is smaller than size_threshold, it encodes the data as Base64 + and publishes directly over NATS. Otherwise, it uploads the data to a fileserver + and publishes only the download URL over NATS. + + Args: + subject: NATS subject to publish the message to + data: List of (dataname, data, type) tuples to send + - dataname: Name of the payload + - data: The actual data to send + - type: Payload type: "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary" + broker_url: URL of the NATS server + fileserver_url: URL of the HTTP file server for large payloads + fileserver_upload_handler: Function to handle fileserver uploads (must return Dict with "status", + "uploadid", "fileid", "url" keys) + size_threshold: Threshold in bytes separating direct vs link transport + correlation_id: Correlation ID for tracing (auto-generated UUID if not provided) + msg_purpose: Purpose of the message: "ACK", "NACK", "updateStatus", "shutdown", "chat", etc. + sender_name: Name of the sender + receiver_name: Name of the receiver (empty string means broadcast) + receiver_id: UUID of the receiver (empty string means broadcast) + reply_to: Topic to reply to (empty string if no reply expected) + reply_to_msg_id: Message ID this message is replying to + is_publish: Whether to automatically publish the message to NATS + nats_connection: Pre-existing NATS connection (if provided, uses this connection instead of + creating a new one; saves connection establishment overhead) + msg_id: Message ID (auto-generated UUID if not provided) + sender_id: Sender ID (auto-generated UUID if not provided) + + Returns: + Tuple of (env, env_json_str) where: + - env: Dict containing all metadata and payloads + - env_json_str: JSON string for publishing to NATS + + Example: + >>> # Send a single payload (still wrapped in a list) + >>> data = {"key": "value"} + >>> env, env_json_str = await smartsend( + ... "my.subject", + ... [("dataname1", data, "dictionary")], + ... broker_url="nats://localhost:4222" + ... ) + >>> + >>> # Send multiple payloads with different types + >>> data1 = {"key1": "value1"} + >>> data2 = [1, 2, 3, 4, 5] + >>> env, env_json_str = await smartsend( + ... "my.subject", + ... [("dataname1", data1, "dictionary"), ("dataname2", data2, "arrowtable")] + ... ) + >>> + >>> # Send a large array using fileserver upload + >>> data = list(range(10_000_000)) # ~80 MB + >>> env, env_json_str = await smartsend( + ... "large.data", + ... [("large_table", data, "arrowtable")] + ... ) + >>> + >>> # Send jsontable (JSON format for human-readable tabular data) + >>> users = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}] + >>> env, env_json_str = await smartsend( + ... "json.data", + ... [("users", users, "jsontable")] + ... ) + >>> + >>> # Mixed content (e.g., chat with text and image) + >>> env, env_json_str = await smartsend( + ... "chat.subject", + ... [ + ... ("message_text", "Hello!", "text"), + ... ("user_image", image_data, "image"), + ... ("audio_clip", audio_data, "audio") + ... ] + ... ) + >>> + >>> # Publish the JSON string directly using NATS request-reply pattern + >>> # reply = await nats.request(broker_url, subject, env_json_str, reply_to=reply_to_topic) + """ + if correlation_id is None: + correlation_id = str(uuid.uuid4()) + if msg_id is None: + msg_id = str(uuid.uuid4()) + if sender_id is None: + sender_id = str(uuid.uuid4()) + + log_trace(correlation_id, f"Starting smartsend for subject: {subject}") + + # Process payloads + payloads = [] + for dataname, payload_data, payload_type in data: + payload_bytes = _serialize_data(payload_data, payload_type) + payload_size = len(payload_bytes) + + log_trace(correlation_id, f"Serialized payload '{dataname}' (type: {payload_type}) size: {payload_size} bytes") + + if payload_size < size_threshold: + # Direct path + payload_b64 = base64.b64encode(payload_bytes).decode('utf-8') + log_trace(correlation_id, f"Using direct transport for {payload_size} bytes") + + payload = _build_payload(dataname, payload_type, payload_bytes, 'direct', payload_b64) + payloads.append(payload) + else: + # Link path + log_trace(correlation_id, "Using link transport, uploading to fileserver") + + response = await fileserver_upload_handler(fileserver_url, dataname, payload_bytes) + + if response['status'] != 200: + raise Exception(f"Failed to upload data to fileserver: {response['status']}") + + log_trace(correlation_id, f"Uploaded to URL: {response['url']}") + + payload = _build_payload(dataname, payload_type, payload_bytes, 'link', response['url']) + payloads.append(payload) + + # Build envelope + env = _build_envelope(subject, payloads, { + 'correlation_id': correlation_id, + 'msg_id': msg_id, + 'msg_purpose': msg_purpose, + 'sender_name': sender_name, + 'sender_id': sender_id, + 'receiver_name': receiver_name, + 'receiver_id': receiver_id, + 'reply_to': reply_to, + 'reply_to_msg_id': reply_to_msg_id, + 'broker_url': broker_url + }) + + env_json_str = json.dumps(env) + + if is_publish: + if nats_connection: + await publish_message(nats_connection, subject, env_json_str, correlation_id) + else: + await publish_message(broker_url, subject, env_json_str, correlation_id) + + return env, env_json_str + + +async def smartreceive( + msg: Any, + fileserver_download_handler: Callable = fetch_with_backoff, + max_retries: int = 5, + base_delay: int = 100, + max_delay: int = 5000 +) -> Dict[str, Any]: + """ + Receive and process NATS messages. + + This function processes incoming NATS messages, handling both direct transport + (base64 decoded payloads) and link transport (URL-based payloads). + It deserializes the data based on the transport type and returns the result. + + Args: + msg: NATS message to process + fileserver_download_handler: Function to handle downloading data from file server URLs + max_retries: Maximum retry attempts for fetching URL + base_delay: Initial delay for exponential backoff in ms + max_delay: Maximum delay for exponential backoff in ms + + Returns: + Dict with envelope metadata and payloads field containing List[Tuple[str, Any, str]] + + Example: + >>> # Receive and process message + >>> env = await smartreceive(msg, fileserver_download_handler=fetch_with_backoff) + >>> # env is a Dict with "payloads" key containing List[Tuple[str, Any, str]] + >>> # Access payloads: for dataname, data, type_ in env["payloads"] + >>> for dataname, data, type_ in env["payloads"]: + >>> print(f"{dataname}: {data} (type: {type_})") + """ + # Parse the JSON envelope + if isinstance(msg, dict): + # Already parsed + env_json_obj = msg + elif hasattr(msg, 'payload'): + # NATS message object + payload = msg.payload if isinstance(msg.payload, str) else msg.payload.decode('utf-8') + env_json_obj = json.loads(payload) + else: + # Assume it's already a JSON string or dict + env_json_obj = json.loads(msg) if isinstance(msg, str) else msg + + log_trace(env_json_obj['correlation_id'], "Processing received message") + + # Process all payloads in the envelope + payloads_list = [] + num_payloads = len(env_json_obj['payloads']) + + for i in range(num_payloads): + payload_obj = env_json_obj['payloads'][i] + transport = payload_obj['transport'] + dataname = payload_obj['dataname'] + + if transport == 'direct': + log_trace(env_json_obj['correlation_id'], f"Direct transport - decoding payload '{dataname}'") + + # Extract base64 payload from the payload + payload_b64 = payload_obj['data'] + + # Decode Base64 payload + payload_bytes = base64.b64decode(payload_b64) + + # Deserialize based on type + data_type = payload_obj['payload_type'] + data = _deserialize_data(payload_bytes, data_type, env_json_obj['correlation_id']) + + payloads_list.append((dataname, data, data_type)) + elif transport == 'link': + # Extract download URL from the payload + url = payload_obj['data'] + log_trace(env_json_obj['correlation_id'], f"Link transport - fetching '{dataname}' from URL: {url}") + + # Fetch with exponential backoff using the download handler + downloaded_data = await fileserver_download_handler( + url, + max_retries, + base_delay, + max_delay, + env_json_obj['correlation_id'] + ) + + # Deserialize based on type + data_type = payload_obj['payload_type'] + data = _deserialize_data(downloaded_data, data_type, env_json_obj['correlation_id']) + + payloads_list.append((dataname, data, data_type)) + else: + raise Exception(f"Unknown transport type for payload '{dataname}': {transport}") + + env_json_obj['payloads'] = payloads_list + return env_json_obj + + +# ---------------------------------------------- Module Exports ---------------------------------------------- # + +class msghandler: + """ + Cross-platform NATS bridge implementation. + + This class provides a convenient interface for msghandler functionality, + encapsulating the main functions and providing a class-based API. + """ + + DEFAULT_SIZE_THRESHOLD = DEFAULT_SIZE_THRESHOLD + DEFAULT_BROKER_URL = DEFAULT_BROKER_URL + DEFAULT_FILESERVER_URL = DEFAULT_FILESERVER_URL + + def __init__(self, broker_url: str = None, fileserver_url: str = None): + """ + Initialize msghandler. + + Args: + broker_url: NATS server URL (defaults to DEFAULT_BROKER_URL) + fileserver_url: HTTP file server URL (defaults to DEFAULT_FILESERVER_URL) + """ + self.broker_url = broker_url or self.DEFAULT_BROKER_URL + self.fileserver_url = fileserver_url or self.DEFAULT_FILESERVER_URL + + async def smartsend( + self, + subject: str, + data: List[Tuple[str, Any, str]], + **kwargs + ) -> Tuple[Dict, str]: + """ + Send data via NATS. + + Args: + subject: NATS subject to publish to + data: List of (dataname, data, type) tuples + **kwargs: Additional options passed to smartsend + + Returns: + Tuple of (env, env_json_str) + """ + kwargs['broker_url'] = kwargs.get('broker_url', self.broker_url) + kwargs['fileserver_url'] = kwargs.get('fileserver_url', self.fileserver_url) + return await smartsend(subject, data, **kwargs) + + async def smartreceive( + self, + msg: Any, + **kwargs + ) -> Dict[str, Any]: + """ + Receive and process NATS message. + + Args: + msg: NATS message to process + **kwargs: Additional options passed to smartreceive + + Returns: + Dict with envelope metadata and payloads + """ + return await smartreceive(msg, **kwargs) + + +# Convenience functions for module-level usage +def send( + subject: str, + data: List[Tuple[str, Any, str]], + **kwargs +) -> Tuple[Dict, str]: + """ + Convenience function for sending data. + + Args: + subject: NATS subject to publish to + data: List of (dataname, data, type) tuples + **kwargs: Additional options + + Returns: + Tuple of (env, env_json_str) + """ + return asyncio.run(smartsend(subject, data, **kwargs)) + + +def receive( + msg: Any, + **kwargs +) -> Dict[str, Any]: + """ + Convenience function for receiving messages. + + Args: + msg: NATS message to process + **kwargs: Additional options + + Returns: + Dict with envelope metadata and payloads + """ + return asyncio.run(smartreceive(msg, **kwargs)) + + +__all__ = [ + 'smartsend', + 'smartreceive', + 'plik_oneshot_upload', + 'fetch_with_backoff', + 'msghandler', + 'send', + 'receive', + 'DEFAULT_SIZE_THRESHOLD', + 'DEFAULT_BROKER_URL', + 'DEFAULT_FILESERVER_URL', + 'NATSClient', + '_serialize_data', + '_deserialize_data', + 'log_trace', + 'publish_message' +] \ No newline at end of file diff --git a/src/natsbridge.rs b/src/natsbridge.rs new file mode 100644 index 0000000..d84a312 --- /dev/null +++ b/src/natsbridge.rs @@ -0,0 +1,1230 @@ +// msghandler Rust Module +// Cross-platform bi-directional data bridge for NATS communication +// Implements smartsend and smartreceive for NATS communication +// with support for both direct payload transport and URL-based transport +// for larger payloads using the Claim-Check pattern. +// +// File Server Handler Architecture: +// The system uses handler functions to abstract file server operations, +// allowing support for different file server implementations +// (e.g., Plik, AWS S3, custom HTTP server). +// +// Multi-Payload Support (Standard API): +// The system uses a standardized tuple format for all payload operations. +// Each payload is (dataname, data, type) and can have a different type, +// enabling mixed-content messages (e.g., chat with text, images, audio). +// +// Supported types: "text", "dictionary", "arrowtable", "jsontable", +// "image", "audio", "video", "binary" + +use async_trait::async_trait; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use chrono::Utc; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::fmt; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::sleep; +use uuid::Uuid; + +// ============================================================================ +// Constants +// ============================================================================ + +/// Default size threshold (0.5MB) for switching from direct to link transport +pub const DEFAULT_SIZE_THRESHOLD: usize = 500_000; + +/// Default NATS server URL +pub const DEFAULT_BROKER_URL: &str = "nats://localhost:4222"; + +/// Default HTTP file server URL for link transport +pub const DEFAULT_FILESERVER_URL: &str = "http://localhost:8080"; + +/// Default max retries for download with exponential backoff +pub const DEFAULT_MAX_RETRIES: u32 = 5; + +/// Default base delay for exponential backoff (milliseconds) +pub const DEFAULT_BASE_DELAY: u64 = 100; + +/// Default max delay for exponential backoff (milliseconds) +pub const DEFAULT_MAX_DELAY: u64 = 5_000; + +// ============================================================================ +// Error Types +// ============================================================================ + +/// Errors that can occur during msghandler operations +#[derive(Debug)] +pub enum msghandlerError { + /// Unsupported or unknown payload type + UnknownPayloadType(String), + /// File server upload failed + UploadFailed(String), + /// File server download failed after retries + DownloadFailed { url: String, retries: u32 }, + /// Unknown transport type + UnknownTransport(String), + /// NATS connection failed + NatConnectionFailed(String), + /// Payload deserialization error + DeserializationError(String), + /// HTTP request error + HttpError { status: u16, message: String }, + /// IO error + IoError(String), + /// JSON serialization/deserialization error + JsonError(String), + /// Base64 decode error + Base64Error(String), + /// Payload size exceeded maximum + SizeExceeded { size: usize, max: usize }, + /// Invalid envelope (missing required fields) + InvalidEnvelope(String), +} + +impl fmt::Display for msghandlerError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + msghandlerError::UnknownPayloadType(p) => write!(f, "Unknown payload_type: {}", p), + msghandlerError::UploadFailed(msg) => write!(f, "Failed to upload: {}", msg), + msghandlerError::DownloadFailed { url, retries } => { + write!(f, "Failed to fetch {} after {} attempts", url, retries) + } + msghandlerError::UnknownTransport(t) => write!(f, "Unknown transport type: {}", t), + msghandlerError::NatConnectionFailed(msg) => write!(f, "NATS connection failed: {}", msg), + msghandlerError::DeserializationError(msg) => { + write!(f, "Deserialization error: {}", msg) + } + msghandlerError::HttpError { status, message } => { + write!(f, "HTTP error {}: {}", status, message) + } + msghandlerError::IoError(msg) => write!(f, "IO error: {}", msg), + msghandlerError::JsonError(msg) => write!(f, "JSON error: {}", msg), + msghandlerError::Base64Error(msg) => write!(f, "Base64 error: {}", msg), + msghandlerError::SizeExceeded { size, max } => { + write!(f, "Payload size {} exceeds max {}", size, max) + } + msghandlerError::InvalidEnvelope(msg) => write!(f, "Invalid envelope: {}", msg), + } + } +} + +impl std::error::Error for msghandlerError {} + +// ============================================================================ +// Payload Enum - Type-safe payload data +// ============================================================================ + +/// Type-safe payload data for sending. Each variant represents a supported +/// payload type with its corresponding data representation. +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum Payload { + Text(String), + Dictionary(JsonValue), + ArrowTable(Vec), + JsonTable(JsonValue), + Image(Vec), + Audio(Vec), + Video(Vec), + Binary(Vec), +} + +impl Payload { + /// Get the payload type string for this variant + pub fn payload_type(&self) -> &'static str { + match self { + Payload::Text(_) => "text", + Payload::Dictionary(_) => "dictionary", + Payload::ArrowTable(_) => "arrowtable", + Payload::JsonTable(_) => "jsontable", + Payload::Image(_) => "image", + Payload::Audio(_) => "audio", + Payload::Video(_) => "video", + Payload::Binary(_) => "binary", + } + } + + /// Get the serialized bytes for this payload + pub fn serialized_bytes(&self) -> Vec { + match self { + Payload::Text(s) => s.as_bytes().to_vec(), + Payload::Dictionary(v) => serde_json::to_vec(v).unwrap_or_default(), + Payload::ArrowTable(b) => b.clone(), + Payload::JsonTable(v) => serde_json::to_vec(v).unwrap_or_default(), + Payload::Image(b) => b.clone(), + Payload::Audio(b) => b.clone(), + Payload::Video(b) => b.clone(), + Payload::Binary(b) => b.clone(), + } + } + + /// Get the size of the serialized bytes + pub fn size(&self) -> usize { + self.serialized_bytes().len() + } +} + +// ============================================================================ +// Message Payload Structure (wire format) +// ============================================================================ + +/// Represents a single payload within a NATS message envelope. +/// Supports both direct transport (base64-encoded data) and link transport (URL-based). +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct MsgPayloadV1 { + /// Unique identifier for this payload (UUID v4) + pub id: String, + /// Name of the payload (e.g., "login_image", "msg") + pub dataname: String, + /// Payload type: "text", "dictionary", "arrowtable", "jsontable", + /// "image", "audio", "video", "binary" + pub payload_type: String, + /// Transport method: "direct" or "link" + pub transport: String, + /// Encoding method: "none", "json", "base64", "arrow-ipc" + pub encoding: String, + /// Size of the payload in bytes + pub size: usize, + /// Payload data (base64 string for direct transport, URL for link transport) + pub data: String, + /// Optional payload-level metadata + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +impl MsgPayloadV1 { + /// Create a new direct transport payload + pub fn new_direct( + dataname: String, + payload_type: String, + data: String, + size: usize, + ) -> Self { + let encoding = match payload_type.as_str() { + "jsontable" => "json".to_string(), + "arrowtable" => "arrow-ipc".to_string(), + _ => "base64".to_string(), + }; + MsgPayloadV1 { + id: Uuid::new_v4().to_string(), + dataname, + payload_type, + transport: "direct".to_string(), + encoding, + size, + data, + metadata: Some({ + let mut m = HashMap::new(); + m.insert( + "payload_bytes".to_string(), + JsonValue::Number(size.into()), + ); + m + }), + } + } + + /// Create a new link transport payload + pub fn new_link( + dataname: String, + payload_type: String, + url: String, + size: usize, + ) -> Self { + let encoding = match payload_type.as_str() { + "jsontable" => "json".to_string(), + "arrowtable" => "arrow-ipc".to_string(), + _ => "none".to_string(), + }; + MsgPayloadV1 { + id: Uuid::new_v4().to_string(), + dataname, + payload_type, + transport: "link".to_string(), + encoding, + size, + data: url, + metadata: None, + } + } +} + +// ============================================================================ +// Message Envelope Structure (wire format) +// ============================================================================ + +/// Represents a complete NATS message envelope containing multiple payloads +/// with metadata for routing, tracing, and message context. +#[derive(Serialize, Deserialize, Clone, Debug, Default)] +pub struct MsgEnvelopeV1 { + /// Unique identifier to track messages across systems (UUID v4) + pub correlation_id: String, + /// Unique message identifier (UUID v4) + pub msg_id: String, + /// Message publication timestamp (ISO 8601 UTC) + pub timestamp: String, + + /// NATS subject/topic to publish the message to + pub send_to: String, + /// Purpose of the message: "ACK", "NACK", "updateStatus", "shutdown", + /// "chat", "command", "event" + pub msg_purpose: String, + /// Sender application name + pub sender_name: String, + /// Sender UUID (UUID v4) + pub sender_id: String, + /// Receiver application name (empty string = broadcast) + pub receiver_name: String, + /// Receiver UUID (empty string = broadcast) + pub receiver_id: String, + + /// Topic where receiver should reply + pub reply_to: String, + /// Message ID this message is replying to + pub reply_to_msg_id: String, + /// NATS broker URL + pub broker_url: String, + + /// Optional message-level metadata + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + /// List of payloads + pub payloads: Vec, +} + +impl MsgEnvelopeV1 { + /// Create a new message envelope + pub fn new(send_to: String, payloads: Vec) -> Self { + MsgEnvelopeV1 { + correlation_id: Uuid::new_v4().to_string(), + msg_id: Uuid::new_v4().to_string(), + timestamp: Utc::now().to_rfc3339(), + send_to, + msg_purpose: "chat".to_string(), + sender_name: "msghandler".to_string(), + sender_id: Uuid::new_v4().to_string(), + receiver_name: String::new(), + receiver_id: String::new(), + reply_to: String::new(), + reply_to_msg_id: String::new(), + broker_url: DEFAULT_BROKER_URL.to_string(), + metadata: Some(HashMap::new()), + payloads, + } + } + + /// Convert the envelope to a JSON string for NATS publishing + pub fn to_json(&self) -> Result { + serde_json::to_string(self).map_err(|e| msghandlerError::JsonError(e.to_string())) + } +} + +// ============================================================================ +// Options Structures +// ============================================================================ + +/// Options for the `smartsend` function +pub struct SmartsendOptions { + /// NATS server URL + pub broker_url: String, + /// HTTP file server URL for large payloads + pub fileserver_url: String, + /// Custom file server upload handler (optional, uses Plik by default) + pub fileserver_upload_handler: Option>, + /// Size threshold in bytes for switching from direct to link transport + pub size_threshold: usize, + /// Correlation ID for distributed tracing (auto-generated if empty) + pub correlation_id: String, + /// Purpose of the message + pub msg_purpose: String, + /// Sender application name + pub sender_name: String, + /// Receiver application name (empty = broadcast) + pub receiver_name: String, + /// Receiver UUID (empty = broadcast) + pub receiver_id: String, + /// Topic to reply to + pub reply_to: String, + /// Message ID being replied to + pub reply_to_msg_id: String, + /// Message ID (auto-generated if empty) + pub msg_id: String, + /// Sender UUID (auto-generated if empty) + pub sender_id: String, +} + +impl Default for SmartsendOptions { + fn default() -> Self { + SmartsendOptions { + broker_url: DEFAULT_BROKER_URL.to_string(), + fileserver_url: DEFAULT_FILESERVER_URL.to_string(), + fileserver_upload_handler: None, + size_threshold: DEFAULT_SIZE_THRESHOLD, + correlation_id: String::new(), + msg_purpose: "chat".to_string(), + sender_name: "msghandler".to_string(), + receiver_name: String::new(), + receiver_id: String::new(), + reply_to: String::new(), + reply_to_msg_id: String::new(), + msg_id: String::new(), + sender_id: String::new(), + } + } +} + +/// Options for the `smartreceive` function +pub struct SmartreceiveOptions { + /// Custom file server download handler (optional, uses exponential backoff by default) + pub fileserver_download_handler: Option>, + /// Maximum retry attempts for fetching a URL + pub max_retries: u32, + /// Initial delay for exponential backoff in milliseconds + pub base_delay: u64, + /// Maximum delay for exponential backoff in milliseconds + pub max_delay: u64, +} + +impl Default for SmartreceiveOptions { + fn default() -> Self { + SmartreceiveOptions { + fileserver_download_handler: None, + max_retries: DEFAULT_MAX_RETRIES, + base_delay: DEFAULT_BASE_DELAY, + max_delay: DEFAULT_MAX_DELAY, + } + } +} + +// ============================================================================ +// File Server Handler Traits +// ============================================================================ + +/// Trait for uploading data to a file server +#[async_trait] +pub trait FileUploadHandler: Send + Sync { + /// Upload data to the file server + /// Returns upload ID, file ID, and download URL + async fn upload( + &self, + file_server_url: &str, + dataname: &str, + data: &[u8], + ) -> Result; +} + +/// Result of a file server upload +#[derive(Debug, Clone)] +pub struct UploadResult { + /// HTTP response status code + pub status: u16, + /// Upload session identifier + pub uploadid: String, + /// File identifier within the session + pub fileid: String, + /// Full download URL + pub url: String, +} + +/// Trait for downloading data from a file server +#[async_trait] +pub trait FileDownloadHandler: Send + Sync { + /// Download data from a URL with retry logic + async fn download( + &self, + url: &str, + max_retries: u32, + base_delay: u64, + max_delay: u64, + correlation_id: &str, + ) -> Result, msghandlerError>; +} + +// ============================================================================ +// Plik One-Shot Upload Handler Implementation +// ============================================================================ + +/// Default file server upload handler using Plik one-shot mode. +/// +/// Workflow: +/// 1. Creates a one-shot upload session (POST /upload with `{"OneShot": true}`) +/// 2. Retrieves upload ID and token from response +/// 3. Uploads binary data as multipart form data with the token +/// 4. Returns identifiers and download URL +pub struct PlikOneshotUploadHandler; + +#[async_trait] +impl FileUploadHandler for PlikOneshotUploadHandler { + async fn upload( + &self, + file_server_url: &str, + dataname: &str, + data: &[u8], + ) -> Result { + let client = Client::new(); + + // Step 1: Create one-shot upload session + let session_body = serde_json::json!({"OneShot": true}); + let session_resp = client + .post(format!("{}/upload", file_server_url)) + .header("Content-Type", "application/json") + .json(&session_body) + .send() + .await + .map_err(|e| msghandlerError::UploadFailed(format!("Failed to create upload session: {}", e)))?; + + if !session_resp.status().is_success() { + return Err(msghandlerError::UploadFailed(format!( + "Session creation failed with status: {}", + session_resp.status() + ))); + } + + let session_json: JsonValue = session_resp + .json() + .await + .map_err(|e| msghandlerError::UploadFailed(format!("Failed to parse session response: {}", e)))?; + + let uploadid = session_json["id"] + .as_str() + .unwrap_or("") + .to_string(); + let uploadtoken = session_json["uploadToken"] + .as_str() + .unwrap_or("") + .to_string(); + + if uploadid.is_empty() || uploadtoken.is_empty() { + return Err(msghandlerError::UploadFailed( + "Missing uploadid or uploadToken in session response".to_string(), + )); + } + + // Step 2: Upload the file as multipart/form-data + let upload_url = format!("{}/file/{}", file_server_url, uploadid); + let form = reqwest::multipart::Form::new() + .part( + "file", + reqwest::multipart::Part::bytes(data.to_vec()) + .file_name(dataname.to_string()) + .mime_str("application/octet-stream") + .map_err(|e| msghandlerError::UploadFailed(format!("Invalid MIME type: {}", e)))?, + ); + let resp = client + .post(&upload_url) + .header("X-UploadToken", &uploadtoken) + .multipart(form) + .send() + .await + .map_err(|e| msghandlerError::UploadFailed(format!("Upload request failed: {}", e)))?; + + if !resp.status().is_success() { + return Err(msghandlerError::UploadFailed(format!( + "Upload failed with status: {}", + resp.status() + ))); + } + + let status_code = resp.status().as_u16(); + let upload_json: JsonValue = resp + .json() + .await + .map_err(|e| msghandlerError::UploadFailed(format!("Failed to parse upload response: {}", e)))?; + + let fileid = upload_json["id"].as_str().unwrap_or("").to_string(); + + let url = format!( + "{}/file/{}/{}/{}", + file_server_url, uploadid, fileid, dataname + ); + + Ok(UploadResult { + status: status_code, + uploadid, + fileid, + url, + }) + } +} + +// ============================================================================ +// Exponential Backoff Download Handler Implementation +// ============================================================================ + +/// Default download handler using exponential backoff retry logic. +/// +/// Workflow: +/// 1. Attempts to fetch data from URL +/// 2. On failure, retries with exponentially increasing delay +/// 3. Capped at max_delay between retries +/// 4. Throws error after max_retries are exhausted +pub struct BackoffDownloadHandler; + +#[async_trait] +impl FileDownloadHandler for BackoffDownloadHandler { + async fn download( + &self, + url: &str, + max_retries: u32, + base_delay: u64, + max_delay: u64, + correlation_id: &str, + ) -> Result, msghandlerError> { + let client = Client::new(); + let mut delay = base_delay; + + for attempt in 1..=max_retries { + match client.get(url).send().await { + Ok(response) if response.status().is_success() => { + log_trace(correlation_id, &format!( + "Successfully fetched {} on attempt {}", + url, attempt + )); + let bytes = response.bytes().await + .map(|b| b.to_vec()) + .map_err(|_e| msghandlerError::DownloadFailed { + url: url.to_string(), + retries: max_retries, + })?; + return Ok(bytes); + } + Ok(response) => { + log_trace(correlation_id, &format!( + "Attempt {} failed with status {}: {}", + attempt, + response.status(), + url + )); + } + Err(e) => { + log_trace(correlation_id, &format!( + "Attempt {} failed: {}: {}", + attempt, + e, + url + )); + } + } + + if attempt < max_retries { + sleep(Duration::from_millis(delay)).await; + delay = (delay * 2).min(max_delay); + } + } + + Err(msghandlerError::DownloadFailed { + url: url.to_string(), + retries: max_retries, + }) + } +} + +// ============================================================================ +// Serialization +// ============================================================================ + +/// Serialize payload data according to the specified payload type. +/// Returns the raw bytes for the serialized data. +fn serialize_data(payload: &Payload) -> Result, msghandlerError> { + match payload { + Payload::Text(s) => Ok(s.as_bytes().to_vec()), + Payload::Dictionary(v) => serde_json::to_vec(v) + .map_err(|e| msghandlerError::DeserializationError(format!("Dictionary serialization failed: {}", e))), + Payload::ArrowTable(b) => Ok(b.clone()), + Payload::JsonTable(v) => serde_json::to_vec(v) + .map_err(|e| msghandlerError::DeserializationError(format!("JsonTable serialization failed: {}", e))), + Payload::Image(b) => Ok(b.clone()), + Payload::Audio(b) => Ok(b.clone()), + Payload::Video(b) => Ok(b.clone()), + Payload::Binary(b) => Ok(b.clone()), + } +} + +// ============================================================================ +// Deserialization +// ============================================================================ + +/// Deserialize bytes back to a Payload based on the payload type. +/// Handles direct transport (base64 decoded) and link transport (fetched bytes). +fn deserialize_data( + payload_bytes: &[u8], + payload_type: &str, + _correlation_id: &str, +) -> Result { + match payload_type { + "text" => { + let text = String::from_utf8(payload_bytes.to_vec()) + .map_err(|e| msghandlerError::DeserializationError(format!("Invalid UTF-8 for text: {}", e)))?; + Ok(Payload::Text(text)) + } + "dictionary" => { + let json_str = String::from_utf8(payload_bytes.to_vec()) + .map_err(|e| msghandlerError::DeserializationError(format!("Invalid UTF-8 for dictionary: {}", e)))?; + let value: JsonValue = serde_json::from_str(&json_str) + .map_err(|e| msghandlerError::DeserializationError(format!("Invalid JSON for dictionary: {}", e)))?; + Ok(Payload::Dictionary(value)) + } + "arrowtable" => { + Ok(Payload::ArrowTable(payload_bytes.to_vec())) + } + "jsontable" => { + let json_str = String::from_utf8(payload_bytes.to_vec()) + .map_err(|e| msghandlerError::DeserializationError(format!("Invalid UTF-8 for jsontable: {}", e)))?; + let value: JsonValue = serde_json::from_str(&json_str) + .map_err(|e| msghandlerError::DeserializationError(format!("Invalid JSON for jsontable: {}", e)))?; + Ok(Payload::JsonTable(value)) + } + "image" => Ok(Payload::Image(payload_bytes.to_vec())), + "audio" => Ok(Payload::Audio(payload_bytes.to_vec())), + "video" => Ok(Payload::Video(payload_bytes.to_vec())), + "binary" => Ok(Payload::Binary(payload_bytes.to_vec())), + _ => Err(msghandlerError::UnknownPayloadType(payload_type.to_string())), + } +} + +// ============================================================================ +// Logging +// ============================================================================ + +/// Log a trace message with correlation ID and timestamp. +/// Uses tokio::task::spawn_blocking to avoid blocking the async runtime. +pub fn log_trace(correlation_id: &str, message: &str) { + let ts = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ"); + eprintln!("[{}] [Correlation: {}] {}", ts, correlation_id, message); +} + +// ============================================================================ +// Public API: smartsend +// ============================================================================ + +/// Send data via NATS with automatic transport selection. +/// +/// This function intelligently routes data delivery based on payload size. +/// If the serialized payload is smaller than `size_threshold`, it encodes the +/// data as Base64 and constructs a "direct" MsgPayloadV1. Otherwise, it uploads +/// the data to a file server and constructs a "link" MsgPayloadV1 with the URL. +/// +/// Each payload in the list can have a different type, enabling mixed-content +/// messages (e.g., chat with text, images, audio). +/// +/// NATS publishing is the caller's responsibility. This function returns the +/// envelope and its JSON string representation. +/// +/// # Arguments +/// - `subject`: NATS subject to publish the message to +/// - `data`: Slice of (dataname, payload, payload_type) tuples +/// - `options`: Configuration options +/// +/// # Returns +/// - `Result<(MsgEnvelopeV1, String), msghandlerError>` containing the envelope and JSON string +/// +/// # Example +/// ```no_run +/// use msghandler::{smartsend, Payload, SmartsendOptions}; +/// +/// # async fn example() -> Result<(), Box> { +/// let (envelope, json_str) = smartsend( +/// "/agent/wine/api/v1/prompt", +/// &[ +/// ("msg".to_string(), Payload::Text("Hello!".to_string()), "text".to_string()), +/// ("data".to_string(), Payload::Binary(vec![1, 2, 3]), "binary".to_string()), +/// ], +/// &SmartsendOptions::default(), +/// ).await?; +/// +/// // Caller publishes to NATS +/// // conn.publish("/agent/wine/api/v1/prompt", &json_str)?; +/// # Ok(()) +/// # } +/// ``` +pub async fn smartsend( + subject: &str, + data: &[(String, Payload, String)], + options: &SmartsendOptions, +) -> Result<(MsgEnvelopeV1, String), msghandlerError> { + let correlation_id = if options.correlation_id.is_empty() { + Uuid::new_v4().to_string() + } else { + options.correlation_id.clone() + }; + + let msg_id = if options.msg_id.is_empty() { + Uuid::new_v4().to_string() + } else { + options.msg_id.clone() + }; + + let sender_id = if options.sender_id.is_empty() { + Uuid::new_v4().to_string() + } else { + options.sender_id.clone() + }; + + log_trace(&correlation_id, &format!( + "Starting smartsend for subject: {}", subject + )); + + let mut payloads: Vec = Vec::new(); + + // Determine the upload handler to use (custom or default Plik) + let upload_handler: Arc = options.fileserver_upload_handler.clone() + .unwrap_or_else(|| Arc::new(PlikOneshotUploadHandler)); + + for (dataname, payload, payload_type) in data { + // Use the explicitly provided payload_type from the tuple, + // or derive from the Payload enum + let ptype = if payload_type.is_empty() { + payload.payload_type().to_string() + } else { + payload_type.clone() + }; + + // Serialize the payload data + let payload_bytes = serialize_data(payload)?; + let payload_size = payload_bytes.len(); + + log_trace(&correlation_id, &format!( + "Serialized payload '{}' (type: {}) size: {} bytes", + dataname, ptype, payload_size + )); + + if payload_size < options.size_threshold { + // Direct transport: Base64 encode and include in NATS message + let payload_b64 = BASE64.encode(&payload_bytes); + log_trace(&correlation_id, &format!( + "Using direct transport for {} bytes", payload_size + )); + + let msg_payload = MsgPayloadV1::new_direct( + dataname.clone(), + ptype, + payload_b64, + payload_size, + ); + payloads.push(msg_payload); + } else { + // Link transport: Upload to file server, include URL in NATS message + log_trace(&correlation_id, "Using link transport, uploading to fileserver"); + + let upload_result = upload_handler + .upload(&options.fileserver_url, dataname, &payload_bytes) + .await?; + + log_trace(&correlation_id, &format!( + "Uploaded to URL: {}", upload_result.url + )); + + let msg_payload = MsgPayloadV1::new_link( + dataname.clone(), + ptype, + upload_result.url, + payload_size, + ); + payloads.push(msg_payload); + } + } + + // Build the message envelope + let env = MsgEnvelopeV1 { + correlation_id: correlation_id.clone(), + msg_id, + timestamp: Utc::now().to_rfc3339(), + send_to: subject.to_string(), + msg_purpose: options.msg_purpose.clone(), + sender_name: options.sender_name.clone(), + sender_id, + receiver_name: options.receiver_name.clone(), + receiver_id: options.receiver_id.clone(), + reply_to: options.reply_to.clone(), + reply_to_msg_id: options.reply_to_msg_id.clone(), + broker_url: options.broker_url.clone(), + metadata: Some(HashMap::new()), + payloads, + }; + + let env_json_str = env.to_json()?; + + log_trace(&correlation_id, "Envelope created successfully"); + + Ok((env, env_json_str)) +} + +// ============================================================================ +// Helper: store deserialized data back into MsgPayloadV1 +// ============================================================================ + +/// Store deserialized Payload data back into a MsgPayloadV1's data field. +/// After smartreceive(), payload.data contains the deserialized content as a string +/// (decoded text, JSON string, or base64 for binary types). +fn store_deserialized_data(payload: &MsgPayloadV1, deserialized: &Payload) -> MsgPayloadV1 { + let mut p = payload.clone(); + match deserialized { + Payload::Text(s) => p.data = s.clone(), + Payload::Dictionary(v) => p.data = serde_json::to_string(v).unwrap_or_default(), + Payload::JsonTable(v) => p.data = serde_json::to_string(v).unwrap_or_default(), + Payload::ArrowTable(b) => p.data = BASE64.encode(b), + Payload::Image(b) | Payload::Audio(b) | Payload::Video(b) | Payload::Binary(b) => { + p.data = BASE64.encode(b); + } + } + p +} + +// ============================================================================ +// Public API: smartreceive +// ============================================================================ + +/// Receive and process messages from NATS. +/// +/// This function processes incoming NATS messages, handling both direct transport +/// (base64 decoded payloads) and link transport (URL-based payloads). +/// It deserializes the data based on the payload type and returns the envelope +/// with deserialized payloads. +/// +/// # Arguments +/// - `msg_json_str`: JSON string from NATS message payload +/// - `options`: Configuration options +/// +/// # Returns +/// - `Result` with deserialized payloads +/// +/// # Example +/// ```no_run +/// use msghandler::{smartreceive, SmartreceiveOptions}; +/// use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +/// +/// # async fn example() -> Result<(), Box> { +/// let msg_json_str = r#"{"correlation_id":"abc123","msg_id":"msg-uuid", +/// "timestamp":"2026-01-01T00:00:00Z","send_to":"/test", +/// "msg_purpose":"chat","sender_name":"test","sender_id":"sender-uuid", +/// "receiver_name":"","receiver_id":"","reply_to":"","reply_to_msg_id":"", +/// "broker_url":"nats://localhost:4222","payloads":[{ +/// "id":"payload-uuid","dataname":"msg","payload_type":"text", +/// "transport":"direct","encoding":"base64","size":5, +/// "data":"SGVsbG8=","metadata":{"payload_bytes":5} +/// }]}"#; +/// +/// let envelope = smartreceive(msg_json_str, &SmartreceiveOptions::default()).await?; +/// +/// for payload in &envelope.payloads { +/// if payload.transport == "direct" { +/// let decoded = BASE64.decode(&payload.data)?; +/// println!("{}: {}", payload.dataname, String::from_utf8_lossy(&decoded)); +/// } else { +/// println!("{}: URL={}", payload.dataname, payload.data); +/// } +/// } +/// # Ok(()) +/// # } +/// ``` +pub async fn smartreceive( + msg_json_str: &str, + options: &SmartreceiveOptions, +) -> Result { + // Parse the JSON envelope + let mut env: MsgEnvelopeV1 = serde_json::from_str(msg_json_str) + .map_err(|e| msghandlerError::InvalidEnvelope(format!( + "Failed to parse envelope JSON: {}", e + )))?; + + let correlation_id = env.correlation_id.clone(); + log_trace(&correlation_id, "Processing received message"); + + // Determine the download handler to use (custom or default backoff) + let download_handler: Arc = options.fileserver_download_handler.clone() + .unwrap_or_else(|| Arc::new(BackoffDownloadHandler)); + + // Process each payload + let mut updated_payloads: Vec = Vec::new(); + + for payload in &env.payloads { + let transport = payload.transport.as_str(); + let dataname = payload.dataname.clone(); + let payload_type = payload.payload_type.clone(); + + match transport { + "direct" => { + log_trace(&correlation_id, &format!( + "Direct transport - decoding payload '{}'", dataname + )); + + // Decode Base64 payload + let payload_bytes = BASE64.decode(&payload.data) + .map_err(|e| msghandlerError::Base64Error(format!( + "Base64 decode failed for '{}': {}", dataname, e + )))?; + + // Deserialize based on type and store result back into payload + let deserialized = deserialize_data( + &payload_bytes, + &payload_type, + &correlation_id, + )?; + let updated = store_deserialized_data(payload, &deserialized); + + updated_payloads.push(updated); + } + "link" => { + let url = payload.data.clone(); + log_trace(&correlation_id, &format!( + "Link transport - fetching '{}' from URL: {}", dataname, url + )); + + // Fetch with exponential backoff + let downloaded_data = download_handler + .download( + &url, + options.max_retries, + options.base_delay, + options.max_delay, + &correlation_id, + ) + .await?; + + // Deserialize based on type and store result back into payload + let deserialized = deserialize_data( + &downloaded_data, + &payload_type, + &correlation_id, + )?; + let updated = store_deserialized_data(payload, &deserialized); + + updated_payloads.push(updated); + } + unknown => { + return Err(msghandlerError::UnknownTransport(format!( + "Unknown transport type '{}' for payload '{}'", + unknown, dataname + ))); + } + } + } + + env.payloads = updated_payloads; + Ok(env) +} + +// ============================================================================ +// Convenience Functions +// ============================================================================ + +/// Send a single text payload +pub async fn send_text( + subject: &str, + text: &str, + options: &SmartsendOptions, +) -> Result<(MsgEnvelopeV1, String), msghandlerError> { + smartsend( + subject, + &[( + "text".to_string(), + Payload::Text(text.to_string()), + "text".to_string(), + )], + options, + ) + .await +} + +/// Send a single dictionary payload +pub async fn send_dictionary( + subject: &str, + data: &JsonValue, + options: &SmartsendOptions, +) -> Result<(MsgEnvelopeV1, String), msghandlerError> { + smartsend( + subject, + &[( + "dictionary".to_string(), + Payload::Dictionary(data.clone()), + "dictionary".to_string(), + )], + options, + ) + .await +} + +/// Send a single binary payload +pub async fn send_binary( + subject: &str, + data: &[u8], + options: &SmartsendOptions, +) -> Result<(MsgEnvelopeV1, String), msghandlerError> { + smartsend( + subject, + &[( + "binary".to_string(), + Payload::Binary(data.to_vec()), + "binary".to_string(), + )], + options, + ) + .await +} + +// ============================================================================ +// Plik File Upload from Disk +// ============================================================================ + +/// Upload a file from disk to a Plik server in one-shot mode. +/// +/// Reads the file at `filepath`, extracts its filename, and uploads it +/// using the Plik one-shot upload protocol (multipart/form-data). +/// +/// # Arguments +/// - `file_server_url`: Base URL of the Plik server (e.g., `"http://localhost:8080"`) +/// - `filepath`: Full path to the local file to upload +/// +/// # Returns +/// - `Result` with uploadid, fileid, and download URL +/// +/// # Example +/// ```no_run +/// use msghandler::plik_upload_file; +/// +/// # async fn example() -> Result<(), Box> { +/// let result = plik_upload_file("http://localhost:8080", "./large_file.zip").await?; +/// println!("Uploaded to: {}", result.url); +/// # Ok(()) +/// # } +/// ``` +pub async fn plik_upload_file( + file_server_url: &str, + filepath: &str, +) -> Result { + // Read the file from disk + let data = tokio::fs::read(filepath).await + .map_err(|e| msghandlerError::IoError(format!( + "Failed to read file '{}': {}", filepath, e + )))?; + + // Extract filename from path + let dataname = Path::new(filepath) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + // Upload using the Plik one-shot handler + PlikOneshotUploadHandler.upload(file_server_url, &dataname, &data).await +} + +// ============================================================================ +// Module Exports +// ============================================================================ + +// All public types are already exported via `pub` on their definitions. +// Key types: +// - `smartsend`, `smartreceive` - main API functions +// - `Payload` - type-safe payload enum +// - `MsgEnvelopeV1`, `MsgPayloadV1` - wire format structs +// - `SmartsendOptions`, `SmartreceiveOptions` - configuration +// - `FileUploadHandler`, `FileDownloadHandler` - trait abstractions +// - `PlikOneshotUploadHandler`, `BackoffDownloadHandler` - default implementations +// - `msghandlerError` - error type + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_payload_serialization() { + let text = Payload::Text("Hello".to_string()); + assert_eq!(text.payload_type(), "text"); + assert_eq!(text.size(), 5); + + let dict = Payload::Dictionary(serde_json::json!({"key": "value"})); + assert_eq!(dict.payload_type(), "dictionary"); + + let binary = Payload::Binary(vec![1, 2, 3]); + assert_eq!(binary.payload_type(), "binary"); + assert_eq!(binary.size(), 3); + } + + #[test] + fn test_serialize_deserialize_text() { + let payload = Payload::Text("Hello, World!".to_string()); + let bytes = serialize_data(&payload).unwrap(); + let deserialized = deserialize_data(&bytes, "text", "test-corr").unwrap(); + match deserialized { + Payload::Text(s) => assert_eq!(s, "Hello, World!"), + _ => panic!("Expected Text payload"), + } + } + + #[test] + fn test_serialize_deserialize_dictionary() { + let dict = serde_json::json!({"name": "Alice", "age": 30}); + let payload = Payload::Dictionary(dict.clone()); + let bytes = serialize_data(&payload).unwrap(); + let deserialized = deserialize_data(&bytes, "dictionary", "test-corr").unwrap(); + match deserialized { + Payload::Dictionary(v) => assert_eq!(v, dict), + _ => panic!("Expected Dictionary payload"), + } + } + + #[test] + fn test_serialize_deserialize_binary() { + let data = vec![0u8, 1, 2, 255, 128]; + let payload = Payload::Binary(data.clone()); + let bytes = serialize_data(&payload).unwrap(); + let deserialized = deserialize_data(&bytes, "binary", "test-corr").unwrap(); + match deserialized { + Payload::Binary(b) => assert_eq!(b, data), + _ => panic!("Expected Binary payload"), + } + } + + #[test] + fn test_envelope_json_roundtrip() { + let payload = MsgPayloadV1::new_direct( + "msg".to_string(), + "text".to_string(), + "SGVsbG8=".to_string(), // "Hello" in base64 + 5, + ); + let env = MsgEnvelopeV1::new("/test/subject".to_string(), vec![payload]); + let json = env.to_json().unwrap(); + let parsed: MsgEnvelopeV1 = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.send_to, "/test/subject"); + assert_eq!(parsed.payloads.len(), 1); + assert_eq!(parsed.payloads[0].dataname, "msg"); + } + + #[test] + fn test_base64_encoding() { + let data = b"Hello, msghandler!"; + let encoded = BASE64.encode(data); + let decoded = BASE64.decode(&encoded).unwrap(); + assert_eq!(decoded, data.to_vec()); + } + + #[test] + fn test_error_display() { + let err = msghandlerError::UnknownPayloadType("custom_type".to_string()); + assert!(format!("{}", err).contains("custom_type")); + + let err = msghandlerError::DownloadFailed { + url: "http://example.com/file".to_string(), + retries: 5, + }; + assert!(format!("{}", err).contains("example.com")); + } + + #[test] + fn test_default_options() { + let opts = SmartsendOptions::default(); + assert_eq!(opts.size_threshold, DEFAULT_SIZE_THRESHOLD); + assert_eq!(opts.broker_url, DEFAULT_BROKER_URL); + assert_eq!(opts.fileserver_url, DEFAULT_FILESERVER_URL); + + let opts = SmartreceiveOptions::default(); + assert_eq!(opts.max_retries, DEFAULT_MAX_RETRIES); + assert_eq!(opts.base_delay, DEFAULT_BASE_DELAY); + assert_eq!(opts.max_delay, DEFAULT_MAX_DELAY); + } +} diff --git a/src/natsbridge_csr.js b/src/natsbridge_csr.js new file mode 100644 index 0000000..7dc5103 --- /dev/null +++ b/src/natsbridge_csr.js @@ -0,0 +1,915 @@ +/** + * msghandler - Cross-Platform Bi-Directional Data Bridge + * Browser-Compatible Implementation (Client-Side Rendering) + * + * This module provides functionality for sending and receiving data across network boundaries + * using NATS as the message bus, with support for both direct payload transport and + * URL-based transport for larger payloads. + * + * Supported payload types: "text", "dictionary", "jsontable", "image", "audio", "video", "binary" + * Note: Browser version does NOT support Apache Arrow IPC (arrowtable) due to browser compatibility constraints. + * Use "jsontable" for tabular data in browser applications. + * + * Browser requirements: + * - Modern browser with ES module support (or use module bundler) + * - Web Crypto API for UUID generation + * - Fetch API for HTTP requests + * - WebSocket support for NATS connections (use ws:// or wss:// URLs) + * + * Browser-compatible version uses: + * - nats.ws for WebSocket-based NATS connections + * - Web Crypto API for UUID generation + * - Uint8Array instead of Buffer + * - fetch API for file server communication + * + * @module msghandlerCSR + */ + +// Import browser-compatible NATS client +import * as nats from 'nats.ws'; + +// Use native fetch available in browsers + +// ---------------------------------------------- Constants ---------------------------------------------- // + +/** + * Default size threshold for switching from direct to link transport (0.5MB) + */ +const DEFAULT_SIZE_THRESHOLD = 500_000; + +/** + * Default NATS server URL (WebSocket protocol) + */ +const DEFAULT_BROKER_URL = 'ws://localhost:4222'; + +/** + * Default HTTP file server URL for link transport + */ +const DEFAULT_FILESERVER_URL = 'http://localhost:8080'; + +// ---------------------------------------------- Utility Functions ---------------------------------------------- // + +/** + * Convert Uint8Array to Base64 string + * @param {Uint8Array} data - Data to encode + * @returns {string} Base64 encoded string + */ +function bufferToBase64(data) { + const bytes = new Uint8Array(data); + const binary = String.fromCharCode(...bytes); + return btoa(binary); +} + +/** + * Convert Base64 string to Uint8Array + * @param {string} base64 - Base64 encoded string + * @returns {Uint8Array} Decoded binary data + */ +function base64ToBuffer(base64) { + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Convert Uint8Array to Base64 string (Unicode-safe version) + * Uses TextEncoder/TextDecoder for proper Unicode handling + * @param {Uint8Array} data - Data to encode + * @returns {string} Base64 encoded string + */ +function bufferToBase64UnicodeSafe(data) { + const bytes = new Uint8Array(data); + // Use TextDecoder to properly handle the bytes as text + const binary = String.fromCharCode(...bytes); + return btoa(binary); +} + +/** + * Convert Base64 string to Uint8Array (Unicode-safe version) + * @param {string} base64 - Base64 encoded string + * @returns {Uint8Array} Decoded binary data + */ +function base64ToBufferUnicodeSafe(base64) { + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +/** + * Generate UUID v4 using Web Crypto API + * @returns {string} UUID string + */ +function uuidv4() { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + array[6] = (array[6] & 0x0f) | 0x40; + array[8] = (array[8] & 0x3f) | 0x80; + return Array.from(array, (val) => val.toString(16).padStart(2, '0').toUpperCase()).join(''); +} + +/** + * Log a trace message with correlation ID and timestamp + * @param {string} correlationId - Correlation ID for tracing + * @param {string} message - Message content to log + */ +function logTrace(correlationId, message) { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] [Correlation: ${correlationId}] ${message}`); +} + +// ---------------------------------------------- Serialization Functions ---------------------------------------------- // + +/** + * Serialize data according to specified format + * @param {any} data - Data to serialize + * @param {string} payloadType - Target format: "text", "dictionary", "jsontable", "image", "audio", "video", "binary" + * @returns {Uint8Array} Binary representation of the serialized data + */ +async function serializeData(data, payloadType) { + if (payloadType === 'text') { + if (typeof data === 'string') { + return new Uint8Array(new TextEncoder().encode(data)); + } else { + throw new Error('Text data must be a string'); + } + } else if (payloadType === 'dictionary') { + const jsonStr = JSON.stringify(data); + return new Uint8Array(new TextEncoder().encode(jsonStr)); + } else if (payloadType === 'jsontable') { + // Serialize array of objects to JSON format + if (!Array.isArray(data)) { + throw new Error('JSON table data must be an array'); + } + const jsonStr = JSON.stringify(data); + return new Uint8Array(new TextEncoder().encode(jsonStr)); + } else if (payloadType === 'image') { + if (data instanceof Uint8Array || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + return new Uint8Array(data); + } else { + throw new Error('Image data must be Uint8Array, ArrayBuffer, or ArrayBuffer view'); + } + } else if (payloadType === 'audio') { + if (data instanceof Uint8Array || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + return new Uint8Array(data); + } else { + throw new Error('Audio data must be Uint8Array, ArrayBuffer, or ArrayBuffer view'); + } + } else if (payloadType === 'video') { + if (data instanceof Uint8Array || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + return new Uint8Array(data); + } else { + throw new Error('Video data must be Uint8Array, ArrayBuffer, or ArrayBuffer view'); + } + } else if (payloadType === 'binary') { + if (data instanceof Uint8Array || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + return new Uint8Array(data); + } else { + throw new Error('Binary data must be Uint8Array, ArrayBuffer, or ArrayBuffer view'); + } + } else { + throw new Error(`Unknown payload_type: ${payloadType}`); + } +} + +/** + * Deserialize bytes to data based on type + * @param {Uint8Array|ArrayBuffer} data - Serialized data as bytes + * @param {string} payloadType - Data type + * @param {string} correlationId - Correlation ID for logging + * @returns {any} Deserialized data + */ +async function deserializeData(data, payloadType, correlationId) { + const buffer = data instanceof Uint8Array ? data : new Uint8Array(data); + + logTrace(correlationId, `deserializeData: type=${payloadType}, bufferLength=${buffer.length}`); + + // Debug: Show first 20 bytes in hex for binary data + if (payloadType === 'jsontable' || payloadType === 'image' || payloadType === 'binary') { + const hexPreview = []; + for (let i = 0; i < Math.min(20, buffer.length); i++) { + hexPreview.push(buffer[i].toString(16).padStart(2, '0')); + } + logTrace(correlationId, `deserializeData: First 20 bytes (hex): ${hexPreview.join(' ')}`); + } + + if (payloadType === 'text') { + const result = new TextDecoder().decode(buffer); + logTrace(correlationId, `deserializeData: text result length=${result.length}`); + return result; + } else if (payloadType === 'dictionary') { + const jsonStr = new TextDecoder().decode(buffer); + const result = JSON.parse(jsonStr); + logTrace(correlationId, `deserializeData: dictionary keys=${Object.keys(result).join(', ')}`); + return result; + } else if (payloadType === 'jsontable') { + const jsonStr = new TextDecoder().decode(buffer); + const result = JSON.parse(jsonStr); + logTrace(correlationId, `deserializeData: jsontable result length=${Array.isArray(result) ? result.length : 'N/A'}`); + return result; + } else if (payloadType === 'image') { + logTrace(correlationId, `deserializeData: image buffer length=${buffer.length}`); + return buffer; + } else if (payloadType === 'audio') { + logTrace(correlationId, `deserializeData: audio buffer length=${buffer.length}`); + return buffer; + } else if (payloadType === 'video') { + logTrace(correlationId, `deserializeData: video buffer length=${buffer.length}`); + return buffer; + } else if (payloadType === 'binary') { + logTrace(correlationId, `deserializeData: binary buffer length=${buffer.length}`); + return buffer; + } else { + throw new Error(`Unknown payload_type: ${payloadType}`); + } +} + +// ---------------------------------------------- File Server Handlers ---------------------------------------------- // + +/** + * Upload data to plik server in one-shot mode + * @param {string} fileServerUrl - Base URL of the plik server + * @param {string} dataname - Name of the file being uploaded + * @param {Uint8Array} data - Raw byte data of the file content + * @returns {Promise<{status: number, uploadid: string, fileid: string, url: string}>} + */ +async function plikOneshotUpload(fileServerUrl, dataname, data) { + const buffer = data instanceof Uint8Array ? data : new Uint8Array(data); + + // Get upload id + const urlGetUploadID = `${fileServerUrl}/upload`; + const headers = { 'Content-Type': 'application/json' }; + const body = JSON.stringify({ OneShot: true }); + + const httpResponse = await fetch(urlGetUploadID, { + method: 'POST', + headers, + body + }); + + const responseJson = await httpResponse.json(); + const uploadid = responseJson.id; + const uploadtoken = responseJson.uploadToken; + + // Upload file + const urlUpload = `${fileServerUrl}/file/${uploadid}`; + const form = new FormData(); + const blob = new Blob([buffer], { type: 'application/octet-stream' }); + form.append('file', blob, dataname); + + const uploadHeaders = { + 'X-UploadToken': uploadtoken + }; + + const uploadResponse = await fetch(urlUpload, { + method: 'POST', + headers: uploadHeaders, + body: form + }); + + const uploadJson = await uploadResponse.json(); + const fileid = uploadJson.id; + + const url = `${fileServerUrl}/file/${uploadid}/${fileid}/${dataname}`; + + return { + status: uploadResponse.status, + uploadid, + fileid, + url + }; +} + +/** + * Fetch data from URL with exponential backoff + * @param {string} url - URL to fetch from + * @param {number} maxRetries - Maximum number of retry attempts + * @param {number} baseDelay - Initial delay in milliseconds + * @param {number} maxDelay - Maximum delay in milliseconds + * @param {string} correlationId - Correlation ID for logging + * @returns {Promise} Fetched data as bytes + */ +async function fetchWithBackoff(url, maxRetries, baseDelay, maxDelay, correlationId) { + let delay = baseDelay; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url); + + if (response.status === 200) { + logTrace(correlationId, `Successfully fetched data from ${url} on attempt ${attempt}`); + const arrayBuffer = await response.arrayBuffer(); + return new Uint8Array(arrayBuffer); + } else { + throw new Error(`Failed to fetch: ${response.status}`); + } + } catch (e) { + logTrace(correlationId, `Attempt ${attempt} failed: ${e.constructor.name} - ${e.message}`); + + if (attempt < maxRetries) { + await new Promise(resolve => setTimeout(resolve, delay)); + delay = Math.min(delay * 2, maxDelay); + } + } + } + + throw new Error(`Failed to fetch data after ${maxRetries} attempts`); +} + +// ---------------------------------------------- NATS Client ---------------------------------------------- // + +/** + * NATS client wrapper for connection management + * Supports both single-use and persistent connection modes + */ +class NATSClient { + /** + * Create a new NATS client + * @param {string} url - NATS server URL (ws:// or wss://) + * @param {boolean} [keepAlive=false] - Keep connection open for multiple publishes + */ + constructor(url, keepAlive = false) { + this.url = url; + this.connection = null; + this.keepAlive = keepAlive; + } + + /** + * Connect to NATS server + * @returns {Promise} + */ + async connect() { + if (this.connection) { + return this.connection; + } + this.connection = await nats.connect({ servers: this.url }); + return this.connection; + } + + /** + * Publish message to NATS subject + * @param {string} subject - NATS subject to publish to + * @param {string} message - Message to publish + * @param {string} correlationId - Correlation ID for logging + */ + async publish(subject, message, correlationId) { + if (!this.connection) { + await this.connect(); + } + await this.connection.publish(subject, message); + logTrace(correlationId, `Message published to ${subject}`); + } + + /** + * Close the NATS connection + */ + async close() { + if (this.connection) { + this.connection.close(); + this.connection = null; + } + } + + /** + * Get the current connection (for external use) + * @returns {NATS.Connection|null} + */ + getConnection() { + return this.connection; + } + + /** + * Check if connected + * @returns {boolean} + */ + isConnected() { + return this.connection !== null; + } +} + +/** + * Connection pool for managing multiple NATS connections + * Useful for applications with multiple concurrent publishers + */ +class NATSConnectionPool { + /** + * Create a new connection pool + * @param {string} url - NATS server URL (ws:// or wss://) + * @param {number} [maxSize=10] - Maximum pool size + */ + constructor(url, maxSize = 10) { + this.url = url; + this.maxSize = maxSize; + this.connections = new Map(); + this.idCounter = 0; + } + + /** + * Get a connection from the pool (or create new) + * @returns {Promise} + */ + async acquire() { + // Try to find an existing idle connection + for (const [id, client] of this.connections) { + if (client.isConnected()) { + return client; + } + } + + // Create new connection if under limit + if (this.connections.size < this.maxSize) { + const id = `conn_${++this.idCounter}`; + const client = new NATSClient(this.url, true); + await client.connect(); + this.connections.set(id, client); + return client; + } + + // Pool exhausted - create new connection (caller should close when done) + const client = new NATSClient(this.url, false); + await client.connect(); + return client; + } + + /** + * Return a connection to the pool + * @param {NATSClient} client - Connection to return + */ + release(client) { + // Only return persistent connections + if (client.keepAlive && client.isConnected()) { + // Connection already in pool, do nothing + return; + } + // Non-persistent connection - close it + client.close(); + } + + /** + * Close all connections in the pool + */ + async closeAll() { + for (const [id, client] of this.connections) { + await client.close(); + } + this.connections.clear(); + } +} + +// ---------------------------------------------- Core Functions ---------------------------------------------- // + +/** + * Publish message to NATS + * @param {string|NATSClient|NATS.Connection} brokerUrlOrClient - NATS URL, client, or connection + * @param {string} subject - NATS subject to publish to + * @param {string} message - JSON message to publish + * @param {string} correlationId - Correlation ID for tracing + * @param {boolean} [closeConnection=true] - Close connection after publish (set false for persistent connections) + */ +async function publishMessage(brokerUrlOrClient, subject, message, correlationId, closeConnection = true) { + let conn; + let shouldClose = false; + + if (brokerUrlOrClient instanceof NATSClient) { + conn = brokerUrlOrClient; + } else if (brokerUrlOrClient && typeof brokerUrlOrClient.publish === 'function') { + // Create a wrapper for direct connection (duck-typing check for NATS connection) + conn = { + async publish(subj, msg) { + await brokerUrlOrClient.publish(subj, msg); + }, + async close() { + await brokerUrlOrClient.close(); + } + }; + shouldClose = true; + } else { + // String URL - create new client + const client = new NATSClient(brokerUrlOrClient); + conn = client; + shouldClose = true; + } + + await conn.publish(subject, message, correlationId); + + // Only close if explicitly requested and it's a short-lived client + if (shouldClose && closeConnection && conn instanceof NATSClient) { + await conn.close(); + } +} + +/** + * Build message envelope from payloads and metadata + * @param {string} subject - NATS subject + * @param {Array} payloads - Array of payload objects + * @param {Object} options - Envelope metadata options + * @returns {Object} Envelope object + */ +function buildEnvelope(subject, payloads, options) { + return { + correlation_id: options.correlation_id, + msg_id: options.msg_id, + timestamp: new Date().toISOString(), + send_to: subject, + msg_purpose: options.msg_purpose, + sender_name: options.sender_name, + sender_id: options.sender_id, + receiver_name: options.receiver_name, + receiver_id: options.receiver_id, + reply_to: options.reply_to, + reply_to_msg_id: options.reply_to_msg_id, + broker_url: options.broker_url, + metadata: options.metadata || {}, + payloads: payloads + }; +} + +/** + * Build payload object from serialized data + * @param {string} dataname - Name of the payload + * @param {string} payloadType - Type of the payload + * @param {Uint8Array} payloadBytes - Serialized payload bytes + * @param {string} transport - Transport type ("direct" or "link") + * @param {string} data - Data (base64 for direct, URL for link) + * @returns {Object} Payload object + */ +function buildPayload(dataname, payloadType, payloadBytes, transport, data) { + // Determine encoding based on payload type (matching Julia implementation) + let encoding = 'base64'; + if (payloadType === 'jsontable') { + encoding = 'json'; + } + + return { + id: uuidv4(), + dataname, + payload_type: payloadType, + transport, + encoding, + size: payloadBytes.byteLength, + data, + metadata: transport === 'direct' ? { payload_bytes: payloadBytes.byteLength } : {} + }; +} + +/** + * Send data via NATS with automatic transport selection + * + * This function intelligently routes data delivery based on payload size. + * If the serialized payload is smaller than size_threshold, it encodes the data as Base64 + * and publishes directly over NATS. Otherwise, it uploads the data to a fileserver + * and publishes only the download URL over NATS. + * + * @param {string} subject - NATS subject to publish the message to + * @param {Array} data - List of [dataname, data, type] tuples to send + * - type: "text", "dictionary", "jsontable", "image", "audio", "video", "binary" + * - Note: "arrowtable" is NOT supported in browser (use "jsontable" for tabular data) + * @param {Object} options - Optional configuration + * @param {string} [options.broker_url=DEFAULT_BROKER_URL] - URL of the NATS server (WebSocket) + * @param {string} [options.fileserver_url=DEFAULT_FILESERVER_URL] - URL of the HTTP file server + * @param {Function} [options.fileserver_upload_handler=plikOneshotUpload] - Function to handle fileserver uploads + * @param {number} [options.size_threshold=DEFAULT_SIZE_THRESHOLD] - Threshold separating direct vs link transport + * @param {string} [options.correlation_id=uuidv4()] - Correlation ID for tracing + * @param {string} [options.msg_purpose="chat"] - Purpose of the message + * @param {string} [options.sender_name="msghandler"] - Name of the sender + * @param {string} [options.receiver_name=""] - Name of the receiver (empty means broadcast) + * @param {string} [options.receiver_id=""] - UUID of the receiver (empty means broadcast) + * @param {string} [options.reply_to=""] - Topic to reply to + * @param {string} [options.reply_to_msg_id=""] - Message ID this message is replying to + * @param {boolean} [options.is_publish=true] - Whether to automatically publish the message + * @param {NATSClient|NATS.Connection} [options.nats_connection=null] - Pre-existing NATS connection + * @param {string} [options.msg_id=uuidv4()] - Message ID + * @param {string} [options.sender_id=uuidv4()] - Sender ID + * @returns {Promise<[Object, string]>} Tuple of [env, env_json_str] + * + * @example + * // Send a single payload + * const [env, envJsonStr] = await msghandlerCSR.smartsend( + * "/test", + * [["dataname1", data1, "dictionary"]], + * { broker_url: "wss://nats.example.com" } + * ); + * + * // Send multiple payloads (use jsontable instead of arrowtable for browser) + * const [env, envJsonStr] = await msghandlerCSR.smartsend( + * "/test", + * [ + * ["dataname1", data1, "dictionary"], + * ["dataname2", tableData, "jsontable"] + * ], + * { broker_url: "wss://nats.example.com" } + * ); + */ +async function smartsend(subject, data, options = {}) { + const { + broker_url = DEFAULT_BROKER_URL, + fileserver_url = DEFAULT_FILESERVER_URL, + fileserver_upload_handler = plikOneshotUpload, + size_threshold = DEFAULT_SIZE_THRESHOLD, + correlation_id = uuidv4(), + msg_purpose = 'chat', + sender_name = 'msghandler', + receiver_name = '', + receiver_id = '', + reply_to = '', + reply_to_msg_id = '', + is_publish = true, + nats_connection = null, + msg_id = uuidv4(), + sender_id = uuidv4() + } = options; + + logTrace(correlation_id, `Starting smartsend for subject: ${subject}`); + logTrace(correlation_id, `smartsend: data array length=${data.length}`); + + // Debug: Log input data structure + for (let i = 0; i < data.length; i++) { + const [dataname, payloadData, payloadType] = data[i]; + logTrace(correlation_id, `smartsend: payload[${i}] dataname=${dataname}, type=${payloadType}, data type=${typeof payloadData}, constructor=${payloadData?.constructor?.name}`); + } + + // Process payloads + const payloads = []; + for (const [dataname, payloadData, payloadType] of data) { + logTrace(correlation_id, `smartsend: Processing payload '${dataname}' type=${payloadType}`); + logTrace(correlation_id, `smartsend: payloadData type=${typeof payloadData}, constructor=${payloadData?.constructor?.name}`); + + const payloadBytes = await serializeData(payloadData, payloadType); + const payloadSize = payloadBytes.byteLength; + + logTrace(correlation_id, `Serialized payload '${dataname}' (type: ${payloadType}) size: ${payloadSize} bytes`); + + // Debug: Show first 20 bytes of serialized data for table type + if (payloadType === 'table') { + const hexPreview = []; + for (let i = 0; i < Math.min(20, payloadBytes.length); i++) { + hexPreview.push(payloadBytes[i].toString(16).padStart(2, '0')); + } + logTrace(correlation_id, `Serialized table data first 20 bytes (hex): ${hexPreview.join(' ')}`); + } + + if (payloadSize < size_threshold) { + // Direct path + const payloadB64 = bufferToBase64(payloadBytes); + logTrace(correlation_id, `Using direct transport for ${payloadSize} bytes, base64 length=${payloadB64.length}`); + + const payload = buildPayload(dataname, payloadType, payloadBytes, 'direct', payloadB64); + payloads.push(payload); + } else { + // Link path + logTrace(correlation_id, `Using link transport, uploading to fileserver`); + + const response = await fileserver_upload_handler(fileserver_url, dataname, payloadBytes); + + if (response.status !== 200) { + throw new Error(`Failed to upload data to fileserver: ${response.status}`); + } + + logTrace(correlation_id, `Uploaded to URL: ${response.url}`); + + const payload = buildPayload(dataname, payloadType, payloadBytes, 'link', response.url); + payloads.push(payload); + } + } + + // Build envelope + const env = buildEnvelope(subject, payloads, { + correlation_id, + msg_id, + msg_purpose, + sender_name, + sender_id, + receiver_name, + receiver_id, + reply_to, + reply_to_msg_id, + broker_url + }); + + const env_json_str = JSON.stringify(env); + + if (is_publish) { + if (nats_connection) { + await publishMessage(nats_connection, subject, env_json_str, correlation_id); + } else { + await publishMessage(broker_url, subject, env_json_str, correlation_id); + } + } + + return [env, env_json_str]; +} + +/** + * Receive and process NATS message + * + * This function processes incoming NATS messages, handling both direct transport + * (base64 decoded payloads) and link transport (URL-based payloads). + * It deserializes the data based on the transport type and returns the result. + * + * @param {Object} msg - NATS message object with payload property + * @param {Object} options - Optional configuration + * @param {Function} [options.fileserver_download_handler=fetchWithBackoff] - Function to handle fileserver downloads + * @param {number} [options.max_retries=5] - Maximum retry attempts for fetching URL + * @param {number} [options.base_delay=100] - Initial delay for exponential backoff in ms + * @param {number} [options.max_delay=5000] - Maximum delay for exponential backoff in ms + * @returns {Promise} Envelope object with processed payloads + * + * @example + * // Receive and process message + * const env = await msghandlerCSR.smartreceive(msg, { + * fileserver_download_handler: msghandlerCSR.fetchWithBackoff, + * max_retries: 5, + * base_delay: 100, + * max_delay: 5000 + * }); + * // env.payloads is an Array of [dataname, data, type] arrays + * for (const [dataname, data, type] of env.payloads) { + * console.log(`${dataname}: ${data} (type: ${type})`); + * } + */ +async function smartreceive(msg, options = {}) { + const { + fileserver_download_handler = fetchWithBackoff, + max_retries = 5, + base_delay = 100, + max_delay = 5000 + } = options; + + // Debug: Log message object structure + logTrace('smartreceive', `smartreceive: msg object keys: ${Object.keys(msg).join(', ')}`); + logTrace('smartreceive', `smartreceive: msg.data type: ${typeof msg.data}, constructor: ${msg.data?.constructor?.name}`); + logTrace('smartreceive', `smartreceive: msg.payload type: ${typeof msg.payload}, constructor: ${msg.payload?.constructor?.name}`); + + // Parse the JSON envelope + // NATS.js v2.x uses msg.data instead of msg.payload + let payload; + if (msg.data !== undefined) { + payload = typeof msg.data === 'string' ? msg.data : new TextDecoder().decode(msg.data); + } else if (msg.payload !== undefined) { + payload = typeof msg.payload === 'string' ? msg.payload : new TextDecoder().decode(msg.payload); + } else { + throw new Error('Message has neither data nor payload property'); + } + + logTrace('smartreceive', `smartreceive: raw payload length=${payload.length}`); + + // Debug: Show first 200 chars of payload + const payloadPreview = payload.substring(0, 200); + logTrace('smartreceive', `smartreceive: payload preview: ${payloadPreview}`); + + let envJsonObj; + try { + envJsonObj = JSON.parse(payload); + } catch (e) { + logTrace('smartreceive', `smartreceive: JSON parse failed: ${e.message}`); + throw e; + } + + logTrace(envJsonObj.correlation_id, 'Processing received message'); + logTrace(envJsonObj.correlation_id, `smartreceive: envelope has ${envJsonObj.payloads.length} payloads`); + + // Process all payloads in the envelope + const payloadsList = []; + const numPayloads = envJsonObj.payloads.length; + + logTrace(envJsonObj.correlation_id, `smartreceive: Processing ${numPayloads} payloads`); + + for (let i = 0; i < numPayloads; i++) { + const payloadObj = envJsonObj.payloads[i]; + const transport = payloadObj.transport; + const dataname = payloadObj.dataname; + const payloadType = payloadObj.payload_type; + + logTrace(envJsonObj.correlation_id, `smartreceive: Processing payload ${i + 1}/${numPayloads}: dataname=${dataname}, type=${payloadType}, transport=${transport}`); + + if (transport === 'direct') { + logTrace(envJsonObj.correlation_id, `Direct transport - decoding payload '${dataname}'`); + + // Extract base64 payload from the payload + const payloadB64 = payloadObj.data; + logTrace(envJsonObj.correlation_id, `Direct transport: base64 length=${payloadB64?.length}`); + + // Decode Base64 payload + const payloadBytes = base64ToBuffer(payloadB64); + logTrace(envJsonObj.correlation_id, `Direct transport: decoded bytes=${payloadBytes.length}`); + + // Deserialize based on type + const dataType = payloadObj.payload_type; + const data = await deserializeData(payloadBytes, dataType, envJsonObj.correlation_id); + logTrace(envJsonObj.correlation_id, `Direct transport: deserialized data type=${typeof data}, constructor=${data?.constructor?.name}`); + + payloadsList.push([dataname, data, dataType]); + } else if (transport === 'link') { + // Extract download URL from the payload + const url = payloadObj.data; + logTrace(envJsonObj.correlation_id, `Link transport - fetching '${dataname}' from URL: ${url}`); + + // Fetch with exponential backoff using the download handler + const downloadedData = await fileserver_download_handler( + url, + max_retries, + base_delay, + max_delay, + envJsonObj.correlation_id + ); + + // Deserialize based on type + const dataType = payloadObj.payload_type; + const data = await deserializeData(downloadedData, dataType, envJsonObj.correlation_id); + + payloadsList.push([dataname, data, dataType]); + } else { + throw new Error(`Unknown transport type for payload '${dataname}': ${transport}`); + } + } + + logTrace(envJsonObj.correlation_id, `smartreceive: Successfully processed all ${payloadsList.length} payloads`); + envJsonObj.payloads = payloadsList; + return envJsonObj; +} + +// ---------------------------------------------- Module Exports ---------------------------------------------- // + +const msghandlerCSR = { + /** + * NATS client class for connection management + * Supports both single-use and persistent connection modes + * + * @example + * // Single-use connection (closes after publish) + * const client = new msghandlerCSR.NATSClient("wss://nats.example.com"); + * await msghandlerCSR.smartsend("/test", [["msg", "Hello", "text"]], { nats_connection: client }); + * await client.close(); + * + * // Persistent connection (keeps connection open) + * const client = new msghandlerCSR.NATSClient("wss://nats.example.com", true); + * await client.connect(); + * await msghandlerCSR.smartsend("/test1", [["msg", "Hello", "text"]], { nats_connection: client, is_publish: false }); + * await msghandlerCSR.publishMessage(client, "/test2", JSON.stringify({msg: "World"}), "trace-id"); + * // Connection remains open for more publishes + * await client.close(); + */ + NATSClient, + + /** + * Connection pool for managing multiple NATS connections + * Useful for applications with multiple concurrent publishers + * + * @example + * const pool = new msghandlerCSR.NATSConnectionPool("wss://nats.example.com", 10); + * const client = await pool.acquire(); + * await msghandlerCSR.smartsend("/test", [["msg", "Hello", "text"]], { nats_connection: client }); + * pool.release(client); + * await pool.closeAll(); + */ + NATSConnectionPool, + + /** + * Send data via NATS with automatic transport selection + */ + smartsend, + + /** + * Receive and process NATS message + */ + smartreceive, + + /** + * Publish message to NATS + * + * @example + * // Using a persistent connection + * const client = new msghandlerCSR.NATSClient("wss://nats.example.com", true); + * await client.connect(); + * await msghandlerCSR.publishMessage(client, "/subject", JSON.stringify({msg: "Hello"}), "trace-id", false); + * // Connection stays open for more publishes + * await client.close(); + */ + publishMessage, + + /** + * Upload data to plik server in one-shot mode + */ + plikOneshotUpload, + + /** + * Fetch data from URL with exponential backoff + */ + fetchWithBackoff, + + /** + * Default constants + */ + DEFAULT_SIZE_THRESHOLD, + DEFAULT_BROKER_URL, + DEFAULT_FILESERVER_URL +}; + +export default msghandlerCSR; diff --git a/src/natsbridge_mpy.py b/src/natsbridge_mpy.py new file mode 100644 index 0000000..2296530 --- /dev/null +++ b/src/natsbridge_mpy.py @@ -0,0 +1,673 @@ +""" +msghandler - Cross-Platform Bi-Directional Data Bridge +MicroPython Implementation + +This module provides functionality for sending and receiving data across network boundaries +using NATS as the message bus, with support for both direct payload transport and +URL-based transport for larger payloads. + +Note: MicroPython has significant constraints compared to desktop implementations: +- Limited memory (~256KB - 1MB) +- No Arrow IPC support (memory constraints) +- Synchronous API (no async/await) +- Lower size threshold for direct transport +""" + +import network +import time +import json +import base64 +import uos +import struct +import random + +# ---------------------------------------------- Constants ---------------------------------------------- # + +""" +Default size threshold for switching from direct to link transport (100KB for MicroPython) +""" +DEFAULT_SIZE_THRESHOLD = 100000 + +""" +Default NATS server URL +""" +DEFAULT_BROKER_URL = "nats://localhost:4222" + +""" +Default HTTP file server URL for link transport +""" +DEFAULT_FILESERVER_URL = "http://localhost:8080" + +""" +Hard limit for payload size in MicroPython (50KB) +""" +MAX_PAYLOAD_SIZE = 50000 + + +# ---------------------------------------------- Utility Functions ---------------------------------------------- # + +def log_trace(correlation_id, message): + """ + Log a trace message with correlation ID and timestamp. + + Args: + correlation_id: Correlation ID for tracing + message: Message content to log + """ + timestamp = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.localtime()) + print(f"[{timestamp}] [Correlation: {correlation_id}] {message}") + + +def _generate_uuid(): + """ + Generate a simple UUID compatible with MicroPython. + + Returns: + UUID string + """ + # Generate a simple UUID-like string + # Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + hex_chars = '0123456789abcdef' + uuid_str = ''.join([random.choice(hex_chars) for _ in range(32)]) + # Insert hyphens at proper positions + return f"{uuid_str[:8]}-{uuid_str[8:12]}-{uuid_str[12:16]}-{uuid_str[16:20]}-{uuid_str[20:]}" + + +# ---------------------------------------------- Serialization Functions ---------------------------------------------- # + +def _serialize_data(data, payload_type): + """ + Serialize data according to specified format. + + Args: + data: Data to serialize (string for "text", dict for "dictionary", + bytes for "image", "audio", "video", "binary") + payload_type: Target format: "text", "dictionary", "image", "audio", "video", "binary" + + Returns: + Binary representation of the serialized data + + Note: + MicroPython does not support "table" type due to memory constraints. + + Raises: + ValueError: If payload_type is not one of the supported types + """ + if payload_type == 'text': + if isinstance(data, str): + return data.encode('utf-8') + else: + raise ValueError('Text data must be a string') + elif payload_type == 'dictionary': + json_str = json.dumps(data) + return json_str.encode('utf-8') + elif payload_type in ('image', 'audio', 'video', 'binary'): + if isinstance(data, (bytes, bytearray, memoryview)): + return bytes(data) + else: + raise ValueError(f'{payload_type} data must be bytes') + else: + raise ValueError(f'Unknown payload_type: {payload_type}') + + +def _deserialize_data(data, payload_type): + """ + Deserialize bytes to data based on type. + + Args: + data: Serialized data as bytes + payload_type: Data type ("text", "dictionary", "image", "audio", "video", "binary") + + Returns: + Deserialized data (String for "text", dict for "dictionary", bytes for others) + + Note: + MicroPython does not support "table" type due to memory constraints. + + Raises: + ValueError: If payload_type is not one of the supported types + """ + if payload_type == 'text': + return data.decode('utf-8') + elif payload_type == 'dictionary': + json_str = data.decode('utf-8') + return json.loads(json_str) + elif payload_type in ('image', 'audio', 'video', 'binary'): + return data + else: + raise ValueError(f'Unknown payload_type: {payload_type}') + + +# ---------------------------------------------- File Server Handlers ---------------------------------------------- # + +def _sync_fileserver_upload(file_server_url, dataname, data): + """ + Synchronous file upload to HTTP server. + + Note: + This is a simplified implementation for MicroPython. + In practice, would use network.HTTP or similar. + Currently raises NotImplementedError as file upload is not fully supported. + + Args: + file_server_url: Base URL of the file server + dataname: Name of the file being uploaded + data: Raw byte data of the file content + + Returns: + Dict with keys: 'status', 'url' + + Raises: + NotImplementedError: File upload is not implemented in MicroPython + """ + raise NotImplementedError("File upload not fully implemented in MicroPython. " + "Use direct transport only for memory-constrained devices.") + + +def _sync_fileserver_download(url, max_retries, base_delay, max_delay, correlation_id): + """ + Synchronous file download with exponential backoff. + + Note: + This is a simplified implementation for MicroPython. + In practice, would use network.HTTP or similar. + Currently raises NotImplementedError as file download is not fully supported. + + Args: + url: URL to download from + max_retries: Maximum retry attempts + base_delay: Initial delay in ms + max_delay: Maximum delay in ms + correlation_id: Correlation ID for logging + + Returns: + Downloaded bytes + + Raises: + NotImplementedError: File download is not implemented in MicroPython + """ + raise NotImplementedError("File download not fully implemented in MicroPython. " + "Use direct transport only for memory-constrained devices.") + + +# ---------------------------------------------- NATS Client ---------------------------------------------- # + +class NATSClient: + """ + NATS client wrapper for MicroPython. + + Note: + This is a simplified implementation for MicroPython. + Full NATS client implementation would require additional network stack support. + """ + + def __init__(self, url=DEFAULT_BROKER_URL): + """ + Initialize NATS client. + + Args: + url: NATS server URL + """ + self.url = url + self._connected = False + + def connect(self): + """ + Connect to NATS server. + + Note: + This is a placeholder implementation. + Actual NATS client would require network stack support. + + Returns: + True if connected, False otherwise + """ + # Placeholder - actual implementation would connect to NATS server + self._connected = True + return self._connected + + def publish(self, subject, message): + """ + Publish message to NATS subject. + + Note: + This is a placeholder implementation. + Actual NATS client would require network stack support. + + Args: + subject: NATS subject to publish to + message: Message to publish + """ + if not self._connected: + raise RuntimeError("Not connected to NATS server") + # Placeholder - actual implementation would publish to NATS + print(f"[NATS] Publish to {subject}: {message[:50]}...") + + def close(self): + """Close the NATS connection.""" + self._connected = False + + +# ---------------------------------------------- Core Functions ---------------------------------------------- # + +def _build_envelope(subject, payloads, options): + """ + Build message envelope from payloads and metadata. + + Args: + subject: NATS subject + payloads: Array of payload objects + options: Envelope metadata options + + Returns: + Envelope dict + """ + return { + 'correlation_id': options['correlation_id'], + 'msg_id': options['msg_id'], + 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.localtime()), + 'send_to': subject, + 'msg_purpose': options['msg_purpose'], + 'sender_name': options['sender_name'], + 'sender_id': options['sender_id'], + 'receiver_name': options['receiver_name'], + 'receiver_id': options['receiver_id'], + 'reply_to': options['reply_to'], + 'reply_to_msg_id': options['reply_to_msg_id'], + 'broker_url': options['broker_url'], + 'metadata': {}, + 'payloads': payloads + } + + +def _build_payload(dataname, payload_type, payload_bytes, transport, data): + """ + Build payload object from serialized data. + + Args: + dataname: Name of the payload + payload_type: Type of the payload + payload_bytes: Serialized payload bytes + transport: Transport type ("direct" or "link") + data: Data (base64 for direct, URL for link) + + Returns: + Payload dict + """ + return { + 'id': _generate_uuid(), + 'dataname': dataname, + 'payload_type': payload_type, + 'transport': transport, + 'encoding': 'base64' if transport == 'direct' else 'none', + 'size': len(payload_bytes), + 'data': data, + 'metadata': {'payload_bytes': len(payload_bytes)} if transport == 'direct' else {} + } + + +def _publish(subject, message, correlation_id): + """ + Publish message to NATS. + + Note: + This is a simplified implementation for MicroPython. + + Args: + subject: NATS subject to publish to + message: JSON message to publish + correlation_id: Correlation ID for logging + """ + log_trace(correlation_id, f"Publishing to {subject}") + # Placeholder - actual implementation would use NATSClient + # client = NATSClient() + # client.connect() + # client.publish(subject, message) + # client.close() + + +def smartsend(subject, data, **kwargs): + """ + Send data via NATS with automatic transport selection. + + This function intelligently routes data delivery based on payload size. + If the serialized payload is smaller than size_threshold, it encodes the data as Base64 + and publishes directly over NATS. Otherwise, it uploads the data to a fileserver + and publishes only the download URL over NATS. + + Note: + MicroPython has memory constraints, so the default size_threshold is lower (100KB). + Table type is not supported due to memory constraints. + + Args: + subject: NATS subject to publish the message to + data: List of (dataname, data, type) tuples to send + - dataname: Name of the payload + - data: The actual data to send + - type: Payload type: "text", "dictionary", "image", "audio", "video", "binary" + broker_url: NATS server URL (default: DEFAULT_BROKER_URL) + fileserver_url: HTTP file server URL (default: DEFAULT_FILESERVER_URL) + fileserver_upload_handler: Function to handle fileserver uploads (default: _sync_fileserver_upload) + size_threshold: Threshold in bytes separating direct vs link transport (default: 100000) + correlation_id: Correlation ID for tracing (auto-generated if not provided) + msg_purpose: Purpose of the message (default: "chat") + sender_name: Name of the sender (default: "msghandler") + receiver_name: Name of the receiver (empty means broadcast) + receiver_id: UUID of the receiver (empty means broadcast) + reply_to: Topic to reply to (empty if no reply expected) + reply_to_msg_id: Message ID this message is replying to + is_publish: Whether to automatically publish the message (default: True) + msg_id: Message ID (auto-generated if not provided) + sender_id: Sender ID (auto-generated if not provided) + + Returns: + Tuple of (env, env_json_str) where: + - env: Dict containing all metadata and payloads + - env_json_str: JSON string for publishing to NATS + + Example: + >>> # Send text payload + >>> env, env_json_str = msghandler.smartsend( + ... "/chat", + ... [("message", "Hello!", "text")], + ... broker_url="nats://localhost:4222" + ... ) + >>> + >>> # Send dictionary payload + >>> env, env_json_str = msghandler.smartsend( + ... "/config", + ... [("config", {"key": "value"}, "dictionary")], + ... broker_url="nats://localhost:4222" + ... ) + >>> + >>> # Send binary payload (image, audio, video) + >>> env, env_json_str = msghandler.smartsend( + ... "/media", + ... [("image", image_bytes, "image")], + ... broker_url="nats://localhost:4222" + ... ) + """ + # Extract options with defaults + correlation_id = kwargs.get('correlation_id', _generate_uuid()) + msg_id = kwargs.get('msg_id', _generate_uuid()) + sender_id = kwargs.get('sender_id', _generate_uuid()) + broker_url = kwargs.get('broker_url', DEFAULT_BROKER_URL) + fileserver_url = kwargs.get('fileserver_url', DEFAULT_FILESERVER_URL) + size_threshold = kwargs.get('size_threshold', DEFAULT_SIZE_THRESHOLD) + msg_purpose = kwargs.get('msg_purpose', 'chat') + sender_name = kwargs.get('sender_name', 'msghandler') + receiver_name = kwargs.get('receiver_name', '') + receiver_id = kwargs.get('receiver_id', '') + reply_to = kwargs.get('reply_to', '') + reply_to_msg_id = kwargs.get('reply_to_msg_id', '') + is_publish = kwargs.get('is_publish', True) + fileserver_upload_handler = kwargs.get('fileserver_upload_handler', _sync_fileserver_upload) + + log_trace(correlation_id, f"Starting smartsend for subject: {subject}") + + # Process payloads + payloads = [] + for dataname, payload_data, payload_type in data: + payload_bytes = _serialize_data(payload_data, payload_type) + payload_size = len(payload_bytes) + + # Check against hard limit for MicroPython + if payload_size > MAX_PAYLOAD_SIZE: + raise MemoryError(f"Payload '{dataname}' exceeds max size {MAX_PAYLOAD_SIZE} bytes") + + log_trace(correlation_id, f"Serialized payload '{dataname}' (type: {payload_type}) size: {payload_size} bytes") + + if payload_size < size_threshold: + # Direct path + payload_b64 = base64.b64encode(payload_bytes).decode('ascii') + log_trace(correlation_id, f"Using direct transport for {payload_size} bytes") + + payload = _build_payload(dataname, payload_type, payload_bytes, 'direct', payload_b64) + payloads.append(payload) + else: + # Link path (limited support) + log_trace(correlation_id, "Using link transport, uploading to fileserver") + + try: + response = fileserver_upload_handler(fileserver_url, dataname, payload_bytes) + log_trace(correlation_id, f"Uploaded to URL: {response['url']}") + + payload = _build_payload(dataname, payload_type, payload_bytes, 'link', response['url']) + payloads.append(payload) + except NotImplementedError: + # Fall back to direct transport if file upload not available + log_trace(correlation_id, "File upload not available, using direct transport") + payload_b64 = base64.b64encode(payload_bytes).decode('ascii') + payload = _build_payload(dataname, payload_type, payload_bytes, 'direct', payload_b64) + payloads.append(payload) + + # Build envelope + env = _build_envelope(subject, payloads, { + 'correlation_id': correlation_id, + 'msg_id': msg_id, + 'msg_purpose': msg_purpose, + 'sender_name': sender_name, + 'sender_id': sender_id, + 'receiver_name': receiver_name, + 'receiver_id': receiver_id, + 'reply_to': reply_to, + 'reply_to_msg_id': reply_to_msg_id, + 'broker_url': broker_url + }) + + env_json_str = json.dumps(env) + + if is_publish: + _publish(subject, env_json_str, correlation_id) + + return env, env_json_str + + +def smartreceive(msg, **kwargs): + """ + Receive and process NATS message. + + This function processes incoming NATS messages, handling both direct transport + (base64 decoded payloads) and link transport (URL-based payloads). + It deserializes the data based on the transport type and returns the result. + + Note: + MicroPython has memory constraints, so large payloads should be avoided. + Table type is not supported due to memory constraints. + + Args: + msg: NATS message to process (can be string, dict, or object with 'payload' attribute) + fileserver_download_handler: Function to handle downloading data from file server URLs + max_retries: Maximum retry attempts (default: 3) + base_delay: Initial delay in ms (default: 100) + max_delay: Maximum delay in ms (default: 1000) + + Returns: + Dict with envelope metadata and payloads field containing List[Tuple[str, Any, str]] + + Example: + >>> # Receive and process message + >>> env = msghandler.smartreceive(msg, fileserver_download_handler=_sync_fileserver_download) + >>> # env is a Dict with "payloads" key containing List[Tuple[str, Any, str]] + >>> for dataname, data, type_ in env["payloads"]: + ... print(f"{dataname}: {data} (type: {type_})") + """ + # Parse the JSON envelope + if isinstance(msg, dict): + # Already parsed + env_json_obj = msg + elif hasattr(msg, 'payload'): + # Object with payload attribute + payload = msg.payload if isinstance(msg.payload, str) else msg.payload.decode('utf-8') + env_json_obj = json.loads(payload) + else: + # Assume it's already a JSON string or dict + env_json_obj = json.loads(msg) if isinstance(msg, str) else msg + + correlation_id = env_json_obj['correlation_id'] + log_trace(correlation_id, "Processing received message") + + # Process all payloads in the envelope + payloads_list = [] + num_payloads = len(env_json_obj['payloads']) + + for i in range(num_payloads): + payload_obj = env_json_obj['payloads'][i] + transport = payload_obj['transport'] + dataname = payload_obj['dataname'] + + if transport == 'direct': + log_trace(correlation_id, f"Direct transport - decoding payload '{dataname}'") + + # Extract base64 payload from the payload + payload_b64 = payload_obj['data'] + + # Decode Base64 payload + payload_bytes = base64.b64decode(payload_b64) + + # Deserialize based on type + data_type = payload_obj['payload_type'] + data = _deserialize_data(payload_bytes, data_type) + + payloads_list.append((dataname, data, data_type)) + elif transport == 'link': + # Extract download URL from the payload + url = payload_obj['data'] + log_trace(correlation_id, f"Link transport - fetching '{dataname}' from URL: {url}") + + # Fetch with exponential backoff using the download handler + fileserver_download_handler = kwargs.get('fileserver_download_handler', _sync_fileserver_download) + max_retries = kwargs.get('max_retries', 3) + base_delay = kwargs.get('base_delay', 100) + max_delay = kwargs.get('max_delay', 1000) + + downloaded_data = fileserver_download_handler( + url, + max_retries, + base_delay, + max_delay, + correlation_id + ) + + # Deserialize based on type + data_type = payload_obj['payload_type'] + data = _deserialize_data(downloaded_data, data_type) + + payloads_list.append((dataname, data, data_type)) + else: + raise ValueError(f"Unknown transport type for payload '{dataname}': {transport}") + + env_json_obj['payloads'] = payloads_list + return env_json_obj + + +# ---------------------------------------------- Module Exports ---------------------------------------------- # + +class msghandler: + """ + MicroPython NATS bridge implementation. + + This class provides a convenient interface for msghandler functionality, + encapsulating the main functions and providing a class-based API. + + Note: + MicroPython has significant constraints: + - No Arrow IPC support (memory constraints) + - Only direct transport (< 100KB threshold enforced) + - Simplified UUID generation + - No async/await (synchronous API) + """ + + DEFAULT_SIZE_THRESHOLD = DEFAULT_SIZE_THRESHOLD + DEFAULT_BROKER_URL = DEFAULT_BROKER_URL + DEFAULT_FILESERVER_URL = DEFAULT_FILESERVER_URL + MAX_PAYLOAD_SIZE = MAX_PAYLOAD_SIZE + + def __init__(self, broker_url=None, fileserver_url=None): + """ + Initialize msghandler. + + Args: + broker_url: NATS server URL (defaults to DEFAULT_BROKER_URL) + fileserver_url: HTTP file server URL (defaults to DEFAULT_FILESERVER_URL) + """ + self.broker_url = broker_url or self.DEFAULT_BROKER_URL + self.fileserver_url = fileserver_url or self.DEFAULT_FILESERVER_URL + + def smartsend(self, subject, data, **kwargs): + """ + Send data via NATS. + + Args: + subject: NATS subject to publish to + data: List of (dataname, data, type) tuples + **kwargs: Additional options passed to smartsend + + Returns: + Tuple of (env, env_json_str) + """ + kwargs['broker_url'] = kwargs.get('broker_url', self.broker_url) + kwargs['fileserver_url'] = kwargs.get('fileserver_url', self.fileserver_url) + return smartsend(subject, data, **kwargs) + + def smartreceive(self, msg, **kwargs): + """ + Receive and process NATS message. + + Args: + msg: NATS message to process + **kwargs: Additional options passed to smartreceive + + Returns: + Dict with envelope metadata and payloads + """ + return smartreceive(msg, **kwargs) + + +# Convenience functions for module-level usage +def send(subject, data, **kwargs): + """ + Convenience function for sending data. + + Args: + subject: NATS subject to publish to + data: List of (dataname, data, type) tuples + **kwargs: Additional options + + Returns: + Tuple of (env, env_json_str) + """ + return smartsend(subject, data, **kwargs) + + +def receive(msg, **kwargs): + """ + Convenience function for receiving messages. + + Args: + msg: NATS message to process + **kwargs: Additional options + + Returns: + Dict with envelope metadata and payloads + """ + return smartreceive(msg, **kwargs) + + +__all__ = [ + 'smartsend', + 'smartreceive', + 'msghandler', + 'send', + 'receive', + 'DEFAULT_SIZE_THRESHOLD', + 'DEFAULT_BROKER_URL', + 'DEFAULT_FILESERVER_URL', + 'MAX_PAYLOAD_SIZE', + 'NATSClient', + '_serialize_data', + '_deserialize_data', + 'log_trace', + '_sync_fileserver_upload', + '_sync_fileserver_download' +] \ No newline at end of file diff --git a/src/natsbridge_ssr.js b/src/natsbridge_ssr.js new file mode 100644 index 0000000..7dc2b0b --- /dev/null +++ b/src/natsbridge_ssr.js @@ -0,0 +1,942 @@ +/** + * msghandler - Cross-Platform Bi-Directional Data Bridge + * JavaScript/Node.js Implementation (Desktop/Server-Side) + * + * This module provides functionality for sending and receiving data across network boundaries + * using NATS as the message bus, with support for both direct payload transport and + * URL-based transport for larger payloads. + * + * Supported payload types: "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary" + * + * Node.js-specific features: + * - Apache Arrow IPC support via apache-arrow + * - TCP NATS connections (nats:// or tls:// URLs) + * - Buffer for binary data handling + * - Connection pooling for high-throughput scenarios + * + * @module msghandler + */ + +const nats = require('nats'); +const crypto = require('crypto'); +// Use native fetch available in Node.js 18+ +const arrow = require('apache-arrow'); + +// ---------------------------------------------- UUID Helper ---------------------------------------------- // + +/** + * Generate UUID v4 using crypto module (Node.js compatible) + * @returns {string} UUID string + */ +function uuidv4() { + return crypto.randomUUID(); +} + +// ---------------------------------------------- Constants ---------------------------------------------- // + +/** + * Default size threshold for switching from direct to link transport (0.5MB) + */ +const DEFAULT_SIZE_THRESHOLD = 500_000; + +/** + * Default NATS server URL + */ +const DEFAULT_BROKER_URL = 'nats://localhost:4222'; + +/** + * Default HTTP file server URL for link transport + */ +const DEFAULT_FILESERVER_URL = 'http://localhost:8080'; + +// ---------------------------------------------- Utility Functions ---------------------------------------------- // + +/** + * Convert Buffer to Base64 string + * @param {Buffer} buffer - Buffer to encode + * @returns {string} Base64 encoded string + */ +function bufferToBase64(buffer) { + return buffer.toString('base64'); +} + +/** + * Log a trace message with correlation ID and timestamp + * @param {string} correlationId - Correlation ID for tracing + * @param {string} message - Message content to log + */ +function logTrace(correlationId, message) { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] [Correlation: ${correlationId}] ${message}`); +} + +// ---------------------------------------------- Serialization Functions ---------------------------------------------- // + +/** + * Serialize data according to specified format + * @param {any} data - Data to serialize + * @param {string} payloadType - Target format: "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary" + * @returns {Buffer} Binary representation of the serialized data + */ +async function serializeData(data, payloadType) { + if (payloadType === 'text') { + if (typeof data === 'string') { + return Buffer.from(data, 'utf8'); + } else { + throw new Error('Text data must be a string'); + } + } else if (payloadType === 'dictionary') { + const jsonStr = JSON.stringify(data); + return Buffer.from(jsonStr, 'utf8'); + } else if (payloadType === 'arrowtable') { + // Convert array of objects to Arrow IPC format + if (!Array.isArray(data) || data.length === 0) { + throw new Error('Arrow table data must be a non-empty array of objects'); + } + + return serializeArrowTable(data); + } else if (payloadType === 'jsontable') { + // Serialize array of objects to JSON format + if (!Array.isArray(data)) { + throw new Error('JSON table data must be an array'); + } + const jsonStr = JSON.stringify(data); + return Buffer.from(jsonStr, 'utf8'); + } else if (payloadType === 'image') { + if (data instanceof Uint8Array || Buffer.isBuffer(data)) { + return Buffer.from(data); + } else { + throw new Error('Image data must be Uint8Array or Buffer'); + } + } else if (payloadType === 'audio') { + if (data instanceof Uint8Array || Buffer.isBuffer(data)) { + return Buffer.from(data); + } else { + throw new Error('Audio data must be Uint8Array or Buffer'); + } + } else if (payloadType === 'video') { + if (data instanceof Uint8Array || Buffer.isBuffer(data)) { + return Buffer.from(data); + } else { + throw new Error('Video data must be Uint8Array or Buffer'); + } + } else if (payloadType === 'binary') { + if (data instanceof Uint8Array || Buffer.isBuffer(data)) { + return Buffer.from(data); + } else { + throw new Error('Binary data must be Uint8Array or Buffer'); + } + } else { + throw new Error(`Unknown payload_type: ${payloadType}`); + } +} + +/** + * Helper function to properly serialize table data to Arrow IPC + * @param {Array} data - Array of objects representing table rows + * @returns {Buffer} Arrow IPC formatted buffer + */ +function serializeArrowTable(data) { + if (!Array.isArray(data) || data.length === 0) { + throw new Error('Table data must be a non-empty array of objects'); + } + + logTrace('serializeArrowTable', `Serializing table with ${data.length} rows`); + + // Use arrow.tableFromArrays which handles the conversion properly + // Convert array of objects to a key-value format expected by tableFromArrays + const columns = {}; + for (const key of Object.keys(data[0])) { + columns[key] = data.map(row => row[key]); + } + + logTrace('serializeArrowTable', `Columns: ${Object.keys(columns).join(', ')}`); + + const table = arrow.tableFromArrays(columns); + + logTrace('serializeArrowTable', `Arrow table created with ${table.numRows} rows, ${table.numCols} cols`); + + // Convert to IPC format + const ipcBuffer = arrow.tableToIPC(table); + + logTrace('serializeArrowTable', `IPC buffer type: ${typeof ipcBuffer}, length: ${ipcBuffer.byteLength}`); + + const resultBuffer = Buffer.from(ipcBuffer); + logTrace('serializeArrowTable', `Result buffer: ${resultBuffer.length} bytes`); + + // Debug: Show first 20 bytes in hex + const hexPreview = resultBuffer.slice(0, 20).toString('hex'); + logTrace('serializeArrowTable', `First 20 bytes (hex): ${hexPreview}`); + + return resultBuffer; +} + +/** + * Deserialize bytes to data based on type + * @param {Buffer|Uint8Array} data - Serialized data as bytes + * @param {string} payloadType - Data type + * @param {string} correlationId - Correlation ID for logging + * @returns {any} Deserialized data + */ +async function deserializeData(data, payloadType, correlationId) { + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + + logTrace(correlationId, `deserializeData: type=${payloadType}, bufferLength=${buffer.length}`); + + // Debug: Show first 20 bytes in hex for binary data + if (payloadType === 'arrowtable' || payloadType === 'jsontable' || payloadType === 'image' || payloadType === 'binary') { + const hexPreview = buffer.slice(0, 20).toString('hex'); + logTrace(correlationId, `deserializeData: First 20 bytes (hex): ${hexPreview}`); + } + + if (payloadType === 'text') { + const result = buffer.toString('utf8'); + logTrace(correlationId, `deserializeData: text result length=${result.length}`); + return result; + } else if (payloadType === 'dictionary') { + const jsonStr = buffer.toString('utf8'); + const result = JSON.parse(jsonStr); + logTrace(correlationId, `deserializeData: dictionary keys=${Object.keys(result).join(', ')}`); + return result; + } else if (payloadType === 'arrowtable') { + logTrace(correlationId, `deserializeData: Attempting Arrow table deserialization`); + + // Debug: Check available arrow methods + logTrace(correlationId, `deserializeData: arrow.tableFromRawBytes exists: ${typeof arrow.tableFromRawBytes}`); + logTrace(correlationId, `deserializeData: arrow.tableFromIPC exists: ${typeof arrow.tableFromIPC}`); + + try { + // Try tableFromRawBytes first (older API) + if (typeof arrow.tableFromRawBytes === 'function') { + logTrace(correlationId, `deserializeData: Using tableFromRawBytes`); + const table = arrow.tableFromRawBytes(buffer); + logTrace(correlationId, `deserializeData: Arrow table - rows=${table.numRows}, cols=${table.numCols}`); + return table; + } + } catch (e) { + logTrace(correlationId, `deserializeData: tableFromRawBytes failed: ${e.message}`); + } + + try { + // Try tableFromIPC (newer API) + if (typeof arrow.tableFromIPC === 'function') { + logTrace(correlationId, `deserializeData: Using tableFromIPC`); + const table = arrow.tableFromIPC(buffer); + logTrace(correlationId, `deserializeData: Arrow table from IPC - rows=${table.numRows}, cols=${table.numCols}`); + return table; + } + } catch (e) { + logTrace(correlationId, `deserializeData: tableFromIPC failed: ${e.message}`); + } + + throw new Error(`Unable to deserialize Arrow table: neither tableFromRawBytes nor tableFromIPC worked`); + } else if (payloadType === 'jsontable') { + const jsonStr = buffer.toString('utf8'); + const result = JSON.parse(jsonStr); + logTrace(correlationId, `deserializeData: jsontable result length=${Array.isArray(result) ? result.length : 'N/A'}`); + return result; + } else if (payloadType === 'image') { + logTrace(correlationId, `deserializeData: image buffer length=${buffer.length}`); + return buffer; + } else if (payloadType === 'audio') { + logTrace(correlationId, `deserializeData: audio buffer length=${buffer.length}`); + return buffer; + } else if (payloadType === 'video') { + logTrace(correlationId, `deserializeData: video buffer length=${buffer.length}`); + return buffer; + } else if (payloadType === 'binary') { + logTrace(correlationId, `deserializeData: binary buffer length=${buffer.length}`); + return buffer; + } else { + throw new Error(`Unknown payload_type: ${payloadType}`); + } +} + +// ---------------------------------------------- File Server Handlers ---------------------------------------------- // + +/** + * Upload data to plik server in one-shot mode + * @param {string} fileServerUrl - Base URL of the plik server + * @param {string} dataname - Name of the file being uploaded + * @param {Buffer|Uint8Array} data - Raw byte data of the file content + * @returns {Promise<{status: number, uploadid: string, fileid: string, url: string}>} + */ +async function plikOneshotUpload(fileServerUrl, dataname, data) { + const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data); + + // Get upload id + const urlGetUploadID = `${fileServerUrl}/upload`; + const headers = { 'Content-Type': 'application/json' }; + const body = JSON.stringify({ OneShot: true }); + + const httpResponse = await fetch(urlGetUploadID, { + method: 'POST', + headers, + body + }); + + const responseJson = await httpResponse.json(); + const uploadid = responseJson.id; + const uploadtoken = responseJson.uploadToken; + + // Upload file + const urlUpload = `${fileServerUrl}/file/${uploadid}`; + const form = new FormData(); + const blob = new Blob([buffer], { type: 'application/octet-stream' }); + form.append('file', blob, dataname); + + const uploadHeaders = { + 'X-UploadToken': uploadtoken + }; + + const uploadResponse = await fetch(urlUpload, { + method: 'POST', + headers: uploadHeaders, + body: form + }); + + const uploadJson = await uploadResponse.json(); + const fileid = uploadJson.id; + + const url = `${fileServerUrl}/file/${uploadid}/${fileid}/${dataname}`; + + return { + status: uploadResponse.status, + uploadid, + fileid, + url + }; +} + +/** + * Fetch data from URL with exponential backoff + * @param {string} url - URL to fetch from + * @param {number} maxRetries - Maximum number of retry attempts + * @param {number} baseDelay - Initial delay in milliseconds + * @param {number} maxDelay - Maximum delay in milliseconds + * @param {string} correlationId - Correlation ID for logging + * @returns {Promise} Fetched data as bytes + */ +async function fetchWithBackoff(url, maxRetries, baseDelay, maxDelay, correlationId) { + let delay = baseDelay; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url); + + if (response.status === 200) { + logTrace(correlationId, `Successfully fetched data from ${url} on attempt ${attempt}`); + const arrayBuffer = await response.arrayBuffer(); + return new Uint8Array(arrayBuffer); + } else { + throw new Error(`Failed to fetch: ${response.status}`); + } + } catch (e) { + logTrace(correlationId, `Attempt ${attempt} failed: ${e.constructor.name} - ${e.message}`); + + if (attempt < maxRetries) { + await new Promise(resolve => setTimeout(resolve, delay)); + delay = Math.min(delay * 2, maxDelay); + } + } + } + + throw new Error(`Failed to fetch data after ${maxRetries} attempts`); +} + +// ---------------------------------------------- NATS Client ---------------------------------------------- // + +/** + * NATS client wrapper for connection management + * Supports both single-use and persistent connection modes + */ +class NATSClient { + /** + * Create a new NATS client + * @param {string} url - NATS server URL (nats:// or tls://) + * @param {boolean} [keepAlive=false] - Keep connection open for multiple publishes + */ + constructor(url, keepAlive = false) { + this.url = url; + this.connection = null; + this.keepAlive = keepAlive; + } + + /** + * Connect to NATS server + * @returns {Promise} + */ + async connect() { + if (this.connection) { + return this.connection; + } + this.connection = await nats.connect({ servers: this.url }); + return this.connection; + } + + /** + * Publish message to NATS subject + * @param {string} subject - NATS subject to publish to + * @param {string} message - Message to publish + * @param {string} correlationId - Correlation ID for logging + */ + async publish(subject, message, correlationId) { + if (!this.connection) { + await this.connect(); + } + await this.connection.publish(subject, message); + logTrace(correlationId, `Message published to ${subject}`); + } + + /** + * Close the NATS connection + */ + async close() { + if (this.connection) { + this.connection.close(); + this.connection = null; + } + } + + /** + * Get the current connection (for external use) + * @returns {NATS.Connection|null} + */ + getConnection() { + return this.connection; + } + + /** + * Check if connected + * @returns {boolean} + */ + isConnected() { + return this.connection !== null; + } +} + +/** + * Connection pool for managing multiple NATS connections + * Useful for applications with multiple concurrent publishers + */ +class NATSConnectionPool { + /** + * Create a new connection pool + * @param {string} url - NATS server URL (nats:// or tls://) + * @param {number} [maxSize=10] - Maximum pool size + */ + constructor(url, maxSize = 10) { + this.url = url; + this.maxSize = maxSize; + this.connections = new Map(); + this.idCounter = 0; + } + + /** + * Get a connection from the pool (or create new) + * @returns {Promise} + */ + async acquire() { + // Try to find an existing idle connection + for (const [id, client] of this.connections) { + if (client.isConnected()) { + return client; + } + } + + // Create new connection if under limit + if (this.connections.size < this.maxSize) { + const id = `conn_${++this.idCounter}`; + const client = new NATSClient(this.url, true); + await client.connect(); + this.connections.set(id, client); + return client; + } + + // Pool exhausted - create new connection (caller should close when done) + const client = new NATSClient(this.url, false); + await client.connect(); + return client; + } + + /** + * Return a connection to the pool + * @param {NATSClient} client - Connection to return + */ + release(client) { + // Only return persistent connections + if (client.keepAlive && client.isConnected()) { + // Connection already in pool, do nothing + return; + } + // Non-persistent connection - close it + client.close(); + } + + /** + * Close all connections in the pool + */ + async closeAll() { + for (const [id, client] of this.connections) { + await client.close(); + } + this.connections.clear(); + } +} + +// ---------------------------------------------- Core Functions ---------------------------------------------- // + +/** + * Publish message to NATS + * @param {string|NATSClient|NATS.Connection} brokerUrlOrClient - NATS URL, client, or connection + * @param {string} subject - NATS subject to publish to + * @param {string} message - JSON message to publish + * @param {string} correlationId - Correlation ID for tracing + * @param {boolean} [closeConnection=true] - Close connection after publish (set false for persistent connections) + */ +async function publishMessage(brokerUrlOrClient, subject, message, correlationId, closeConnection = true) { + let conn; + let shouldClose = false; + + if (brokerUrlOrClient instanceof NATSClient) { + conn = brokerUrlOrClient; + } else if (brokerUrlOrClient && typeof brokerUrlOrClient.publish === 'function') { + // Create a wrapper for direct connection (duck-typing check for NATS connection) + conn = { + async publish(subj, msg) { + await brokerUrlOrClient.publish(subj, msg); + }, + async close() { + await brokerUrlOrClient.close(); + } + }; + shouldClose = true; + } else { + // String URL - create new client + const client = new NATSClient(brokerUrlOrClient); + conn = client; + shouldClose = true; + } + + await conn.publish(subject, message, correlationId); + + // Only close if explicitly requested and it's a short-lived client + if (shouldClose && closeConnection && conn instanceof NATSClient) { + await conn.close(); + } +} + +/** + * Build message envelope from payloads and metadata + * @param {string} subject - NATS subject + * @param {Array} payloads - Array of payload objects + * @param {Object} options - Envelope metadata options + * @returns {Object} Envelope object + */ +function buildEnvelope(subject, payloads, options) { + return { + correlation_id: options.correlation_id, + msg_id: options.msg_id, + timestamp: new Date().toISOString(), + send_to: subject, + msg_purpose: options.msg_purpose, + sender_name: options.sender_name, + sender_id: options.sender_id, + receiver_name: options.receiver_name, + receiver_id: options.receiver_id, + reply_to: options.reply_to, + reply_to_msg_id: options.reply_to_msg_id, + broker_url: options.broker_url, + metadata: options.metadata || {}, + payloads: payloads + }; +} + +/** + * Build payload object from serialized data + * @param {string} dataname - Name of the payload + * @param {string} payloadType - Type of the payload + * @param {Buffer} payloadBytes - Serialized payload bytes + * @param {string} transport - Transport type ("direct" or "link") + * @param {string} data - Data (base64 for direct, URL for link) + * @returns {Object} Payload object + */ +function buildPayload(dataname, payloadType, payloadBytes, transport, data) { + // Determine encoding based on payload type (matching Julia implementation) + let encoding = 'base64'; + if (payloadType === 'jsontable') { + encoding = 'json'; + } else if (payloadType === 'arrowtable') { + encoding = 'arrow-ipc'; + } + + return { + id: uuidv4(), + dataname, + payload_type: payloadType, + transport, + encoding, + size: payloadBytes.byteLength, + data, + metadata: transport === 'direct' ? { payload_bytes: payloadBytes.byteLength } : {} + }; +} + +/** + * Send data via NATS with automatic transport selection + * + * This function intelligently routes data delivery based on payload size. + * If the serialized payload is smaller than size_threshold, it encodes the data as Base64 + * and publishes directly over NATS. Otherwise, it uploads the data to a fileserver + * and publishes only the download URL over NATS. + * + * @param {string} subject - NATS subject to publish the message to + * @param {Array} data - List of [dataname, data, type] tuples to send + * - type: "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary" + * @param {Object} options - Optional configuration + * @param {string} [options.broker_url=DEFAULT_BROKER_URL] - URL of the NATS server + * @param {string} [options.fileserver_url=DEFAULT_FILESERVER_URL] - URL of the HTTP file server + * @param {Function} [options.fileserver_upload_handler=plikOneshotUpload] - Function to handle fileserver uploads + * @param {number} [options.size_threshold=DEFAULT_SIZE_THRESHOLD] - Threshold separating direct vs link transport + * @param {string} [options.correlation_id=crypto.randomUUID()] - Correlation ID for tracing + * @param {string} [options.msg_purpose="chat"] - Purpose of the message + * @param {string} [options.sender_name="msghandler"] - Name of the sender + * @param {string} [options.receiver_name=""] - Name of the receiver (empty means broadcast) + * @param {string} [options.receiver_id=""] - UUID of the receiver (empty means broadcast) + * @param {string} [options.reply_to=""] - Topic to reply to + * @param {string} [options.reply_to_msg_id=""] - Message ID this message is replying to + * @param {boolean} [options.is_publish=true] - Whether to automatically publish the message + * @param {NATSClient|NATS.Connection} [options.nats_connection=null] - Pre-existing NATS connection + * @param {string} [options.msg_id=crypto.randomUUID()] - Message ID + * @param {string} [options.sender_id=crypto.randomUUID()] - Sender ID + * @returns {Promise<[Object, string]>} Tuple of [env, env_json_str] + * + * @example + * // Send a single payload + * const [env, envJsonStr] = await smartsend( + * "/test", + * [["dataname1", data1, "dictionary"]], + * { broker_url: "nats://localhost:4222" } + * ); + * + * // Send multiple payloads + * const [env, envJsonStr] = await smartsend( + * "/test", + * [ + * ["dataname1", data1, "dictionary"], + * ["dataname2", data2, "arrowtable"] + * ], + * { broker_url: "nats://localhost:4222" } + * ); + * + * // Send with pre-existing connection + * const client = await msghandler.NATSClient.connect("nats://localhost:4222"); + * const [env, envJsonStr] = await smartsend( + * "/test", + * [["data", myData, "text"]], + * { nats_connection: client } + * ); + */ +async function smartsend(subject, data, options = {}) { + const { + broker_url = DEFAULT_BROKER_URL, + fileserver_url = DEFAULT_FILESERVER_URL, + fileserver_upload_handler = plikOneshotUpload, + size_threshold = DEFAULT_SIZE_THRESHOLD, + correlation_id = uuidv4(), + msg_purpose = 'chat', + sender_name = 'msghandler', + receiver_name = '', + receiver_id = '', + reply_to = '', + reply_to_msg_id = '', + is_publish = true, + nats_connection = null, + msg_id = uuidv4(), + sender_id = uuidv4() + } = options; + + logTrace(correlation_id, `Starting smartsend for subject: ${subject}`); + logTrace(correlation_id, `smartsend: data array length=${data.length}`); + + // Debug: Log input data structure + for (let i = 0; i < data.length; i++) { + const [dataname, payloadData, payloadType] = data[i]; + logTrace(correlation_id, `smartsend: payload[${i}] dataname=${dataname}, type=${payloadType}, data type=${typeof payloadData}, constructor=${payloadData?.constructor?.name}`); + } + + // Process payloads + const payloads = []; + for (const [dataname, payloadData, payloadType] of data) { + logTrace(correlation_id, `smartsend: Processing payload '${dataname}' type=${payloadType}`); + logTrace(correlation_id, `smartsend: payloadData type=${typeof payloadData}, constructor=${payloadData?.constructor?.name}`); + + const payloadBytes = await serializeData(payloadData, payloadType); + const payloadSize = payloadBytes.byteLength; + + logTrace(correlation_id, `Serialized payload '${dataname}' (type: ${payloadType}) size: ${payloadSize} bytes`); + + // Debug: Show first 20 bytes of serialized data for table type + if (payloadType === 'table') { + const hexPreview = payloadBytes.slice(0, 20).toString('hex'); + logTrace(correlation_id, `Serialized table data first 20 bytes (hex): ${hexPreview}`); + } + + if (payloadSize < size_threshold) { + // Direct path + const payloadB64 = bufferToBase64(payloadBytes); + logTrace(correlation_id, `Using direct transport for ${payloadSize} bytes, base64 length=${payloadB64.length}`); + + const payload = buildPayload(dataname, payloadType, payloadBytes, 'direct', payloadB64); + payloads.push(payload); + } else { + // Link path + logTrace(correlation_id, `Using link transport, uploading to fileserver`); + + const response = await fileserver_upload_handler(fileserver_url, dataname, payloadBytes); + + if (response.status !== 200) { + throw new Error(`Failed to upload data to fileserver: ${response.status}`); + } + + logTrace(correlation_id, `Uploaded to URL: ${response.url}`); + + const payload = buildPayload(dataname, payloadType, payloadBytes, 'link', response.url); + payloads.push(payload); + } + } + + // Build envelope + const env = buildEnvelope(subject, payloads, { + correlation_id, + msg_id, + msg_purpose, + sender_name, + sender_id, + receiver_name, + receiver_id, + reply_to, + reply_to_msg_id, + broker_url + }); + + const env_json_str = JSON.stringify(env); + + if (is_publish) { + if (nats_connection) { + await publishMessage(nats_connection, subject, env_json_str, correlation_id); + } else { + await publishMessage(broker_url, subject, env_json_str, correlation_id); + } + } + + return [env, env_json_str]; +} + +/** + * Receive and process NATS message + * + * This function processes incoming NATS messages, handling both direct transport + * (base64 decoded payloads) and link transport (URL-based payloads). + * It deserializes the data based on the transport type and returns the result. + * + * @param {Object} msg - NATS message object with payload property + * @param {Object} options - Optional configuration + * @param {Function} [options.fileserver_download_handler=fetchWithBackoff] - Function to handle fileserver downloads + * @param {number} [options.max_retries=5] - Maximum retry attempts for fetching URL + * @param {number} [options.base_delay=100] - Initial delay for exponential backoff in ms + * @param {number} [options.max_delay=5000] - Maximum delay for exponential backoff in ms + * @returns {Promise} Envelope object with processed payloads + * + * @example + * // Receive and process message + * const env = await smartreceive(msg, { + * fileserver_download_handler: fetchWithBackoff, + * max_retries: 5, + * base_delay: 100, + * max_delay: 5000 + * }); + * // env.payloads is an Array of [dataname, data, type] arrays + * for (const [dataname, data, type] of env.payloads) { + * console.log(`${dataname}: ${data} (type: ${type})`); + * } + */ +async function smartreceive(msg, options = {}) { + const { + fileserver_download_handler = fetchWithBackoff, + max_retries = 5, + base_delay = 100, + max_delay = 5000 + } = options; + + // Debug: Log message object structure + logTrace('smartreceive', `smartreceive: msg object keys: ${Object.keys(msg).join(', ')}`); + logTrace('smartreceive', `smartreceive: msg.data type: ${typeof msg.data}, constructor: ${msg.data?.constructor?.name}`); + logTrace('smartreceive', `smartreceive: msg.payload type: ${typeof msg.payload}, constructor: ${msg.payload?.constructor?.name}`); + + // Parse the JSON envelope + // NATS.js v2.x uses msg.data instead of msg.payload + let payload; + if (msg.data !== undefined) { + payload = typeof msg.data === 'string' ? msg.data : Buffer.from(msg.data).toString('utf8'); + } else if (msg.payload !== undefined) { + payload = typeof msg.payload === 'string' ? msg.payload : Buffer.from(msg.payload).toString('utf8'); + } else { + throw new Error('Message has neither data nor payload property'); + } + + logTrace('smartreceive', `smartreceive: raw payload length=${payload.length}`); + + // Debug: Show first 200 chars of payload + const payloadPreview = payload.substring(0, 200); + logTrace('smartreceive', `smartreceive: payload preview: ${payloadPreview}`); + + let envJsonObj; + try { + envJsonObj = JSON.parse(payload); + } catch (e) { + logTrace('smartreceive', `smartreceive: JSON parse failed: ${e.message}`); + throw e; + } + + logTrace(envJsonObj.correlation_id, 'Processing received message'); + logTrace(envJsonObj.correlation_id, `smartreceive: envelope has ${envJsonObj.payloads.length} payloads`); + + // Process all payloads in the envelope + const payloadsList = []; + const numPayloads = envJsonObj.payloads.length; + + logTrace(envJsonObj.correlation_id, `smartreceive: Processing ${numPayloads} payloads`); + + for (let i = 0; i < numPayloads; i++) { + const payloadObj = envJsonObj.payloads[i]; + const transport = payloadObj.transport; + const dataname = payloadObj.dataname; + const payloadType = payloadObj.payload_type; + + logTrace(envJsonObj.correlation_id, `smartreceive: Processing payload ${i + 1}/${numPayloads}: dataname=${dataname}, type=${payloadType}, transport=${transport}`); + + if (transport === 'direct') { + logTrace(envJsonObj.correlation_id, `Direct transport - decoding payload '${dataname}'`); + + // Extract base64 payload from the payload + const payloadB64 = payloadObj.data; + logTrace(envJsonObj.correlation_id, `Direct transport: base64 length=${payloadB64?.length}`); + + // Decode Base64 payload + const payloadBytes = Buffer.from(payloadB64, 'base64'); + logTrace(envJsonObj.correlation_id, `Direct transport: decoded bytes=${payloadBytes.length}`); + + // Deserialize based on type + const dataType = payloadObj.payload_type; + const data = await deserializeData(payloadBytes, dataType, envJsonObj.correlation_id); + logTrace(envJsonObj.correlation_id, `Direct transport: deserialized data type=${typeof data}, constructor=${data?.constructor?.name}`); + + payloadsList.push([dataname, data, dataType]); + } else if (transport === 'link') { + // Extract download URL from the payload + const url = payloadObj.data; + logTrace(envJsonObj.correlation_id, `Link transport - fetching '${dataname}' from URL: ${url}`); + + // Fetch with exponential backoff using the download handler + const downloadedData = await fileserver_download_handler( + url, + max_retries, + base_delay, + max_delay, + envJsonObj.correlation_id + ); + + // Deserialize based on type + const dataType = payloadObj.payload_type; + const data = await deserializeData(downloadedData, dataType, envJsonObj.correlation_id); + + payloadsList.push([dataname, data, dataType]); + } else { + throw new Error(`Unknown transport type for payload '${dataname}': ${transport}`); + } + } + + logTrace(envJsonObj.correlation_id, `smartreceive: Successfully processed all ${payloadsList.length} payloads`); + envJsonObj.payloads = payloadsList; + return envJsonObj; +} + +// ---------------------------------------------- Module Exports ---------------------------------------------- // + +const msghandler = { + /** + * NATS client class for connection management + * Supports both single-use and persistent connection modes + * + * @example + * // Single-use connection (closes after publish) + * const client = new msghandler.NATSClient("nats://localhost:4222"); + * await msghandler.smartsend("/test", [["msg", "Hello", "text"]], { nats_connection: client }); + * await client.close(); + * + * // Persistent connection (keeps connection open) + * const client = new msghandler.NATSClient("nats://localhost:4222", true); + * await client.connect(); + * await msghandler.smartsend("/test1", [["msg", "Hello", "text"]], { nats_connection: client, is_publish: false }); + * await msghandler.publishMessage(client, "/test2", JSON.stringify({msg: "World"}), "trace-id"); + * // Connection remains open for more publishes + * await client.close(); + */ + NATSClient, + + /** + * Connection pool for managing multiple NATS connections + * Useful for applications with multiple concurrent publishers + * + * @example + * const pool = new msghandler.NATSConnectionPool("nats://localhost:4222", 10); + * const client = await pool.acquire(); + * await msghandler.smartsend("/test", [["msg", "Hello", "text"]], { nats_connection: client }); + * pool.release(client); + * await pool.closeAll(); + */ + NATSConnectionPool, + + /** + * Send data via NATS with automatic transport selection + */ + smartsend, + + /** + * Receive and process NATS message + */ + smartreceive, + + /** + * Publish message to NATS + * + * @example + * // Using a persistent connection + * const client = new msghandler.NATSClient("nats://localhost:4222", true); + * await client.connect(); + * await msghandler.publishMessage(client, "/subject", JSON.stringify({msg: "Hello"}), "trace-id", false); + * // Connection stays open for more publishes + * await client.close(); + */ + publishMessage, + + /** + * Upload data to plik server in one-shot mode + */ + plikOneshotUpload, + + /** + * Fetch data from URL with exponential backoff + */ + fetchWithBackoff, + + /** + * Default constants + */ + DEFAULT_SIZE_THRESHOLD, + DEFAULT_BROKER_URL, + DEFAULT_FILESERVER_URL +}; + +module.exports = msghandler; \ No newline at end of file diff --git a/test/large_image.png b/test/large_image.png new file mode 100644 index 0000000000000000000000000000000000000000..2a89fd8e1d36d1d6abf90cad15142ea9b83dcf13 GIT binary patch literal 1232619 zcmV(`K-0g8P)T<%s@A*DH~bSxQQqC>JNxXP_h+qIRrORoRjb*52_Ap=%U^!)@`ZQ5*sZ_cd;4O$ z^e=49pZm{dz1w?c?LBMVT5Iuhyl1^xJfCyM-~KqB#=~E?c(3Jm;{A@#$Maz}j;H(a z&waliKR3JIKecf@-;d{hu=Tt9yxZ-zA3rx6ztj1gn(HYZL@yhWUt}VxsfBOU9YjNDUbsu!w`jxkKTyL|}^{m&Z-+Dfe4|l ^FT5Kd$k% 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≪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%0Ai$<@hFxM; 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

bs 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

gyFG65MO}qnsJ(NCdhZ+DrvP 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�hs6?^JcyZb&|H&^z79$8{) zU^o|S(#N4Zl)?Mgqap++!y$~f_Z~8FDS#6^d9cYLkqEk!Z14-u)ln*cR)gYxNb_zd zI0&A7^c_k`O?%sh6DS$f$3lbJr+w44OBor$)4sMwTa0AEPB^KvCN7s(+t)X{{PbJf zZ@<{vzkjoGKLRv+KRzD{$QhK3*{lsts+<=L$8=K!HeZ0;F4e@{%!ZGvD4!MkP_m)t^t|8ZkMp;nZ&jaM+ zz=kuMvk!|6IAWimS9JD#HVvGt4Fnm-^&GMhfn;$;$=j$*dOutwOIA3ZR?;B4Zga!2 z#`+zZ<>e*b%QK44tHTy=R{frjfA5FPI?HM6eN_5mz2taPPSn>ynVe;&(24HlR^_Hl7buu7=}{hpOq^xC16fMLH6{gUkvR z<+dbxpQFzOeS@79lUSclauv^h_tA$es(k4M*hkp_IwEN*XX9^^jWW}mps=GnBjJR! zc6zOX?sUQ?8CilMPGvQjVzwF;e}5g>&A zT}BUDR+0#5`ktL1x^W~~XBfYh(brGiBk`JjoCzeH%@nXk@R8Ak*<6v7T`dey+Z6o3 zxi9qDvGS)La{b=#Wfdv~x@=Cyj%V8}VRt;2Ap;M4|2@Jqvr2~(K23z)6Ou`Z8O(T^ z*gkgz-JOV+B)D({!4BKz$u{X)AB{13v|1u64$Jiq2G-?nZl5 z;~Bioq!o}Od9N>QW&_OH;Qs_!68(!bj94z%=05ak^wKeL?LOR&NHb(`-xVxP=e(D* zE|`7WPWY<7c<+RHWBs;Wv%xm{y~bp5841XO-e@oBhkx49^C_~M5x4YUP7GOyLEJa^ zJk;$rp>SqdO!#o@A$OLxwCd*_i7BiT|Gq$;d@q)N^50zlg{2^rv!S0pX0bnI`3sDl z7F3Qs`gYhTODabVUecw+x<^-E5y zezr`|!0cUNup(o3cA)th9drgYInOL1ZRg-pPx*+ApAFSmced~|AQP=_<<1HqQAoEb zsLTiD#_t3Ecu%=eq=_J10bf# zuBP-3l?f{9l9@Eg!q|&)ayoELR4b4HYJiDFS$$&$12nHPUIB|(!P1qA&jf*LV31PT zy_US^!YYj)^fjoC`xIzsKq=48_W~!%!W^{E{WjygJOpUdM+RWi_mA>_{<@#~srF47 z9Rw==c?`S9i#!Engb;xgrM(Bt<`Vegf)t5ezg9I=%HsVkssf`#=dNJ?0uCjz`0=9Mu2 zeBW4+-kFKQ{i}+mM31ha)zvx4>+h(1d6i9&6Fqkf&~#b5V?E+~{!Od|v(Ec{OGC(L zDc%b(R!i9y%Tkc%lr2b0Vw}UWGk8JI8mhJut5I1!%XA9F$;q5`9CoUv!<{`_;W#f@ z#WE&J;<`;brO?@gu9jZ2pw0Kn<}-#m+a)UM*|6SK0jQLd!qFXNdXAn!1#}|5cUcTF zp1!dH&gOGcg@>`6`d@l~aOjIX2VJiZ`(kIOF3&1^Cb3pScc!Dj--yOr=!?ciw4OSR zRk;VKij62Mof-Y?j0ET>=T&k$zE`%J=M?S;GS|}_t>KXETt8OLI9$7t5mmB^fCIA} zu|(B0!;=cyMi4vty&THCux~O^1e23&)cL505vE#4-wx#Zv?brwi+?C_`Di!CM?2p` z9*;?X_de}aF$7w2occ%%$bl=;-DSF`lKNLkEIhNF?pJ#lL)? zy2a|K8WcF7gS4ny>WhG_Z48#F{|G6yJbOf%*iOJ8p zk=A1PTUeKQM9`yOF@HaPHr5rada=N1SHlVB!ps%^16PEC4shT zDOc?w4$*s*JcpEV%Ga7Pfh9^oBq4M&- zr_;deCNuK2*^%UBx}_6zf?iFEUvoKzFimf4HYv6F9?r_xV}>V(LlrL4cNF*IfsbHA z%41g8e9gi@LPDc#M@?f?SbUssp1v$(!w{|#~ujX~`d#k{4ONKCibv z9>ifP*N-!RKkuijtKw4_koQoA_jDLS@x*{&R48JWQ%8n7GG#HuR!(dqq_kxQc8^Dd z9H^oYdsa{l@{tK8XzQ;L^sYWj?2s$FUhV0JAC7-N+ViI$>~ejM*Tt~siw9`e_zcG# z4tGfPu`l-x41l-W4W)jE9&VZ^wnq>I!>+&CST!mGjzw)Al~(qQwSDRbkrP8`ml6Kz z4cDP9H4wRrZETP8*S2k(!-guF&tKcy=%c90;O&AJ%L%dQz43W3mn-%y=2RV3GR^u6 z*Qi_SrLZy74e`vTASBf*KQlmp_8GdC6<`Esk{VX_+3bw>8%ZRSj%_`AK_R1lwq5{N zbT-ApQ4+nFdb2X{D=Z`-=sklkBwG-K?R^Ag>MNCAP|&NHJPPPwd?&}G z+vMQd2b+)t4K{+XK~c}MO#N;m;&PdHH{ zbD801gbnwuxi_kmVGT5-JfVqUnn&hb=b~V{xaB2fN051liJZui4*EzeK`t6~;#^UM zGrCNb6C@j@;b%Q4yHF>1QXqI({gL1+AyHBn$`+T@1+z0Notr)mq5j3DAYIj1emxJr z&e0~Fuj{A@DJka{a|b@8jqCU^c{bjw?^Dp!RRJyoW?lvv4T@U`p!6)zX{-RZJ2lD6pUeQmh7RAV5}6Ilbe~KG`W!l)u2pFod21feV-Z5 z=FV3Nh?O!5;%7eQ3$u4(qvRG7=_XB$Gm9R)p9 zP`Kj#bdUU9?{>cT2ixvGN6ckBUcUxR(b#MHXz!Jf*+Xj-GD*(8e4`4>zTTcIMITE6 zK9W!P2MDId#Att{f7GKbf|f1OC%7`qWFOB(8EBNl==mzBqz2lk^-S1lZFFCy{Gp2I z(921J#2_utiC~u0)x9j#KnnDGv>W(Yh#}YhwcBoI==JeDoTrLP-e3n$vJq2q*v7L9 zLaOx@Bvx{6V?FgOni0BUe@Rm+qth*$WDCn#H3i_TQ2Zh1vblsnl(%xY^~Ui#!p&lV zawAX*)xp~*hd!g?G8&k>I$*n~(7`7;Ger(iKNM5 zQS%#>D}o1$UZP-`6zzWu0j@#WU1?Ysk`hEvbHSkFWm@+csxkjlV5|9anp!V%fWq$! zp&cSn@HcYIb!0jiY$QPl*8>NnXUW^?>zk8qI9l=kepFg6N5JvUIa;`%ryec;q#cBVtpmt?vl#H@7c@Y*9Gj13$vl1m92SJg7(_)oqdFYH$EOf$-~(gbv742 zCKmE0($TQ;5vCzS)YDn6CKzQvFa7-&z$jyWm=9Zi{n4(!{$soSH3m=yJ7R@`?+jqj zj2Z;uq0nxwqzJKUR673qAbL3}BCajMSV!sSkfGy!j)e}^#J(Pm`paj#e)?=rub)sM zJA(cg>RdU*83LPuZObgJLj<=eK*ICygsq}SY!A4U_jheKLg4C>XE1^-8K@3L*Z346 zoBZtid*5zJBOJQPp();s?E${eraA(CKQmxmqO(#9nUgk*V(8kh8d=fX+uN}o*N|!W z0f5lOJyuZn`}Ho%rx=~^^7LDKyKVMd4?}VF8DLBF?`@rXlLl`D;0kIAi7_mLiX~r% z*AzODQ+AjiS=FA5UaVywwEAL(qxw1Yj?2~_y>4m{N2R4kP`@ys?;M<3Iv`CBlHG$0 z!h7X}LNJ>LLwD*q1sOYM+%;uLyYzsKGs5T8AW`+}Nw4el^zWQaou>>6>SX(PR`EA* z8pd9fAiMCauS=Cz{v2)Kl=i-7OIzt#!JRNH9dCqAwclGA@aSQCI-gJPDd$QK2&)^c zeku5p&O;S(2mN)^!1gxTR65I*_PL9Mql9B#vnK^}(A=r<%D4|D^y+PIs=AE;pp%hK zy(K!+u;qZXI)%98c6RC{%`F)x$7(Hvm?4lNBrAhcLD0+wYG=$B9M0WjTZ7GTB1oC` zxE9G!e>h-FYm}0SqF$-KDJGlbN_%Yer2q5%dmp`Tvn*EaPcc+}OUiV@;CN*PPK^#a zd>2*-c=CE2*4;FqS&30pNWhNggjH6C?|}91{C$n~0%x{!hUKzoSn_xdn z!unTRk7`)2uo($aCgi$ubc^%nmvvOOePl#cFwgav_YL=N`Ai7xFbfOkWvm;-jsgbH zby|A_BLn0;{sw__$+gb_zRTUvSzm!v^4IVFJeOR*!?Ow>5dGy3K1RnL)9^5$DwYhR z4&UF-uxWIW?DQ?E3(N58VtBSO!OYK*P*HSbMggj+06;c@hVE?swr-Zjx0}`RM#IpK2z+M1nVs7C`x%=Z zfyGir=Bf;VROW7;?=|T*qCRPf%7Dcffe3_b&sbEqvDzL+Z+0TF8o_}va45K z4rS+GUyn+SW`Kyy0ZL{u%}I$psR+`%G8lWeh`xUu*32q1Ak+GmdN#BfU|IPf!*V0P zCp%-k&hjMr#Wq4wb}Gq{wInp-P)--0xpsfE%kvM`KmFR?fBxH;p%MLphn(!Uug1x^ z;e>%CV2k15u@`iZ1B$o5Gb4_X2r*zbs;_GdoGwq#G2<Wg?M8A({ zRQ3ZoHN}P=DC2yGj)|b9h2m{HGyQubJaJ_eySG#@ZOQ?3{etQoU_CKW%4L3qYIB1SddI7_{gU_N2GBEc zsB~5u_ajJMS}^IZ_1AHpZ|&_k!apDJ7@ie_jwk0$Ga%CuVpcbQjOv5}olKPQe2bY& z@-&Q~^lv{o%{}~`9y85ErEigCd_UF>RaK{U2LfP?doJaUWKj%EUX52kK)j=`tn*%5 z9~Cq(Z=q_tH$jqHj@sBY(4K6PdC$02+;8gio`$U3x##G6365eTG544#=~Mq1)(8%3 z3x@{K7$1S)x4Ae?eZ_h~m&sO?^ebZ>VpCbAnG#Qel*QT0WTW;_b_4;*Na*ovU$bNc zV@0fukq&yMa45`L$+guS`|9$(PW3M!-VynMh@HIm^Rw`$IBVvZsc2iT;abIE~lSmuYRXrv=dB zK44yv$P912q#rQWBT^nIonWuzCdwVbL5h8+)d~_ucw~o#XlIaJIw#D}hfr zkZv+l1)hwMneJ2YG5iJppZugTm$ZyM)V25?OX@Vrn$D0XNy3J&I2-guWJ99gYX=IX zQb+oo;c>Z#W$0Vzbm$`w@{3u{i0$cUsrL`QZ`cn;z>V?|iE;&o<=lIRbgN69fCChx^I<89F7Ol>t#Ct9%};+gv;GPK%E;ZrywjB#!ffcyPblR$AD}O zCeqOF-A=(@VU%jaxkM&@ayC1Kmk(ID`TcPtd1!S|#iBUR7Sns>{ZFsg{mu^ZFt+XC zcbuJMg}so?djURAhUF8R#*9Ll>ql-m0&m}r7k zhDQbwfdzXHT1_Lay$FYD=Nc7cqFqV4a!>^;piUk<-d(ZoF=+VPFJJ7%6<7HhO4$3B z!dY7xkam!n0NPN4?8DbU+4(qZhvV&4w=hoYC5=DI{AOboNI#v0X#q@)qw~)b;%G3q z=M2gukA2Fh_5m>t8Pypoa9I8I9)c`9S3c9reD2!u)BBf;EuVghOw#VvBsa(_GM?)Z zG(=xk#0k~j88pOzVGkNSfODtQ$tC>0qA$!MtG7JAT6_M{%F9po`qQuN<%eG%Z~4hC zudnPQ1B|Wa8DFx3ht)v_tD2y;JpUe0-RYpRU$An|gOc`6rSAqW`sGSEjsfgy;~ZC> z)z0-Yo@wvZ!jd+QUl1A0W|}F6*<}d!7m`m-Q*0)w#Po;P>m_kN5fXbiCgUES7IOj134VXr5wNu@rSp$1TZ(nx&IVNuvWf z%NTS{rvsXqN1k&99&K{)q^q38GT5K(n{CC3U{(`MaEbXoC7hlJNIc4n8FH&(zLmc( zl0UEkHJ`!d&IgL$_dQ|PzV6V6AQDI?he4taOe1V%m@_yLAp?rvdYg3GN9TAxo4&r- zU)?jWp`b1*Q+wFAM#7F(ojw@>t1~{+W>90btTXdldV4^8fJn-Rbq`2xffMFW$C+!v`q`0;YYmS4g^9-~kVdbF zL&GsoZPwS=Pv|^2KqN%MVh`PC!_H$ZCiYE|b18AbC8Msa2|2EHJNX53!@cA+1Fp&D z&o)aoh$hI8glZu_LYkmeK=4>NSFuh0z2^WcnB$O0Wk$b_s&ejMpByk7?~D5w8TMpO z&S15t&D3i~AUS_YDb%KNdXHgVCWdu1Rt1tI6Jdj@^}u`phI;x=(=Gd z4v7FPhDf)tvts4k_l={Za8FfZAzB|v5wlFSsG-`V+=b0w_rtngyvCK%GZ$V;i|P*MVIMDle~s>(2(VQ~ts zl=X>AWL9Y?HD%T22tA){l6c*7G6haquNg;LAO1cFAJ#RM(mBALgEJ|nd2Oft=rH=4 zTP3BRr#*twai7S_;{w5c)zH7-Pwd&_MT6+d+1Xflt-v^U3~TlM3;KEkEvIHEA;Le; zP}17W)gVjs1@Xeq!HqEr&DZue-h9;v66#yN!FS z2>60I@p!4vk9b<1>~;fm?Ay0*_HsDF+wE@a^Ha*0(Wx%WfE&ut;9d$VWyRDoGVkMK zuyy`Wa6Gb9vy52jc5cj_GFxUJoCuqK9`794Jiq`V@ZOPhaAO<;m)CAjpFZ2$&wmZ| z5Y6}QB5q+VqDRNzU}na3sbnm?V?J9z7LOMnU$^65Kh|`4`O%(#{inA4@LPNS>5pU9 z+2#6jR25%H+JRmZ(4L#+_yXgwfH*@qw+L1i!&u#Y;Nsmuzaij8Gdr}t=A%R~wgXy$ zKye~Yu~E&XWlFh?#4Veit1@gPDhXP7p20B8{_MB+qw z^Z5Siv)^vF*aI1msNC~h1yf~&WB%`APnTcW`?oKLVgDR^hM2P2+xwg8=4rnS)iw4< zQ1eX||2y(nrsqEgASXR{_LpcRb^c2lDrchF>$g~k+L+m9MFVTTrjah$lM_2VNjVZh zULD)F9)Z|K)Ibh6?SyC-R84R@sv1|yguX8}`~dVs=zfRpP{y3{Wi6e6an5!VT3|Q; zTE|0a>s1xP%bXjoPpnh`9DL#!*eFaUY0BjDj&XocEeB9%JpoxVyUcZh+Zs__h z`DiDf$!V|w&BX8FQ2KJVOdunm-vg4i?-Oj=L;@o;2&)p&@6v20`kDKZJJ(G%Fng@D zJ`ChrANB@;i`HtVAF&|81Z<&cvNv0X(>&9~|kQ7_QDf{E+d6Kro2XN*lf^d-RqL zf(~4lYf#b3fh03LYw7bys`%S(d+wXnxoXM1H;#UZ)6V`{uHD>AH>18xnxT|TY|O~6 z?#048|58TSTjxpU?T(qC8(47xDPQ6AvT_K0!+>j3qJ_~#K_Gv>Z$tL2GuV>k zp3!LMfXmW8oqs-_FP~+q^bY?Y@rHWrQJ6vD08GLn_1YrYuu+#`kTv1UJfqRR(k-7& z=jg0Q__su_;6Ao#-FaqZka!+3@67un8X;tBwK1!uWIayK-(XLft5sUy;ZQ07n?Ldz zJg_s=a2U3p%zzRgn>Oln&0^nu52Nwdy(lwVrwkL--kA)JLf`mri! z1(kD^ZGO-B8D?l1ey1;xK~e7>vQsr3MV-&6!q9+)a%1KKu(F-d%$jUDz`m1m)dqIn z|LYm+py01Q7vma^gTJq{DGIKLMnq&jAjm6bXawWd`GC*m*s+(>^Phfx&R&*>{fzfb z$C<&z9p&+j42aRAvx6SkiA8+4Gbr%MCe&I6Dd;P@MAamI<{6Rqcl+V#ha-@?+tce4 z4(07z^gFqtj_g?lBclg0OZ4AeNSTY_kSobdl%dP}r{Q=s4)Zgb4X1Ecm~D7^c1{uu zMRig}WiWXuI|{-P7=|E(xIV?&yt`catduA3*?O}3vDbURk9-c;Q#!HEQ63Ggk^SyD zyR3Db|L$3zBgop<&$hh&k^S(Szi%%;{`RQ0{l?bg`^(D@=nY%OJR*!=P_y$e!aI92 z+@UW43%%#F#C~^UX7-M(tmDBw(=?O0w4GToHlKR{MZKV&GAPqV0*W#!&HncrphwXs zv9U51*J=@1Wq*kR0}t%q?_s#1!}bxtV~64_MNeID*shTsbH+$d9B77JoB9wVD0N-D zdx8TAgUj+`I0kQ>|?{dj@ejpULPIz8LpVNh{-Y2x+Yw-qgZGbm7tyelHhS^ z+v3bO&gd!;h>Q3ug7m$xFPf+y`V{^qm4UWYepI;`h@wJY=zl3d?d-Xk0-rK`l?+Vv z9(|q{wp+^~wmbcVojMsdQFR#yEt_(V{w}2^Qm_3j1NpIwwCpv(A^0FTj zw6XQ$>j`xQT8QTf%VybUXS~<=C7_3US2BLsXc^#O zUW|1Vd5Kz9pf_UXWjxIq^E7epS_a=5Z`4I_r)LWk1joD*-uxdFp>ql;3rLvyC-`YN3%M{II)|87OLJ4JfP6D8S zEt=e$%>h>*mN*}#XQpgaPjUka4)Dk3IGsmat1cfK)bsCI)rf5WLy#2r&T5FV!&=a} z=jwYA{o~ge7?$tchn<}uYLww|7dYiX9?)6c3OB`4;SS8Yo`H(pU^EIiq*+WOvm%`z zu*@wHG=yX56)`{jreM3WT)jl!h99@XB&amyw0jfjevEAG|q;X**Y6Ra+cDI zdd9FWJ2YzCOXDPVIMaYC+}_^f65%5p3e{QnG+U~%8g%=51YFV60B8T{2p0UE-Klrz zojc7-Auis7JSh%$jA_h#*3=VOnb_n(haKuuP6Aga6=|Q&$wy_=O%2H3Q$%LpmmR$> zjS+r8#{m0Y_%rl9^;n1fegxdd+WHJD576x8)o#E48jjC;DBe&`s>}t9b2AV&+?nw@ z{;ki)vK&hK`lCJl_^0;z>%V_|{(E-);m0Eo`{@W~KP64E&l_`slO<${&qcc6a6I>x z4rX~E*$MsQL>b5TUQtTNjXgK}WZ>l#iN;&mE6-A?&ud|`H3Bl|ubmXmsHz{itazW! zN&@6(p${9uRDmN?`Ut)stLCAI3p1wbW}-B*K}s&+>VdALSUxQEcK?@2S=5M)GXaJIro4) zp|TRIfP~ui3+25X6;bbVJD%SmBIfhxJYc*8YYJ!Bd7`K6(PO7E01jnbnoY^FVtSC0w=z}083@bKh0RsKDw$@Jx=G(o zd&|#CTen07N@S&pDv6_HFbdHU?~B*(|+{$_%tF zf@7{j5nR=0v#pn@2U`K4`tI&=uhD;f*Z|Skcdo~{{hB)z``dzsc$|bQMi2S%llyH$DoepD`wcY`$1=`o=|4Uq zgp??m{YC}i((igEpbspoz~KwVT-$1>UF`W@s5B!O>n7MSX7*Vku2{7-rjg31*elX& z492xT`&_xC4c0vuynv!fozk@EpBRyR~PD#`e}l7>l(b7DPw2w3FY zX|~Dye)&BO-uQVxy+59XZa_UNa6NEie?(3EAfOk?-V+be(?ONegrG2Jm`FKh2%Z9~ zx%pR(k2)I?{_kZd9u0xM*2nBv!@%`mZ4>hkgqZeuhw@(*1&Jk%L%e?j0fXP7`opR+ z<~sK)4pTH!ca-VfkX(E=R6|g+hb6?!vBI_?)vsR0OMZqjfV_;R% zA;l7oCgdbJq0)15iOwFPLR7#`1L-VxFiMrzr?b>R5B~N2TMYEOK0O_d?z`QNwYxt5 zU>6^3SzvhT;RV%(?+YPUL3tle^-g1jtd#%Gkk5c@MUbni>3|r4S!t(&s&p8KTzr6Y zOtLX#q@R9v{+JRGW~un3EE)}(euqYKLqO&V$g6+;?+C!wqdMrp+Sc9!ny_yrGeF&E ze{F1z_x(RAHf{NA>!&}nr(gXScKM?}wU=N0nO#5qkzKE^QEjuRp0WqgdlAbxGjawa zSYdXU`YAqkW|w_`gS=tyCbqVuU^c=`CIi;lFK9ICN(ulnf{bKjq-D4WIgQtG{#OJ> z5y8;eWFvNF$dnwox16(sYtMUZ!7ocqH0jeDGpYNSI?`-Q*`)rl$TSS%fGay8)| znwr8<-L*2qyy>_+=;h%=m+?IDb_GsK5iFLOVCoVsJO0z!Q0;`7hmCF%tiEsn_JpGF zd*64>K?Jlm0|N$YnPTEOR-&uRo|x=qqjSXBhc**C(DCjg08QK6$E>yNp|Nf=lVx0g zYD;io0|KE9{YbXMk~XoOfVabM=6VQqRK~Aj4&tQV1IoG(^3|t`0wdJc_me}apmb|( z>M88>9hm%PVlckn2W(MxGXg!_AspC_KH^f0Z4CaqLoU1pU|j&k;`?g899=lG44ROj zS07X*&HM0Y$9~kQw{C<|-3AFSx8ht&URh|u%wX#?NyQ!UGK4%ZNAS_xiRmjOn6xP@C@_8IQK`LSsy>V2mCZA8l3X$x6;^jO5}i!xI!9bs=Qz54to{|BMK?2zL%mlaCWr?W??{7?+n>vb}kV=dH+ zwg?7B$fiC#*iq0J27<5vZ|ukV?ZZ~?#Mhg$WqTTkgownfDOB}ZVUR?2JEMYk<8ZtC zAmBUMk@kRw`7Gn>%V&FkKb%>wa;@l>xvrQq=D~w!v7eq_qc7>@^_sA#VsNY>->A%V zo&^MpaDtP{#SPv%%aby8XSq|)giZT7%hQ8sO)_IAIl?|ao=IMdFwWcCJpvq8?B2`u zb{LH2l?o@42*nEua_?PojJQQczZ{i{oyVbB0&ttYaV;rL5Q^)yJ~=pKJ_dw-ApM0JE2NBvKMI^wv=`eNzNsfEH-``EnV<=sRq!wgDqxrU@FAY^Fcv2Dk**L?Pr7|3gm|s$% zdxBhTzeT3Yv*ZD5SV3bP3}yvz*tXr6eMZpK8*u#BFGTL$+r=+5)L0u@2vmWFMp4YVl0yWs`0Izk(S0o22G=}$)2@T@? zo{^KLGlH9Bk|lJsQH~j4R0RmeHG@vx6EZvQ%eoxb;ULMe8I_U?9fy{cHQ1Dv)t*=v zo0E*N*+J?;I`sLhb1BS7?h@})RYjIQ#QGLiCZdmN0fl%5A02TZbZ1GY-dGVkHPrX1 z+VtR`1XRhlWl=B-xRK7uDIlBt1NEHF3D#1~&iy>O;@l7VLK8G^pWtSJfT=a*Tn_7; zFaX?qw?XV7{g3qps-)#vNATNHbK*K(H)iVRJby5m%!+x`##8k-!l{4Wi>g}apY_y|N1W z3Xu~1lt3u)UX7Ji5o$d82q#~*6{5t(+N%hHzXQ~8&!mF;eHY9cB_!Exc#^X|-K*}wKV)07qPAAD=APv=Wu?gXf3Rn8du--z za4Cp{ev#gIY=goH`JQh+-WRVyqP6E9j%x{-R8sei;JK%4==y6<&_kyTm0SQh8(K_l zTr89nA{~s=K)Es$#9ReZGpjxTV;lije+cOKDjN!T3e@6F3JvYp6dBQcU59bif|Lj7 zWWosO3|A?D?|8l=b2Ih2F-vP)D+NZw2=wWBtDQ4I^#jCFy=IBuwl+CdB{w-tG?Us( zU4{TKWI=@p?(G`GHG4R;I1mNdUK(Q8QP(5rx*pegUayC@o?^N#3*H3Zy z*E(i>c*R4R$Go@J$!L|U7R+(MO4H%^#(tIoNrODiGLxHU0~uRRjw5Jy7kk8^@0Am{0 z9F;6*SG>0)==ND)$J&1LwQ-nO1DoReQKl%D*Ldyo=f8JU!8{226RR)3w(AeSvE}LW z09in$zfpC3KAded{uUCzXmEPhvu2Q9$Z&`=C}zNo^ttcR_t&C7(P`o1Y?IPFDDA1} zOW#Nq(1PqC`gBNBTdL-e85t%q&R|`yVQgJKqM8|1(KQnW+x-S(92GIi);)|&I859l zKvSnca0+z%S`I-|uyS$cO;-7WCDEau{zCt}V@}&8;J?SdkdA*p&W=08eh=#r0N*xL zWUCz&UGt>JQDLi2HuEL^IemFywYDhxHbGGYU#@au`+#RX>fdt)lEx(iGC6z0iE6YV z(&Z7zIGn{3aCbk1i@n{JSoUMX8RT-_INXHv$`*QQ|1q&p^PQ!g2a;pc?; zIP@=JHVf}Zq#aB2Ggvg>UrYl|A)J*27Q7yZghD>+6qxp$w}qc~_8^pW@|TJ1XMC1} z52rsIvj%vYSDHPM6M_8#2P_>|1Hn>l4~f7+!Yd*QCLx23m-Byije<~RZf9>*O%^uES&;6; z{UX6s^Cw4clhMUfBVQWG?@8KMn>0|zSPC)A{ z%^%FQ(>KH3EM!*{=r($v`LhT{YfYC`Ro9SP5)Xi&NC{}{yY-#{dFcw{jJ8SVf7lKM ziF~4TnIhgY23`gCvg;Y6By|fL4cKJVrr5VmpU#H95BOT8liR!1$?thw$Hx!+N;QU_ zWH)B#;0-UH$uAVt6nk{k5%kMV%^jOVnaj%0AU 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 z