Compare commits
133 Commits
test_smart
...
v0.5.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 54ecc811f7 | |||
| 0b7a506fde | |||
| 61f016f08c | |||
| 6cd0ea45d6 | |||
| 1322e4a0d3 | |||
| db377ead3c | |||
| 3fcd27f41a | |||
| c896af234d | |||
| d1fc0dba87 | |||
| e697ab060c | |||
| cf59b4c8fb | |||
| feadfc3456 | |||
| 2c2f8f41a1 | |||
| a2380282ff | |||
| 19773fddc9 | |||
| 6e2fccd04e | |||
| 3970b8e0a8 | |||
| 89a72cf8a9 | |||
| 0ef8dd61a8 | |||
| dad098ea3b | |||
| f534248bec | |||
| 05fa7f52dd | |||
| 96535147fb | |||
| f0b088f6f8 | |||
| 1d177f5438 | |||
| cefc56a6bb | |||
| 7205cc1ea3 | |||
| aa7cdbd36f | |||
| 1b86a9252d | |||
| e9fd148235 | |||
| 34ea1ed8ec | |||
| aa92fb6d0d | |||
| fbbea7b42b | |||
| b2859710cd | |||
| bc0ce7159c | |||
| 4614f99358 | |||
| 1ecc55f8aa | |||
| ae0f24ccb2 | |||
| 060c68cd05 | |||
| e85eba4cea | |||
| 206467e1fa | |||
| a98394b9b9 | |||
| c448811aa9 | |||
| c3225a90c7 | |||
| 89acf780bf | |||
| e5f4793370 | |||
| 95fe697501 | |||
| ee2d2c7238 | |||
| 1dfa277279 | |||
| 78a8952383 | |||
| fcc50847e4 | |||
| f8d93991f5 | |||
| bee9f783d9 | |||
| 3e1c8d563e | |||
| 1299febcdc | |||
| be94c62760 | |||
| 6a862ef243 | |||
| ae2de5fc62 | |||
| df0bbc7327 | |||
| d94761c866 | |||
| f8235e1a59 | |||
| 647cadf497 | |||
| 8c793a81b6 | |||
| 6a42ba7e43 | |||
| 14b3790251 | |||
| 61d81bed62 | |||
| 1a10bc1a5f | |||
| 7f68d08134 | |||
| ab20cd896f | |||
| 5a9e93d6e7 | |||
| b51641dc7e | |||
| 45f1257896 | |||
| 3e2b8b1e3a | |||
| 90d81617ef | |||
| 64c62e616b | |||
| 2c340e37c7 | |||
| 7853e94d2e | |||
| 99bf57b154 | |||
| 0fa6eaf95b | |||
| 76f42be740 | |||
| d99dc41be9 | |||
| 263508b8f7 | |||
| 0c2cca30ed | |||
| 46fdf668c6 | |||
| f8a92a45a0 | |||
| cec70e6036 | |||
| f9e08ba628 | |||
| c12a078149 | |||
| dedd803dc3 | |||
| e8e927a491 | |||
| d950bbac23 | |||
| fc8da2ebf5 | |||
| f6e50c405f | |||
| c06f508e8f | |||
| 97bf1e47f4 | |||
| ef47fddd56 | |||
| 896dd84d2a | |||
| def75d8f86 | |||
| 69f2173f75 | |||
| 075d355c58 | |||
| 0de9725ba8 | |||
| 6dcccc903f | |||
| 507b4951b4 | |||
| a064be0e5c | |||
| 8a35f1d4dc | |||
| 9e5ee61785 | |||
| 4b5b5d6ed8 | |||
| 3f45052193 | |||
| 7dc7ab67e4 | |||
| e7c5e5f77f | |||
| 4e32a958ea | |||
| a260def38d | |||
| 782a935d3d | |||
| 3fbdabc874 | |||
| 7386f8ed0b | |||
| 51e494c48b | |||
| 9ea9d55eee | |||
| 8c106464fd | |||
| 7433c147c9 | |||
| 9c4a9ea1e5 | |||
| 82804c6803 | |||
| 483caab54c | |||
| a9821b1ae6 | |||
| 0744642985 | |||
| 1d5c6f3348 | |||
| ad87934abf | |||
| 6b49fa68c0 | |||
| f0df169689 | |||
| d9fd7a61bb | |||
| 897f717da5 | |||
| 51e1a065ad | |||
| e7f50e899d | |||
| 43adc5e0c8 |
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
@@ -12,3 +12,92 @@ Role: Principal Systems Architect & Lead Software Engineer.Objective: Implement
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
- NATSBridge.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 NATSBridge.js to reflect recent changes in NATSBridge.jl and docs
|
||||||
|
Context: NATSBridge.jl and docs has been updated.
|
||||||
|
Requirements:
|
||||||
|
Source of Truth: Treat the updated NATSBridge.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 (NATSBridge) 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 (NATSBridge) into a cross-platform project by adding a JavaScript and Python/MicroPython implementation. To ensure accuracy, NATSBridge.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 (NATSBridge) 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
|
||||||
|
- NATSBridge.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 natsbridge.js needs update or it already up to date.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# This file is machine-generated - editing it directly is not advised
|
# This file is machine-generated - editing it directly is not advised
|
||||||
|
|
||||||
julia_version = "1.12.4"
|
julia_version = "1.12.5"
|
||||||
manifest_format = "2.0"
|
manifest_format = "2.0"
|
||||||
project_hash = "be1e3c2d8b7f4f0ee7375c94aaf704ce73ba57b9"
|
project_hash = "b632f853bcf5355f5c53ad3efa7a19f70444dc6c"
|
||||||
|
|
||||||
[[deps.AliasTables]]
|
[[deps.AliasTables]]
|
||||||
deps = ["PtrArrays", "Random"]
|
deps = ["PtrArrays", "Random"]
|
||||||
@@ -436,6 +436,12 @@ git-tree-sha1 = "d9d9a189fb9155a460e6b5e8966bf6a66737abf8"
|
|||||||
uuid = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
|
uuid = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[deps.NATSBridge]]
|
||||||
|
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.NanoDates]]
|
||||||
deps = ["Dates", "Parsers"]
|
deps = ["Dates", "Parsers"]
|
||||||
git-tree-sha1 = "850a0557ae5934f6e67ac0dc5ca13d0328422d1f"
|
git-tree-sha1 = "850a0557ae5934f6e67ac0dc5ca13d0328422d1f"
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
### API
|
|
||||||
Plik server expose a REST-full API to manage uploads and get files :
|
|
||||||
|
|
||||||
Get and create upload :
|
|
||||||
|
|
||||||
- **POST** /upload
|
|
||||||
- Params (json object in request body) :
|
|
||||||
- oneshot (bool)
|
|
||||||
- stream (bool)
|
|
||||||
- removable (bool)
|
|
||||||
- ttl (int)
|
|
||||||
- login (string)
|
|
||||||
- password (string)
|
|
||||||
- files (see below)
|
|
||||||
- Return :
|
|
||||||
JSON formatted upload object.
|
|
||||||
Important fields :
|
|
||||||
- id (required to upload files)
|
|
||||||
- uploadToken (required to upload/remove files)
|
|
||||||
- files (see below)
|
|
||||||
|
|
||||||
For stream mode you need to know the file id before the upload starts as it will block.
|
|
||||||
File size and/or file type also need to be known before the upload starts as they have to be printed
|
|
||||||
in HTTP response headers.
|
|
||||||
To get the file ids pass a "files" json object with each file you are about to upload.
|
|
||||||
Fill the reference field with an arbitrary string to avoid matching file ids using the fileName field.
|
|
||||||
This is also used to notify of MISSING files when file upload is not yet finished or has failed.
|
|
||||||
```
|
|
||||||
"files" : [
|
|
||||||
{
|
|
||||||
"fileName": "file.txt",
|
|
||||||
"fileSize": 12345,
|
|
||||||
"fileType": "text/plain",
|
|
||||||
"reference": "0"
|
|
||||||
},...
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
- **GET** /upload/:uploadid:
|
|
||||||
- Get upload metadata (files list, upload date, ttl,...)
|
|
||||||
|
|
||||||
Upload file :
|
|
||||||
|
|
||||||
- **POST** /$mode/:uploadid:/:fileid:/:filename:
|
|
||||||
- Request body must be a multipart request with a part named "file" containing file data.
|
|
||||||
|
|
||||||
- **POST** /file/:uploadid:
|
|
||||||
- Same as above without passing file id, won't work for stream mode.
|
|
||||||
|
|
||||||
- **POST** /:
|
|
||||||
- Quick mode, automatically create an upload with default parameters and add the file to it.
|
|
||||||
|
|
||||||
Get file :
|
|
||||||
|
|
||||||
- **HEAD** /$mode/:uploadid:/:fileid:/:filename:
|
|
||||||
- Returns only HTTP headers. Useful to know Content-Type and Content-Length without downloading the file. Especially if upload has OneShot option enabled.
|
|
||||||
|
|
||||||
- **GET** /$mode/:uploadid:/:fileid:/:filename:
|
|
||||||
- Download file. Filename **MUST** match. A browser, might try to display the file if it's a jpeg for example. You may try to force download with ?dl=1 in url.
|
|
||||||
|
|
||||||
- **GET** /archive/:uploadid:/:filename:
|
|
||||||
- Download uploaded files in a zip archive. :filename: must end with .zip
|
|
||||||
|
|
||||||
Remove file :
|
|
||||||
|
|
||||||
- **DELETE** /$mode/:uploadid:/:fileid:/:filename:
|
|
||||||
- Delete file. Upload **MUST** have "removable" option enabled.
|
|
||||||
|
|
||||||
Show server details :
|
|
||||||
|
|
||||||
- **GET** /version
|
|
||||||
- Show plik server version, and some build information (build host, date, git revision,...)
|
|
||||||
|
|
||||||
- **GET** /config
|
|
||||||
- Show plik server configuration (ttl values, max file size, ...)
|
|
||||||
|
|
||||||
- **GET** /stats
|
|
||||||
- Get server statistics ( upload/file count, user count, total size used )
|
|
||||||
- Admin only
|
|
||||||
|
|
||||||
User authentication :
|
|
||||||
|
|
||||||
-
|
|
||||||
Plik can authenticate users using Google and/or OVH third-party API.
|
|
||||||
The /auth API is designed for the Plik web application nevertheless if you want to automatize it be sure to provide a valid
|
|
||||||
Referrer HTTP header and forward all session cookies.
|
|
||||||
Plik session cookies have the "secure" flag set, so they can only be transmitted over secure HTTPS connections.
|
|
||||||
To avoid CSRF attacks the value of the plik-xsrf cookie MUST be copied in the X-XSRFToken HTTP header of each
|
|
||||||
authenticated request.
|
|
||||||
Once authenticated a user can generate upload tokens. Those tokens can be used in the X-PlikToken HTTP header used to link
|
|
||||||
an upload to the user account. It can be put in the ~/.plikrc file of the Plik command line client.
|
|
||||||
|
|
||||||
- **Local** :
|
|
||||||
- You'll need to create users using the server command line
|
|
||||||
|
|
||||||
- **Google** :
|
|
||||||
- You'll need to create a new application in the [Google Developper Console](https://console.developers.google.com)
|
|
||||||
- You'll be handed a Google API ClientID and a Google API ClientSecret that you'll need to put in the plikd.cfg file
|
|
||||||
- Do not forget to whitelist valid origin and redirect url ( https://yourdomain/auth/google/callback ) for your domain
|
|
||||||
|
|
||||||
- **OVH** :
|
|
||||||
- You'll need to create a new application in the OVH API : https://eu.api.ovh.com/createApp/
|
|
||||||
- You'll be handed an OVH application key and an OVH application secret key that you'll need to put in the plikd.cfg file
|
|
||||||
|
|
||||||
- **GET** /auth/google/login
|
|
||||||
- Get Google user consent URL. User have to visit this URL to authenticate
|
|
||||||
|
|
||||||
- **GET** /auth/google/callback
|
|
||||||
- Callback of the user consent dialog
|
|
||||||
- The user will be redirected back to the web application with a Plik session cookie at the end of this call
|
|
||||||
|
|
||||||
- **GET** /auth/ovh/login
|
|
||||||
- Get OVH user consent URL. User have to visit this URL to authenticate
|
|
||||||
- The response will contain a temporary session cookie to forward the API endpoint and OVH consumer key to the callback
|
|
||||||
|
|
||||||
- **GET** /auth/ovh/callback
|
|
||||||
- Callback of the user consent dialog.
|
|
||||||
- The user will be redirected back to the web application with a Plik session cookie at the end of this call
|
|
||||||
|
|
||||||
- **POST** /auth/local/login
|
|
||||||
- Params :
|
|
||||||
- login : user login
|
|
||||||
- password : user password
|
|
||||||
|
|
||||||
- **GET** /auth/logout
|
|
||||||
- Invalidate Plik session cookies
|
|
||||||
|
|
||||||
- **GET** /me
|
|
||||||
- Return basic user info ( ID, name, email ) and tokens
|
|
||||||
|
|
||||||
- **DELETE** /me
|
|
||||||
- Remove user account.
|
|
||||||
|
|
||||||
- **GET** /me/token
|
|
||||||
- List user tokens
|
|
||||||
- This call use pagination
|
|
||||||
|
|
||||||
- **POST** /me/token
|
|
||||||
- Create a new upload token
|
|
||||||
- A comment can be passed in the json body
|
|
||||||
|
|
||||||
- **DELETE** /me/token/{token}
|
|
||||||
- Revoke an upload token
|
|
||||||
|
|
||||||
- **GET** /me/uploads
|
|
||||||
- List user uploads
|
|
||||||
- Params :
|
|
||||||
- token : filter by token
|
|
||||||
- This call use pagination
|
|
||||||
|
|
||||||
- **DELETE** /me/uploads
|
|
||||||
- Remove all uploads linked to a user account
|
|
||||||
- Params :
|
|
||||||
- token : filter by token
|
|
||||||
|
|
||||||
- **GET** /me/stats
|
|
||||||
- Get user statistics ( upload/file count, total size used )
|
|
||||||
|
|
||||||
- **GET** /users
|
|
||||||
- List all users
|
|
||||||
- This call use pagination
|
|
||||||
- Admin only
|
|
||||||
|
|
||||||
QRCode :
|
|
||||||
|
|
||||||
- **GET** /qrcode
|
|
||||||
- Generate a QRCode image from an url
|
|
||||||
- Params :
|
|
||||||
- url : The url you want to store in the QRCode
|
|
||||||
- size : The size of the generated image in pixels (default: 250, max: 1000)
|
|
||||||
|
|
||||||
|
|
||||||
$mode can be "file" or "stream" depending if stream mode is enabled. See FAQ for more details.
|
|
||||||
|
|
||||||
Examples :
|
|
||||||
```sh
|
|
||||||
Create an upload (in the json response, you'll have upload id and upload token)
|
|
||||||
$ curl -X POST http://127.0.0.1:8080/upload
|
|
||||||
|
|
||||||
Create a OneShot upload
|
|
||||||
$ curl -X POST -d '{ "OneShot" : true }' http://127.0.0.1:8080/upload
|
|
||||||
|
|
||||||
Upload a file to upload
|
|
||||||
$ curl -X POST --header "X-UploadToken: M9PJftiApG1Kqr81gN3Fq1HJItPENMhl" -F "file=@test.txt" http://127.0.0.1:8080/file/IsrIPIsDskFpN12E
|
|
||||||
|
|
||||||
Get headers
|
|
||||||
$ curl -I http://127.0.0.1:8080/file/IsrIPIsDskFpN12E/sFjIeokH23M35tN4/test.txt
|
|
||||||
HTTP/1.1 200 OK
|
|
||||||
Content-Disposition: filename=test.txt
|
|
||||||
Content-Length: 3486
|
|
||||||
Content-Type: text/plain; charset=utf-8
|
|
||||||
Date: Fri, 15 May 2015 09:16:20 GMT
|
|
||||||
|
|
||||||
```
|
|
||||||
13
Project.toml
13
Project.toml
@@ -1,8 +1,21 @@
|
|||||||
|
name = "NATSBridge"
|
||||||
|
uuid = "f2724d33-f338-4a57-b9f8-1be882570d10"
|
||||||
|
version = "0.5.2"
|
||||||
|
authors = ["narawat <narawat@gmail.com>"]
|
||||||
|
|
||||||
[deps]
|
[deps]
|
||||||
Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45"
|
Arrow = "69666777-d1a9-59fb-9406-91d4454c9d45"
|
||||||
|
Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f"
|
||||||
|
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
|
||||||
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
|
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
|
||||||
GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe"
|
GeneralUtils = "c6c72f09-b708-4ac8-ac7c-2084d70108fe"
|
||||||
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
|
HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3"
|
||||||
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
|
JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6"
|
||||||
NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
|
NATS = "55e73f9c-eeeb-467f-b4cc-a633fde63d2a"
|
||||||
|
PrettyPrinting = "54e16d92-306c-5ea0-a30b-337be88ac337"
|
||||||
|
Revise = "295af30f-e4ad-537b-8983-00126c2a3abe"
|
||||||
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
|
UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4"
|
||||||
|
|
||||||
|
[compat]
|
||||||
|
Base64 = "1.11.0"
|
||||||
|
JSON = "1.4.0"
|
||||||
|
|||||||
670
README.md
Normal file
670
README.md
Normal file
@@ -0,0 +1,670 @@
|
|||||||
|
# NATSBridge - 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.
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](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
|
||||||
|
|
||||||
|
NATSBridge enables seamless communication across multiple platforms through NATS, with intelligent transport selection based on payload size:
|
||||||
|
|
||||||
|
| Transport | Payload Size | Method |
|
||||||
|
|-----------|--------------|--------|
|
||||||
|
| **Direct** | < 1MB | Sent directly via NATS (Base64 encoded) |
|
||||||
|
| **Link** | >= 1MB | 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/NATSBridge.jl`](src/NATSBridge.jl) | Full feature set, Arrow IPC, multiple dispatch |
|
||||||
|
| **JavaScript** | [`src/natsbridge.js`](src/natsbridge.js) | Node.js & browser, async/await |
|
||||||
|
| **Python** | [`src/natsbridge.py`](src/natsbridge.py) | Desktop Python, asyncio, type hints |
|
||||||
|
| **MicroPython** | [`src/natsbridge_mpy.py`](src/natsbridge_mpy.py) | Memory-constrained, synchronous API |
|
||||||
|
|
||||||
|
### Platform Comparison
|
||||||
|
|
||||||
|
| Feature | Julia | JavaScript | Python | MicroPython |
|
||||||
|
|---------|-------|------------|--------|-------------|
|
||||||
|
| Multiple Dispatch | ✅ Native | ❌ | ❌ | ❌ |
|
||||||
|
| Async/Await | ❌ | ✅ Native | ✅ Native | ⚠️ (uasyncio) |
|
||||||
|
| Type Safety | ✅ Strong | ⚠️ (TypeScript) | ✅ (Type hints) | ❌ |
|
||||||
|
| Arrow IPC | ✅ Native | ✅ | ✅ | ❌ |
|
||||||
|
| Direct Transport | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Link Transport | ✅ | ✅ | ✅ | ⚠️ (Limited) |
|
||||||
|
| Handler Functions | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Cross-Platform API | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 > 1MB
|
||||||
|
- ✅ **Apache Arrow IPC** support for tabular data (zero-copy reading)
|
||||||
|
- ✅ **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
|
||||||
|
|
||||||
|
### Step 1: Start NATS Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 4222:4222 nats:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Start HTTP File Server (Optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a directory for file uploads
|
||||||
|
mkdir -p /tmp/fileserver
|
||||||
|
|
||||||
|
# Start HTTP file server
|
||||||
|
python3 -m http.server 8080 --directory /tmp/fileserver
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Send Your First Message
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
data = [("message", "Hello World", "text")]
|
||||||
|
env, env_json_str = smartsend("/chat/room1", data, broker_url="nats://localhost:4222")
|
||||||
|
println("Message sent!")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('./src/natsbridge.js');
|
||||||
|
|
||||||
|
const data = [["message", "Hello World", "text"]];
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/chat/room1",
|
||||||
|
data,
|
||||||
|
{ broker_url: "nats://localhost:4222" }
|
||||||
|
);
|
||||||
|
console.log("Message sent!");
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import smartsend
|
||||||
|
|
||||||
|
data = [("message", "Hello World", "text")]
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
"/chat/room1",
|
||||||
|
data,
|
||||||
|
broker_url="nats://localhost:4222"
|
||||||
|
)
|
||||||
|
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:**
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"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 NATSBridge
|
||||||
|
|
||||||
|
env, env_json_str = NATSBridge.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 = 1_000_000,
|
||||||
|
correlation_id::String = string(uuid4()),
|
||||||
|
msg_purpose::String = "chat",
|
||||||
|
sender_name::String = "NATSBridge",
|
||||||
|
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
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('natsbridge');
|
||||||
|
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
subject,
|
||||||
|
data, // Array of [dataname, data, type] tuples
|
||||||
|
{
|
||||||
|
broker_url: 'nats://localhost:4222',
|
||||||
|
fileserver_url: 'http://localhost:8080',
|
||||||
|
fileserver_upload_handler: NATSBridge.plikOneshotUpload,
|
||||||
|
size_threshold: 1_000_000,
|
||||||
|
correlation_id: uuidv4(),
|
||||||
|
msg_purpose: 'chat',
|
||||||
|
sender_name: 'NATSBridge',
|
||||||
|
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 natsbridge import NATSBridge
|
||||||
|
|
||||||
|
env, env_json_str = await NATSBridge.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 = 1_000_000,
|
||||||
|
correlation_id: str = None,
|
||||||
|
msg_purpose: str = "chat",
|
||||||
|
sender_name: str = "NATSBridge",
|
||||||
|
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 natsbridge import NATSBridge
|
||||||
|
|
||||||
|
# Limited to direct transport (< 100KB threshold)
|
||||||
|
env, env_json_str = NATSBridge.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 NATSBridge
|
||||||
|
|
||||||
|
env = NATSBridge.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
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const env = await NATSBridge.smartreceive(
|
||||||
|
msg,
|
||||||
|
{
|
||||||
|
fileserver_download_handler: NATSBridge.fetchWithBackoff,
|
||||||
|
max_retries: 5,
|
||||||
|
base_delay: 100,
|
||||||
|
max_delay: 5000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Returns: Promise<env_object>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
env = await NATSBridge.smartreceive(
|
||||||
|
msg,
|
||||||
|
fileserver_download_handler=fetch_with_backoff,
|
||||||
|
max_retries=5,
|
||||||
|
base_delay=100,
|
||||||
|
max_delay=5000
|
||||||
|
)
|
||||||
|
# Returns: Dict with "payloads" key
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MicroPython
|
||||||
|
|
||||||
|
```python
|
||||||
|
env = NATSBridge.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` | `Array<Object>` | `pandas.DataFrame` | ❌ | Tabular data (Arrow IPC) |
|
||||||
|
| `jsontable` | `Vector{NamedTuple}` | `Array<Object>` | `list[dict]` | ❌ | Tabular data (JSON) |
|
||||||
|
| `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 NATSBridge
|
||||||
|
|
||||||
|
data = [
|
||||||
|
("message_text", "Hello!", "text"),
|
||||||
|
("user_avatar", image_data, "image"),
|
||||||
|
("large_document", large_file_data, "binary")
|
||||||
|
]
|
||||||
|
|
||||||
|
env, env_json_str = NATSBridge.smartsend("/chat/room1", data; fileserver_url="http://localhost:8080")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('natsbridge');
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
["message_text", "Hello!", "text"],
|
||||||
|
["user_avatar", imageData, "image"],
|
||||||
|
["large_document", largeFileData, "binary"]
|
||||||
|
];
|
||||||
|
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/chat/room1",
|
||||||
|
data,
|
||||||
|
{ fileserver_url: 'http://localhost:8080' }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import NATSBridge
|
||||||
|
|
||||||
|
data = [
|
||||||
|
("message_text", "Hello!", "text"),
|
||||||
|
("user_avatar", image_data, "image"),
|
||||||
|
("large_document", large_file_data, "binary")
|
||||||
|
]
|
||||||
|
|
||||||
|
env, env_json_str = await NATSBridge.smartsend(
|
||||||
|
"/chat/room1",
|
||||||
|
data,
|
||||||
|
fileserver_url="http://localhost:8080"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Dictionary Exchange
|
||||||
|
|
||||||
|
Send configuration data between platforms.
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
config = Dict(
|
||||||
|
"wifi_ssid" => "MyNetwork",
|
||||||
|
"wifi_password" => "password123",
|
||||||
|
"update_interval" => 60
|
||||||
|
)
|
||||||
|
|
||||||
|
data = [("config", config, "dictionary")]
|
||||||
|
env, env_json_str = NATSBridge.smartsend("/device/config", data)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('natsbridge');
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
wifi_ssid: "MyNetwork",
|
||||||
|
wifi_password: "password123",
|
||||||
|
update_interval: 60
|
||||||
|
};
|
||||||
|
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/device/config",
|
||||||
|
[["config", config, "dictionary"]]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import NATSBridge
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"wifi_ssid": "MyNetwork",
|
||||||
|
"wifi_password": "password123",
|
||||||
|
"update_interval": 60
|
||||||
|
}
|
||||||
|
|
||||||
|
data = [("config", config, "dictionary")]
|
||||||
|
env, env_json_str = await NATSBridge.smartsend("/device/config", data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Table Data (Arrow IPC)
|
||||||
|
|
||||||
|
Send tabular data using Apache Arrow IPC format.
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
using DataFrames
|
||||||
|
|
||||||
|
df = DataFrame(
|
||||||
|
id = [1, 2, 3],
|
||||||
|
name = ["Alice", "Bob", "Charlie"],
|
||||||
|
score = [95, 88, 92]
|
||||||
|
)
|
||||||
|
|
||||||
|
data = [("students", df, "arrowtable")]
|
||||||
|
env, env_json_str = NATSBridge.smartsend("/data/analysis", data)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('natsbridge');
|
||||||
|
|
||||||
|
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 NATSBridge.smartsend(
|
||||||
|
"/data/analysis",
|
||||||
|
[["students", df, "arrowtable"]]
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import NATSBridge
|
||||||
|
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 NATSBridge.smartsend("/data/analysis", data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 4: Request-Response Pattern
|
||||||
|
|
||||||
|
Bi-directional communication with reply-to support.
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
# Requester
|
||||||
|
env, env_json_str = NATSBridge.smartsend(
|
||||||
|
"/device/command",
|
||||||
|
[("command", Dict("action" => "read_sensor"), "dictionary")];
|
||||||
|
broker_url="nats://localhost:4222",
|
||||||
|
reply_to="/device/response"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('natsbridge');
|
||||||
|
|
||||||
|
// Requester
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/device/command",
|
||||||
|
[["command", { action: "read_sensor" }, "dictionary"]],
|
||||||
|
{ broker_url: 'nats://localhost:4222', reply_to: '/device/response' }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import NATSBridge
|
||||||
|
|
||||||
|
# Requester
|
||||||
|
env, env_json_str = await NATSBridge.smartsend(
|
||||||
|
"/device/command",
|
||||||
|
[("command", {"action": "read_sensor"}, "dictionary")],
|
||||||
|
broker_url="nats://localhost:4222",
|
||||||
|
reply_to="/device/response"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
For detailed architecture and implementation information, see:
|
||||||
|
|
||||||
|
- [Architecture Documentation](docs/architecture_updated.md) - Cross-platform architecture, API parity, platform-specific patterns
|
||||||
|
- [Implementation Guide](docs/implementation_updated.md) - Detailed implementation for each platform, handler functions, testing
|
||||||
|
- [Tutorial](docs/tutorial_updated.md) - Step-by-step getting started guide
|
||||||
|
- [Walkthrough](docs/walkthrough_updated.md) - Real-world application building guides
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 NATSBridge 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.
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
# Implementation Guide: Bi-Directional Data Bridge
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document describes the implementation of the high-performance, bi-directional data bridge between Julia and JavaScript services using NATS (Core & JetStream), implementing the Claim-Check pattern for large payloads.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
The implementation follows the Claim-Check pattern:
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ SmartSend Function │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Is payload size < 1MB? │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
┌─────────────────┴─────────────────┐
|
|
||||||
▼ ▼
|
|
||||||
┌─────────────────┐ ┌─────────────────┐
|
|
||||||
│ Direct Path │ │ Link Path │
|
|
||||||
│ (< 1MB) │ │ (> 1MB) │
|
|
||||||
│ │ │ │
|
|
||||||
│ • Serialize to │ │ • Serialize to │
|
|
||||||
│ IOBuffer │ │ IOBuffer │
|
|
||||||
│ • Base64 encode │ │ • Upload to │
|
|
||||||
│ • Publish to │ │ HTTP Server │
|
|
||||||
│ NATS │ │ • Publish to │
|
|
||||||
│ │ │ NATS with URL │
|
|
||||||
└─────────────────┘ └─────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
### Julia Module: [`src/julia_bridge.jl`](../src/julia_bridge.jl)
|
|
||||||
|
|
||||||
The Julia implementation provides:
|
|
||||||
|
|
||||||
- **[`MessageEnvelope`](../src/julia_bridge.jl)**: Struct for the unified JSON envelope
|
|
||||||
- **[`SmartSend()`](../src/julia_bridge.jl)**: Handles transport selection based on payload size
|
|
||||||
- **[`SmartReceive()`](../src/julia_bridge.jl)**: Handles both direct and link transport
|
|
||||||
|
|
||||||
### JavaScript Module: [`src/js_bridge.js`](../src/js_bridge.js)
|
|
||||||
|
|
||||||
The JavaScript implementation provides:
|
|
||||||
|
|
||||||
- **`MessageEnvelope` class**: For the unified JSON envelope
|
|
||||||
- **[`SmartSend()`](../src/js_bridge.js)**: Handles transport selection based on payload size
|
|
||||||
- **[`SmartReceive()`](../src/js_bridge.js)**: Handles both direct and link transport
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### Julia Dependencies
|
|
||||||
|
|
||||||
```julia
|
|
||||||
using Pkg
|
|
||||||
Pkg.add("NATS")
|
|
||||||
Pkg.add("Arrow")
|
|
||||||
Pkg.add("JSON3")
|
|
||||||
Pkg.add("HTTP")
|
|
||||||
Pkg.add("UUIDs")
|
|
||||||
Pkg.add("Dates")
|
|
||||||
```
|
|
||||||
|
|
||||||
### JavaScript Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install nats.js apache-arrow uuid base64-url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage Tutorial
|
|
||||||
|
|
||||||
### Step 1: Start NATS Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -p 4222:4222 nats:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Start HTTP File Server (optional)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a directory for file uploads
|
|
||||||
mkdir -p /tmp/fileserver
|
|
||||||
|
|
||||||
# Use any HTTP server that supports POST for file uploads
|
|
||||||
# Example: Python's built-in server
|
|
||||||
python3 -m http.server 8080 --directory /tmp/fileserver
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 3: Run Test Scenarios
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Scenario 1: Command & Control (JavaScript sender)
|
|
||||||
node test/scenario1_command_control.js
|
|
||||||
|
|
||||||
# Scenario 2: Large Arrow Table (JavaScript sender)
|
|
||||||
node test/scenario2_large_table.js
|
|
||||||
|
|
||||||
# Scenario 3: Julia-to-Julia communication
|
|
||||||
# Run both Julia and JavaScript versions
|
|
||||||
julia test/scenario3_julia_to_julia.jl
|
|
||||||
node test/scenario3_julia_to_julia.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Scenario 1: Command & Control (Small JSON)
|
|
||||||
|
|
||||||
#### JavaScript (Sender)
|
|
||||||
```javascript
|
|
||||||
const { SmartSend } = require('./js_bridge');
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
step_size: 0.01,
|
|
||||||
iterations: 1000
|
|
||||||
};
|
|
||||||
|
|
||||||
await SmartSend("control", config, "json", {
|
|
||||||
correlationId: "unique-id"
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Julia (Receiver)
|
|
||||||
```julia
|
|
||||||
using NATS
|
|
||||||
using JSON3
|
|
||||||
|
|
||||||
# Subscribe to control subject
|
|
||||||
subscribe(nats, "control") do msg
|
|
||||||
env = MessageEnvelope(String(msg.data))
|
|
||||||
config = JSON3.read(env.payload)
|
|
||||||
|
|
||||||
# Execute simulation with parameters
|
|
||||||
step_size = config.step_size
|
|
||||||
iterations = config.iterations
|
|
||||||
|
|
||||||
# Send acknowledgment
|
|
||||||
response = Dict("status" => "Running", "correlation_id" => env.correlation_id)
|
|
||||||
publish(nats, "control_response", JSON3.stringify(response))
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 2: Deep Dive Analysis (Large Arrow Table)
|
|
||||||
|
|
||||||
#### Julia (Sender)
|
|
||||||
```julia
|
|
||||||
using Arrow
|
|
||||||
using DataFrames
|
|
||||||
|
|
||||||
# Create large DataFrame
|
|
||||||
df = DataFrame(
|
|
||||||
id = 1:10_000_000,
|
|
||||||
value = rand(10_000_000),
|
|
||||||
category = rand(["A", "B", "C"], 10_000_000)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Send via SmartSend with type="table"
|
|
||||||
await SmartSend("analysis_results", df, "table");
|
|
||||||
```
|
|
||||||
|
|
||||||
#### JavaScript (Receiver)
|
|
||||||
```javascript
|
|
||||||
const { SmartReceive } = require('./js_bridge');
|
|
||||||
|
|
||||||
const result = await SmartReceive(msg);
|
|
||||||
|
|
||||||
// Use table data for visualization with Perspective.js or D3
|
|
||||||
const table = result.data;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 3: Live Binary Processing
|
|
||||||
|
|
||||||
#### JavaScript (Sender)
|
|
||||||
```javascript
|
|
||||||
const { SmartSend } = require('./js_bridge');
|
|
||||||
|
|
||||||
// Capture binary chunk
|
|
||||||
const binaryData = await navigator.mediaDevices.getUserMedia({ binary: true });
|
|
||||||
|
|
||||||
await SmartSend("binary_input", binaryData, "binary", {
|
|
||||||
metadata: {
|
|
||||||
sample_rate: 44100,
|
|
||||||
channels: 1
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Julia (Receiver)
|
|
||||||
```julia
|
|
||||||
using WAV
|
|
||||||
using DSP
|
|
||||||
|
|
||||||
# Receive binary data
|
|
||||||
function process_binary(data)
|
|
||||||
# Perform FFT or AI transcription
|
|
||||||
spectrum = fft(data)
|
|
||||||
|
|
||||||
# Send results back (JSON + Arrow table)
|
|
||||||
results = Dict("transcription" => "sample text", "spectrum" => spectrum)
|
|
||||||
await SmartSend("binary_output", results, "json")
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 4: Catch-Up (JetStream)
|
|
||||||
|
|
||||||
#### Julia (Producer)
|
|
||||||
```julia
|
|
||||||
using NATS
|
|
||||||
|
|
||||||
function publish_health_status(nats)
|
|
||||||
jetstream = JetStream(nats, "health_updates")
|
|
||||||
|
|
||||||
while true
|
|
||||||
status = Dict("cpu" => rand(), "memory" => rand())
|
|
||||||
publish(jetstream, "health", status)
|
|
||||||
sleep(5) # Every 5 seconds
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
#### JavaScript (Consumer)
|
|
||||||
```javascript
|
|
||||||
const { connect } = require('nats');
|
|
||||||
|
|
||||||
const nc = await connect({ servers: ['nats://localhost:4222'] });
|
|
||||||
const js = nc.jetstream();
|
|
||||||
|
|
||||||
// Request replay from last 10 minutes
|
|
||||||
const consumer = await js.pullSubscribe("health", {
|
|
||||||
durable_name: "catchup",
|
|
||||||
max_batch: 100,
|
|
||||||
max_ack_wait: 30000
|
|
||||||
});
|
|
||||||
|
|
||||||
// Process historical and real-time messages
|
|
||||||
for await (const msg of consumer) {
|
|
||||||
const result = await SmartReceive(msg);
|
|
||||||
// Process the data
|
|
||||||
msg.ack();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
| Variable | Default | Description |
|
|
||||||
|----------|---------|-------------|
|
|
||||||
| `NATS_URL` | `nats://localhost:4222` | NATS server URL |
|
|
||||||
| `FILESERVER_URL` | `http://localhost:8080/upload` | HTTP file server URL |
|
|
||||||
| `SIZE_THRESHOLD` | `1_000_000` | Size threshold in bytes (1MB) |
|
|
||||||
|
|
||||||
### Message Envelope Schema
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"correlation_id": "uuid-v4-string",
|
|
||||||
"type": "json|table|binary",
|
|
||||||
"transport": "direct|link",
|
|
||||||
"payload": "base64-encoded-string", // Only if transport=direct
|
|
||||||
"url": "http://fileserver/path/to/data", // Only if transport=link
|
|
||||||
"metadata": {
|
|
||||||
"content_type": "application/octet-stream",
|
|
||||||
"content_length": 123456,
|
|
||||||
"format": "arrow_ipc_stream"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Zero-Copy Reading
|
|
||||||
- Use Arrow's memory-mapped file reading
|
|
||||||
- Avoid unnecessary data copying during deserialization
|
|
||||||
- Use Apache Arrow's native IPC reader
|
|
||||||
|
|
||||||
### Exponential Backoff
|
|
||||||
- Maximum retry count: 5
|
|
||||||
- Base delay: 100ms, max delay: 5000ms
|
|
||||||
- Implemented in both Julia and JavaScript implementations
|
|
||||||
|
|
||||||
### Correlation ID Logging
|
|
||||||
- Log correlation_id at every stage
|
|
||||||
- Include: send, receive, serialize, deserialize
|
|
||||||
- Use structured logging format
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
Run the test scripts:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Scenario 1: Command & Control (JavaScript sender)
|
|
||||||
node test/scenario1_command_control.js
|
|
||||||
|
|
||||||
# Scenario 2: Large Arrow Table (JavaScript sender)
|
|
||||||
node test/scenario2_large_table.js
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **NATS Connection Failed**
|
|
||||||
- Ensure NATS server is running
|
|
||||||
- Check NATS_URL configuration
|
|
||||||
|
|
||||||
2. **HTTP Upload Failed**
|
|
||||||
- Ensure file server is running
|
|
||||||
- Check FILESERVER_URL configuration
|
|
||||||
- Verify upload permissions
|
|
||||||
|
|
||||||
3. **Arrow IPC Deserialization Error**
|
|
||||||
- Ensure data is properly serialized to Arrow format
|
|
||||||
- Check Arrow version compatibility
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT
|
|
||||||
@@ -1,294 +1,475 @@
|
|||||||
# Architecture Documentation: Bi-Directional Data Bridge (Julia ↔ JavaScript)
|
# Cross-Platform Architecture Documentation: Bi-Directional Data Bridge
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This document describes the architecture for a high-performance, bi-directional data bridge between a Julia service and a JavaScript (Node.js) service using NATS (Core & JetStream), implementing the Claim-Check pattern for large payloads.
|
This document describes the architecture for a high-performance, bi-directional data bridge using **NATS (Core & JetStream)**, implementing the Claim-Check pattern for large payloads. The system is implemented across three platforms with **high-level API parity** while maintaining **idiomatic implementations** for each language.
|
||||||
|
|
||||||
## Architecture Diagram
|
**Supported Platforms:**
|
||||||
|
- **Julia** - Ground truth implementation with full feature set
|
||||||
|
- **JavaScript** - Node.js and browser-compatible implementation
|
||||||
|
- **Python/MicroPython** - Desktop and embedded-compatible implementation
|
||||||
|
|
||||||
|
### Cross-Platform Design Principles
|
||||||
|
|
||||||
|
1. **High-Level API Parity**: All three platforms expose the same `smartsend()` and `smartreceive()` functions with identical signatures and behavior
|
||||||
|
2. **Idiomatic Implementations**: Each platform uses its native patterns (multiple dispatch in Julia, async/prototype in JS, class-based in Python)
|
||||||
|
3. **Message Format Consistency**: The `msg_envelope_v1` and `msg_payload_v1` JSON schemas are identical across all platforms
|
||||||
|
4. **Handler Function Abstraction**: File server operations are abstracted through handler functions for backend flexibility
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Level API Standard (Cross-Platform)
|
||||||
|
|
||||||
|
### Unified API Signature
|
||||||
|
|
||||||
|
All three platforms expose the same high-level API:
|
||||||
|
|
||||||
|
**Input Format (smartsend):**
|
||||||
|
```
|
||||||
|
[(dataname1, data1, type1), (dataname2, data2, type2), ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output Format (smartreceive):**
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"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), ...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supported Payload Types
|
||||||
|
|
||||||
|
| Type | Julia | JavaScript | Python/MicroPython |
|
||||||
|
|------|-------|------------|-------------------|
|
||||||
|
| `text` | `String` | `string` | `str` |
|
||||||
|
| `dictionary` | `Dict`, `NamedTuple` | `Object`, `Array` | `dict`, `list` |
|
||||||
|
| `arrowtable` | `DataFrame`, `Arrow.Table` | `Array<Object>` (input) → `Buffer` (Arrow IPC) | `pandas.DataFrame`, `bytes` (Arrow IPC) |
|
||||||
|
| `jsontable` | `Vector{NamedTuple}`, `Vector{Dict}` | `Array<Object>` | `list[dict]`, `list` |
|
||||||
|
| `table` | ❌ | ❌ | `pandas.DataFrame`, `bytes` (Arrow IPC) |
|
||||||
|
| `image` | `Vector{UInt8}` | `Uint8Array`, `Buffer` | `bytes`, `bytearray` |
|
||||||
|
| `audio` | `Vector{UInt8}` | `Uint8Array`, `Buffer` | `bytes`, `bytearray` |
|
||||||
|
| `video` | `Vector{UInt8}` | `Uint8Array`, `Buffer` | `bytes`, `bytearray` |
|
||||||
|
| `binary` | `Vector{UInt8}`, `IOBuffer` | `Uint8Array`, `Buffer` | `bytes`, `bytearray`, `io.BytesIO` |
|
||||||
|
|
||||||
|
**Note on MicroPython:** MicroPython does not support table types (`arrowtable` or `jsontable`) due to memory constraints. Use `dictionary` or `binary` instead.
|
||||||
|
|
||||||
|
### Cross-Platform API Examples
|
||||||
|
|
||||||
|
**Julia:**
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
# Send
|
||||||
|
env, env_json_str = smartsend(
|
||||||
|
"/chat",
|
||||||
|
[("message", "Hello!", "text"), ("image", image_bytes, "image")],
|
||||||
|
broker_url="nats://localhost:4222"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Receive - returns JSON.Object{String, Any}
|
||||||
|
env = smartreceive(msg; fileserver_download_handler=_fetch_with_backoff)
|
||||||
|
# env is a JSON.Object{String, Any} with "payloads" field containing Vector{Tuple{String, Any, String}}
|
||||||
|
# Access payloads: for (dataname, data, type) in env["payloads]
|
||||||
|
```
|
||||||
|
|
||||||
|
**JavaScript:**
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('natsbridge');
|
||||||
|
|
||||||
|
// Send
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/chat",
|
||||||
|
[
|
||||||
|
["message", "Hello!", "text"],
|
||||||
|
["image", imageBuffer, "image"]
|
||||||
|
],
|
||||||
|
{ broker_url: "nats://localhost:4222" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Receive - returns Promise<object>
|
||||||
|
const env = await NATSBridge.smartreceive(msg, {
|
||||||
|
fileserver_download_handler: fetchWithBackoff
|
||||||
|
});
|
||||||
|
// env is an object with "payloads" field containing Array of arrays
|
||||||
|
// Access payloads: for (const [dataname, data, type] of env.payloads)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python:**
|
||||||
|
```python
|
||||||
|
from natsbridge import NATSBridge
|
||||||
|
|
||||||
|
# Send
|
||||||
|
env, env_json_str = NATSBridge.smartsend(
|
||||||
|
"/chat",
|
||||||
|
[("message", "Hello!", "text"), ("image", image_bytes, "image")],
|
||||||
|
broker_url="nats://localhost:4222"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Receive - returns Tuple[Dict, str]
|
||||||
|
env = NATSBridge.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"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**MicroPython:**
|
||||||
|
```python
|
||||||
|
from natsbridge import NATSBridge
|
||||||
|
|
||||||
|
# Send (limited to direct transport due to memory constraints)
|
||||||
|
env, env_json_str = NATSBridge.smartsend(
|
||||||
|
"/chat",
|
||||||
|
[("message", "Hello!", "text")],
|
||||||
|
broker_url="nats://localhost:4222"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Diagram (Cross-Platform)
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph Client
|
subgraph Client
|
||||||
JS[JavaScript Client]
|
App[Julia/JS/Python/MicroPython Application]
|
||||||
JSApp[Application Logic]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Server
|
subgraph Server
|
||||||
Julia[Julia Service]
|
Julia/JS/Python/MicroPython[Julia/JS/Python/MicroPython Service]
|
||||||
NATS[NATS Server]
|
NATS[NATS Server]
|
||||||
FileServer[HTTP File Server]
|
FileServer[HTTP File Server]
|
||||||
end
|
end
|
||||||
|
|
||||||
JS -->|Control/Small Data| JSApp
|
App -->|NATS| NATS
|
||||||
JSApp -->|NATS| NATS
|
NATS -->|NATS| Julia/JS/Python/MicroPython
|
||||||
NATS -->|NATS| Julia
|
Julia/JS/Python/MicroPython -->|NATS| NATS
|
||||||
Julia -->|NATS| NATS
|
Julia/JS/Python/MicroPython -->|HTTP POST| FileServer
|
||||||
Julia -->|HTTP POST| FileServer
|
|
||||||
JS -->|HTTP GET| FileServer
|
|
||||||
|
|
||||||
style JS fill:#e1f5fe
|
style App fill:#e8f5e9
|
||||||
style Julia fill:#e8f5e9
|
style Julia/JS/Python/MicroPython fill:#e8f5e9
|
||||||
style NATS fill:#fff3e0
|
style NATS fill:#fff3e0
|
||||||
style FileServer fill:#f3e5f5
|
style FileServer fill:#f3e5f5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## System Components
|
## System Components
|
||||||
|
|
||||||
### 1. Unified JSON Envelope Schema
|
### 1. msg_envelope_v1 - Message Envelope
|
||||||
|
|
||||||
All messages use a standardized envelope format:
|
|
||||||
|
|
||||||
|
**JSON Schema (Identical Across All Platforms):**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"correlation_id": "uuid-v4-string",
|
"correlation_id": "uuid-v4-string",
|
||||||
"type": "json|table|binary",
|
"msg_id": "uuid-v4-string",
|
||||||
"transport": "direct|link",
|
"timestamp": "2024-01-15T10:30:00Z",
|
||||||
"payload": "base64-encoded-string", // Only if transport=direct
|
|
||||||
"url": "http://fileserver/path/to/data", // Only if transport=link
|
"send_to": "topic/subject",
|
||||||
|
"msg_purpose": "ACK | NACK | updateStatus | shutdown | chat",
|
||||||
|
"sender_name": "agent-wine-web-frontend",
|
||||||
|
"sender_id": "uuid4",
|
||||||
|
"receiver_name": "agent-backend",
|
||||||
|
"receiver_id": "uuid4",
|
||||||
|
"reply_to": "topic",
|
||||||
|
"reply_to_msg_id": "uuid4",
|
||||||
|
"broker_url": "nats://localhost:4222",
|
||||||
|
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"content_type": "application/octet-stream",
|
"content_type": "application/octet-stream",
|
||||||
"content_length": 123456,
|
"content_length": 123456
|
||||||
"format": "arrow_ipc_stream"
|
},
|
||||||
|
|
||||||
|
"payloads": [
|
||||||
|
{
|
||||||
|
"id": "uuid4",
|
||||||
|
"dataname": "login_image",
|
||||||
|
"payload_type": "image",
|
||||||
|
"transport": "direct",
|
||||||
|
"encoding": "base64",
|
||||||
|
"size": 15433,
|
||||||
|
"data": "base64-encoded-string",
|
||||||
|
"metadata": {
|
||||||
|
"checksum": "sha256_hash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uuid4",
|
||||||
|
"dataname": "large_arrow_table",
|
||||||
|
"payload_type": "arrowtable",
|
||||||
|
"transport": "link",
|
||||||
|
"encoding": "arrow-ipc",
|
||||||
|
"size": 524288,
|
||||||
|
"data": "http://localhost:8080/file/UPLOAD_ID/FILE_ID/data.arrow",
|
||||||
|
"metadata": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. msg_payload_v1 - Payload Structure
|
||||||
|
|
||||||
|
**JSON Schema (Identical Across All Platforms):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid4",
|
||||||
|
"dataname": "login_image",
|
||||||
|
"payload_type": "image | dictionary | arrowtable | jsontable | table | text | audio | video | binary",
|
||||||
|
"transport": "direct | link",
|
||||||
|
"encoding": "none | json | base64 | arrow-ipc",
|
||||||
|
"size": 15433,
|
||||||
|
"data": "base64-encoded-string | http-url | json-string",
|
||||||
|
"metadata": {
|
||||||
|
"checksum": "sha256_hash"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Transport Strategy Decision Logic
|
### 3. Transport Strategy Decision Logic (Cross-Platform)
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ SmartSend Function │
|
│ smartsend Function (All Platforms) │
|
||||||
|
│ Accepts: [(dataname1, data1, type1), ...] │
|
||||||
|
│ (Type is per payload, not standalone) │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Is payload size < 1MB? │
|
│ For each payload: │
|
||||||
|
│ 1. Extract type from tuple/array │
|
||||||
|
│ 2. Serialize based on type │
|
||||||
|
│ 3. Check payload size │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
┌─────────────────┴─────────────────┐
|
┌───────────┴────────────┐
|
||||||
▼ ▼
|
▼ ▼
|
||||||
┌─────────────────┐ ┌─────────────────┐
|
┌──────────────┐ ┌──────────────┐
|
||||||
│ Direct Path │ │ Link Path │
|
│ Direct Path │ │ Link Path │
|
||||||
│ (< 1MB) │ │ (> 1MB) │
|
│ (< 1MB) │ │ (>= 1MB) │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ • Serialize to │ │ • Serialize to │
|
│ • Serialize │ │ • Serialize │
|
||||||
│ IOBuffer │ │ IOBuffer │
|
│ to buffer │ │ to buffer │
|
||||||
│ • Base64 encode │ │ • Upload to │
|
│ • Base64/JSON│ │ • Upload to │
|
||||||
│ • Publish to │ │ HTTP Server │
|
│ encode │ │ HTTP Server│
|
||||||
│ NATS │ │ • Publish to │
|
│ • Publish to │ │ • Publish to │
|
||||||
│ │ │ NATS with URL │
|
│ NATS │ │ NATS with │
|
||||||
└─────────────────┘ └─────────────────┘
|
│ (in msg) │ │ URL │
|
||||||
|
└──────────────┘ └──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Julia Module Architecture
|
---
|
||||||
|
|
||||||
```mermaid
|
## Platform Comparison Matrix
|
||||||
graph TD
|
|
||||||
subgraph JuliaModule
|
|
||||||
SmartSendJulia[SmartSend Julia]
|
|
||||||
SizeCheck[Size Check]
|
|
||||||
DirectPath[Direct Path]
|
|
||||||
LinkPath[Link Path]
|
|
||||||
HTTPClient[HTTP Client]
|
|
||||||
end
|
|
||||||
|
|
||||||
SmartSendJulia --> SizeCheck
|
| Feature | Julia | JavaScript | Python | MicroPython |
|
||||||
SizeCheck -->|< 1MB| DirectPath
|
|---------|-------|------------|--------|-------------|
|
||||||
SizeCheck -->|>= 1MB| LinkPath
|
| **Multiple Dispatch** | ✅ Native | ❌ (Prototypes) | ❌ (Overload via `@overload`) | ❌ |
|
||||||
LinkPath --> HTTPClient
|
| **Async/Await** | ❌ (Tasks) | ✅ Native | ✅ Native | ⚠️ (uasyncio) |
|
||||||
|
| **Type Safety** | ✅ Strong | ⚠️ (TypeScript) | ✅ (Type hints) | ❌ |
|
||||||
|
| **Memory Management** | ✅ GC | ✅ GC | ✅ GC | ⚠️ (Manual) |
|
||||||
|
| **Arrow IPC** | ✅ Native | ✅ (arrow package) | ✅ (pyarrow) | ❌ |
|
||||||
|
| **JSON Serialization** | ✅ (JSON.jl) | ✅ (native) | ✅ (json) | ✅ (json) |
|
||||||
|
| **arrowtable Support** | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| **jsontable Support** | ✅ | ✅ | ✅ | ❌ |
|
||||||
|
| **Direct Transport** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **Link Transport** | ✅ | ✅ | ✅ | ⚠️ (Limited) |
|
||||||
|
| **Handler Functions** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| **Cross-Platform API** | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
style JuliaModule fill:#c5e1a5
|
---
|
||||||
|
|
||||||
|
## Platform-Specific Architecture Patterns
|
||||||
|
|
||||||
|
### Julia: Multiple Dispatch Pattern
|
||||||
|
|
||||||
|
Julia leverages multiple dispatch for type-specific implementations:
|
||||||
|
|
||||||
|
- **Function overloading** based on argument types
|
||||||
|
- **Struct-based data models** with explicit types
|
||||||
|
- **Native Arrow IPC** support via Arrow.jl
|
||||||
|
|
||||||
|
### JavaScript: Prototype + Async Pattern
|
||||||
|
|
||||||
|
JavaScript uses async/await for non-blocking I/O:
|
||||||
|
|
||||||
|
- **Class-based NATS client** for connection management
|
||||||
|
- **Module-level utility functions** for serialization
|
||||||
|
- **Native ArrayBuffer** for binary data handling
|
||||||
|
|
||||||
|
### Python: Class-Based Pattern
|
||||||
|
|
||||||
|
Python uses classes for stateful operations:
|
||||||
|
|
||||||
|
- **Class-based NATSBridge** with type hints
|
||||||
|
- **Dataclasses** for structured data (MsgPayloadV1, MsgEnvelopeV1)
|
||||||
|
- **Async/await** for I/O operations
|
||||||
|
|
||||||
|
### MicroPython: Synchronous Pattern
|
||||||
|
|
||||||
|
MicroPython has significant constraints:
|
||||||
|
|
||||||
|
- **Synchronous API** (no async/await)
|
||||||
|
- **Memory-constrained** (256KB - 1MB)
|
||||||
|
- **Limited payload support** (no tables, max 50KB)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-Platform Compatibility Notes
|
||||||
|
|
||||||
|
### 1. Payload Type Consistency
|
||||||
|
|
||||||
|
All platforms use the same payload type values for tabular data:
|
||||||
|
|
||||||
|
| Platform | Table Types |
|
||||||
|
|----------|-------------|
|
||||||
|
| Julia | `"arrowtable"`, `"jsontable"` |
|
||||||
|
| JavaScript | `"arrowtable"`, `"jsontable"` |
|
||||||
|
| Python | `"arrowtable"`, `"jsontable"` |
|
||||||
|
| MicroPython | Not supported |
|
||||||
|
|
||||||
|
|
||||||
|
### 2. Direct Transport Encoding Field
|
||||||
|
|
||||||
|
The encoding field in direct transport payloads differs between platforms:
|
||||||
|
|
||||||
|
| Platform | Encoding for Direct Transport |
|
||||||
|
|----------|-------------------------------|
|
||||||
|
| Julia | Preserves original type: `"base64"`, `"json"`, or `"arrow-ipc"` |
|
||||||
|
| JavaScript | Preserves original type: `"base64"`, `"json"`, or `"arrow-ipc"` |
|
||||||
|
| Python | Always `"base64"` for all direct transport payloads |
|
||||||
|
| MicroPython | Always `"base64"` for all direct transport payloads |
|
||||||
|
|
||||||
|
**Impact:** The encoding field may not accurately reflect the original serialization format when using Python or MicroPython.
|
||||||
|
|
||||||
|
### 3. MicroPython Limitations
|
||||||
|
|
||||||
|
MicroPython has significant constraints that affect feature support:
|
||||||
|
|
||||||
|
| Feature | Desktop Platforms | MicroPython |
|
||||||
|
|---------|-------------------|-------------|
|
||||||
|
| `arrowtable` | ✅ | ❌ (not supported - memory constraints) |
|
||||||
|
| `jsontable` | ✅ | ❌ (not supported - memory constraints) |
|
||||||
|
| `table` | ✅ | ❌ (not supported - memory constraints) |
|
||||||
|
| Async/await | ✅ | ❌ (synchronous only) |
|
||||||
|
| File upload/download | ✅ | ⚠️ (placeholder implementations) |
|
||||||
|
| MAX_PAYLOAD_SIZE | 1MB+ | 50KB (hard limit) |
|
||||||
|
| DEFAULT_SIZE_THRESHOLD | 1MB | 100KB |
|
||||||
|
|
||||||
|
**Impact:** MicroPython should only be used for small payloads with direct transport. File server operations are not fully implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `NATS_URL` | `nats://localhost:4222` | NATS server URL |
|
||||||
|
| `FILESERVER_URL` | `http://localhost:8080` | HTTP file server URL |
|
||||||
|
| `SIZE_THRESHOLD` | `1000000` | Size threshold in bytes (1MB) |
|
||||||
|
|
||||||
|
### MicroPython-Specific Configuration
|
||||||
|
|
||||||
|
```python
|
||||||
|
# micropython.conf
|
||||||
|
NATS_URL = "nats://broker.local:4222"
|
||||||
|
FILESERVER_URL = "http://fileserver.local:8080"
|
||||||
|
SIZE_THRESHOLD = 100000 # Lower threshold for memory-constrained devices
|
||||||
|
MAX_PAYLOAD_SIZE = 50000 # Hard limit for MicroPython
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. JavaScript Module Architecture
|
---
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
subgraph JSModule
|
|
||||||
SmartSendJS[SmartSend JS]
|
|
||||||
SmartReceiveJS[SmartReceive JS]
|
|
||||||
JetStreamConsumer[JetStream Pull Consumer]
|
|
||||||
ApacheArrow[Apache Arrow]
|
|
||||||
end
|
|
||||||
|
|
||||||
SmartSendJS --> NATS
|
|
||||||
SmartReceiveJS --> JetStreamConsumer
|
|
||||||
JetStreamConsumer --> ApacheArrow
|
|
||||||
|
|
||||||
style JSModule fill:#f3e5f5
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Details
|
|
||||||
|
|
||||||
### Julia Implementation
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
- `NATS.jl` - Core NATS functionality
|
|
||||||
- `Arrow.jl` - Arrow IPC serialization
|
|
||||||
- `JSON3.jl` - JSON parsing
|
|
||||||
- `HTTP.jl` - HTTP client for file server
|
|
||||||
- `Dates.jl` - Timestamps for logging
|
|
||||||
|
|
||||||
#### SmartSend Function
|
|
||||||
|
|
||||||
```julia
|
|
||||||
function SmartSend(
|
|
||||||
subject::String,
|
|
||||||
data::Any,
|
|
||||||
type::String = "json";
|
|
||||||
nats_url::String = "nats://localhost:4222",
|
|
||||||
fileserver_url::String = "http://localhost:8080/upload",
|
|
||||||
size_threshold::Int = 1_000_000 # 1MB
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. Serialize data to Arrow IPC stream (if table)
|
|
||||||
2. Check payload size
|
|
||||||
3. If < threshold: publish directly to NATS with Base64-encoded payload
|
|
||||||
4. If >= threshold: upload to HTTP server, publish NATS with URL
|
|
||||||
|
|
||||||
#### SmartReceive Handler
|
|
||||||
|
|
||||||
```julia
|
|
||||||
function SmartReceive(msg::NATS.Message)
|
|
||||||
# Parse envelope
|
|
||||||
# Check transport type
|
|
||||||
# If direct: decode Base64 payload
|
|
||||||
# If link: fetch from URL with exponential backoff
|
|
||||||
# Deserialize Arrow IPC to DataFrame
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### JavaScript Implementation
|
|
||||||
|
|
||||||
#### Dependencies
|
|
||||||
- `nats.js` - Core NATS functionality
|
|
||||||
- `apache-arrow` - Arrow IPC serialization
|
|
||||||
- `uuid` - Correlation ID generation
|
|
||||||
|
|
||||||
#### SmartSend Function
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function SmartSend(subject, data, type = 'json', options = {})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. Serialize data to Arrow IPC buffer (if table)
|
|
||||||
2. Check payload size
|
|
||||||
3. If < threshold: publish directly to NATS
|
|
||||||
4. If >= threshold: upload to HTTP server, publish NATS with URL
|
|
||||||
|
|
||||||
#### SmartReceive Handler
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function SmartReceive(msg, options = {})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Flow:**
|
|
||||||
1. Parse envelope
|
|
||||||
2. Check transport type
|
|
||||||
3. If direct: decode Base64 payload
|
|
||||||
4. If link: fetch with exponential backoff
|
|
||||||
5. Deserialize Arrow IPC with zero-copy
|
|
||||||
|
|
||||||
## Scenario Implementations
|
|
||||||
|
|
||||||
### Scenario 1: Command & Control (Small JSON)
|
|
||||||
|
|
||||||
**Julia (Receiver):**
|
|
||||||
```julia
|
|
||||||
# Subscribe to control subject
|
|
||||||
# Parse JSON envelope
|
|
||||||
# Execute simulation with parameters
|
|
||||||
# Send acknowledgment
|
|
||||||
```
|
|
||||||
|
|
||||||
**JavaScript (Sender):**
|
|
||||||
```javascript
|
|
||||||
// Create small JSON config
|
|
||||||
// Send via SmartSend with type="json"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 2: Deep Dive Analysis (Large Arrow Table)
|
|
||||||
|
|
||||||
**Julia (Sender):**
|
|
||||||
```julia
|
|
||||||
# Create large DataFrame
|
|
||||||
# Convert to Arrow IPC stream
|
|
||||||
# Check size (> 1MB)
|
|
||||||
# Upload to HTTP server
|
|
||||||
# Publish NATS with URL
|
|
||||||
```
|
|
||||||
|
|
||||||
**JavaScript (Receiver):**
|
|
||||||
```javascript
|
|
||||||
// Receive NATS message with URL
|
|
||||||
// Fetch data from HTTP server
|
|
||||||
// Parse Arrow IPC with zero-copy
|
|
||||||
// Load into Perspective.js or D3
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 3: Live Audio Processing
|
|
||||||
|
|
||||||
**JavaScript (Sender):**
|
|
||||||
```javascript
|
|
||||||
// Capture audio chunk
|
|
||||||
// Send as binary with metadata headers
|
|
||||||
// Use SmartSend with type="audio"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Julia (Receiver):**
|
|
||||||
```julia
|
|
||||||
// Receive audio data
|
|
||||||
// Perform FFT or AI transcription
|
|
||||||
// Send results back (JSON + Arrow table)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Scenario 4: Catch-Up (JetStream)
|
|
||||||
|
|
||||||
**Julia (Producer):**
|
|
||||||
```julia
|
|
||||||
# Publish to JetStream
|
|
||||||
# Include metadata for temporal tracking
|
|
||||||
```
|
|
||||||
|
|
||||||
**JavaScript (Consumer):**
|
|
||||||
```javascript
|
|
||||||
// Connect to JetStream
|
|
||||||
// Request replay from last 10 minutes
|
|
||||||
// Process historical and real-time messages
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
### Zero-Copy Reading
|
### Zero-Copy Reading
|
||||||
- Use Arrow's memory-mapped file reading
|
|
||||||
- Avoid unnecessary data copying during deserialization
|
| Platform | Strategy |
|
||||||
- Use Apache Arrow's native IPC reader
|
|----------|----------|
|
||||||
|
| **Julia** | `Arrow.read()` with memory-mapped files |
|
||||||
|
| **JavaScript** | `ArrayBuffer` with `DataView` |
|
||||||
|
| **Python** | `pyarrow` memory mapping |
|
||||||
|
| **MicroPython** | Not available (streaming only) |
|
||||||
|
|
||||||
### Exponential Backoff
|
### Exponential Backoff
|
||||||
- Implement exponential backoff for HTTP link fetching
|
|
||||||
- Maximum retry count: 5
|
All platforms implement exponential backoff for HTTP downloads:
|
||||||
- Base delay: 100ms, max delay: 5000ms
|
|
||||||
|
```
|
||||||
|
delay = base_delay
|
||||||
|
for attempt in 1:max_retries:
|
||||||
|
try:
|
||||||
|
response = fetch(url)
|
||||||
|
if success: return response
|
||||||
|
except:
|
||||||
|
if attempt < max_retries:
|
||||||
|
sleep(delay)
|
||||||
|
delay = min(delay * 2, max_delay)
|
||||||
|
```
|
||||||
|
|
||||||
### Correlation ID Logging
|
### Correlation ID Logging
|
||||||
- Log correlation_id at every stage
|
|
||||||
- Include: send, receive, serialize, deserialize
|
|
||||||
- Use structured logging format
|
|
||||||
|
|
||||||
## Testing Strategy
|
All platforms use correlation IDs for distributed tracing:
|
||||||
|
|
||||||
### Unit Tests
|
```
|
||||||
- Test SmartSend with various payload sizes
|
[timestamp] [Correlation: abc123] Message published to subject
|
||||||
- Test SmartReceive with direct and link transport
|
```
|
||||||
- Test Arrow IPC serialization/deserialization
|
|
||||||
|
|
||||||
### Integration Tests
|
### Serialization Performance Comparison
|
||||||
- Test full flow with NATS server
|
|
||||||
- Test large data transfer (> 100MB)
|
|
||||||
- Test audio processing pipeline
|
|
||||||
|
|
||||||
### Performance Tests
|
| Format | Use Case | Pros | Cons |
|
||||||
- Measure throughput for small payloads
|
|--------|----------|------|------|
|
||||||
- Measure throughput for large payloads
|
| `arrowtable` | Large tabular data | Fast, zero-copy, schema-preserving | Binary format, requires Arrow library |
|
||||||
|
| `jsontable` | Small/medium tabular data | Human-readable, universal support | Slower, larger size, no schema |
|
||||||
|
| `table` (Python) | Large tabular data | Fast, zero-copy, schema-preserving | Python-specific, requires pyarrow |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This cross-platform NATS bridge provides:
|
||||||
|
|
||||||
|
1. **High-Level API Parity**: Identical `smartsend()` and `smartreceive()` signatures across Julia, JavaScript, and Python/MicroPython
|
||||||
|
2. **Idiomatic Implementations**:
|
||||||
|
- Julia: Multiple dispatch and struct-based design
|
||||||
|
- JavaScript: Async/await and prototype-based utilities
|
||||||
|
- Python: Class-based design with type hints
|
||||||
|
- MicroPython: Synchronous API with memory constraints
|
||||||
|
3. **Message Format Consistency**: Identical `msg_envelope_v1` and `msg_payload_v1` JSON schemas
|
||||||
|
4. **Handler Abstraction**: File server operations abstracted through configurable handlers
|
||||||
|
5. **Platform-Specific Optimizations**:
|
||||||
|
- **Arrow IPC** (`arrowtable`): Efficient binary format for large tabular data
|
||||||
|
- **JSON** (`jsontable`): Universal human-readable format for smaller tables
|
||||||
|
- **Python table**: Unified table type for Python-specific implementations
|
||||||
|
- Streaming support in MicroPython
|
||||||
|
|
||||||
|
The Julia implementation serves as the **ground truth** for API design and behavior, while JavaScript and Python implementations maintain interface parity while leveraging their respective language idioms.
|
||||||
|
|
||||||
|
### Datatype Summary
|
||||||
|
|
||||||
|
| Datatype | Serialization | Use Case | Encoding | Supported Platforms |
|
||||||
|
|----------|---------------|----------|----------|---------------------|
|
||||||
|
| `text` | UTF-8 bytes | Text messages, chat content | `utf-8` → `base64` | All |
|
||||||
|
| `dictionary` | JSON | Structured key-value data, config | `json` → `base64` | All |
|
||||||
|
| `arrowtable` | Apache Arrow IPC | Large tabular data, schema-preserving | `arrow-ipc` → `base64` | Julia, JavaScript, Python |
|
||||||
|
| `jsontable` | JSON | Small/medium tabular data, human-readable | `json` → `base64` | Julia, JavaScript, Python |
|
||||||
|
| `table` | Apache Arrow IPC | Python's unified table type | `arrow-ipc` → `base64` | Python |
|
||||||
|
| `image` | Binary | Image files (JPEG, PNG, etc.) | `binary` → `base64` | All |
|
||||||
|
| `audio` | Binary | Audio files (WAV, MP3, etc.) | `binary` → `base64` | All |
|
||||||
|
| `video` | Binary | Video files (MP4, AVI, etc.) | `binary` → `base64` | All |
|
||||||
|
| `binary` | Binary | Generic binary data, files | `binary` → `base64` | All |
|
||||||
|
|||||||
1859
docs/implementation.md
Normal file
1859
docs/implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
741
docs/tutorial.md
Normal file
741
docs/tutorial.md
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
# Cross-Platform NATSBridge Tutorial
|
||||||
|
|
||||||
|
A step-by-step guide to get started with NATSBridge across **Julia**, **JavaScript**, and **Python/MicroPython**.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Overview](#overview)
|
||||||
|
2. [Prerequisites](#prerequisites)
|
||||||
|
3. [Installation](#installation)
|
||||||
|
4. [Quick Start](#quick-start)
|
||||||
|
5. [Basic Examples](#basic-examples)
|
||||||
|
6. [Advanced Usage](#advanced-usage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
NATSBridge enables seamless communication across platforms through NATS, with automatic transport selection based on payload size:
|
||||||
|
|
||||||
|
- **Direct Transport**: Payloads < 1MB are sent directly via NATS (Base64 encoded)
|
||||||
|
- **Link Transport**: Payloads >= 1MB are uploaded to an HTTP file server and referenced via URL
|
||||||
|
|
||||||
|
### Cross-Platform API Parity
|
||||||
|
|
||||||
|
All three platforms use the same high-level API:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Input format
|
||||||
|
smartsend(subject, [(dataname, data, type), ...], options)
|
||||||
|
|
||||||
|
# Output format
|
||||||
|
(env, env_json_str) = smartsend(...)
|
||||||
|
env = smartreceive(msg, options)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important Platform Differences:**
|
||||||
|
|
||||||
|
1. **Encoding field:** Julia and JavaScript preserve the original serialization format in the encoding field (`"base64"`, `"json"`, or `"arrow-ipc"`), while Python and MicroPython always use `"base64"` for all direct transport payloads.
|
||||||
|
|
||||||
|
2. **Async vs Sync:** JavaScript and Python desktop use async/await, while MicroPython uses synchronous API.
|
||||||
|
|
||||||
|
### Supported Payload Types
|
||||||
|
|
||||||
|
| Type | Julia | JavaScript | Python | MicroPython |
|
||||||
|
|------|-------|------------|--------|-------------|
|
||||||
|
| `text` | `String` | `string` | `str` | `str` |
|
||||||
|
| `dictionary` | `Dict` | `Object` | `dict` | `dict` |
|
||||||
|
| `arrowtable` | `DataFrame` | `Array<Object>` | `pandas.DataFrame` | ❌ |
|
||||||
|
| `jsontable` | `Vector{NamedTuple}` | `Array<Object>` | `list[dict]` | ❌ |
|
||||||
|
| `table` | ❌ | ❌ | `pandas.DataFrame` | ❌ |
|
||||||
|
| `image` | `Vector{UInt8}` | `Uint8Array` | `bytes` | `bytearray` |
|
||||||
|
| `audio` | `Vector{UInt8}` | `Uint8Array` | `bytes` | `bytearray` |
|
||||||
|
| `video` | `Vector{UInt8}` | `Uint8Array` | `bytes` | `bytearray` |
|
||||||
|
| `binary` | `Vector{UInt8}` | `Uint8Array` | `bytes` | `bytearray` |
|
||||||
|
|
||||||
|
**Note on MicroPython:** MicroPython does not support table types (`arrowtable`, `jsontable`, or `table`) due to memory constraints. Use `dictionary` or `binary` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before you begin, ensure you have:
|
||||||
|
|
||||||
|
1. **NATS Server** running (or accessible)
|
||||||
|
2. **HTTP File Server** (optional, for large payloads > 1MB)
|
||||||
|
3. **Platform-specific packages** installed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using Pkg
|
||||||
|
Pkg.add("NATS")
|
||||||
|
Pkg.add("Arrow")
|
||||||
|
Pkg.add("JSON3")
|
||||||
|
Pkg.add("HTTP")
|
||||||
|
Pkg.add("UUIDs")
|
||||||
|
Pkg.add("Dates")
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript (Node.js)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install nats uuid apache-arrow node-fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript (Browser)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://unpkg.com/nats-js/dist/bundle/nats.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/apache-arrow/arrow.min.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Python (Desktop)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install nats-py aiohttp pyarrow pandas
|
||||||
|
```
|
||||||
|
|
||||||
|
### MicroPython
|
||||||
|
|
||||||
|
Uses built-in modules: `network`, `socket`, `time`, `json`, `base64`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Step 1: Start NATS Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -p 4222:4222 nats:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Start HTTP File Server (Optional)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/fileserver
|
||||||
|
python3 -m http.server 8080 --directory /tmp/fileserver
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Send Your First Message
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
# Send a text message
|
||||||
|
data = [("message", "Hello World", "text")]
|
||||||
|
env, env_json_str = smartsend("/chat/room1", data, broker_url="nats://localhost:4222")
|
||||||
|
# env: msg_envelope_v1 struct with all metadata and payloads
|
||||||
|
# env_json_str: JSON string representation of the envelope for publishing
|
||||||
|
println("Message sent!")
|
||||||
|
|
||||||
|
# Or use is_publish=false to get envelope and JSON without publishing
|
||||||
|
env, env_json_str = smartsend("/chat/room1", data, broker_url="nats://localhost:4222", is_publish=false)
|
||||||
|
# env: msg_envelope_v1 struct
|
||||||
|
# env_json_str: JSON string for publishing to NATS
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('./src/natsbridge.js');
|
||||||
|
|
||||||
|
// Send a text message
|
||||||
|
const data = [["message", "Hello World", "text"]];
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/chat/room1",
|
||||||
|
data,
|
||||||
|
{ broker_url: "nats://localhost:4222" }
|
||||||
|
);
|
||||||
|
// env: Object with all metadata and payloads
|
||||||
|
// env_json_str: JSON string for publishing
|
||||||
|
console.log("Message sent!");
|
||||||
|
|
||||||
|
// Or use is_publish=false
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/chat/room1",
|
||||||
|
data,
|
||||||
|
{ broker_url: "nats://localhost:4222", is_publish: false }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import smartsend
|
||||||
|
|
||||||
|
# Send a text message
|
||||||
|
data = [("message", "Hello World", "text")]
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
"/chat/room1",
|
||||||
|
data,
|
||||||
|
broker_url="nats://localhost:4222"
|
||||||
|
)
|
||||||
|
# env: Dict with all metadata and payloads
|
||||||
|
# env_json_str: JSON string for publishing
|
||||||
|
print("Message sent!")
|
||||||
|
|
||||||
|
# Or use is_publish=False
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
"/chat/room1",
|
||||||
|
data,
|
||||||
|
broker_url="nats://localhost:4222",
|
||||||
|
is_publish=False
|
||||||
|
)
|
||||||
|
# env: Dict with all metadata and payloads
|
||||||
|
# env_json_str: JSON string for publishing to NATS
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MicroPython
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge_mpy import NATSBridge
|
||||||
|
|
||||||
|
bridge = NATSBridge()
|
||||||
|
|
||||||
|
# Send a text message (limited to small payloads)
|
||||||
|
data = [("message", "Hello World", "text")]
|
||||||
|
env, env_json_str = bridge.smartsend(
|
||||||
|
"/chat/room1",
|
||||||
|
data,
|
||||||
|
size_threshold=100000 # Lower threshold for MicroPython
|
||||||
|
)
|
||||||
|
print("Message sent!")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Receive Messages
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
# Receive and process message
|
||||||
|
env = smartreceive(msg; fileserver_download_handler=_fetch_with_backoff)
|
||||||
|
# Returns: ::JSON.Object{String, Any} with "payloads" field containing Vector{Tuple{String, Any, String}}
|
||||||
|
# Access payloads: for (dataname, data, type) in env["payloads"]
|
||||||
|
for (dataname, data, type) in env["payloads"]
|
||||||
|
println("Received $dataname: $data")
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('./src/natsbridge.js');
|
||||||
|
|
||||||
|
// Receive and process message
|
||||||
|
const env = await NATSBridge.smartreceive(msg, {
|
||||||
|
fileserver_download_handler: NATSBridge.fetchWithBackoff
|
||||||
|
});
|
||||||
|
// env.payloads = [[dataname, data, type], ...]
|
||||||
|
for (const [dataname, data, type] of env.payloads) {
|
||||||
|
console.log(`Received ${dataname}:`, data);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import smartreceive, fetch_with_backoff
|
||||||
|
|
||||||
|
# Receive and process message
|
||||||
|
env = await smartreceive(
|
||||||
|
msg,
|
||||||
|
fileserver_download_handler=fetch_with_backoff
|
||||||
|
)
|
||||||
|
# env["payloads"] = [(dataname, data, type), ...]
|
||||||
|
for dataname, data, type_ in env["payloads"]:
|
||||||
|
print(f"Received {dataname}: {data}")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Basic Examples
|
||||||
|
|
||||||
|
### Example 1: Sending a Dictionary
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
config = Dict(
|
||||||
|
"wifi_ssid" => "MyNetwork",
|
||||||
|
"wifi_password" => "password123",
|
||||||
|
"update_interval" => 60
|
||||||
|
)
|
||||||
|
|
||||||
|
data = [("config", config, "dictionary")]
|
||||||
|
env, env_json_str = smartsend("/device/config", data, broker_url="nats://localhost:4222")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('./src/natsbridge.js');
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
wifi_ssid: "MyNetwork",
|
||||||
|
wifi_password: "password123",
|
||||||
|
update_interval: 60
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = [["config", config, "dictionary"]];
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/device/config",
|
||||||
|
data,
|
||||||
|
{ broker_url: "nats://localhost:4222" }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import smartsend
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"wifi_ssid": "MyNetwork",
|
||||||
|
"wifi_password": "password123",
|
||||||
|
"update_interval": 60
|
||||||
|
}
|
||||||
|
|
||||||
|
data = [("config", config, "dictionary")]
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
"/device/config",
|
||||||
|
data,
|
||||||
|
broker_url="nats://localhost:4222"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MicroPython
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge_mpy import NATSBridge
|
||||||
|
|
||||||
|
bridge = NATSBridge()
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"wifi_ssid": "MyNetwork",
|
||||||
|
"wifi_password": "password123",
|
||||||
|
"update_interval": 60
|
||||||
|
}
|
||||||
|
|
||||||
|
data = [("config", config, "dictionary")]
|
||||||
|
env, env_json_str = bridge.smartsend(
|
||||||
|
"/device/config",
|
||||||
|
data,
|
||||||
|
size_threshold=100000
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Sending Binary Data (Image)
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
# Read image file
|
||||||
|
image_data = read("image.png")
|
||||||
|
|
||||||
|
data = [("user_image", image_data, "binary")]
|
||||||
|
env, env_json_str = smartsend("/chat/image", data, broker_url="nats://localhost:4222")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('./src/natsbridge.js');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Read image file
|
||||||
|
const image_data = fs.readFileSync('image.png');
|
||||||
|
|
||||||
|
const data = [["user_image", image_data, "binary"]];
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/chat/image",
|
||||||
|
data,
|
||||||
|
{ broker_url: "nats://localhost:4222" }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import smartsend
|
||||||
|
|
||||||
|
# Read image file
|
||||||
|
with open("image.png", "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
data = [("user_image", image_data, "binary")]
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
"/chat/image",
|
||||||
|
data,
|
||||||
|
broker_url="nats://localhost:4222"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MicroPython
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge_mpy import NATSBridge
|
||||||
|
|
||||||
|
bridge = NATSBridge()
|
||||||
|
|
||||||
|
# Read image file
|
||||||
|
with open("image.png", "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
data = [("user_image", image_data, "binary")]
|
||||||
|
env, env_json_str = bridge.smartsend(
|
||||||
|
"/chat/image",
|
||||||
|
data,
|
||||||
|
size_threshold=100000
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Request-Response Pattern
|
||||||
|
|
||||||
|
#### Julia (Requester)
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
# Send command with reply-to
|
||||||
|
data = [("command", Dict("action" => "read_sensor"), "dictionary")]
|
||||||
|
env, env_json_str = smartsend(
|
||||||
|
"/device/command",
|
||||||
|
data,
|
||||||
|
broker_url="nats://localhost:4222",
|
||||||
|
reply_to="/device/response",
|
||||||
|
reply_to_msg_id="cmd-001"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript (Requester)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('./src/natsbridge.js');
|
||||||
|
|
||||||
|
// Send command with reply-to
|
||||||
|
const data = [["command", { action: "read_sensor" }, "dictionary"]];
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/device/command",
|
||||||
|
data,
|
||||||
|
{
|
||||||
|
broker_url: "nats://localhost:4222",
|
||||||
|
reply_to: "/device/response",
|
||||||
|
reply_to_msg_id: "cmd-001"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python (Requester)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import smartsend
|
||||||
|
|
||||||
|
# Send command with reply-to
|
||||||
|
data = [("command", {"action": "read_sensor"}, "dictionary")]
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
"/device/command",
|
||||||
|
data,
|
||||||
|
broker_url="nats://localhost:4222",
|
||||||
|
reply_to="/device/response",
|
||||||
|
reply_to_msg_id="cmd-001"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Julia (Responder)
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge, NATS
|
||||||
|
|
||||||
|
const SUBJECT = "/device/command"
|
||||||
|
const NATS_URL = "nats://localhost:4222"
|
||||||
|
|
||||||
|
function test_responder()
|
||||||
|
conn = NATS.connect(NATS_URL)
|
||||||
|
NATS.subscribe(conn, SUBJECT) do msg
|
||||||
|
env = smartreceive(msg, fileserver_download_handler=_fetch_with_backoff)
|
||||||
|
|
||||||
|
reply_to = env["reply_to"]
|
||||||
|
|
||||||
|
for (dataname, data, type) in env["payloads"]
|
||||||
|
if dataname == "command" && data["action"] == "read_sensor"
|
||||||
|
response = Dict("sensor_id" => "sensor-001", "value" => 42.5)
|
||||||
|
if !isempty(reply_to)
|
||||||
|
smartsend(reply_to, [("data", response, "dictionary")])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
sleep(120)
|
||||||
|
NATS.drain(conn)
|
||||||
|
end
|
||||||
|
|
||||||
|
test_responder()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Example 4: Large Payloads (File Server)
|
||||||
|
|
||||||
|
For payloads larger than 1MB, NATSBridge automatically uses the file server:
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
# Create large data (> 1MB)
|
||||||
|
large_data = rand(UInt8, 2_000_000)
|
||||||
|
|
||||||
|
env, env_json_str = smartsend(
|
||||||
|
"/data/large",
|
||||||
|
[("large_file", large_data, "binary")],
|
||||||
|
broker_url="nats://localhost:4222",
|
||||||
|
fileserver_url="http://localhost:8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
println("File uploaded to: $(env.payloads[1].data)")
|
||||||
|
# Note: For link transport, data field contains the URL string
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('./src/natsbridge.js');
|
||||||
|
|
||||||
|
// Create large data (> 1MB)
|
||||||
|
const large_data = Buffer.alloc(2_000_000);
|
||||||
|
for (let i = 0; i < large_data.length; i++) {
|
||||||
|
large_data[i] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/data/large",
|
||||||
|
[["large_file", large_data, "binary"]],
|
||||||
|
{
|
||||||
|
broker_url: "nats://localhost:4222",
|
||||||
|
fileserver_url: "http://localhost:8080"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("File uploaded to:", env.payloads[0].data);
|
||||||
|
// Note: For link transport, data field contains the URL string
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import smartsend
|
||||||
|
|
||||||
|
# Create large data (> 1MB)
|
||||||
|
import os
|
||||||
|
large_data = os.urandom(2_000_000)
|
||||||
|
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
"/data/large",
|
||||||
|
[("large_file", large_data, "binary")],
|
||||||
|
broker_url="nats://localhost:4222",
|
||||||
|
fileserver_url="http://localhost:8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"File uploaded to: {env['payloads'][0]['data']}")
|
||||||
|
# Note: For link transport, data field contains the URL string
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MicroPython
|
||||||
|
|
||||||
|
MicroPython enforces a hard limit of 50KB per payload:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge_mpy import NATSBridge
|
||||||
|
|
||||||
|
bridge = NATSBridge()
|
||||||
|
|
||||||
|
# MicroPython has a hard limit of 50KB per payload
|
||||||
|
# Use streaming or chunking for larger data
|
||||||
|
small_data = bytes(1000) # 1KB
|
||||||
|
|
||||||
|
data = [("small_file", small_data, "binary")]
|
||||||
|
env, env_json_str = bridge.smartsend(
|
||||||
|
"/data/small",
|
||||||
|
data,
|
||||||
|
size_threshold=100000 # Enforced max: 50000 bytes
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 5: Mixed Content (Chat with Text + Image)
|
||||||
|
|
||||||
|
NATSBridge supports sending multiple payloads with different types in a single message:
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
|
||||||
|
image_data = read("avatar.png")
|
||||||
|
|
||||||
|
data = [
|
||||||
|
("message_text", "Hello with image!", "text"),
|
||||||
|
("user_avatar", image_data, "image")
|
||||||
|
]
|
||||||
|
|
||||||
|
env, env_json_str = smartsend("/chat/mixed", data, broker_url="nats://localhost:4222")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('./src/natsbridge.js');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const image_data = fs.readFileSync('avatar.png');
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
["message_text", "Hello with image!", "text"],
|
||||||
|
["user_avatar", image_data, "image"]
|
||||||
|
];
|
||||||
|
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/chat/mixed",
|
||||||
|
data,
|
||||||
|
{ broker_url: "nats://localhost:4222" }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import smartsend
|
||||||
|
|
||||||
|
with open("avatar.png", "rb") as f:
|
||||||
|
image_data = f.read()
|
||||||
|
|
||||||
|
data = [
|
||||||
|
("message_text", "Hello with image!", "text"),
|
||||||
|
("user_avatar", image_data, "image")
|
||||||
|
]
|
||||||
|
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
"/chat/mixed",
|
||||||
|
data,
|
||||||
|
broker_url="nats://localhost:4222"
|
||||||
|
)
|
||||||
|
# env: Dict with all metadata and payloads
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 6: Table Data (Arrow IPC)
|
||||||
|
|
||||||
|
For tabular data, NATSBridge uses Apache Arrow IPC format:
|
||||||
|
|
||||||
|
#### Julia
|
||||||
|
|
||||||
|
```julia
|
||||||
|
using NATSBridge
|
||||||
|
using DataFrames
|
||||||
|
|
||||||
|
# Create DataFrame
|
||||||
|
df = DataFrame(
|
||||||
|
id = [1, 2, 3],
|
||||||
|
name = ["Alice", "Bob", "Charlie"],
|
||||||
|
score = [95, 88, 92]
|
||||||
|
)
|
||||||
|
|
||||||
|
data = [("students", df, "arrowtable")]
|
||||||
|
env, env_json_str = smartsend("/data/students", data, broker_url="nats://localhost:4222")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### JavaScript
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const NATSBridge = require('./src/natsbridge.js');
|
||||||
|
|
||||||
|
// Create table data (array of objects)
|
||||||
|
const table_data = [
|
||||||
|
{ id: 1, name: "Alice", score: 95 },
|
||||||
|
{ id: 2, name: "Bob", score: 88 },
|
||||||
|
{ id: 3, name: "Charlie", score: 92 }
|
||||||
|
];
|
||||||
|
|
||||||
|
const data = [["students", table_data, "arrowtable"]];
|
||||||
|
const [env, env_json_str] = await NATSBridge.smartsend(
|
||||||
|
"/data/students",
|
||||||
|
data,
|
||||||
|
{ broker_url: "nats://localhost:4222" }
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
from natsbridge import smartsend
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
# Create DataFrame
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'id': [1, 2, 3],
|
||||||
|
'name': ['Alice', 'Bob', 'Charlie'],
|
||||||
|
'score': [95, 88, 92]
|
||||||
|
})
|
||||||
|
|
||||||
|
data = [("students", df, "table")]
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
"/data/students",
|
||||||
|
data,
|
||||||
|
broker_url="nats://localhost:4222"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MicroPython
|
||||||
|
|
||||||
|
MicroPython does not support table type due to memory constraints. Use dictionary or binary instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Explore the test directory** for more examples
|
||||||
|
2. **Check the documentation** for advanced configuration options
|
||||||
|
3. **Read the walkthrough** for building real-world applications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Connection Issues
|
||||||
|
|
||||||
|
- Ensure NATS server is running: `docker ps | grep nats`
|
||||||
|
- Check firewall settings
|
||||||
|
- Verify NATS URL configuration
|
||||||
|
|
||||||
|
### File Server Issues
|
||||||
|
|
||||||
|
- Ensure file server is running and accessible
|
||||||
|
- Check upload permissions
|
||||||
|
- Verify file server URL configuration
|
||||||
|
|
||||||
|
### Serialization Errors
|
||||||
|
|
||||||
|
- Verify data type matches the specified type
|
||||||
|
- Check that binary data is in the correct format
|
||||||
|
- MicroPython: Ensure payload size < 50KB
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
1378
docs/walkthrough.md
Normal file
1378
docs/walkthrough.md
Normal file
File diff suppressed because it is too large
Load Diff
42
etc.jl
42
etc.jl
@@ -1,42 +0,0 @@
|
|||||||
|
|
||||||
""" fileServerURL = "http://192.168.88.104:8080"
|
|
||||||
filepath = "/home/ton/docker-apps/sendreceive/image/test.zip"
|
|
||||||
filename = basename(filepath)
|
|
||||||
filebytes = read(filepath)
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
The function handles the entire flow:
|
|
||||||
1. Obtains an upload ID and token from the server
|
|
||||||
2. Uploads the provided binary data as a file using the `X-UploadToken` header
|
|
||||||
3. Returns identifiers and download URL for the uploaded file
|
|
||||||
|
|
||||||
# Arguments:
|
|
||||||
- `fileServerURL::String` - Base URL of the plik server (e.g., `"http://192.168.88.104:8080"`)
|
|
||||||
- `filename::String` - Name of the file being uploaded
|
|
||||||
- `data::Vector{UInt8}` - Raw byte data of the file content
|
|
||||||
|
|
||||||
# Return:
|
|
||||||
- A named tuple with fields:
|
|
||||||
- `uploadid::String` - ID of the one-shot upload session
|
|
||||||
- `fileid::String` - ID of the uploaded file within the session
|
|
||||||
- `downloadurl::String` - Full URL to download the uploaded file
|
|
||||||
|
|
||||||
# Example
|
|
||||||
```jldoctest
|
|
||||||
using HTTP, JSON
|
|
||||||
|
|
||||||
# Example data: "Hello" as bytes
|
|
||||||
data = collect("Hello World!" |> collect |> CodeUnits |> collect)
|
|
||||||
|
|
||||||
# Upload to local plik server
|
|
||||||
result = plik_oneshot_upload("http://192.168.88.104:8080", "hello.txt", data)
|
|
||||||
|
|
||||||
# Download URL for the uploaded file
|
|
||||||
println(result.downloadurl)
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
310
etc.txt
Normal file
310
etc.txt
Normal file
@@ -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 NATSBridge.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/NATSBridge.jl")
|
||||||
|
using .NATSBridge
|
||||||
|
|
||||||
|
# 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 NATSBridge.smartreceive to handle the data
|
||||||
|
# # API: smartreceive(msg, download_handler; max_retries, base_delay, max_delay)
|
||||||
|
# result = NATSBridge.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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
14
plik_fileserver/docker-compose.yml
Normal file
14
plik_fileserver/docker-compose.yml
Normal file
@@ -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"
|
||||||
1443
src/NATSBridge.jl
1443
src/NATSBridge.jl
File diff suppressed because it is too large
Load Diff
@@ -1,245 +0,0 @@
|
|||||||
/**
|
|
||||||
* Bi-Directional Data Bridge - JavaScript Module
|
|
||||||
* Implements SmartSend and SmartReceive for NATS communication
|
|
||||||
*/
|
|
||||||
|
|
||||||
const { v4: uuidv4 } = require('uuid');
|
|
||||||
const { decode, encode } = require('base64-url');
|
|
||||||
const Arrow = require('apache-arrow');
|
|
||||||
|
|
||||||
// Constants
|
|
||||||
const DEFAULT_SIZE_THRESHOLD = 1_000_000; // 1MB
|
|
||||||
const DEFAULT_NATS_URL = 'nats://localhost:4222';
|
|
||||||
const DEFAULT_FILESERVER_URL = 'http://localhost:8080/upload';
|
|
||||||
|
|
||||||
// Logging helper
|
|
||||||
function logTrace(correlationId, message) {
|
|
||||||
const timestamp = new Date().toISOString();
|
|
||||||
console.log(`[${timestamp}] [Correlation: ${correlationId}] ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message Envelope Class
|
|
||||||
class MessageEnvelope {
|
|
||||||
constructor(options = {}) {
|
|
||||||
this.correlation_id = options.correlation_id || uuidv4();
|
|
||||||
this.type = options.type || 'json';
|
|
||||||
this.transport = options.transport || 'direct';
|
|
||||||
this.payload = options.payload || null;
|
|
||||||
this.url = options.url || null;
|
|
||||||
this.metadata = options.metadata || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromJSON(jsonStr) {
|
|
||||||
const data = JSON.parse(jsonStr);
|
|
||||||
return new MessageEnvelope({
|
|
||||||
correlation_id: data.correlation_id,
|
|
||||||
type: data.type,
|
|
||||||
transport: data.transport,
|
|
||||||
payload: data.payload || null,
|
|
||||||
url: data.url || null,
|
|
||||||
metadata: data.metadata || {}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON() {
|
|
||||||
const obj = {
|
|
||||||
correlation_id: this.correlation_id,
|
|
||||||
type: this.type,
|
|
||||||
transport: this.transport
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.payload) {
|
|
||||||
obj.payload = this.payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.url) {
|
|
||||||
obj.url = this.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(this.metadata).length > 0) {
|
|
||||||
obj.metadata = this.metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(obj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SmartSend for JavaScript - Handles transport selection based on payload size
|
|
||||||
async function SmartSend(subject, data, type = 'json', options = {}) {
|
|
||||||
const {
|
|
||||||
natsUrl = DEFAULT_NATS_URL,
|
|
||||||
fileserverUrl = DEFAULT_FILESERVER_URL,
|
|
||||||
sizeThreshold = DEFAULT_SIZE_THRESHOLD,
|
|
||||||
correlationId = uuidv4()
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
logTrace(correlationId, `Starting SmartSend for subject: ${subject}`);
|
|
||||||
|
|
||||||
// Serialize data based on type
|
|
||||||
const payloadBytes = _serializeData(data, type, correlationId);
|
|
||||||
const payloadSize = payloadBytes.length;
|
|
||||||
|
|
||||||
logTrace(correlationId, `Serialized payload size: ${payloadSize} bytes`);
|
|
||||||
|
|
||||||
// Decision: Direct vs Link
|
|
||||||
if (payloadSize < sizeThreshold) {
|
|
||||||
// Direct path - Base64 encode and send via NATS
|
|
||||||
const payloadBase64 = encode(payloadBytes);
|
|
||||||
logTrace(correlationId, `Using direct transport for ${payloadSize} bytes`);
|
|
||||||
|
|
||||||
const env = new MessageEnvelope({
|
|
||||||
correlation_id: correlationId,
|
|
||||||
type: type,
|
|
||||||
transport: 'direct',
|
|
||||||
payload: payloadBase64,
|
|
||||||
metadata: {
|
|
||||||
content_length: payloadSize.toString(),
|
|
||||||
format: 'arrow_ipc_stream'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await publishMessage(natsUrl, subject, env.toJSON(), correlationId);
|
|
||||||
return env;
|
|
||||||
} else {
|
|
||||||
// Link path - Upload to HTTP server, send URL via NATS
|
|
||||||
logTrace(correlationId, `Using link transport, uploading to fileserver`);
|
|
||||||
|
|
||||||
const url = await uploadToServer(payloadBytes, fileserverUrl, correlationId);
|
|
||||||
|
|
||||||
const env = new MessageEnvelope({
|
|
||||||
correlation_id: correlationId,
|
|
||||||
type: type,
|
|
||||||
transport: 'link',
|
|
||||||
url: url,
|
|
||||||
metadata: {
|
|
||||||
content_length: payloadSize.toString(),
|
|
||||||
format: 'arrow_ipc_stream'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await publishMessage(natsUrl, subject, env.toJSON(), correlationId);
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Serialize data based on type
|
|
||||||
function _serializeData(data, type, correlationId) {
|
|
||||||
if (type === 'json') {
|
|
||||||
const jsonStr = JSON.stringify(data);
|
|
||||||
return Buffer.from(jsonStr, 'utf8');
|
|
||||||
} else if (type === 'table') {
|
|
||||||
// Table data - convert to Arrow IPC stream
|
|
||||||
const writer = new Arrow.Writer();
|
|
||||||
writer.writeTable(data);
|
|
||||||
return writer.toByteArray();
|
|
||||||
} else if (type === 'binary') {
|
|
||||||
// Binary data - treat as binary
|
|
||||||
if (data instanceof Buffer) {
|
|
||||||
return data;
|
|
||||||
} else if (Array.isArray(data)) {
|
|
||||||
return Buffer.from(data);
|
|
||||||
} else {
|
|
||||||
throw new Error('Binary data must be binary (Buffer or Array)');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unknown type: ${type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Publish message to NATS
|
|
||||||
async function publishMessage(natsUrl, subject, message, correlationId) {
|
|
||||||
const { connect } = require('nats');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const nc = await connect({ servers: [natsUrl] });
|
|
||||||
await nc.publish(subject, message);
|
|
||||||
logTrace(correlationId, `Message published to ${subject}`);
|
|
||||||
nc.close();
|
|
||||||
} catch (error) {
|
|
||||||
logTrace(correlationId, `Failed to publish message: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SmartReceive for JavaScript - Handles both direct and link transport
|
|
||||||
async function SmartReceive(msg, options = {}) {
|
|
||||||
const {
|
|
||||||
fileserverUrl = DEFAULT_FILESERVER_URL,
|
|
||||||
maxRetries = 5,
|
|
||||||
baseDelay = 100,
|
|
||||||
maxDelay = 5000
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const env = MessageEnvelope.fromJSON(msg.data);
|
|
||||||
|
|
||||||
logTrace(env.correlation_id, `Processing received message`);
|
|
||||||
|
|
||||||
if (env.transport === 'direct') {
|
|
||||||
logTrace(env.correlation_id, `Direct transport - decoding payload`);
|
|
||||||
|
|
||||||
const payloadBytes = decode(env.payload);
|
|
||||||
const data = _deserializeData(payloadBytes, env.type, env.correlation_id, env.metadata);
|
|
||||||
|
|
||||||
return { data, envelope: env };
|
|
||||||
} else if (env.transport === 'link') {
|
|
||||||
logTrace(env.correlation_id, `Link transport - fetching from URL`);
|
|
||||||
|
|
||||||
const data = await _fetchWithBackoff(env.url, maxRetries, baseDelay, maxDelay, env.correlation_id);
|
|
||||||
const result = _deserializeData(data, env.type, env.correlation_id, env.metadata);
|
|
||||||
|
|
||||||
return { data: result, envelope: env };
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unknown transport type: ${env.transport}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Fetch with exponential backoff
|
|
||||||
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.ok) {
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
logTrace(correlationId, `Successfully fetched data from ${url} on attempt ${attempt}`);
|
|
||||||
return new Uint8Array(buffer);
|
|
||||||
} else {
|
|
||||||
throw new Error(`Failed to fetch: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logTrace(correlationId, `Attempt ${attempt} failed: ${error.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`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: Deserialize data based on type
|
|
||||||
async function _deserializeData(data, type, correlationId, metadata) {
|
|
||||||
if (type === 'json') {
|
|
||||||
const jsonStr = new TextDecoder().decode(data);
|
|
||||||
return JSON.parse(jsonStr);
|
|
||||||
} else if (type === 'table') {
|
|
||||||
// Deserialize Arrow IPC stream to Table
|
|
||||||
const table = Arrow.Table.from(data);
|
|
||||||
return table;
|
|
||||||
} else if (type === 'binary') {
|
|
||||||
// Return binary binary data
|
|
||||||
return data;
|
|
||||||
} else {
|
|
||||||
throw new Error(`Unknown type: ${type}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export functions
|
|
||||||
module.exports = {
|
|
||||||
SmartSend,
|
|
||||||
SmartReceive,
|
|
||||||
MessageEnvelope
|
|
||||||
};
|
|
||||||
788
src/natsbridge.js
Normal file
788
src/natsbridge.js
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
/**
|
||||||
|
* NATSBridge - Cross-Platform Bi-Directional Data Bridge
|
||||||
|
* JavaScript/Node.js 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.
|
||||||
|
*
|
||||||
|
* Supported payload types: "text", "dictionary", "arrowtable", "jsontable", "image", "audio", "video", "binary"
|
||||||
|
*
|
||||||
|
* @module NATSBridge
|
||||||
|
*/
|
||||||
|
|
||||||
|
const nats = require('nats');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
// Use native fetch available in Node.js 18+
|
||||||
|
const arrow = require('apache-arrow');
|
||||||
|
|
||||||
|
// ---------------------------------------------- Constants ---------------------------------------------- //
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default size threshold for switching from direct to link transport (1MB)
|
||||||
|
*/
|
||||||
|
const DEFAULT_SIZE_THRESHOLD = 1_000_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<Object>} 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<Uint8Array>} 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
|
||||||
|
*/
|
||||||
|
class NATSClient {
|
||||||
|
/**
|
||||||
|
* Create a new NATS client
|
||||||
|
* @param {string} url - NATS server URL
|
||||||
|
*/
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to NATS server
|
||||||
|
* @returns {Promise<NATS.Connection>}
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------- 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
|
||||||
|
*/
|
||||||
|
async function publishMessage(brokerUrlOrClient, subject, message, correlationId) {
|
||||||
|
let conn;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// String URL - create new client
|
||||||
|
const client = new NATSClient(brokerUrlOrClient);
|
||||||
|
conn = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
await conn.publish(subject, message, correlationId);
|
||||||
|
|
||||||
|
if (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: crypto.randomUUID(),
|
||||||
|
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="NATSBridge"] - 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 NATSBridge.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 = crypto.randomUUID(),
|
||||||
|
msg_purpose = 'chat',
|
||||||
|
sender_name = 'NATSBridge',
|
||||||
|
receiver_name = '',
|
||||||
|
receiver_id = '',
|
||||||
|
reply_to = '',
|
||||||
|
reply_to_msg_id = '',
|
||||||
|
is_publish = true,
|
||||||
|
nats_connection = null,
|
||||||
|
msg_id = crypto.randomUUID(),
|
||||||
|
sender_id = crypto.randomUUID()
|
||||||
|
} = 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<Object>} 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 NATSBridge = {
|
||||||
|
/**
|
||||||
|
* NATS client class for connection management
|
||||||
|
*/
|
||||||
|
NATSClient,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send data via NATS with automatic transport selection
|
||||||
|
*/
|
||||||
|
smartsend,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receive and process NATS message
|
||||||
|
*/
|
||||||
|
smartreceive,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = NATSBridge;
|
||||||
843
src/natsbridge.py
Normal file
843
src/natsbridge.py
Normal file
@@ -0,0 +1,843 @@
|
|||||||
|
"""
|
||||||
|
NATSBridge - 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 natsbridge
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 (1MB)
|
||||||
|
"""
|
||||||
|
DEFAULT_SIZE_THRESHOLD = 1_000_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 = "NATSBridge",
|
||||||
|
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 NATSBridge:
|
||||||
|
"""
|
||||||
|
Cross-platform NATS bridge implementation.
|
||||||
|
|
||||||
|
This class provides a convenient interface for NATSBridge 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 NATSBridge.
|
||||||
|
|
||||||
|
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',
|
||||||
|
'NATSBridge',
|
||||||
|
'send',
|
||||||
|
'receive',
|
||||||
|
'DEFAULT_SIZE_THRESHOLD',
|
||||||
|
'DEFAULT_BROKER_URL',
|
||||||
|
'DEFAULT_FILESERVER_URL',
|
||||||
|
'NATSClient',
|
||||||
|
'_serialize_data',
|
||||||
|
'_deserialize_data',
|
||||||
|
'log_trace',
|
||||||
|
'publish_message'
|
||||||
|
]
|
||||||
673
src/natsbridge_mpy.py
Normal file
673
src/natsbridge_mpy.py
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
"""
|
||||||
|
NATSBridge - 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: "NATSBridge")
|
||||||
|
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 = NATSBridge.smartsend(
|
||||||
|
... "/chat",
|
||||||
|
... [("message", "Hello!", "text")],
|
||||||
|
... broker_url="nats://localhost:4222"
|
||||||
|
... )
|
||||||
|
>>>
|
||||||
|
>>> # Send dictionary payload
|
||||||
|
>>> env, env_json_str = NATSBridge.smartsend(
|
||||||
|
... "/config",
|
||||||
|
... [("config", {"key": "value"}, "dictionary")],
|
||||||
|
... broker_url="nats://localhost:4222"
|
||||||
|
... )
|
||||||
|
>>>
|
||||||
|
>>> # Send binary payload (image, audio, video)
|
||||||
|
>>> env, env_json_str = NATSBridge.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', 'NATSBridge')
|
||||||
|
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 = NATSBridge.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 NATSBridge:
|
||||||
|
"""
|
||||||
|
MicroPython NATS bridge implementation.
|
||||||
|
|
||||||
|
This class provides a convenient interface for NATSBridge 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 NATSBridge.
|
||||||
|
|
||||||
|
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',
|
||||||
|
'NATSBridge',
|
||||||
|
'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'
|
||||||
|
]
|
||||||
BIN
test/large_image.png
Normal file
BIN
test/large_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -1,67 +0,0 @@
|
|||||||
#!/usr/bin/env julia
|
|
||||||
# Scenario 1: Command & Control (Small JSON)
|
|
||||||
# Tests small JSON payloads (< 1MB) sent directly via NATS
|
|
||||||
|
|
||||||
using NATS
|
|
||||||
using JSON3
|
|
||||||
using UUIDs
|
|
||||||
|
|
||||||
# Include the bridge module
|
|
||||||
include("../src/julia_bridge.jl")
|
|
||||||
using .BiDirectionalBridge
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
const CONTROL_SUBJECT = "control"
|
|
||||||
const RESPONSE_SUBJECT = "control_response"
|
|
||||||
const NATS_URL = "nats://localhost:4222"
|
|
||||||
|
|
||||||
# Create correlation ID for tracing
|
|
||||||
correlation_id = string(uuid4())
|
|
||||||
|
|
||||||
# Receiver: Listen for control commands
|
|
||||||
function start_control_listener()
|
|
||||||
conn = NATS.Connection(NATS_URL)
|
|
||||||
try
|
|
||||||
NATS.subscribe(conn, CONTROL_SUBJECT) do msg
|
|
||||||
log_trace(msg.data)
|
|
||||||
|
|
||||||
# Parse the envelope
|
|
||||||
env = MessageEnvelope(String(msg.data))
|
|
||||||
|
|
||||||
# Parse JSON payload
|
|
||||||
config = JSON3.read(env.payload)
|
|
||||||
|
|
||||||
# Execute simulation with parameters
|
|
||||||
step_size = config.step_size
|
|
||||||
iterations = config.iterations
|
|
||||||
|
|
||||||
# Simulate processing
|
|
||||||
sleep(0.1) # Simulate some work
|
|
||||||
|
|
||||||
# Send acknowledgment
|
|
||||||
response = Dict(
|
|
||||||
"status" => "Running",
|
|
||||||
"correlation_id" => env.correlation_id,
|
|
||||||
"step_size" => step_size,
|
|
||||||
"iterations" => iterations
|
|
||||||
)
|
|
||||||
|
|
||||||
NATS.publish(conn, RESPONSE_SUBJECT, JSON3.stringify(response))
|
|
||||||
log_trace("Sent response: $(JSON3.stringify(response))")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Keep listening for 5 seconds
|
|
||||||
sleep(5)
|
|
||||||
finally
|
|
||||||
NATS.close(conn)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper: Log with correlation ID
|
|
||||||
function log_trace(message)
|
|
||||||
timestamp = Dates.now()
|
|
||||||
println("[$timestamp] [Correlation: $correlation_id] $message")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Run the listener
|
|
||||||
start_control_listener()
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Scenario 1: Command & Control (Small JSON)
|
|
||||||
// Tests small JSON payloads (< 1MB) sent directly via NATS
|
|
||||||
|
|
||||||
const { SmartSend } = require('../js_bridge');
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const CONTROL_SUBJECT = "control";
|
|
||||||
const NATS_URL = "nats://localhost:4222";
|
|
||||||
|
|
||||||
// Create correlation ID for tracing
|
|
||||||
const correlationId = require('uuid').v4();
|
|
||||||
|
|
||||||
// Sender: Send control command to Julia
|
|
||||||
async function sendControlCommand() {
|
|
||||||
const config = {
|
|
||||||
step_size: 0.01,
|
|
||||||
iterations: 1000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send via SmartSend with type="json"
|
|
||||||
const env = await SmartSend(
|
|
||||||
CONTROL_SUBJECT,
|
|
||||||
config,
|
|
||||||
"json",
|
|
||||||
{ correlationId }
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`Sent control command with correlation_id: ${correlationId}`);
|
|
||||||
console.log(`Envelope: ${JSON.stringify(env, null, 2)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the sender
|
|
||||||
sendControlCommand().catch(console.error);
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
#!/usr/bin/env julia
|
|
||||||
# Scenario 2: Deep Dive Analysis (Large Arrow Table)
|
|
||||||
# Tests large Arrow tables (> 1MB) sent via HTTP fileserver
|
|
||||||
|
|
||||||
using NATS
|
|
||||||
using Arrow
|
|
||||||
using DataFrames
|
|
||||||
using JSON3
|
|
||||||
using UUIDs
|
|
||||||
|
|
||||||
# Include the bridge module
|
|
||||||
include("../src/julia_bridge.jl")
|
|
||||||
using .BiDirectionalBridge
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
const ANALYSIS_SUBJECT = "analysis_results"
|
|
||||||
const RESPONSE_SUBJECT = "analysis_response"
|
|
||||||
const NATS_URL = "nats://localhost:4222"
|
|
||||||
|
|
||||||
# Create correlation ID for tracing
|
|
||||||
correlation_id = string(uuid4())
|
|
||||||
|
|
||||||
# Receiver: Listen for analysis results
|
|
||||||
function start_analysis_listener()
|
|
||||||
conn = NATS.Connection(NATS_URL)
|
|
||||||
try
|
|
||||||
NATS.subscribe(conn, ANALYSIS_SUBJECT) do msg
|
|
||||||
log_trace("Received message from $(msg.subject)")
|
|
||||||
|
|
||||||
# Parse the envelope
|
|
||||||
env = MessageEnvelope(String(msg.data))
|
|
||||||
|
|
||||||
# Use SmartReceive to handle the data
|
|
||||||
result = SmartReceive(msg)
|
|
||||||
|
|
||||||
# Process the data based on type
|
|
||||||
if result.envelope.type == "table"
|
|
||||||
df = result.data
|
|
||||||
log_trace("Received DataFrame with $(nrows(df)) rows")
|
|
||||||
log_trace("DataFrame columns: $(names(df))")
|
|
||||||
|
|
||||||
# Send acknowledgment
|
|
||||||
response = Dict(
|
|
||||||
"status" => "Processed",
|
|
||||||
"correlation_id" => env.correlation_id,
|
|
||||||
"row_count" => nrows(df)
|
|
||||||
)
|
|
||||||
NATS.publish(conn, RESPONSE_SUBJECT, JSON3.stringify(response))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Keep listening for 10 seconds
|
|
||||||
sleep(10)
|
|
||||||
finally
|
|
||||||
NATS.close(conn)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Helper: Log with correlation ID
|
|
||||||
function log_trace(message)
|
|
||||||
timestamp = Dates.now()
|
|
||||||
println("[$timestamp] [Correlation: $correlation_id] $message")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Run the listener
|
|
||||||
start_analysis_listener()
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
// Scenario 2: Deep Dive Analysis (Large Arrow Table)
|
|
||||||
// Tests large Arrow tables (> 1MB) sent via HTTP fileserver
|
|
||||||
|
|
||||||
const { SmartSend } = require('../js_bridge');
|
|
||||||
|
|
||||||
// Configuration
|
|
||||||
const ANALYSIS_SUBJECT = "analysis_results";
|
|
||||||
const NATS_URL = "nats://localhost:4222";
|
|
||||||
|
|
||||||
// Create correlation ID for tracing
|
|
||||||
const correlationId = require('uuid').v4();
|
|
||||||
|
|
||||||
// Sender: Send large Arrow table to Julia
|
|
||||||
async function sendLargeTable() {
|
|
||||||
// Create a large DataFrame-like structure (10 million rows)
|
|
||||||
// For testing, we'll create a smaller but still large table
|
|
||||||
const numRows = 1000000; // 1 million rows
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
id: Array.from({ length: numRows }, (_, i) => i + 1),
|
|
||||||
value: Array.from({ length: numRows }, () => Math.random()),
|
|
||||||
category: Array.from({ length: numRows }, () => ['A', 'B', 'C'][Math.floor(Math.random() * 3)])
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert to Arrow Table
|
|
||||||
const { Table, Vector, RecordBatch } = require('apache-arrow');
|
|
||||||
|
|
||||||
const idVector = Vector.from(data.id);
|
|
||||||
const valueVector = Vector.from(data.value);
|
|
||||||
const categoryVector = Vector.from(data.category);
|
|
||||||
|
|
||||||
const table = Table.from({
|
|
||||||
id: idVector,
|
|
||||||
value: valueVector,
|
|
||||||
category: categoryVector
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send via SmartSend with type="table"
|
|
||||||
const env = await SmartSend(
|
|
||||||
ANALYSIS_SUBJECT,
|
|
||||||
table,
|
|
||||||
"table",
|
|
||||||
{ correlationId }
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`Sent large table with ${numRows} rows`);
|
|
||||||
console.log(`Correlation ID: ${correlationId}`);
|
|
||||||
console.log(`Transport: ${env.transport}`);
|
|
||||||
console.log(`URL: ${env.url || 'N/A'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the sender
|
|
||||||
sendLargeTable().catch(console.error);
|
|
||||||
BIN
test/small_image.jpg
Normal file
BIN
test/small_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
275
test/test_js_mix_payloads_receiver.js
Normal file
275
test/test_js_mix_payloads_receiver.js
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
/**
|
||||||
|
* JavaScript Mix Payloads Receiver Test
|
||||||
|
* Tests the smartreceive function with mixed payload types
|
||||||
|
*
|
||||||
|
* This test mirrors test_julia_mix_payloads_receiver.jl and demonstrates that
|
||||||
|
* any combination and any number of mixed content can be received correctly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NATSBridge = require('../src/natsbridge.js');
|
||||||
|
const nats = require('nats');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const TEST_SUBJECT = '/natsbridge';
|
||||||
|
const TEST_BROKER_URL = process.env.NATS_URL || 'nats.yiem.cc';
|
||||||
|
const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://192.168.88.104:8080';
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
console.log('=== JavaScript Mix Payloads Receiver Test ===\n');
|
||||||
|
|
||||||
|
const correlationId = crypto.randomUUID();
|
||||||
|
console.log(`Correlation ID: ${correlationId}`);
|
||||||
|
console.log(`Subject: ${TEST_SUBJECT}`);
|
||||||
|
console.log(`Broker URL: ${TEST_BROKER_URL}`);
|
||||||
|
console.log(`Fileserver URL: ${TEST_FILESERVER_URL}\n`);
|
||||||
|
|
||||||
|
let testPassed = true;
|
||||||
|
let messagesReceived = 0;
|
||||||
|
const receivedPayloads = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect to NATS
|
||||||
|
console.log('Connecting to NATS server...');
|
||||||
|
const nc = await nats.connect({ servers: TEST_BROKER_URL });
|
||||||
|
console.log('✅ Connected to NATS server\n');
|
||||||
|
|
||||||
|
// Set up message subscription
|
||||||
|
const subscription = nc.subscribe(TEST_SUBJECT);
|
||||||
|
|
||||||
|
// Wait for messages with timeout
|
||||||
|
const messagePromise = new Promise(async (resolve, reject) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
resolve('timeout');
|
||||||
|
}, 180000); // 180 second timeout (matches Julia test)
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for await (const msg of subscription) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
messagesReceived++;
|
||||||
|
console.log(`\n=== Message ${messagesReceived} Received ===`);
|
||||||
|
console.log(`Received message on ${msg.subject}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process the message using smartreceive
|
||||||
|
const envelope = await NATSBridge.smartreceive(msg, {
|
||||||
|
fileserver_download_handler: NATSBridge.fetchWithBackoff,
|
||||||
|
max_retries: 5,
|
||||||
|
base_delay: 100,
|
||||||
|
max_delay: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Correlation ID: ${envelope.correlation_id}`);
|
||||||
|
console.log(`Message ID: ${envelope.msg_id}`);
|
||||||
|
console.log(`Timestamp: ${envelope.timestamp}`);
|
||||||
|
console.log(`Purpose: ${envelope.msg_purpose}`);
|
||||||
|
console.log(`Sender: ${envelope.sender_name}`);
|
||||||
|
console.log(`Number of payloads: ${envelope.payloads.length}`);
|
||||||
|
|
||||||
|
receivedPayloads.push(envelope);
|
||||||
|
|
||||||
|
// Validate envelope structure
|
||||||
|
console.log('\n=== Envelope Validation ===');
|
||||||
|
|
||||||
|
if (envelope.payloads.length < 1) {
|
||||||
|
console.log(`❌ Expected at least 1 payload, got ${envelope.payloads.length}`);
|
||||||
|
testPassed = false;
|
||||||
|
} else {
|
||||||
|
console.log(`✅ Correct number of payloads: ${envelope.payloads.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process all payloads in the envelope
|
||||||
|
console.log('\n=== Processing Payloads ===');
|
||||||
|
for (let i = 0; i < envelope.payloads.length; i++) {
|
||||||
|
const [dataname, data, dataType] = envelope.payloads[i];
|
||||||
|
|
||||||
|
console.log(`\n--- Payload ${i + 1}: ${dataname} (type: ${dataType}) ---`);
|
||||||
|
|
||||||
|
// Validate data based on type
|
||||||
|
if (dataType === 'text') {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
console.log(`✅ Text data received (${data.length} chars)`);
|
||||||
|
console.log(` First 200 chars: "${data.substring(0, 200)}${data.length > 200 ? '...' : ''}"`);
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
const outputPath = `./received_${dataname}.txt`;
|
||||||
|
require('fs').writeFileSync(outputPath, data);
|
||||||
|
console.log(` Saved to: ${outputPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Text data is not a string, got: ${typeof data}`);
|
||||||
|
testPassed = false;
|
||||||
|
}
|
||||||
|
} else if (dataType === 'dictionary') {
|
||||||
|
if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
|
||||||
|
console.log(`✅ Dictionary data received`);
|
||||||
|
console.log(` Keys: ${Object.keys(data).join(', ')}`);
|
||||||
|
|
||||||
|
// Save to JSON file
|
||||||
|
const outputPath = `./received_${dataname}.json`;
|
||||||
|
require('fs').writeFileSync(outputPath, JSON.stringify(data, null, 2));
|
||||||
|
console.log(` Saved to: ${outputPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Dictionary data is not an object, got: ${typeof data}`);
|
||||||
|
testPassed = false;
|
||||||
|
}
|
||||||
|
} else if (dataType === 'arrowtable') {
|
||||||
|
// Arrow tables have numRows and numCols properties
|
||||||
|
if (data && typeof data === 'object' &&
|
||||||
|
(data.numRows !== undefined || data.numRows !== null) &&
|
||||||
|
(data.numCols !== undefined || data.numCols !== null)) {
|
||||||
|
console.log(`✅ Arrow table data received`);
|
||||||
|
console.log(` Rows: ${data.numRows}, Columns: ${data.numCols}`);
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
const outputPath = `./received_${dataname}.arrow`;
|
||||||
|
// Note: Actual Arrow IPC serialization would require apache-arrow library
|
||||||
|
console.log(` Saved to: ${outputPath}`);
|
||||||
|
} else if (data && typeof data === 'object') {
|
||||||
|
// Some Arrow implementations may have different properties
|
||||||
|
console.log(`✅ Arrow table data received (non-standard format)`);
|
||||||
|
console.log(` Keys: ${Object.keys(data).join(', ')}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Arrow table data is not a valid object, got: ${typeof data}`);
|
||||||
|
testPassed = false;
|
||||||
|
}
|
||||||
|
} else if (dataType === 'jsontable') {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
console.log(`✅ JSON table data received`);
|
||||||
|
console.log(` Rows: ${data.length}`);
|
||||||
|
if (data.length > 0) {
|
||||||
|
console.log(` Columns: ${Object.keys(data[0]).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to JSON file
|
||||||
|
const outputPath = `./received_${dataname}.json`;
|
||||||
|
require('fs').writeFileSync(outputPath, JSON.stringify(data, null, 2));
|
||||||
|
console.log(` Saved to: ${outputPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ JSON table data is not an array, got: ${typeof data}`);
|
||||||
|
testPassed = false;
|
||||||
|
}
|
||||||
|
} else if (dataType === 'image') {
|
||||||
|
if (data instanceof Buffer || data instanceof Uint8Array) {
|
||||||
|
const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
console.log(`✅ Image data received (${dataBuffer.length} bytes)`);
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
const outputPath = `./received_${dataname}.bin`;
|
||||||
|
require('fs').writeFileSync(outputPath, dataBuffer);
|
||||||
|
console.log(` Saved to: ${outputPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Image data is not a Buffer or Uint8Array, got: ${typeof data}`);
|
||||||
|
testPassed = false;
|
||||||
|
}
|
||||||
|
} else if (dataType === 'audio') {
|
||||||
|
if (data instanceof Buffer || data instanceof Uint8Array) {
|
||||||
|
const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
console.log(`✅ Audio data received (${dataBuffer.length} bytes)`);
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
const outputPath = `./received_${dataname}.bin`;
|
||||||
|
require('fs').writeFileSync(outputPath, dataBuffer);
|
||||||
|
console.log(` Saved to: ${outputPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Audio data is not a Buffer or Uint8Array, got: ${typeof data}`);
|
||||||
|
testPassed = false;
|
||||||
|
}
|
||||||
|
} else if (dataType === 'video') {
|
||||||
|
if (data instanceof Buffer || data instanceof Uint8Array) {
|
||||||
|
const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
console.log(`✅ Video data received (${dataBuffer.length} bytes)`);
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
const outputPath = `./received_${dataname}.bin`;
|
||||||
|
require('fs').writeFileSync(outputPath, dataBuffer);
|
||||||
|
console.log(` Saved to: ${outputPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Video data is not a Buffer or Uint8Array, got: ${typeof data}`);
|
||||||
|
testPassed = false;
|
||||||
|
}
|
||||||
|
} else if (dataType === 'binary') {
|
||||||
|
if (data instanceof Buffer || data instanceof Uint8Array) {
|
||||||
|
const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
console.log(`✅ Binary data received (${dataBuffer.length} bytes)`);
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
const outputPath = `./received_${dataname}`;
|
||||||
|
require('fs').writeFileSync(outputPath, dataBuffer);
|
||||||
|
console.log(` Saved to: ${outputPath}`);
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Binary data is not a Buffer or Uint8Array, got: ${typeof data}`);
|
||||||
|
testPassed = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`❌ Unknown data type: ${dataType}`);
|
||||||
|
testPassed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
console.log('\n=== Verification Summary ===');
|
||||||
|
const textCount = envelope.payloads.filter(p => p[2] === 'text').length;
|
||||||
|
const dictCount = envelope.payloads.filter(p => p[2] === 'dictionary').length;
|
||||||
|
const arrowtableCount = envelope.payloads.filter(p => p[2] === 'arrowtable').length;
|
||||||
|
const jsontableCount = envelope.payloads.filter(p => p[2] === 'jsontable').length;
|
||||||
|
const imageCount = envelope.payloads.filter(p => p[2] === 'image').length;
|
||||||
|
const audioCount = envelope.payloads.filter(p => p[2] === 'audio').length;
|
||||||
|
const videoCount = envelope.payloads.filter(p => p[2] === 'video').length;
|
||||||
|
const binaryCount = envelope.payloads.filter(p => p[2] === 'binary').length;
|
||||||
|
|
||||||
|
console.log(`Text payloads: ${textCount}`);
|
||||||
|
console.log(`Dictionary payloads: ${dictCount}`);
|
||||||
|
console.log(`Arrow table payloads: ${arrowtableCount}`);
|
||||||
|
console.log(`JSON table payloads: ${jsontableCount}`);
|
||||||
|
console.log(`Image payloads: ${imageCount}`);
|
||||||
|
console.log(`Audio payloads: ${audioCount}`);
|
||||||
|
console.log(`Video payloads: ${videoCount}`);
|
||||||
|
console.log(`Binary payloads: ${binaryCount}`);
|
||||||
|
|
||||||
|
// Stop after receiving at least one valid message
|
||||||
|
if (messagesReceived >= 1) {
|
||||||
|
resolve('done');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error processing message: ${error.message}`);
|
||||||
|
console.error(error.stack);
|
||||||
|
testPassed = false;
|
||||||
|
resolve('error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Waiting for messages...\n');
|
||||||
|
|
||||||
|
// Wait for message or timeout
|
||||||
|
const result = await messagePromise;
|
||||||
|
|
||||||
|
// Close NATS connection
|
||||||
|
await nc.close();
|
||||||
|
console.log('\n✅ NATS connection closed');
|
||||||
|
|
||||||
|
// Final result
|
||||||
|
console.log('\n=== Test Result ===');
|
||||||
|
if (messagesReceived === 0) {
|
||||||
|
console.log('❌ NO MESSAGES RECEIVED');
|
||||||
|
console.log('Make sure to run the sender test first: node test/test_js_mix_payloads_sender.js');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (result === 'error') {
|
||||||
|
console.log('❌ ERROR PROCESSING MESSAGES');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (testPassed) {
|
||||||
|
console.log('✅ ALL TESTS PASSED');
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.log('❌ SOME TESTS FAILED');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Test failed with error:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest();
|
||||||
207
test/test_js_mix_payloads_sender.js
Normal file
207
test/test_js_mix_payloads_sender.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* JavaScript Mix Payloads Sender Test
|
||||||
|
* Tests the smartsend function with mixed payload types
|
||||||
|
*
|
||||||
|
* This test mirrors test_julia_mix_payloads_sender.jl and demonstrates that
|
||||||
|
* any combination and any number of mixed content can be sent correctly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NATSBridge = require('../src/natsbridge.js');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TEST_SUBJECT = '/natsbridge';
|
||||||
|
const TEST_BROKER_URL = process.env.NATS_URL || 'nats.yiem.cc';
|
||||||
|
const TEST_FILESERVER_URL = process.env.FILESERVER_URL || 'http://192.168.88.104:8080';
|
||||||
|
const SIZE_THRESHOLD = 1_000_000; // 1MB threshold
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
console.log('=== JavaScript Mix Payloads Sender Test ===\n');
|
||||||
|
|
||||||
|
const correlationId = crypto.randomUUID();
|
||||||
|
console.log(`Correlation ID: ${correlationId}`);
|
||||||
|
console.log(`Subject: ${TEST_SUBJECT}`);
|
||||||
|
console.log(`Broker URL: ${TEST_BROKER_URL}`);
|
||||||
|
console.log(`Fileserver URL: ${TEST_FILESERVER_URL}`);
|
||||||
|
console.log(`Size Threshold: ${SIZE_THRESHOLD} bytes (1MB)\n`);
|
||||||
|
|
||||||
|
// Helper: Log with correlation ID
|
||||||
|
function logTrace(message) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
console.log(`[${timestamp}] [Correlation: ${correlationId}] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sample data for each type (mirroring Julia test)
|
||||||
|
const textData = 'Hello! This is a test chat message. 🎉\nHow are you doing today? 😊';
|
||||||
|
|
||||||
|
const dictData = {
|
||||||
|
type: 'chat',
|
||||||
|
sender: 'serviceA',
|
||||||
|
receiver: 'serviceB',
|
||||||
|
metadata: {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
priority: 'high',
|
||||||
|
tags: ['urgent', 'chat', 'test']
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
text: 'This is a JSON-formatted chat message with nested structure.',
|
||||||
|
format: 'markdown',
|
||||||
|
mentions: ['user1', 'user2']
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Arrow table data (small - direct transport)
|
||||||
|
const arrowTableSmall = [
|
||||||
|
{ id: 1, name: 'Alice', score: 95, active: true },
|
||||||
|
{ id: 2, name: 'Bob', score: 88, active: false },
|
||||||
|
{ id: 3, name: 'Charlie', score: 92, active: true },
|
||||||
|
{ id: 4, name: 'Diana', score: 78, active: true },
|
||||||
|
{ id: 5, name: 'Eve', score: 85, active: false },
|
||||||
|
{ id: 6, name: 'Frank', score: 91, active: true },
|
||||||
|
{ id: 7, name: 'Grace', score: 89, active: true },
|
||||||
|
{ id: 8, name: 'Henry', score: 76, active: false },
|
||||||
|
{ id: 9, name: 'Ivy', score: 94, active: true },
|
||||||
|
{ id: 10, name: 'Jack', score: 82, active: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Json table data (small - direct transport)
|
||||||
|
const jsonTableSmall = [
|
||||||
|
{ id: 1, name: 'Alice', score: 95, active: true },
|
||||||
|
{ id: 2, name: 'Bob', score: 88, active: false },
|
||||||
|
{ id: 3, name: 'Charlie', score: 92, active: true },
|
||||||
|
{ id: 4, name: 'Diana', score: 78, active: true },
|
||||||
|
{ id: 5, name: 'Eve', score: 85, active: false },
|
||||||
|
{ id: 6, name: 'Frank', score: 91, active: true },
|
||||||
|
{ id: 7, name: 'Grace', score: 89, active: true },
|
||||||
|
{ id: 8, name: 'Henry', score: 76, active: false },
|
||||||
|
{ id: 9, name: 'Ivy', score: 94, active: true },
|
||||||
|
{ id: 10, name: 'Jack', score: 82, active: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Audio data (small binary - direct transport)
|
||||||
|
const audioData = Buffer.alloc(100);
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
audioData[i] = Math.floor(Math.random() * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video data (small binary - direct transport)
|
||||||
|
const videoData = Buffer.alloc(150);
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
videoData[i] = Math.floor(Math.random() * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binary data (small - direct transport)
|
||||||
|
const binaryData = Buffer.alloc(200);
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
binaryData[i] = Math.floor(Math.random() * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large data for link transport testing
|
||||||
|
const largeArrowTable = [];
|
||||||
|
for (let i = 1; i <= 20000; i++) {
|
||||||
|
largeArrowTable.push({
|
||||||
|
id: i,
|
||||||
|
name: `user_${i}`,
|
||||||
|
score: Math.floor(Math.random() * 51) + 50,
|
||||||
|
active: Math.random() > 0.5,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeJsonTable = [];
|
||||||
|
for (let i = 1; i <= 50000; i++) {
|
||||||
|
largeJsonTable.push({
|
||||||
|
id: i,
|
||||||
|
name: `user_${i}`,
|
||||||
|
score: Math.floor(Math.random() * 51) + 50,
|
||||||
|
active: Math.random() > 0.5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeAudioData = Buffer.alloc(1_500_000);
|
||||||
|
for (let i = 0; i < 1_500_000; i++) {
|
||||||
|
largeAudioData[i] = Math.floor(Math.random() * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeVideoData = Buffer.alloc(1_500_000);
|
||||||
|
for (let i = 0; i < 1_500_000; i++) {
|
||||||
|
largeVideoData[i] = Math.floor(Math.random() * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
const largeBinaryData = Buffer.alloc(1_500_000);
|
||||||
|
for (let i = 0; i < 1_500_000; i++) {
|
||||||
|
largeBinaryData[i] = Math.floor(Math.random() * 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read image files from disk (following Julia test pattern)
|
||||||
|
const file_path_small_image = path.join(__dirname, 'small_image.jpg');
|
||||||
|
const file_data_small_image = fs.readFileSync(file_path_small_image);
|
||||||
|
const filename_small_image = path.basename(file_path_small_image);
|
||||||
|
|
||||||
|
const file_path_large_image = path.join(__dirname, 'large_image.png');
|
||||||
|
const file_data_large_image = fs.readFileSync(file_path_large_image);
|
||||||
|
const filename_large_image = path.basename(file_path_large_image);
|
||||||
|
|
||||||
|
logTrace('Creating payloads list with mixed content');
|
||||||
|
|
||||||
|
// Create payloads list - mixed content with both small and large data
|
||||||
|
// Small data uses direct transport, large data uses link transport
|
||||||
|
const payloads = [
|
||||||
|
// Small data (direct transport) - text, dictionary, arrowtable, jsontable, small image
|
||||||
|
['chat_text', textData, 'text'],
|
||||||
|
['chat_json', dictData, 'dictionary'],
|
||||||
|
// ['arrow_table_small', arrowTableSmall, 'arrowtable'],
|
||||||
|
['json_table_small', jsonTableSmall, 'jsontable'],
|
||||||
|
[filename_small_image, file_data_small_image, 'binary'],
|
||||||
|
|
||||||
|
// Large data (link transport) - large arrowtable, large jsontable, large image, large audio, large video, large binary
|
||||||
|
// ['arrow_table_large', largeArrowTable, 'arrowtable'],
|
||||||
|
['json_table_large', largeJsonTable, 'jsontable'],
|
||||||
|
[filename_large_image, file_data_large_image, 'binary'],
|
||||||
|
// ['audio_clip_large', largeAudioData, 'audio'],
|
||||||
|
// ['video_clip_large', largeVideoData, 'video'],
|
||||||
|
// ['binary_file_large', largeBinaryData, 'binary']
|
||||||
|
];
|
||||||
|
|
||||||
|
logTrace(`Total payloads: ${payloads.length}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send the message
|
||||||
|
console.log('Sending mixed payloads...\n');
|
||||||
|
const [env, envJsonStr] = await NATSBridge.smartsend(
|
||||||
|
TEST_SUBJECT,
|
||||||
|
payloads,
|
||||||
|
{
|
||||||
|
broker_url: TEST_BROKER_URL,
|
||||||
|
fileserver_url: TEST_FILESERVER_URL,
|
||||||
|
fileserver_upload_handler: NATSBridge.plikOneshotUpload,
|
||||||
|
size_threshold: SIZE_THRESHOLD,
|
||||||
|
correlation_id: correlationId,
|
||||||
|
msg_purpose: 'chat',
|
||||||
|
sender_name: 'js-mix-test',
|
||||||
|
receiver_name: '',
|
||||||
|
receiver_id: '',
|
||||||
|
reply_to: '',
|
||||||
|
reply_to_msg_id: '',
|
||||||
|
is_publish: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\n=== Envelope Created ===');
|
||||||
|
console.log(`Correlation ID: ${env.correlation_id}`);
|
||||||
|
console.log(`Message ID: ${env.msg_id}`);
|
||||||
|
console.log(`Timestamp: ${env.timestamp}`);
|
||||||
|
console.log(`Subject: ${env.send_to}`);
|
||||||
|
console.log(`Purpose: ${env.msg_purpose}`);
|
||||||
|
console.log(`Sender: ${env.sender_name}`);
|
||||||
|
console.log(`Payloads: ${env.payloads.length}\n`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Test failed with error:', error.message);
|
||||||
|
console.error(error.stack);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runTest();
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
#!/usr/bin/env julia
|
|
||||||
# Test script for large payload testing using binary transport
|
|
||||||
# Tests sending a large file (> 1MB) via smartsend with binary type
|
|
||||||
|
|
||||||
using NATS, JSON, UUIDs, Dates
|
|
||||||
|
|
||||||
# Include the bridge module
|
|
||||||
include("../src/NATSBridge.jl")
|
|
||||||
using .NATSBridge
|
|
||||||
|
|
||||||
# Configuration
|
|
||||||
const SUBJECT = "/large_binary_test"
|
|
||||||
const NATS_URL = "nats.yiem.cc"
|
|
||||||
const FILESERVER_URL = "http://192.168.88.104:8080"
|
|
||||||
|
|
||||||
# Create correlation ID for tracing
|
|
||||||
correlation_id = string(uuid4())
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------------------------ #
|
|
||||||
# test file transfer #
|
|
||||||
# ------------------------------------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
# File path for large binary payload test
|
|
||||||
const FILE_PATH = "./testFile_small.zip"
|
|
||||||
const filename = basename(FILE_PATH)
|
|
||||||
|
|
||||||
# Helper: Log with correlation ID
|
|
||||||
function log_trace(message)
|
|
||||||
timestamp = Dates.now()
|
|
||||||
println("[$timestamp] [Correlation: $correlation_id] $message")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Sender: Send large binary file via smartsend
|
|
||||||
function test_large_binary_send()
|
|
||||||
conn = NATS.connect(NATS_URL)
|
|
||||||
# Read the large file as binary data
|
|
||||||
log_trace("Reading large file: $FILE_PATH")
|
|
||||||
file_data = read(FILE_PATH)
|
|
||||||
|
|
||||||
file_size = length(file_data)
|
|
||||||
log_trace("File size: $file_size bytes")
|
|
||||||
|
|
||||||
# Use smartsend with binary type - will automatically use link transport
|
|
||||||
# if file size exceeds the threshold (1MB by default)
|
|
||||||
env = NATSBridge.smartsend(
|
|
||||||
SUBJECT,
|
|
||||||
file_data,
|
|
||||||
"binary",
|
|
||||||
nats_url = NATS_URL,
|
|
||||||
fileserver_url = FILESERVER_URL;
|
|
||||||
dataname=filename
|
|
||||||
)
|
|
||||||
|
|
||||||
log_trace("Sent message with transport: $(env.transport)")
|
|
||||||
log_trace("Envelope type: $(env.type)")
|
|
||||||
|
|
||||||
# Check if link transport was used
|
|
||||||
if env.transport == "link"
|
|
||||||
log_trace("Using link transport - file uploaded to HTTP server")
|
|
||||||
log_trace("URL: $(env.url)")
|
|
||||||
else
|
|
||||||
log_trace("Using direct transport - payload sent via NATS")
|
|
||||||
end
|
|
||||||
|
|
||||||
NATS.drain(conn)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Receiver: Listen for messages and verify large payload handling
|
|
||||||
function test_large_binary_receive()
|
|
||||||
conn = NATS.connect(NATS_URL)
|
|
||||||
NATS.subscribe(conn, SUBJECT) do msg
|
|
||||||
log_trace("Received message on $(msg.subject)")
|
|
||||||
|
|
||||||
# Use NATSBridge.smartreceive to handle the data
|
|
||||||
result = NATSBridge.smartreceive(msg)
|
|
||||||
# Check transport type
|
|
||||||
if result.envelope.transport == "direct"
|
|
||||||
log_trace("Received direct transport")
|
|
||||||
else
|
|
||||||
# For link transport, result.data is the URL
|
|
||||||
log_trace("Received link transport")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verify the received data matches the original
|
|
||||||
if result.envelope.type == "binary"
|
|
||||||
if isa(result.data, Vector{UInt8})
|
|
||||||
file_size = length(result.data)
|
|
||||||
log_trace("Received $(file_size) bytes of binary data")
|
|
||||||
|
|
||||||
# Save received data to a test file
|
|
||||||
println("metadata ", result.envelope.metadata)
|
|
||||||
dataname = result.envelope.metadata["dataname"]
|
|
||||||
if dataname != "NA"
|
|
||||||
output_path = "./new_$dataname"
|
|
||||||
write(output_path, result.data)
|
|
||||||
log_trace("Saved received data to $output_path")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Verify file size
|
|
||||||
original_size = length(read(FILE_PATH))
|
|
||||||
if file_size == result.envelope.metadata["content_length"]
|
|
||||||
log_trace("SUCCESS: File size matches! Original: $(result.envelope.metadata["content_length"]) bytes")
|
|
||||||
else
|
|
||||||
log_trace("WARNING: File size mismatch! Original: $(result.envelope.metadata["content_length"]), Received: $file_size")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Keep listening for 10 seconds
|
|
||||||
sleep(120)
|
|
||||||
NATS.drain(conn)
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
# Run the test
|
|
||||||
println("Starting large binary payload test...")
|
|
||||||
println("Correlation ID: $correlation_id")
|
|
||||||
println("File: $FILE_PATH")
|
|
||||||
|
|
||||||
# Run sender first
|
|
||||||
println("start smartsend")
|
|
||||||
test_large_binary_send()
|
|
||||||
|
|
||||||
# # Run receiver
|
|
||||||
# println("testing smartreceive")
|
|
||||||
# test_large_binary_receive()
|
|
||||||
|
|
||||||
println("Test completed.")
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
251
test/test_julia_mix_payloads_receiver.jl
Normal file
251
test/test_julia_mix_payloads_receiver.jl
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
#!/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 NATSBridge.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/NATSBridge.jl")
|
||||||
|
using .NATSBridge
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
const SUBJECT = "/natsbridge"
|
||||||
|
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)
|
||||||
|
NATS.subscribe(conn, SUBJECT) do msg
|
||||||
|
log_trace("Received message on $(msg.subject)")
|
||||||
|
|
||||||
|
# Use NATSBridge.smartreceive to handle the data
|
||||||
|
# API: smartreceive(msg, download_handler; max_retries, base_delay, max_delay)
|
||||||
|
result = NATSBridge.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 == "arrowtable"
|
||||||
|
# Arrow table data - should be Arrow.Table
|
||||||
|
if isa(data, Arrow.Table)
|
||||||
|
log_trace(" Type: Arrow.Table")
|
||||||
|
|
||||||
|
# Convert to DataFrame for display and save
|
||||||
|
df = DataFrame(data)
|
||||||
|
@show df[1:3, :]
|
||||||
|
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 Arrow.Table, got $(typeof(data))")
|
||||||
|
end
|
||||||
|
|
||||||
|
elseif data_type == "jsontable"
|
||||||
|
# JSON table data - should be Vector{Dict} or Vector{NamedTuple}
|
||||||
|
@show "jsontable" typeof(data)
|
||||||
|
if isa(data, Vector{Any})
|
||||||
|
log_trace(" Type: Vector{Dict/NamedTuple}")
|
||||||
|
|
||||||
|
# Convert to DataFrame for display and save
|
||||||
|
df = DataFrame(data)
|
||||||
|
@show df[1:3, :]
|
||||||
|
log_trace(" Converted to DataFrame: $(size(df, 1)) rows x $(size(df, 2)) columns")
|
||||||
|
|
||||||
|
# Save as 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 Vector{Dict/NamedTuple}, 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"
|
||||||
|
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"])
|
||||||
|
arrowtable_count = count(x -> x[3] == "arrowtable", result["payloads"])
|
||||||
|
jsontable_count = count(x -> x[3] == "jsontable", result["payloads"])
|
||||||
|
table_count = count(x -> x[3] == "table", result["payloads"]) # backward compatibility
|
||||||
|
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("Arrow table payloads: $arrowtable_count")
|
||||||
|
log_trace("JSON table payloads: $jsontable_count")
|
||||||
|
log_trace("Table payloads (backward compat): $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 == "arrowtable"
|
||||||
|
# log_trace("$dataname: $(size(data, 1)) rows x $(size(data, 2)) columns (Arrow.Table)")
|
||||||
|
elseif data_type == "jsontable"
|
||||||
|
log_trace("$dataname: $(length(data)) rows (Vector{Dict/NamedTuple})")
|
||||||
|
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(180)
|
||||||
|
NATS.drain(conn)
|
||||||
|
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")
|
||||||
|
test_mix_receive()
|
||||||
|
|
||||||
|
println("\nTest completed.")
|
||||||
258
test/test_julia_mix_payloads_sender.jl
Normal file
258
test/test_julia_mix_payloads_sender.jl
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
#!/usr/bin/env julia
|
||||||
|
# Test script for mixed-content message testing
|
||||||
|
# Tests sending a mix of text, dictionary, arrowtable, jsontable, image, audio, video, and binary data
|
||||||
|
# from Julia serviceA to Julia serviceB using NATSBridge.jl smartsend
|
||||||
|
#
|
||||||
|
# This test demonstrates that any combination and any number of mixed content
|
||||||
|
# can be sent and received correctly.
|
||||||
|
#
|
||||||
|
# Key concept: DataFrames are the main table representation in Julia.
|
||||||
|
# The NATSBridge.jl library handles serialization:
|
||||||
|
# - For "arrowtable" type: DataFrame is serialized to Arrow IPC format
|
||||||
|
# - For "jsontable" type: DataFrame is converted to Vector{Dict} and then to JSON
|
||||||
|
|
||||||
|
using NATS, JSON, UUIDs, Dates, PrettyPrinting, DataFrames, Arrow, HTTP, Base64
|
||||||
|
|
||||||
|
# Include the bridge module
|
||||||
|
include("../src/NATSBridge.jl")
|
||||||
|
using .NATSBridge
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
const SUBJECT = "/natsbridge"
|
||||||
|
const NATS_URL = "nats.yiem.cc"
|
||||||
|
const FILESERVER_URL = "http://192.168.88.104:8080"
|
||||||
|
|
||||||
|
# Create correlation ID for tracing
|
||||||
|
correlation_id = string(uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------------------------ #
|
||||||
|
# test mixed content transfer #
|
||||||
|
# ------------------------------------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
|
# Helper: Log with correlation ID
|
||||||
|
function log_trace(message)
|
||||||
|
timestamp = Dates.now()
|
||||||
|
println("[$timestamp] [Correlation: $correlation_id] $message")
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
# File upload handler for plik server
|
||||||
|
function plik_upload_handler(fileserver_url::String, dataname::String, data::Vector{UInt8})::Dict{String, Any}
|
||||||
|
# Get upload ID
|
||||||
|
url_getUploadID = "$fileserver_url/upload"
|
||||||
|
headers = ["Content-Type" => "application/json"]
|
||||||
|
body = """{ "OneShot" : true }"""
|
||||||
|
httpResponse = HTTP.request("POST", url_getUploadID, headers, body; body_is_form=false)
|
||||||
|
responseJson = JSON.parse(String(httpResponse.body))
|
||||||
|
uploadid = responseJson["id"]
|
||||||
|
uploadtoken = responseJson["uploadToken"]
|
||||||
|
|
||||||
|
# Upload file
|
||||||
|
file_multipart = HTTP.Multipart(dataname, IOBuffer(data), "application/octet-stream")
|
||||||
|
url_upload = "$fileserver_url/file/$uploadid"
|
||||||
|
headers = ["X-UploadToken" => uploadtoken]
|
||||||
|
|
||||||
|
form = HTTP.Form(Dict("file" => file_multipart))
|
||||||
|
httpResponse = HTTP.post(url_upload, headers, form)
|
||||||
|
responseJson = JSON.parse(String(httpResponse.body))
|
||||||
|
|
||||||
|
fileid = responseJson["id"]
|
||||||
|
url = "$fileserver_url/file/$uploadid/$fileid/$dataname"
|
||||||
|
|
||||||
|
return Dict("status" => httpResponse.status, "uploadid" => uploadid, "fileid" => fileid, "url" => url)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
# Helper: Create sample data for each type
|
||||||
|
function create_sample_data()
|
||||||
|
# Text data (small - direct transport)
|
||||||
|
text_data = "Hello! This is a test chat message. 🎉\nHow are you doing today? 😊"
|
||||||
|
|
||||||
|
# Dictionary/JSON data (medium - could be direct or link)
|
||||||
|
dict_data = Dict(
|
||||||
|
"type" => "chat",
|
||||||
|
"sender" => "serviceA",
|
||||||
|
"receiver" => "serviceB",
|
||||||
|
"metadata" => Dict(
|
||||||
|
"timestamp" => string(Dates.now()),
|
||||||
|
"priority" => "high",
|
||||||
|
"tags" => ["urgent", "chat", "test"]
|
||||||
|
),
|
||||||
|
"content" => Dict(
|
||||||
|
"text" => "This is a JSON-formatted chat message with nested structure.",
|
||||||
|
"format" => "markdown",
|
||||||
|
"mentions" => ["user1", "user2"]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Arrow table data (DataFrame - small - direct transport)
|
||||||
|
# Uses Arrow IPC format for efficient binary serialization
|
||||||
|
# NATSBridge.jl handles serialization: DataFrame -> Arrow IPC
|
||||||
|
arrow_table_small = DataFrame(
|
||||||
|
id = 1:10,
|
||||||
|
name = ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank", "Grace", "Henry", "Ivy", "Jack"],
|
||||||
|
score = rand(50:100, 10),
|
||||||
|
active = rand([true, false], 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Arrow table data (DataFrame - large - link transport)
|
||||||
|
# ~1.5MB of Arrow data (200,000 rows) - should trigger link transport
|
||||||
|
# NATSBridge.jl handles serialization: DataFrame -> Arrow IPC
|
||||||
|
arrow_table_large = DataFrame(
|
||||||
|
id = 1:2_000_000,
|
||||||
|
name = ["user_$i" for i in 1:2_000_000],
|
||||||
|
score = rand(50:100, 2_000_000),
|
||||||
|
active = rand([true, false], 2_000_000),
|
||||||
|
timestamp = [string(Dates.now()) for _ in 1:2_000_000]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Json table data (DataFrame - small - direct transport)
|
||||||
|
# Uses JSON format for human-readable tabular data
|
||||||
|
# NATSBridge.jl handles serialization: DataFrame -> Vector{Dict} -> JSON
|
||||||
|
json_table_small = DataFrame(
|
||||||
|
id = 1:10,
|
||||||
|
name = ["Alice", "Bob", "Charlie", "Diana", "Eve", "Frank", "Grace", "Henry", "Ivy", "Jack"],
|
||||||
|
score = rand(50:100, 10),
|
||||||
|
active = rand([true, false], 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Json table data (DataFrame - large - link transport)
|
||||||
|
# ~1.5MB of JSON data (150,000 rows) - should trigger link transport
|
||||||
|
# NATSBridge.jl handles serialization: DataFrame -> Vector{Dict} -> JSON
|
||||||
|
json_table_large = DataFrame(
|
||||||
|
id = 1:2_000_000,
|
||||||
|
name = ["user_$i" for i in 1:2_000_000],
|
||||||
|
score = rand(50:100, 2_000_000),
|
||||||
|
active = rand([true, false], 2_000_000)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audio data (small binary - direct transport)
|
||||||
|
audio_data = UInt8[rand(1:255) for _ in 1:100]
|
||||||
|
|
||||||
|
# Audio data (large - link transport)
|
||||||
|
# ~1.5MB of audio-like data
|
||||||
|
large_audio_data = UInt8[rand(1:255) for _ in 1:1_500_000]
|
||||||
|
|
||||||
|
# Video data (small binary - direct transport)
|
||||||
|
video_data = UInt8[rand(1:255) for _ in 1:150]
|
||||||
|
|
||||||
|
# Video data (large - link transport)
|
||||||
|
# ~1.5MB of video-like data
|
||||||
|
large_video_data = UInt8[rand(1:255) for _ in 1:1_500_000]
|
||||||
|
|
||||||
|
# Binary data (small - direct transport)
|
||||||
|
binary_data = UInt8[rand(1:255) for _ in 1:200]
|
||||||
|
|
||||||
|
# Binary data (large - link transport)
|
||||||
|
# ~1.5MB of binary data
|
||||||
|
large_binary_data = UInt8[rand(1:255) for _ in 1:1_500_000]
|
||||||
|
|
||||||
|
return (
|
||||||
|
text_data,
|
||||||
|
dict_data,
|
||||||
|
arrow_table_small,
|
||||||
|
arrow_table_large,
|
||||||
|
json_table_small,
|
||||||
|
json_table_large,
|
||||||
|
audio_data,
|
||||||
|
large_audio_data,
|
||||||
|
video_data,
|
||||||
|
large_video_data,
|
||||||
|
binary_data,
|
||||||
|
large_binary_data
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
# Sender: Send mixed content via smartsend
|
||||||
|
function test_mix_send()
|
||||||
|
# Create sample data
|
||||||
|
(text_data, dict_data, arrow_table_small, arrow_table_large, json_table_small, json_table_large, audio_data, large_audio_data, video_data, large_video_data, binary_data, large_binary_data) = create_sample_data()
|
||||||
|
|
||||||
|
# Read image files from disk (following test_julia_file_sender.jl pattern)
|
||||||
|
# Small image - should use direct transport
|
||||||
|
file_path_small_image = "./test/small_image.jpg"
|
||||||
|
file_data_small_image = read(file_path_small_image)
|
||||||
|
filename_small_image = basename(file_path_small_image)
|
||||||
|
|
||||||
|
# Large image - should use link transport
|
||||||
|
file_path_large_image = "./test/large_image.png"
|
||||||
|
file_data_large_image = read(file_path_large_image)
|
||||||
|
filename_large_image = basename(file_path_large_image)
|
||||||
|
|
||||||
|
# Create payloads list - mixed content with both small and large data
|
||||||
|
# Small data uses direct transport, large data uses link transport
|
||||||
|
# Key: Pass DataFrame directly and specify type as "arrowtable" or "jsontable"
|
||||||
|
# NATSBridge.jl handles the serialization internally
|
||||||
|
payloads = [
|
||||||
|
# Small data (direct transport) - text, dictionary, arrowtable, jsontable, small image
|
||||||
|
("chat_text", text_data, "text"),
|
||||||
|
("chat_json", dict_data, "dictionary"),
|
||||||
|
# ("arrow_table_small", arrow_table_small, "arrowtable"),
|
||||||
|
("json_table_small", json_table_small, "jsontable"),
|
||||||
|
(filename_small_image, file_data_small_image, "binary"),
|
||||||
|
|
||||||
|
# Large data (link transport) - large arrowtable, large jsontable, large image, large audio, large video, large binary
|
||||||
|
# ("arrow_table_large", arrow_table_large, "arrowtable"),
|
||||||
|
("json_table_large", json_table_large, "jsontable"),
|
||||||
|
(filename_large_image, file_data_large_image, "binary"),
|
||||||
|
("audio_clip_large", large_audio_data, "audio"),
|
||||||
|
("video_clip_large", large_video_data, "video"),
|
||||||
|
("binary_file_large", large_binary_data, "binary")
|
||||||
|
]
|
||||||
|
|
||||||
|
# Use smartsend with mixed content
|
||||||
|
sendinfo = NATSBridge.smartsend(
|
||||||
|
SUBJECT,
|
||||||
|
payloads; # List of (dataname, data, type) tuples
|
||||||
|
broker_url = NATS_URL,
|
||||||
|
fileserver_url = FILESERVER_URL,
|
||||||
|
fileserver_upload_handler = plik_upload_handler,
|
||||||
|
size_threshold = 1_000_000, # 1MB threshold
|
||||||
|
correlation_id = correlation_id,
|
||||||
|
msg_purpose = "chat",
|
||||||
|
sender_name = "mix_sender",
|
||||||
|
receiver_name = "",
|
||||||
|
receiver_id = "",
|
||||||
|
reply_to = "",
|
||||||
|
reply_to_msg_id = "",
|
||||||
|
is_publish = true # Publish the message to NATS
|
||||||
|
)
|
||||||
|
|
||||||
|
env, env_json_str = sendinfo
|
||||||
|
log_trace("Sent message with $(length(env.payloads)) payloads")
|
||||||
|
|
||||||
|
# Log transport type for each payload
|
||||||
|
for (i, payload) in enumerate(env.payloads)
|
||||||
|
log_trace("Payload $i ('$payload.dataname'):")
|
||||||
|
log_trace(" Transport: $(payload.transport)")
|
||||||
|
log_trace(" Type: $(payload.payload_type)")
|
||||||
|
log_trace(" Size: $(payload.size) bytes")
|
||||||
|
log_trace(" Encoding: $(payload.encoding)")
|
||||||
|
|
||||||
|
if payload.transport == "link"
|
||||||
|
log_trace(" URL: $(payload.data)")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
println("\n--- Transport Summary ---")
|
||||||
|
direct_count = count(p -> p.transport == "direct", env.payloads)
|
||||||
|
link_count = count(p -> p.transport == "link", env.payloads)
|
||||||
|
log_trace("Direct transport: $direct_count payloads")
|
||||||
|
log_trace("Link transport: $link_count payloads")
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
# Run the test
|
||||||
|
println("Starting mixed-content transport test...")
|
||||||
|
println("Correlation ID: $correlation_id")
|
||||||
|
|
||||||
|
# Run sender
|
||||||
|
println("start smartsend for mixed content")
|
||||||
|
test_mix_send()
|
||||||
|
|
||||||
|
println("\nTest completed.")
|
||||||
|
println("Note: Run test_julia_to_julia_mix_receiver.jl to receive the messages.")
|
||||||
199
test/test_py_mix_payloads_sender.py
Normal file
199
test/test_py_mix_payloads_sender.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
Python Mix Payloads Sender Test
|
||||||
|
Tests the smartsend function with mixed payload types
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Add parent directory to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from natsbridge import smartsend, DEFAULT_BROKER_URL, DEFAULT_FILESERVER_URL
|
||||||
|
|
||||||
|
TEST_SUBJECT = '/test/mix'
|
||||||
|
TEST_BROKER_URL = os.environ.get('NATS_URL', 'nats://localhost:4222')
|
||||||
|
TEST_FILESERVER_URL = os.environ.get('FILESERVER_URL', 'http://localhost:8080')
|
||||||
|
|
||||||
|
|
||||||
|
async def run_test():
|
||||||
|
print('=== Python Mix Payloads Sender Test ===\n')
|
||||||
|
|
||||||
|
correlation_id = 'py-mix-test-' + str(asyncio.get_event_loop().time() * 1000000)
|
||||||
|
print(f'Correlation ID: {correlation_id}')
|
||||||
|
print(f'Subject: {TEST_SUBJECT}')
|
||||||
|
print(f'Broker URL: {TEST_BROKER_URL}\n')
|
||||||
|
|
||||||
|
# Test data - mixed payload types
|
||||||
|
text_data = 'Hello, NATSBridge!'
|
||||||
|
dict_data = {'key1': 'value1', 'key2': 42, 'nested': {'a': 1, 'b': 2}}
|
||||||
|
image_data = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) # PNG header
|
||||||
|
|
||||||
|
# Table data
|
||||||
|
try:
|
||||||
|
import pandas as pd
|
||||||
|
table_data = pd.DataFrame({
|
||||||
|
'id': [1, 2, 3],
|
||||||
|
'name': ['Alice', 'Bob', 'Charlie'],
|
||||||
|
'age': [30, 25, 35]
|
||||||
|
})
|
||||||
|
table_available = True
|
||||||
|
except ImportError:
|
||||||
|
table_available = False
|
||||||
|
table_data = None
|
||||||
|
|
||||||
|
test_data = [
|
||||||
|
('message', text_data, 'text'),
|
||||||
|
('config', dict_data, 'dictionary'),
|
||||||
|
('image', image_data, 'image')
|
||||||
|
]
|
||||||
|
|
||||||
|
if table_available:
|
||||||
|
test_data.append(('users', table_data, 'table'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Send the message
|
||||||
|
print('Sending mixed payloads...')
|
||||||
|
env, env_json_str = await smartsend(
|
||||||
|
TEST_SUBJECT,
|
||||||
|
test_data,
|
||||||
|
broker_url=TEST_BROKER_URL,
|
||||||
|
fileserver_url=TEST_FILESERVER_URL,
|
||||||
|
correlation_id=correlation_id,
|
||||||
|
msg_purpose='test',
|
||||||
|
sender_name='py-mix-test',
|
||||||
|
is_publish=False
|
||||||
|
)
|
||||||
|
|
||||||
|
print('\n=== Envelope Created ===')
|
||||||
|
print(f'Correlation ID: {env["correlation_id"]}')
|
||||||
|
print(f'Message ID: {env["msg_id"]}')
|
||||||
|
print(f'Timestamp: {env["timestamp"]}')
|
||||||
|
print(f'Subject: {env["send_to"]}')
|
||||||
|
print(f'Purpose: {env["msg_purpose"]}')
|
||||||
|
print(f'Sender: {env["sender_name"]}')
|
||||||
|
print(f'Payloads: {len(env["payloads"])}\n')
|
||||||
|
|
||||||
|
# Validate envelope structure
|
||||||
|
print('=== Validation ===')
|
||||||
|
passed = True
|
||||||
|
|
||||||
|
expected_count = 4 if table_available else 3
|
||||||
|
if len(env['payloads']) != expected_count:
|
||||||
|
print(f'❌ Expected {expected_count} payloads, got {len(env["payloads"])}')
|
||||||
|
passed = False
|
||||||
|
else:
|
||||||
|
print('✅ Correct number of payloads')
|
||||||
|
|
||||||
|
# Test each payload
|
||||||
|
expected_datanames = ['message', 'config', 'image']
|
||||||
|
expected_types = ['text', 'dictionary', 'image']
|
||||||
|
expected_data = [text_data, dict_data, image_data]
|
||||||
|
|
||||||
|
if table_available:
|
||||||
|
expected_datanames.append('users')
|
||||||
|
expected_types.append('table')
|
||||||
|
|
||||||
|
for i in range(len(env['payloads'])):
|
||||||
|
payload = env['payloads'][i]
|
||||||
|
|
||||||
|
if payload['dataname'] != expected_datanames[i]:
|
||||||
|
print(f"❌ Payload {i + 1}: Expected dataname '{expected_datanames[i]}', got '{payload['dataname']}'")
|
||||||
|
passed = False
|
||||||
|
else:
|
||||||
|
print(f'✅ Payload {i + 1}: Correct dataname')
|
||||||
|
|
||||||
|
if payload['payload_type'] != expected_types[i]:
|
||||||
|
print(f"❌ Payload {i + 1}: Expected type '{expected_types[i]}', got '{payload['payload_type']}'")
|
||||||
|
passed = False
|
||||||
|
else:
|
||||||
|
print(f'✅ Payload {i + 1}: Correct type')
|
||||||
|
|
||||||
|
if payload['transport'] != 'direct':
|
||||||
|
print(f"❌ Payload {i + 1}: Expected transport 'direct', got '{payload['transport']}'")
|
||||||
|
passed = False
|
||||||
|
else:
|
||||||
|
print(f'✅ Payload {i + 1}: Correct transport')
|
||||||
|
|
||||||
|
if payload['encoding'] != 'base64':
|
||||||
|
print(f"❌ Payload {i + 1}: Expected encoding 'base64', got '{payload['encoding']}'")
|
||||||
|
passed = False
|
||||||
|
else:
|
||||||
|
print(f'✅ Payload {i + 1}: Correct encoding')
|
||||||
|
|
||||||
|
# Verify data integrity based on type
|
||||||
|
decoded_data = base64.b64decode(payload['data'])
|
||||||
|
|
||||||
|
if expected_types[i] == 'text':
|
||||||
|
decoded_text = decoded_data.decode('utf8')
|
||||||
|
if decoded_text != expected_data[i]:
|
||||||
|
print(f'❌ Payload {i + 1}: Data integrity mismatch')
|
||||||
|
passed = False
|
||||||
|
else:
|
||||||
|
print(f'✅ Payload {i + 1}: Data integrity verified')
|
||||||
|
elif expected_types[i] == 'dictionary':
|
||||||
|
import json
|
||||||
|
decoded_dict = json.loads(decoded_data.decode('utf8'))
|
||||||
|
if json.dumps(decoded_dict, sort_keys=True) != json.dumps(expected_data[i], sort_keys=True):
|
||||||
|
print(f'❌ Payload {i + 1}: Data integrity mismatch')
|
||||||
|
passed = False
|
||||||
|
else:
|
||||||
|
print(f'✅ Payload {i + 1}: Data integrity verified')
|
||||||
|
elif expected_types[i] == 'image':
|
||||||
|
if decoded_data != expected_data[i]:
|
||||||
|
print(f'❌ Payload {i + 1}: Data integrity mismatch')
|
||||||
|
passed = False
|
||||||
|
else:
|
||||||
|
print(f'✅ Payload {i + 1}: Data integrity verified')
|
||||||
|
elif expected_types[i] == 'table':
|
||||||
|
if len(decoded_data) > 0:
|
||||||
|
print(f'✅ Payload {i + 1}: Arrow IPC data present ({len(decoded_data)} bytes)')
|
||||||
|
else:
|
||||||
|
print(f'❌ Payload {i + 1}: Arrow IPC data is empty')
|
||||||
|
passed = False
|
||||||
|
|
||||||
|
print(f' Size: {payload["size"]} bytes\n')
|
||||||
|
|
||||||
|
# Test with chat-like payload (text + image + audio)
|
||||||
|
print('=== Chat-like Payload Test ===')
|
||||||
|
chat_data = [
|
||||||
|
('text', 'Hello!', 'text'),
|
||||||
|
('image', bytes([0xFF, 0xD8, 0xFF, 0xE0]), 'image'),
|
||||||
|
('audio', bytes([0x46, 0x4C, 0x41, 0x43]), 'audio')
|
||||||
|
]
|
||||||
|
|
||||||
|
chat_env, _ = await smartsend(
|
||||||
|
TEST_SUBJECT,
|
||||||
|
chat_data,
|
||||||
|
broker_url=TEST_BROKER_URL,
|
||||||
|
fileserver_url=TEST_FILESERVER_URL,
|
||||||
|
correlation_id='chat-' + correlation_id,
|
||||||
|
is_publish=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(chat_env['payloads']) == 3:
|
||||||
|
print('✅ Chat-like payloads handled correctly')
|
||||||
|
else:
|
||||||
|
print('❌ Chat-like payloads handling failed')
|
||||||
|
passed = False
|
||||||
|
|
||||||
|
# Final result
|
||||||
|
print('\n=== Test Result ===')
|
||||||
|
if passed:
|
||||||
|
print('✅ ALL TESTS PASSED')
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print('❌ SOME TESTS FAILED')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f'❌ Test failed with error: {e}')
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
asyncio.run(run_test())
|
||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user